Class: Musa::REPL::REPL

Inherits:
Object show all
Defined in:
lib/musa-dsl/repl/repl.rb

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:

  1. DynamicProxy: For transparent method delegation (recommended for complex DSLs)
  2. Direct Binding: Pass a Ruby Binding object directly for inline context setup

Protocol Flow

  1. Optional path: #path\n/path/to/file\n#begin\n (injects @user_pathname)
  2. Code block: #begin\ncode\nmore code\n#end\n
  3. 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.

Examples:

With DynamicProxy (complex DSL)

class MyDSL
  include Musa::REPL::CustomizableDSLContext
  # ... DSL methods ...
end

repl = Musa::REPL::REPL.new(
  bind: Musa::Extension::DynamicProxy::DynamicProxy.new(MyDSL.new),
  port: 1327
)

With direct Binding (musalce-server pattern)

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

dsl_context = MyDSL.new
context_proxy = Musa::Extension::DynamicProxy::DynamicProxy.new(dsl_context)
repl = REPL.new(
  bind: context_proxy,
  after_eval: -> (source) { log_execution(source) }
)

See Also:

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

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)

Examples:

With DynamicProxy and named parameters

dsl_context = MyDSL.new
custom_logger = Musa::Logger::Logger.new
REPL.new(
  bind: Musa::Extension::DynamicProxy::DynamicProxy.new(dsl_context),
  port: 1327,
  after_eval: -> (src) { log_execution(src) },
  logger: custom_logger
)

With direct Binding (musalce-server pattern)

# Inside a context setup block:
@repl = REPL.new(binding, highlight_exception: false)

Deferred binding

repl = REPL.new  # Start server without binding
# ... later ...
context = MyDSL.new
repl.bind = Musa::Extension::DynamicProxy::DynamicProxy.new(context)

Parameters:

  • bind (Binding, DynamicProxy, nil) (defaults to: nil)

    execution context (can be set later)

  • port (Integer, nil) (defaults to: nil)

    TCP port to listen on (default: 1327)

  • after_eval (Proc, nil) (defaults to: nil)

    callback invoked after successful code execution

  • logger (Logger, nil) (defaults to: nil)

    logger instance (creates default if nil)

  • highlight_exception (Boolean) (defaults to: true)

    enable ANSI color in exception output (default: true)

Yields:

  • (source)

    Called via after_eval after successful execution

Yield Parameters:

  • source (String)

    the executed source code



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.full_message(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.full_message(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.full_message(highlight: @highlight_exception, order: :top) }
      retry

    end
  end
end

Instance Method Details

#bind=(bind) ⇒ Object

Note:

Can only be set once

Note:

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.

Examples:

With Ruby Binding

# binding.receiver returns the DSLContext instance
repl.bind = binding  # Inside DSL context

With DynamicProxy

# proxy.receiver returns the wrapped object
dsl_context = MyDSL.new
repl.bind = Musa::Extension::DynamicProxy::DynamicProxy.new(dsl_context)

Parameters:

  • bind (Binding, DynamicProxy, Object)

    binding context with receiver

Returns:

  • (Object)

    the bind object

Raises:

  • (RuntimeError)

    if bind is already set



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

Note:

Thread-safe for multi-threaded code execution

Note:

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.

Examples:

From evaluated code

# In REPL-evaluated code:
puts "Starting sequence..."
sequencer.at 4 { puts "Bar 4!" }
# Output appears in editor

Multiple messages

repl.puts("Line 1", "Line 2", "Line 3")
# Sends three separate lines to client

Parameters:

  • messages (Array<Object>)

    messages to send (converted to strings)

Returns:

  • (nil)

    always returns nil like Kernel#puts



436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/musa-dsl/repl/repl.rb', line 436

def puts(*messages)
  if @connection
    messages.each do |message|
      send output: @connection, content: message&.to_s
    end
  else
    @logger.warn('REPL') do
      "trying to print a message in MusaLCE but the client is not connected. Ignoring message \'#{message} \'."
    end
  end

  nil
end

#stopvoid

Note:

After stopping, the REPL cannot be restarted (would need new instance)

Note:

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

  1. Sets run flag to false (stops accepting new connections)
  2. Terminates main server thread
  3. Terminates all client threads
  4. Clears thread tracking

Examples:

repl = REPL.new(bind: context)
# ... later
repl.stop  # Clean shutdown


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