Class: Musa::REPL::REPL
Overview
TCP-based REPL server for live coding.
The REPL class implements a multi-threaded TCP server that accepts connections from live coding clients (like MusaLCE for VSCode/Atom), receives Ruby code, executes it in a bound DSL context, and sends back results or exceptions.
Threading Model
- Main thread: Accepts client connections
- Client threads: One per connected client, handles requests
- Mutex protection: Serializes code execution for safety (@@repl_mutex)
Binding Options
The REPL can be bound to a DSL context in two ways:
- DynamicProxy: For transparent method delegation (recommended for complex DSLs)
- Direct Binding: Pass a Ruby Binding object directly for inline context setup
Protocol Flow
-
Optional path:
#path\n/path/to/file\n#begin\n(injects @user_pathname) -
Code block:
#begin\ncode\nmore code\n#end\n -
Server responses:
- Echo:
//echo\ncode\n//end\n - Error:
//error\nerror details\n//backtrace\nstack\n//end\n - Output: Regular lines (from puts calls)
- Echo:
Error Handling
Errors are captured and formatted with:
- Source code context (3 lines: before, error line, after)
- Error class and message
- Filtered backtrace (shows only REPL-executed code)
- Optional ANSI syntax highlighting (via highlight_exception parameter)
Integration with Sequencer
If the bound context has a sequencer with on_error support, the REPL automatically hooks into it to report async errors during playback.
With DynamicProxy (complex DSL) (a reading: this starts a listening server):
class MyDSL
include Musa::REPL::CustomizableDSLContext
# ... DSL methods ...
end
repl = Musa::REPL::REPL.new(
Musa::Extension::DynamicProxy::DynamicProxy.new(MyDSL.new),
port: 1327
)
With a direct Binding, as musalce-server does it (a reading: the last line starts a listening server):
sequencer.with(keep_block_context: false) do
# Define DSL methods in this context
def play(note); end
def at(pos, &block); end
# Create REPL with this binding
@repl = Musa::REPL::REPL.new(binding, highlight_exception: false)
end
With after_eval callback (a reading: this starts a listening server):
dsl_context = MyDSL.new
context_proxy = Musa::Extension::DynamicProxy::DynamicProxy.new(dsl_context)
repl = REPL.new(
context_proxy,
after_eval: -> (source) { log_execution(source) }
)
Constant Summary collapse
- @@repl_mutex =
Class-level mutex for serializing code execution.
Ensures only one code block executes at a time across all clients.
Mutex.new
Instance Method Summary collapse
-
#bind=(bind) ⇒ Object
Sets or updates the binding context.
-
#initialize(bind = nil, port: nil, after_eval: nil, logger: nil, highlight_exception: true) {|source| ... } ⇒ REPL
constructor
Creates a new REPL server.
-
#puts(*messages) ⇒ nil
Sends messages to the connected REPL client.
-
#stop ⇒ void
Stops the REPL server and cleans up all threads.
Constructor Details
#initialize(bind = nil, port: nil, after_eval: nil, logger: nil, highlight_exception: true) {|source| ... } ⇒ REPL
Creates a new REPL server.
The server starts immediately in a background thread, listening for connections on the specified port (default 1327).
Binding Patterns
The first parameter can be:
- Binding: Ruby binding object (inline DSL setup, see musalce-server)
- DynamicProxy: Proxy object wrapping a DSL context
- nil: Binding can be set later via #bind=
Parameter Options
Named parameters provide additional configuration:
- port: TCP port (default: 1327)
- after_eval: Callback for successful executions
- logger: Custom logger (creates default if nil)
- highlight_exception: ANSI colors in errors (default: true, musalce-server uses false)
With DynamicProxy and named parameters (a reading: this starts a listening server):
dsl_context = MyDSL.new
custom_logger = Musa::Logger::Logger.new
REPL.new(
Musa::Extension::DynamicProxy::DynamicProxy.new(dsl_context),
port: 1327,
after_eval: -> (src) { log_execution(src) },
logger: custom_logger
)
Binding it later (a reading: REPL.new starts a listening server):
repl = REPL.new # Start server without binding
# ... later ...
context = MyDSL.new
repl.bind = Musa::Extension::DynamicProxy::DynamicProxy.new(context)
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/musa-dsl/repl/repl.rb', line 223 def initialize(bind = nil, port: nil, after_eval: nil, logger: nil, highlight_exception: true) self.bind = bind port ||= 1327 @logger = logger || Musa::Logger::Logger.new @highlight_exception = highlight_exception @block_source = nil @client_threads = [] @run = true # Start main server thread @main_thread = Thread.new do @server = TCPServer.new(port) begin # Accept client connections while (@connection = @server.accept) && @run # Spawn thread for each client @client_threads << Thread.new do buffer = nil begin # Process lines from client while (line = @connection.gets) && @run @logger.warn('REPL') { 'input line is nil; will close connection...' } if line.nil? line.chomp! case line when '#path' # Start path block buffer = StringIO.new when '#begin' # Save path (if provided), start code block user_path = buffer&.string @bind.receiver.instance_variable_set(:@user_pathname, Pathname.new(user_path)) if user_path buffer = StringIO.new when '#end' # Execute accumulated code block @@repl_mutex.synchronize do @block_source = buffer.string begin # Echo code to client send_echo @block_source, output: @connection # Execute in DSL context @bind.receiver.execute @block_source, '(repl)', 1 rescue StandardError, ScriptError => e # Handle execution errors @logger.warn('REPL') { 'code execution error' } @logger.warn('REPL') { e.(highlight: @highlight_exception, order: :top) } send_exception e, output: @connection else # Success: invoke callback after_eval.call @block_source if after_eval end end else # Accumulate code lines buffer.puts line end end rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e # Connection errors @logger.warn('REPL') { 'lost connection' } @logger.warn('REPL') { e.(highlight: @highlight_exception, order: :top) } ensure # Clean up connection @logger.debug('REPL') { "closing connection (running #{@run})" } @connection.close end end end rescue Errno::ECONNRESET, Errno::EPIPE => e # Server socket errors - retry @logger.warn('REPL') { 'connection failure while getting server port; will retry...' } @logger.warn('REPL') { e.(highlight: @highlight_exception, order: :top) } retry end end end |
Instance Method Details
#bind=(bind) ⇒ Object
Can only be set once
Automatically hooks into bind.receiver.sequencer.on_error if available
Sets or updates the binding context.
The binding context is where code will be executed. The REPL accesses
the execution context via bind.receiver.execute(source, file, line):
- Binding: Uses Ruby's
Binding#receiver(returns the binding's self) - DynamicProxy: Uses
DynamicProxy#receiver(returns wrapped object)
Requirements
The bind.receiver object must implement:
execute(source, file, line): Evaluates source code- Optionally
sequencer.on_error: For async error reporting
Sequencer Integration
If bind.receiver has a sequencer with on_error support, the REPL
automatically hooks into it to report sequencer errors to the client
during playback.
The two ways to hand it a context (readings: they need a live REPL, and binding it is a once-only operation):
# binding.receiver returns the DSLContext instance
repl.bind = binding # Inside DSL context
# proxy.receiver returns the wrapped object
repl.bind = Musa::Extension::DynamicProxy::DynamicProxy.new(MyDSL.new)
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/musa-dsl/repl/repl.rb', line 355 def bind=(bind) raise 'Already binded' if @bind @bind = bind return unless @bind if @bind.receiver.respond_to?(:sequencer) && @bind.receiver.sequencer.respond_to?(:on_error) @bind.receiver.sequencer.on_error do |e| send_exception e, output: @connection end end end |
#puts(*messages) ⇒ nil
Thread-safe for multi-threaded code execution
Messages sent via #send with proper escaping
Sends messages to the connected REPL client.
This method allows code running in the REPL to send output back to the client (editor). It's designed to be called from within evaluated code, typically as a replacement for standard Kernel#puts.
Behavior
- If client is connected: sends all messages via TCP
- If no client connected: logs warning and ignores messages
- Always returns nil (like Kernel#puts)
Use in DSL Context
The DSL context can override Kernel#puts to redirect output to the client:
def puts(*args)
repl.puts(*args)
end
This allows code like puts "Debug: #{value}" to appear in the editor.
What it looks like from evaluated code (readings: these reach a connected editor, so they need a live REPL):
puts "Starting sequence..."
sequencer.at(4) { puts "Bar 4!" }
# Output appears in the editor
repl.puts("Line 1", "Line 2", "Line 3")
# Sends three separate lines to the client
443 444 445 446 447 448 449 450 451 452 453 454 455 |
# File 'lib/musa-dsl/repl/repl.rb', line 443 def puts(*) if @connection .each do || send output: @connection, content: &.to_s end else @logger.warn('REPL') do "trying to print a message in MusaLCE but the client is not connected. Ignoring message \'#{} \'." end end nil end |
#stop ⇒ void
After stopping, the REPL cannot be restarted (would need new instance)
Uses Thread.pass to ensure thread scheduling
This method returns an undefined value.
Stops the REPL server and cleans up all threads.
This method terminates both the main server thread and all client threads, ensuring a clean shutdown. It's safe to call even if the server is already stopped.
Shutdown Process
- Sets run flag to false (stops accepting new connections)
- Terminates main server thread
- Terminates all client threads
- Clears thread tracking
A reading -- this starts a listening server:
repl = REPL.new(context)
# ... later
repl.stop # Clean shutdown
394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/musa-dsl/repl/repl.rb', line 394 def stop @run = false @main_thread.terminate Thread.pass @main_thread = nil @client_threads.each { |t| t.terminate; Thread.pass } @client_threads.clear end |