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)
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.
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)
217 218 219 220 221 222 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 |
# File 'lib/musa-dsl/repl/repl.rb', line 217 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.
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/musa-dsl/repl/repl.rb', line 349 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.
436 437 438 439 440 441 442 443 444 445 446 447 448 |
# File 'lib/musa-dsl/repl/repl.rb', line 436 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
387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/musa-dsl/repl/repl.rb', line 387 def stop @run = false @main_thread.terminate Thread.pass @main_thread = nil @client_threads.each { |t| t.terminate; Thread.pass } @client_threads.clear end |