Class: Plushie::Runtime

Inherits:
Object
  • Object
show all
Includes:
Commands, Subscriptions
Defined in:
lib/plushie/runtime.rb,
lib/plushie/runtime/windows.rb,
lib/plushie/runtime/commands.rb,
lib/plushie/runtime/subscriptions.rb,
sig/plushie/runtime.rbs,
sig/plushie/runtime/windows.rbs

Overview

Core event loop for Plushie applications.

Owns the Elm-style update cycle: event -> model -> view -> diff -> patch. Processes events sequentially from a thread-safe queue. All state is owned by the runtime thread: no shared mutable state.

Defined Under Namespace

Modules: Commands, Subscriptions, Windows

Constant Summary collapse

SDK_LOG_LEVELS =

Returns:

  • (Hash[Symbol, Integer])
{
  off: Logger::UNKNOWN,
  error: Logger::ERROR,
  warning: Logger::WARN,
  warn: Logger::WARN,
  info: Logger::INFO,
  debug: Logger::DEBUG,
  trace: Logger::DEBUG
}.freeze
DEFAULT_LOG_LEVEL =

Returns:

  • (Object)
Object.new.freeze
COALESCABLE_TYPES =

Coalescable event buffering

Returns:

  • (Array[Symbol])
%i[move scroll scrolled resize].freeze
VIEW_ERROR_WARN_THRESHOLD =

View errors

Returns:

  • (Integer)
5
INTERACT_TIMEOUT_S =

Interact

Returns:

  • (Integer)
15

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app:, transport: :spawn, format: :msgpack, daemon: false, binary: nil, log_level: DEFAULT_LOG_LEVEL, token: nil, dev: false, dev_dirs: nil) ⇒ Runtime

Returns a new instance of Runtime.

Parameters:

  • app (Object)

    app instance (includes Plushie::App)

  • transport (:spawn, :stdio, Array(:iostream, adapter)) (defaults to: :spawn)

    transport mode

  • format (:msgpack, :json) (defaults to: :msgpack)

    wire format

  • daemon (Boolean) (defaults to: false)

    keep running after last window closes

  • binary (String, nil) (defaults to: nil)

    renderer binary path

  • log_level (Symbol) (defaults to: DEFAULT_LOG_LEVEL)

    SDK logger level and fallback renderer log level. Omitted keeps the SDK logger at warn and the renderer fallback at error.

  • token (String, nil) (defaults to: nil)

    authentication token for the renderer

  • dev (Boolean) (defaults to: false)

    enable live code reloading via DevServer

  • dev_dirs (Array<String>, nil) (defaults to: nil)

    directories to watch (default: ["lib/"])



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/plushie/runtime.rb', line 47

def initialize(app:, transport: :spawn, format: :msgpack, daemon: false,
  binary: nil, log_level: DEFAULT_LOG_LEVEL, token: nil, dev: false, dev_dirs: nil)
  validate_app!(app)
  validate_transport!(transport)

  @app = app
  @transport = transport
  @format = format
  @daemon = daemon
  @binary = binary
  @log_level_explicit = !log_level.equal?(DEFAULT_LOG_LEVEL)
  @log_level = renderer_log_level(log_level)
  @token = token
  @dev = dev
  @dev_dirs = dev_dirs

  @event_queue = BoundedQueue.new
  @model = nil
  @previous_tree = nil
  @bridge = nil
  @dev_server = nil
  @running = false
  @timer_scheduler = TimerScheduler.new

  @async_tasks = {}        # tag -> {thread:, nonce:}
  @pending_effects = {}    # wire_id -> timer_thread
  @effect_tags = {}        # tag -> wire_id
  @effect_ids = {}         # wire_id -> tag
  @effect_kinds = {}       # wire_id -> kind string

  # Coalescable event buffer. High-frequency events (move, scroll,
  # scrolled, resize) are stored here, keyed by (window_id, id, type),
  # and flushed at the next event_queue iteration. Last-wins so
  # bursts collapse to the latest value, except scroll events which
  # accumulate their delta_x / delta_y. Keeps update() from drowning
  # in pointer-move events when the host's update() is slow.
  @pending_coalesce = {}   # [window_id, id, type] -> event
  @coalesce_order = []     # insertion order for deterministic flush
  @pending_timers = {}     # event_key -> {thread:, nonce:}
  @subscriptions = {}      # sub_key -> {sub_type:, ...}
  @subscription_keys = []  # sorted keys for short-circuit
  @canvas_widgets = {}     # scoped_id -> CanvasWidget::RegistryEntry
  @consecutive_errors = 0
  @consecutive_view_errors = 0
  @widget_statuses = {}    # id -> status string
  @focused_widget_id = nil # currently focused widget ID
  @memo_cache = {} # : Hash[untyped, untyped]
  @diagnostics = []        # accumulated prop validation diagnostics
  @diagnostics_mutex = Mutex.new
  @dispatch_depth = 0      # Command.dispatch chain position
  @pending_runtime_events = [] # : Array[untyped]
  @pending_stub_acks = {}  # kind -> Queue (for sync ack round-trip)
  @pending_await_async = {} # tag -> Queue (for sync await)
  @pending_interact = nil   # {id:, action:, selector:, result_queue:, timeout_timer:} for current interact
  @tracked_windows = Set.new # active window IDs
  @restarting = false
  @runtime_thread = nil

  @logger = Logger.new($stderr, level: sdk_log_level(log_level), progname: "plushie")
end

Instance Attribute Details

#appObject (readonly)

Accessors for Runtime submodules (Windows, etc.).



35
36
37
# File 'lib/plushie/runtime.rb', line 35

def app
  @app
end

#loggerObject (readonly)

Accessors for Runtime submodules (Windows, etc.).



35
36
37
# File 'lib/plushie/runtime.rb', line 35

def logger
  @logger
end

#modelObject (readonly)

Accessors for Runtime submodules (Windows, etc.).



35
36
37
# File 'lib/plushie/runtime.rb', line 35

def model
  @model
end

Instance Method Details

#advance_pending_runtime_events

This method returns an undefined value.



448
449
450
451
452
# File 'lib/plushie/runtime.rb', line 448

def advance_pending_runtime_events
  @pending_runtime_events.each do |entry|
    entry[:remaining] -= 1 if entry[:remaining] > 0
  end
end

#apply_deferred_interact_side_effects

This method returns an undefined value.



1094
1095
1096
1097
1098
1099
1100
1101
# File 'lib/plushie/runtime.rb', line 1094

def apply_deferred_interact_side_effects
  ops = @deferred_interact_window_ops
  @deferred_interact_window_ops = nil
  @tracked_windows, = Windows.apply_ops(self, ops, @tracked_windows) if ops
  sync_subscriptions
rescue => e
  handle_view_error(e)
end

#apply_event(event)

This method returns an undefined value.

Process an event through update + commands WITHOUT rendering. Used by interact_step to batch events before a single render. Matches Elixir's apply_event (runtime.ex lines 972-987).

Parameters:

  • event (Object)


1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
# File 'lib/plushie/runtime.rb', line 1136

def apply_event(event)
  # Route through canvas widget handlers
  unless @canvas_widgets.empty?
    routed_event, @canvas_widgets = CanvasWidget.dispatch_through_widgets(@canvas_widgets, event)
    return if routed_event.nil?

    event = routed_event
  end

  saved_model = @model
  result = @app.update(@model, event)
  @model, commands = unwrap_result(result)
  @consecutive_errors = 0
  execute_commands(commands)
rescue NoMatchingPatternError => e
  @model = saved_model
  handle_callback_error("update", e,
    hint: "Add an `else` clause to your update method to handle unmatched events")
rescue => e
  @model = saved_model
  handle_callback_error("update", e)
end

#await_async(tag, timeout: 5) ⇒ :ok

Waits for an async task with the given tag to complete.

If the task has already completed, returns immediately. Otherwise blocks until the task finishes and its result has been processed through update.

Parameters:

  • tag (Symbol)

    the async command tag

  • timeout (Numeric) (defaults to: 5)

    max wait in seconds

  • timeout: (Numeric) (defaults to: 5)

Returns:

  • (:ok)

Raises:



251
252
253
254
255
256
257
258
259
260
# File 'lib/plushie/runtime.rb', line 251

def await_async(tag, timeout: 5)
  ack_queue = Thread::Queue.new
  enqueued = BoundedQueue.push(@event_queue, [:await_async, tag, ack_queue], timeout: Float(timeout))
  raise Plushie::Error, format_await_async_timeout(tag) if enqueued.nil?

  result = ack_queue.pop(timeout: Float(timeout))
  raise Plushie::Error, format_await_async_timeout(tag) if result.nil?

  :ok
end

#bridge_send_window_op(op, window_id, settings = {})

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Send a window operation to the renderer via the bridge.

Parameters:

  • op (String, Symbol)
  • window_id (String)
  • settings (Hash[Symbol | String, untyped]) (defaults to: {})


213
214
215
216
# File 'lib/plushie/runtime.rb', line 213

def bridge_send_window_op(op, window_id, settings = {})
  bridge = @bridge or return
  bridge.send_encoded(Protocol::Encode.encode_window_op(op, window_id, Encode.encode_props(settings), @format))
end

#build_renderer_exit(reason) ⇒ Object

Converts a raw renderer exit reason into a structured RendererExit.

Parameters:

  • reason (Object)

Returns:

  • (Object)


1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
# File 'lib/plushie/runtime.rb', line 1235

def build_renderer_exit(reason)
  case reason
  in {type: :connection_closed, reason: :heartbeat_timeout}
    RendererExit.new(
      type: :heartbeat_timeout,
      message: "renderer unresponsive (heartbeat timeout)"
    )
  in {type: :connection_closed}
    RendererExit.new(
      type: :connection_lost,
      message: "renderer connection closed"
    )
  in {type: :connection_error, error:}
    RendererExit.new(
      type: :crash,
      message: "renderer connection error",
      details: {error_class: safe_class_name(error)}
    )
  in Exception
    RendererExit.new(
      type: :crash,
      message: "renderer exited unexpectedly",
      details: {exception_class: safe_class_name(reason)}
    )
  else
    RendererExit.new(
      type: :crash,
      message: "renderer exited unexpectedly",
      details: {reason_type: safe_class_name(reason)}
    )
  end
end

#build_settingsHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/plushie/runtime.rb', line 289

def build_settings
  # @type var settings: Hash[Symbol, untyped]
  settings = begin
    @app.settings
  rescue => e
    @logger.warn("plushie: settings callback error: #{e.class}: #{e.message}")
    {}
  end
  wc = Plushie.configuration.widget_config
  settings = settings.merge(widget_config: wc) if wc && !wc.empty?
  settings = settings.merge(validate_props: true) if Plushie.configuration.validate_props
  settings
end

#cancel_task(tag) Originally defined in module Commands

This method returns an undefined value.

Cancel a running async/stream task. Marks the entry as cancelled instead of deleting it. The async result handler owns cleanup, preventing a race where Thread.kill triggers the rescue block that pushes an async_result after the entry was already deleted.

Parameters:

  • tag (Symbol)

#check_max_rate(key, spec) Originally defined in module Subscriptions

This method returns an undefined value.

Check if a single subscription's max_rate needs updating.

Parameters:

#coalescable_event?(event) ⇒ Boolean

Parameters:

  • event (Object)

Returns:

  • (Boolean)


464
465
466
# File 'lib/plushie/runtime.rb', line 464

def coalescable_event?(event)
  event.is_a?(Event::Widget) && COALESCABLE_TYPES.include?(event.type)
end

#coalesce_event(event)

This method returns an undefined value.

Parameters:



468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'lib/plushie/runtime.rb', line 468

def coalesce_event(event)
  key = [event.window_id, event.id, event.type]
  # Scroll events accumulate delta_x / delta_y so a burst of small
  # wheel ticks delivers one Scroll with the summed deltas, not a
  # lost-to-overwrite chain. Other types are last-wins.
  merged =
    if event.type == :scroll && @pending_coalesce.key?(key)
      existing = @pending_coalesce[key]
      combine_scroll_events(existing, event)
    else
      event
    end

  @coalesce_order << key unless @pending_coalesce.key?(key)
  @pending_coalesce[key] = merged
end

#combine_scroll_events(old, new_event) ⇒ Event::Widget

Parameters:

Returns:



485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# File 'lib/plushie/runtime.rb', line 485

def combine_scroll_events(old, new_event)
  # Both events share the same (window_id, id, type) key. Sum the
  # deltas and keep the newest metadata (modifiers, pointer kind).
  empty = {} # : Hash[untyped, untyped]
  old_val = old.value.is_a?(Hash) ? old.value : empty
  new_val = new_event.value.is_a?(Hash) ? new_event.value : empty
  dx = (old_val[:delta_x] || old_val["delta_x"] || 0) +
    (new_val[:delta_x] || new_val["delta_x"] || 0)
  dy = (old_val[:delta_y] || old_val["delta_y"] || 0) +
    (new_val[:delta_y] || new_val["delta_y"] || 0)
  merged_value = new_val.merge(delta_x: dx, delta_y: dy)
  Event::Widget.new(
    type: new_event.type,
    id: new_event.id,
    value: merged_value,
    window_id: new_event.window_id,
    scope: new_event.scope
  )
end

#describe_diagnostic(diag) ⇒ String

Handle a status event from the renderer. Updates internal focus tracking state and dispatches derived :focused/:blurred events Render a typed Diagnostic variant as a single-line summary for the log channel. Falls back to inspect for variants without a natural one-line form.

Parameters:

  • diag (Object)

Returns:

  • (String)


887
888
889
890
891
892
893
894
# File 'lib/plushie/runtime.rb', line 887

def describe_diagnostic(diag)
  return diag.inspect unless diag.respond_to?(:to_h)
  kind = diag.class.name.to_s.split("::").last.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
  fields = diag.to_h.reject { |_, v| v.nil? }
  return kind if fields.empty?
  pairs = fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
  "#{kind}: #{pairs}"
end

#diff_subscriptions(new_by_key, new_sorted_keys) Originally defined in module Subscriptions

This method returns an undefined value.

Full diff of subscription sets.

Parameters:

#dispatch_event(event)

This method returns an undefined value.

Event dispatch

Parameters:

  • event (Object)


519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/plushie/runtime.rb', line 519

def dispatch_event(event)
  # Intercept effect stub ack responses
  if event.is_a?(Hash) && event[:type] == :effect_stub_ack
    ack_queue = @pending_stub_acks.delete(event[:kind])
    ack_queue&.push(:ok)
    return
  end

  # Intercept interact_step / interact_response for pending interact.
  # Check the response ID matches the pending interact to reject stale
  # responses from timed-out interactions. Events from stale responses
  # are still dispatched through update: only the caller completion
  # is skipped.
  if event.is_a?(Hash)
    event_type = (event[:type] || event["type"])&.to_sym
    response_id = event[:id] || event["id"]
    pending = @pending_interact
    if %i[interact_step interact_response].include?(event_type)
      if pending && response_id == pending[:id]
        if event_type == :interact_step
          handle_interact_step(event)
        else
          handle_interact_response(event)
        end
      else
        # Stale or orphaned response. Dispatch events through update
        # but don't complete any pending interact.
        extract_interact_events(event).each { |ev| dispatch_event(ev) }
      end
      return
    end
  end

  # Intercept status events for focus tracking. The raw :status event
  # is absorbed; derived :focused/:blurred events are dispatched.
  if event.is_a?(Event::Widget) && event.type == :status
    handle_status_event(event)
    return
  end

  # Intercept structured diagnostics from the renderer's diagnostic
  # channel (never delivered to update). Log at the renderer's
  # severity level; pattern-match on event.diagnostic for typed
  # access to the variant payload.
  if event.is_a?(Event::DiagnosticMessage)
    msg = describe_diagnostic(event.diagnostic)
    case event.level
    when :error then @logger.error("plushie: diagnostic: #{msg}")
    when :info then @logger.info("plushie: diagnostic: #{msg}")
    else @logger.warn("plushie: diagnostic: #{msg}")
    end
    @diagnostics_mutex.synchronize { @diagnostics << event }
    return
  end

  # Resolve effect responses: map wire_id -> tag and decode the
  # payload into a typed Event::Effect::Result.*.
  if event.is_a?(Hash) && event[:type] == :effect_response
    wire_id = event[:wire_id]
    @logger.debug("plushie: effect response with nil wire_id: #{format_hash(event)}") if wire_id.nil?
    timer = @pending_effects.delete(wire_id)
    stop_thread(timer)
    tag = @effect_ids.delete(wire_id)
    kind = @effect_kinds.delete(wire_id)
    @effect_tags.delete(tag) if tag
    return unless tag

    # @effect_ids and @effect_kinds are always written together in
    # execute_effect, so if tag was present kind is too. Fall back
    # to "" if the invariant ever breaks; decode will surface it as
    # an Error result rather than crashing the loop.
    typed = Event::Effect::Result.decode(kind || "", event[:status], event[:payload])
    event = Event::Effect.new(tag: tag, result: typed)

  end

  # Route through canvas widget handlers before app.update.
  # Handlers can consume, transform, or ignore the event.
  # Wrapped in rescue so widget handler errors don't crash the runtime.
  unless @canvas_widgets.empty?
    begin
      widgets_before = @canvas_widgets
      routed_event, @canvas_widgets = CanvasWidget.dispatch_through_widgets(@canvas_widgets, event)
      if routed_event.nil?
        # Event consumed by a widget handler. If the registry changed
        # (widget state updated), re-render to pick up view changes.
        rerender_after_widget_state_change(widgets_before) if @canvas_widgets != widgets_before
        return
      end
      event = routed_event
    rescue => e
      @logger.warn("plushie: widget event routing error: #{e.class}: #{e.message}")
      return
    end
  end

  saved_model = @model

  result = @app.update(@model, event)
  @model, commands = unwrap_result(result)
  @consecutive_errors = 0

  render_and_patch
  execute_commands(commands)
  sync_subscriptions
rescue NoMatchingPatternError => e
  @model = saved_model
  handle_callback_error("update", e,
    hint: "Add an `else` clause to your update method to handle unmatched events")
rescue => e
  @model = saved_model
  handle_callback_error("update", e)
end

#enqueue_runtime_event(message) ⇒ Object Originally defined in module Commands

Parameters:

  • message (Object)

Returns:

  • (Object)

#event_loop

This method returns an undefined value.

Event loop



336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/plushie/runtime.rb', line 336

def event_loop
  while @running
    # Flush any pending coalescables before blocking on the queue.
    # This mirrors Elixir's zero-delay send_after: coalescables
    # survive only until the next scheduler tick, and here the
    # "tick" is the boundary between inbound message batches.
    if !@pending_coalesce.empty? && @event_queue.empty? && !pending_runtime_event_ready?
      flush_coalescables
    end

    msg = next_event_message
    break if msg == :shutdown

    # A fresh entry into the event loop resets the
    # `Command.dispatch` chain counter. The `:dispatched_event`
    # branch below overrides it with the chain position so the
    # guard in `execute_done` caps a pathological update loop.
    @dispatch_depth = 0

    case msg
    in [:renderer_event, event]
      # Coalescable widget events collapse on (window_id, id, type);
      # scroll deltas accumulate.
      if coalescable_event?(event)
        coalesce_event(event)
      else
        flush_coalescables
        dispatch_event(event)
      end
    in [:renderer_exited, reason]
      handle_renderer_exit(reason)
    in [:renderer_restarted]
      handle_renderer_restarted
    in [:async_result, tag, nonce, result]
      handle_async_result(tag, nonce, result)
    in [:stream_value, tag, nonce, value]
      handle_stream_value(tag, nonce, value)
    in [:timer_tick, tag]
      handle_timer_tick(tag)
    in [:dispatched_event, depth, event]
      # A `Command.dispatch` follow-up: set the depth so the guard
      # in `execute_done` caps the chain, then dispatch the event
      # through the normal update cycle.
      @dispatch_depth = depth
      dispatch_event(event)
    in [:send_after_event, event, nonce]
      entry = @pending_timers[event]
      if entry && entry[:nonce] == nonce
        @pending_timers.delete(event)
        dispatch_event(event)
      end
    in [:effect_timeout, id]
      handle_effect_timeout(id)
    in [:interact_timeout, id]
      handle_interact_timeout(id)
    in [:register_effect_stub, kind, response, ack_queue]
      if @restarting
        ack_queue.push({error: "renderer is restarting"})
      elsif @pending_stub_acks.key?(kind)
        ack_queue.push({error: "stub ack already pending for #{kind}"})
      else
        @bridge.send_register_effect_stub(kind, response)
        @pending_stub_acks[kind] = ack_queue
      end
    in [:unregister_effect_stub, kind, ack_queue]
      if @restarting
        ack_queue.push({error: "renderer is restarting"})
      elsif @pending_stub_acks.key?(kind)
        ack_queue.push({error: "stub ack already pending for #{kind}"})
      else
        @bridge.send_unregister_effect_stub(kind)
        @pending_stub_acks[kind] = ack_queue
      end
    in [:interact, action, selector, payload, result_queue]
      handle_interact_request(action, selector, payload, result_queue)
    in [:await_async, tag, ack_queue]
      if @pending_await_async.key?(tag)
        ack_queue.push({error: "await already in progress for #{tag}"})
      elsif @async_tasks.key?(tag)
        @pending_await_async[tag] = ack_queue
      else
        ack_queue.push(:ok)
      end
    in :force_rerender
      @consecutive_errors = 0
      @consecutive_view_errors = 0
      render_and_patch
    else
      @logger.debug("plushie: unknown message: #{msg.inspect}")
    end
  end
end

#execute_async(callable, tag) Originally defined in module Commands

This method returns an undefined value.

Spawn a dedicated thread for async work with nonce tracking.

Parameters:

  • callable (Proc)
  • tag (Symbol)

#execute_commands(cmd) Originally defined in module Commands

This method returns an undefined value.

Execute a command or list of commands, threading state.

Parameters:

#execute_done(value, mapper) Originally defined in module Commands

This method returns an undefined value.

Dispatch a done command immediately.

Guards against pathological update chains that keep returning another Command.dispatch: the current chain position lives on @dispatch_depth, the new event would be at depth + 1, and past DISPATCH_DEPTH_LIMIT the runtime drops the command and surfaces a typed DispatchLoopExceeded diagnostic.

Parameters:

  • value (Object)
  • mapper (Proc)

#execute_effect(payload) Originally defined in module Commands

This method returns an undefined value.

Execute an effect request (send to renderer + start timeout).

Parameters:

  • payload (Hash[Symbol, untyped])

#execute_send_after(delay_ms, event) Originally defined in module Commands

This method returns an undefined value.

Schedule a delayed event. Uses a monotonic nonce to handle the Thread.kill race: if the old timer already pushed its event to the queue before being killed, the handler discards stale nonces.

Parameters:

  • delay_ms (Integer)
  • event (Object)

#execute_stream(callable, tag) Originally defined in module Commands

This method returns an undefined value.

Spawn a thread for streaming work with emit callback.

Parameters:

  • callable (Proc)
  • tag (Symbol)

#extract_interact_events(response) ⇒ Array[untyped]

Parameters:

  • response (Object)

Returns:

  • (Array[untyped])


1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
# File 'lib/plushie/runtime.rb', line 1159

def extract_interact_events(response)
  raw = response[:events] || response["events"] || []
  raw.filter_map do |e|
    if e.is_a?(Hash)
      Protocol::Decode.decode_event(e.transform_keys(&:to_s))
    else
      e
    end
  end
end

#fail_pending_interact(reason)

This method returns an undefined value.

Parameters:

  • reason (String)


1170
1171
1172
1173
1174
1175
1176
1177
# File 'lib/plushie/runtime.rb', line 1170

def fail_pending_interact(reason)
  pending = @pending_interact
  @pending_interact = nil
  return unless pending

  pending[:timeout_timer]&.kill
  pending[:result_queue]&.push({error: reason})
end

#flush_coalescables

This method returns an undefined value.



505
506
507
508
509
510
511
512
513
514
515
# File 'lib/plushie/runtime.rb', line 505

def flush_coalescables
  return if @pending_coalesce.empty?
  order = @coalesce_order
  pending = @pending_coalesce
  @coalesce_order = []
  @pending_coalesce = {}
  order.each do |key|
    event = pending[key]
    dispatch_event(event) if event
  end
end

#flush_pending_effects_on_exit(effect_result, execute_returned_commands: true)

This method returns an undefined value.

Flush pending effect requests: the renderer that would have responded is gone. Deliver error events through update so the app can react. Each effect is removed individually before its error event so new effects started during the flush survive. Rendering is skipped since the renderer is dead.

Parameters:

  • result (Object)
  • execute_returned_commands: (bool execute_returned_commands) (defaults to: true)


1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
# File 'lib/plushie/runtime.rb', line 1193

def flush_pending_effects_on_exit(effect_result, execute_returned_commands: true)
  ids = @pending_effects.keys
  ids.each do |id|
    timer = @pending_effects.delete(id)
    stop_thread(timer)
    tag = @effect_ids.delete(id)
    @effect_kinds.delete(id)
    @effect_tags.delete(tag) if tag
    next unless tag

    event = Event::Effect.new(tag: tag, result: effect_result)
    saved_model = @model
    begin
      update_result = @app.update(@model, event)
      @model, commands = unwrap_result(update_result)
      execute_commands(commands) if execute_returned_commands
    rescue => e
      @model = saved_model
      handle_callback_error("update (effect flush)", e)
    end
  end
end

#flush_pending_stub_acks

This method returns an undefined value.

Flush pending stub ack queues with an error so callers know the old renderer's stub registry was lost. Callers must re-register.



1218
1219
1220
1221
# File 'lib/plushie/runtime.rb', line 1218

def flush_pending_stub_acks
  @pending_stub_acks.each_value { |q| q.push({error: "renderer_restarted"}) }
  @pending_stub_acks.clear
end

#format_await_async_timeout(tag) ⇒ String

Parameters:

  • tag (Symbol)

Returns:

  • (String)


1129
1130
1131
# File 'lib/plushie/runtime.rb', line 1129

def format_await_async_timeout(tag)
  "await_async timed out: tag=#{tag}"
end

#format_hash(hash) ⇒ String

Parameters:

  • hash (Hash[untyped, untyped])

Returns:

  • (String)


1121
1122
1123
# File 'lib/plushie/runtime.rb', line 1121

def format_hash(hash)
  "{#{hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
end

#format_interact_target(action, selector) ⇒ String

Parameters:

  • action (String)
  • selector (Object)

Returns:

  • (String)


1114
1115
1116
1117
1118
1119
# File 'lib/plushie/runtime.rb', line 1114

def format_interact_target(action, selector)
  target = "action=#{action}"
  return target if selector.nil?

  "#{target} selector=#{format_hash(selector)}"
end

#format_interact_timeout(action, selector) ⇒ String

Parameters:

  • action (String)
  • selector (Object)

Returns:

  • (String)


1125
1126
1127
# File 'lib/plushie/runtime.rb', line 1125

def format_interact_timeout(action, selector)
  "interact timed out: #{format_interact_target(action, selector)}"
end

#get_diagnosticsArray<Event::System>

Returns and clears accumulated prop validation diagnostics.

The renderer emits diagnostic events when validate_props is enabled. These are intercepted by the runtime (never delivered to update) and accumulated. This method atomically retrieves and clears the list.

Returns:



187
188
189
190
191
192
193
# File 'lib/plushie/runtime.rb', line 187

def get_diagnostics
  @diagnostics_mutex.synchronize do
    result = @diagnostics.dup
    @diagnostics.clear
    result
  end
end

#get_focusedString?

Returns the ID of the currently focused widget, or nil. Focus is tracked automatically from renderer status events.

Returns:

  • (String, nil)


199
200
201
# File 'lib/plushie/runtime.rb', line 199

def get_focused
  @focused_widget_id
end

#handle_async_result(tag, nonce, result)

This method returns an undefined value.

Async handling

Parameters:

  • tag (Symbol)
  • nonce (Integer)
  • result (Object)


747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
# File 'lib/plushie/runtime.rb', line 747

def handle_async_result(tag, nonce, result)
  entry = @async_tasks[tag]
  return unless entry

  case entry[:nonce]
  when :cancelled
    # Task was intentionally cancelled. Silent cleanup.
    @async_tasks.delete(tag)
  when nonce
    # Normal completion or crash. Dispatch through update.
    @async_tasks.delete(tag)
    dispatch_event(Event::Async.new(tag: tag, result: result))
    notify_await_async(tag)
  else
    @logger.debug("plushie: stale async result for tag=#{tag} (nonce mismatch)")
    nil
  end
end

#handle_callback_error(callback_name, error, hint: nil)

This method returns an undefined value.

Error handling

Parameters:

  • callback_name (String)
  • error (Exception)
  • hint: (String, nil) (defaults to: nil)


947
948
949
950
951
952
953
954
955
956
# File 'lib/plushie/runtime.rb', line 947

def handle_callback_error(callback_name, error, hint: nil)
  @consecutive_errors += 1
  if @consecutive_errors <= 100
    @logger.error("plushie: exception in #{callback_name}: #{error.class}: #{error.message}")
    @logger.error("  Hint: #{hint}") if hint
    error.backtrace&.first(5)&.each { |line| @logger.error("  #{line}") }
  elsif (@consecutive_errors % 1000).zero?
    @logger.error("plushie: #{@consecutive_errors} consecutive errors in #{callback_name} (suppressing)")
  end
end

#handle_effect_timeout(id)

This method returns an undefined value.

Effect handling

Parameters:

  • id (String)


800
801
802
803
804
805
806
807
808
809
810
811
# File 'lib/plushie/runtime.rb', line 800

def handle_effect_timeout(id)
  timer = @pending_effects.delete(id)
  return unless timer

  stop_thread(timer)
  tag = @effect_ids.delete(id)
  @effect_kinds.delete(id)
  @effect_tags.delete(tag) if tag
  return unless tag

  dispatch_event(Event::Effect.new(tag: tag, result: Event::Effect::Result::Timeout.new))
end

#handle_interact_request(action, selector, payload, result_queue)

This method returns an undefined value.

Parameters:

  • action (String)
  • selector (Object)
  • payload (Hash[Symbol, untyped])
  • result_queue (Thread::Queue[untyped])


1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
# File 'lib/plushie/runtime.rb', line 1041

def handle_interact_request(action, selector, payload, result_queue)
  id = SecureRandom.hex(4)
  queue = @event_queue
  timer = Thread.new do
    sleep(INTERACT_TIMEOUT_S)
    BoundedQueue.push(queue, [:interact_timeout, id])
  end
  timer.name = "plushie-interact-timeout"
  @pending_interact = {
    id: id,
    action: action,
    selector: selector,
    result_queue: result_queue,
    timeout_timer: timer
  }
  bridge = @bridge or raise Plushie::Error, "bridge not started"
  bridge.send_encoded(
    Protocol::Encode.encode_interact(id, action, selector, payload, @format)
  )
end

#handle_interact_response(response)

This method returns an undefined value.

Parameters:

  • response (Object)


1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
# File 'lib/plushie/runtime.rb', line 1074

def handle_interact_response(response)
  flush_coalescables
  events = extract_interact_events(response)
  if events.empty?
    apply_deferred_interact_side_effects
  else
    @deferred_interact_window_ops = nil
    events.each { |ev| dispatch_event(ev) }
  end
  pending = @pending_interact
  @pending_interact = nil
  return unless pending

  # @type var result: Hash[Symbol, untyped]
  result = {events: events}
  result[:view_error] = true if @consecutive_view_errors > 0
  pending[:timeout_timer]&.kill
  pending[:result_queue]&.push(result)
end

#handle_interact_step(response)

This method returns an undefined value.

Parameters:

  • response (Object)


1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
# File 'lib/plushie/runtime.rb', line 1062

def handle_interact_step(response)
  # Flush pending coalescables so interact-step ordering stays
  # deterministic and the snapshot reflects all prior events.
  flush_coalescables
  events = extract_interact_events(response)
  # Process events WITHOUT rendering after each one.
  # Matches Elixir's apply_event which defers view/render.
  events.each { |ev| apply_event(ev) }
  # Render once and send a single snapshot (headless protocol).
  render_and_snapshot(window_ops_order: :defer_window_ops)
end

#handle_interact_timeout(id)

This method returns an undefined value.

Parameters:

  • id (String)


1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
# File 'lib/plushie/runtime.rb', line 1103

def handle_interact_timeout(id)
  pending = @pending_interact
  return unless pending && pending[:id] == id

  target = format_interact_target(pending[:action], pending[:selector])
  @logger.warn("plushie: interact timed out: #{target} (id #{id})")
  @pending_interact = nil
  pending[:timeout_timer]&.kill
  pending[:result_queue]&.push({error: format_interact_timeout(pending[:action], pending[:selector])})
end

#handle_renderer_exit(reason)

This method returns an undefined value.

Renderer exit / restart

Parameters:

  • reason (Object)


815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# File 'lib/plushie/runtime.rb', line 815

def handle_renderer_exit(reason)
  renderer_exit = build_renderer_exit(reason)
  @logger.warn("plushie: renderer exited: #{renderer_exit.message}")
  fail_pending_interact("renderer_exited")
  flush_pending_effects_on_exit(Event::Effect::Result::RendererRestarted.new)
  flush_pending_stub_acks
  @canvas_widgets = {}
  @widget_statuses = {}
  @focused_widget_id = nil
  @restarting = true
  # @type var recovery_error: Exception?
  recovery_error = nil
  begin
    @model = @app.handle_renderer_exit(@model, renderer_exit)
  rescue => e
    @logger.error("plushie: handle_renderer_exit error: #{e.class}: #{e.message}")
    recovery_error = e
  end

  # If the recovery callback failed, dispatch a recovery_failed event
  # so the app can react (show an error banner, reset to safe state).
  if recovery_error
    dispatch_event(Event::System.new(
      type: :recovery_failed,
      value: {error: recovery_error.message, renderer_exit: renderer_exit.message} # : Hash[Symbol, untyped]
    ))
  end

  @restarting = false
  @running = false unless @daemon
end

#handle_renderer_restarted

This method returns an undefined value.



847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
# File 'lib/plushie/runtime.rb', line 847

def handle_renderer_restarted
  @logger.info("plushie: renderer restarted, re-sending settings and snapshot")
  @consecutive_errors = 0
  @consecutive_view_errors = 0

  # Clear stale interaction state from the old renderer.
  fail_pending_interact("renderer_restarted")
  flush_pending_effects_on_exit(Event::Effect::Result::RendererRestarted.new)
  flush_pending_stub_acks
  @canvas_widgets = {}
  @widget_statuses = {}
  @focused_widget_id = nil
  @memo_cache = {}
  # Keep @previous_tree intact. render_and_snapshot overwrites it on
  # success. If view fails, resend_last_snapshot uses it as a fallback
  # so the new renderer has something to display.

  # The new renderer expects Settings as the first message.
  send_settings

  # Reset tracked windows so sync_windows re-opens them all.
  @tracked_windows = Set.new

  # Re-render to get a fresh tree and send a full snapshot.
  render_and_snapshot

  # Reset renderer subscriptions so sync sees them as new and
  # re-sends subscribe messages to the fresh renderer.
  reset_renderer_subscriptions
  sync_subscriptions
  @restarting = false
end

#handle_status_event(event)

This method returns an undefined value.

Status-based focus tracking

Parameters:



897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
# File 'lib/plushie/runtime.rb', line 897

def handle_status_event(event)
  status = event.value
  return unless status.is_a?(String)

  id = event.id
  prev_status = @widget_statuses[id]
  @widget_statuses[id] = status

  if status == "focused"
    @focused_widget_id = id
  elsif prev_status == "focused" && @focused_widget_id == id
    @focused_widget_id = nil
  end

  # Derive focused/blurred events from status transitions
  if prev_status != "focused" && status == "focused"
    dispatch_event(Event::Widget.new(
      type: :focused, id: id, window_id: event.window_id, scope: event.scope
    ))
  elsif prev_status == "focused" && status != "focused"
    dispatch_event(Event::Widget.new(
      type: :blurred, id: id, window_id: event.window_id, scope: event.scope
    ))
  end
end

#handle_stream_value(tag, nonce, value)

This method returns an undefined value.

Parameters:

  • tag (Symbol)
  • nonce (Integer)
  • value (Object)


766
767
768
769
770
771
# File 'lib/plushie/runtime.rb', line 766

def handle_stream_value(tag, nonce, value)
  entry = @async_tasks[tag]
  return unless entry && entry[:nonce] == nonce

  dispatch_event(Event::Stream.new(tag: tag, value: value))
end

#handle_timer_tick(tag)

This method returns an undefined value.

-- Timer handling -------------------------------------------------------

Parameters:

  • tag (Object)


775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'lib/plushie/runtime.rb', line 775

def handle_timer_tick(tag)
  # Check if this is a canvas widget timer
  unless @canvas_widgets.empty?
    result = CanvasWidget.handle_widget_timer(@canvas_widgets, tag)
    if result
      event_or_nil, @canvas_widgets = result
      if event_or_nil
        dispatch_event(event_or_nil)
      else
        # Widget handled the timer internally; re-render for state changes
        render_and_patch
        sync_subscriptions
      end
      return
    end
  end

  dispatch_event(Event::Timer.new(
    tag: tag,
    timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
  ))
end

#handle_view_error(error)

This method returns an undefined value.

Parameters:

  • error (Exception)


960
961
962
963
964
965
966
967
# File 'lib/plushie/runtime.rb', line 960

def handle_view_error(error)
  @consecutive_view_errors += 1
  handle_callback_error("view", error)
  return unless @consecutive_view_errors == VIEW_ERROR_WARN_THRESHOLD

  @logger.warn("plushie: view has failed #{VIEW_ERROR_WARN_THRESHOLD} consecutive times; UI is stale")
  inject_frozen_ui_overlay
end

#initialize_app

This method returns an undefined value.



321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/plushie/runtime.rb', line 321

def initialize_app
  # @type var init_opts: Hash[Symbol, untyped]
  init_opts = {}
  result = @app.init(init_opts)
  @model, commands = unwrap_result(result)

  render_and_snapshot
  execute_commands(commands)
  sync_subscriptions

  @running = true
end

#inject_frozen_ui_overlay

This method returns an undefined value.

Inject a red error bar into the stale tree to alert the user that the UI is frozen due to view errors. Runs in every mode; the overlay is a production safety net, not a dev-only banner.



972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
# File 'lib/plushie/runtime.rb', line 972

def inject_frozen_ui_overlay
  tree = @previous_tree
  return unless tree && @bridge

  overlay_node = Node.new(
    id: "__frozen_ui__",
    type: "container",
    props: {
      width: "fill",
      height: 40,
      padding: 8,
      style: {background: "#dc2626"}
    },
    children: [
      Node.new(id: "__frozen_ui_text__", type: "text", props: {
        content: "View error: UI is frozen. Fix the error and save to reload.",
        size: 14,
        color: "#ffffff"
      })
    ]
  )

  # Inject the overlay as the first child of the root
  if tree.children.any?
    first_window = tree.children[0]
    new_children = [overlay_node] + first_window.children
    new_window = first_window.with(children: new_children)
    rest = tree.children[1..] || []
    new_tree = tree.with(children: [new_window] + rest)

    bridge = @bridge
    ops = Tree.diff(@previous_tree, new_tree)
    if !ops.empty? && bridge
      bridge.send_encoded(Protocol::Encode.encode_patch(ops, @format))
      @previous_tree = new_tree
    end
  end
rescue => e
  @logger.debug("plushie: failed to inject frozen UI overlay: #{e.message}")
end

#interact(action, selector = nil, payload = {}, timeout: 5) ⇒ Array<Object>

Simulate a user interaction with a widget.

Sends an interact message through the bridge and blocks until the renderer responds. Used by scripting and automation: the test session has its own interact that runs synchronously within the test process.

Parameters:

  • action (String)

    interaction type ("click", "type_text", etc.)

  • selector (Hash, nil) (defaults to: nil)

    target widget selector ("id", value: "btn")

  • payload (Hash) (defaults to: {})

    action-specific parameters

  • timeout (Numeric) (defaults to: 5)

    max wait in seconds

  • timeout: (Numeric) (defaults to: 5)

Returns:

  • (Array<Object>)

    events produced by the interaction

Raises:



230
231
232
233
234
235
236
237
238
239
240
# File 'lib/plushie/runtime.rb', line 230

def interact(action, selector = nil, payload = {}, timeout: 5)
  result_queue = Thread::Queue.new
  enqueued = BoundedQueue.push(@event_queue, [:interact, action, selector, payload, result_queue], timeout: Float(timeout))
  raise Plushie::Error, format_interact_timeout(action, selector) if enqueued.nil?

  result = result_queue.pop(timeout: Float(timeout))
  raise Plushie::Error, format_interact_timeout(action, selector) if result.nil?
  raise Plushie::Error, result[:error] if result.is_a?(Hash) && result[:error]

  result.is_a?(Hash) ? result.fetch(:events, []) : []
end

#next_event_messageObject

Returns:

  • (Object)


429
430
431
432
433
434
435
# File 'lib/plushie/runtime.rb', line 429

def next_event_message
  return shift_pending_runtime_event if pending_runtime_event_ready?

  msg = @event_queue.pop || :shutdown
  advance_pending_runtime_events if msg != :shutdown
  msg
end

#normalize_view_tree(view_tree) ⇒ Node

Parameters:

Returns:



699
700
701
702
703
704
# File 'lib/plushie/runtime.rb', line 699

def normalize_view_tree(view_tree)
  UI::MemoCache.seed(@memo_cache)
  result = Tree.normalize_view(view_tree, registry: @canvas_widgets)
  @memo_cache = UI::MemoCache.capture
  result
end

#notify_await_async(tag)

This method returns an undefined value.

-- Await async notification --------------------------------------------

Parameters:

  • tag (Symbol)


1030
1031
1032
1033
# File 'lib/plushie/runtime.rb', line 1030

def notify_await_async(tag)
  ack_queue = @pending_await_async.delete(tag)
  ack_queue&.push(:ok)
end

#pending_runtime_event_ready?Boolean

Returns:

  • (Boolean)


437
438
439
440
441
442
# File 'lib/plushie/runtime.rb', line 437

def pending_runtime_event_ready?
  entry = @pending_runtime_events.first
  return false unless entry

  entry[:remaining] <= 0 || @event_queue.empty?
end

#register_effect_stub(kind, response, timeout: 5) ⇒ :ok

Register an effect stub with the renderer. Blocks until the renderer confirms the stub is stored.

Parameters:

  • kind (String)

    effect kind (e.g. "clipboard_read")

  • response (Object)

    the canned response to return

  • timeout (Numeric) (defaults to: 5)

    max wait in seconds

  • timeout: (Numeric) (defaults to: 5)

Returns:

  • (:ok)

Raises:



153
154
155
156
157
158
159
160
161
162
# File 'lib/plushie/runtime.rb', line 153

def register_effect_stub(kind, response, timeout: 5)
  ack_queue = Thread::Queue.new
  enqueued = BoundedQueue.push(@event_queue, [:register_effect_stub, kind, response, ack_queue], timeout: Float(timeout))
  raise Plushie::Error, "effect stub registration timed out for #{kind}" if enqueued.nil?

  result = ack_queue.pop(timeout: Float(timeout))
  raise Plushie::Error, "effect stub registration timed out for #{kind}" if result.nil?

  :ok
end

#render_and_patch

This method returns an undefined value.



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
# File 'lib/plushie/runtime.rb', line 667

def render_and_patch
  Thread.current[:_plushie_canvas_counter] = 0
  new_tree = normalize_view_tree(@app.view(@model))
  canvas_widgets = CanvasWidget.derive_registry(new_tree) if new_tree

  if @previous_tree.nil?
    _new_windows, window_ops = Windows.plan_sync(self, new_tree, nil, @tracked_windows)

    @tracked_windows, = Windows.apply_ops(self, window_ops, @tracked_windows)
    wire = Tree.node_to_wire(new_tree)
    encoded = Protocol::Encode.encode_snapshot(wire, @format)
    bridge = @bridge or raise Plushie::Error, "bridge not started"
    bridge.send_encoded(encoded)
  else
    _new_windows, window_ops = Windows.plan_sync(self, new_tree, @previous_tree, @tracked_windows)

    ops = Tree.diff(@previous_tree, new_tree)

    @tracked_windows, = Windows.apply_ops(self, window_ops, @tracked_windows)
    unless ops.empty?
      bridge = @bridge or raise Plushie::Error, "bridge not started"
      bridge.send_encoded(Protocol::Encode.encode_patch(ops, @format))
    end
  end

  @previous_tree = new_tree
  @canvas_widgets = canvas_widgets if new_tree
  @consecutive_view_errors = 0
rescue => e
  handle_view_error(e)
end

#render_and_snapshot(window_ops_order: :before_snapshot)

This method returns an undefined value.

Rendering

Parameters:

  • window_ops_order: (:before_snapshot, :defer_window_ops) (defaults to: :before_snapshot)


635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
# File 'lib/plushie/runtime.rb', line 635

def render_and_snapshot(window_ops_order: :before_snapshot)
  committed = false
  window_ops_accepted = false
  Thread.current[:_plushie_canvas_counter] = 0
  tree = normalize_view_tree(@app.view(@model))
  canvas_widgets = CanvasWidget.derive_registry(tree) if tree

  tree or raise Plushie::Error, "missing normalized view tree"
  _new_windows, window_ops = Windows.plan_sync(self, tree, nil, @tracked_windows)

  if window_ops_order == :before_snapshot
    @tracked_windows, window_ops_accepted = Windows.apply_ops(self, window_ops, @tracked_windows)
  end

  wire = Tree.node_to_wire(tree)
  encoded = Protocol::Encode.encode_snapshot(wire, @format)
  bridge = @bridge or raise Plushie::Error, "bridge not started"
  bridge.send_encoded(encoded)
  @previous_tree = tree
  committed = true
  @canvas_widgets = canvas_widgets if tree

  if window_ops_order == :defer_window_ops
    @deferred_interact_window_ops = window_ops
  end

  @consecutive_view_errors = 0
rescue => e
  handle_view_error(e)
  resend_last_snapshot unless committed || window_ops_accepted
end

#renderer_log_level(level) ⇒ Symbol

Parameters:

  • level (Object)

Returns:

  • (Symbol)


309
310
311
312
313
# File 'lib/plushie/runtime.rb', line 309

def renderer_log_level(level)
  return :error if level.equal?(DEFAULT_LOG_LEVEL)

  level
end

#rerender_after_widget_state_change(widgets_before)

This method returns an undefined value.

Re-render after a widget's handle_event returned ... without emitting an event. The widget state changed but the app's update was never called, so we need to re-render to pick up any view changes driven by the new widget state.

widgets_before is the registry before the event was dispatched. On view error we revert to this to prevent state-tree desync.

Parameters:



1020
1021
1022
1023
1024
1025
1026
# File 'lib/plushie/runtime.rb', line 1020

def rerender_after_widget_state_change(widgets_before)
  render_and_patch
  sync_subscriptions
rescue => e
  @canvas_widgets = widgets_before
  handle_view_error(e)
end

#resend_last_snapshot

This method returns an undefined value.

Re-send the last known snapshot. Used as a fallback when view fails during interact_step: the renderer expects a snapshot response and will hang without one.



709
710
711
712
713
714
715
716
717
718
# File 'lib/plushie/runtime.rb', line 709

def resend_last_snapshot
  tree = @previous_tree
  bridge = @bridge
  return unless tree && bridge

  wire = Tree.node_to_wire(tree)
  bridge.send_encoded(Protocol::Encode.encode_snapshot(wire, @format))
rescue => e
  @logger.error("plushie: failed to resend fallback snapshot: #{e.class}: #{e.message}")
end

#reset_renderer_subscriptions

This method returns an undefined value.

Clear renderer-side subscriptions so sync_subscriptions sees them as new and re-sends subscribe messages to the fresh renderer. Timer subscriptions are kept alive: they run locally.



1226
1227
1228
1229
1230
1231
1232
# File 'lib/plushie/runtime.rb', line 1226

def reset_renderer_subscriptions
  renderer_keys = @subscriptions.each_with_object([]) do |(key, entry), keys|
    keys << key if entry[:sub_type] == :renderer
  end
  renderer_keys.each { |key| @subscriptions.delete(key) }
  @subscription_keys = @subscriptions.keys.sort_by(&:to_s)
end

#run

This method returns an undefined value.

Run the event loop in the calling thread (blocking).



109
110
111
112
113
114
115
116
117
118
# File 'lib/plushie/runtime.rb', line 109

def run
  @runtime_thread = Thread.current
  start_bridge
  start_dev_server if @dev
  initialize_app
  event_loop
ensure
  shutdown
  @runtime_thread = nil
end

#safe_class_name(value) ⇒ String

Parameters:

  • value (Object)

Returns:

  • (String)


1268
1269
1270
# File 'lib/plushie/runtime.rb', line 1268

def safe_class_name(value)
  Object.instance_method(:class).bind_call(value).name || "anonymous"
end

#sdk_log_level(level) ⇒ Integer

Parameters:

  • level (Object)

Returns:

  • (Integer)


303
304
305
306
307
# File 'lib/plushie/runtime.rb', line 303

def sdk_log_level(level)
  return Logger::WARN if level.equal?(DEFAULT_LOG_LEVEL)

  SDK_LOG_LEVELS.fetch(level, Logger::ERROR)
end

#send_advance_frame(timestamp) Originally defined in module Commands

This method returns an undefined value.

Advance the animation clock.

Parameters:

  • timestamp (Integer)

#send_command(payload) Originally defined in module Commands

This method returns an undefined value.

Send a single widget-targeted command via the unified wire format.

Parameters:

  • payload (Hash[Symbol, untyped])

#send_commands(commands) Originally defined in module Commands

This method returns an undefined value.

Send batched widget-targeted commands.

Parameters:

  • commands (Array[Hash[Symbol, untyped]])

#send_image_op(payload) Originally defined in module Commands

This method returns an undefined value.

Send an image operation.

Parameters:

  • payload (Hash[Symbol, untyped])

#send_load_font(family, data) Originally defined in module Commands

This method returns an undefined value.

Send a typed load_font message to the renderer.

Parameters:

  • family (String)
  • data (String)

#send_settings

This method returns an undefined value.

Resync helpers



1183
1184
1185
1186
# File 'lib/plushie/runtime.rb', line 1183

def send_settings
  bridge = @bridge or return
  bridge.send_encoded(Protocol::Encode.encode_settings(build_settings, @format))
end

#send_system_op(payload) Originally defined in module Commands

This method returns an undefined value.

Send a system-level operation.

Parameters:

  • payload (Hash[Symbol, untyped])

#send_system_query(payload) Originally defined in module Commands

This method returns an undefined value.

Send a system-level query.

Parameters:

  • payload (Hash[Symbol, untyped])

#send_widget_op(op, payload) Originally defined in module Commands

This method returns an undefined value.

Send a widget operation to the renderer.

Parameters:

  • op (Symbol)
  • payload (Hash[Symbol, untyped])

#send_window_op(payload) Originally defined in module Commands

This method returns an undefined value.

Send a window operation to the renderer.

Parameters:

  • payload (Hash[Symbol, untyped])

#send_window_query(payload) Originally defined in module Commands

This method returns an undefined value.

Send a window query (response arrives as effect_response or op_query_response).

Parameters:

  • payload (Hash[Symbol, untyped])

#shift_pending_runtime_eventObject

Returns:

  • (Object)


444
445
446
# File 'lib/plushie/runtime.rb', line 444

def shift_pending_runtime_event
  @pending_runtime_events.shift.fetch(:message)
end

#shutdown

This method returns an undefined value.

Shutdown



1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
# File 'lib/plushie/runtime.rb', line 1274

def shutdown
  @dev_server&.stop
  @bridge&.stop
  @pending_interact&.dig(:timeout_timer)&.kill
  @async_tasks.each_value do |entry|
    entry[:thread]&.kill
    entry[:thread]&.join(0.5)
  end
  @async_tasks.clear
  flush_pending_effects_on_exit(
    Event::Effect::Result::Cancelled.new,
    execute_returned_commands: false
  )
  @pending_coalesce.clear
  @coalesce_order = []
  @pending_timers.each_value do |entry|
    entry[:thread]&.kill
    entry[:thread]&.join(0.5)
  end
  @pending_timers.clear
  @timer_scheduler.stop
  @subscriptions.clear
  # Flush pending stub acks so callers don't hang
  @pending_stub_acks.each_value { |q| q.push(:ok) }
  @pending_stub_acks.clear
  # Flush pending await_async so callers don't hang
  @pending_await_async.each_value { |q| q.push(:ok) }
  @pending_await_async.clear
  # Flush pending interact so callers don't hang
  fail_pending_interact("runtime_shutdown")
end

#startRuntime

Start the event loop in a background thread.

Returns:



122
123
124
125
126
127
# File 'lib/plushie/runtime.rb', line 122

def start
  thread = Thread.new { run }
  thread.name = "plushie-runtime"
  @loop_thread = thread
  self
end

#start_bridge

This method returns an undefined value.

-- Lifecycle -----------------------------------------------------------



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/plushie/runtime.rb', line 266

def start_bridge
  @bridge = if @log_level_explicit
    Bridge.new(
      event_queue: @event_queue,
      format: @format,
      binary: @binary,
      transport: @transport,
      log_level: @log_level,
      token: @token
    )
  else
    Bridge.new(
      event_queue: @event_queue,
      format: @format,
      binary: @binary,
      transport: @transport,
      token: @token
    )
  end
  bridge = @bridge or raise Plushie::Error, "bridge not started"
  bridge.start(settings: build_settings)
end

#start_dev_server

This method returns an undefined value.

Lifecycle



78
79
80
81
82
# File 'sig/plushie/runtime.rbs', line 78

def start_dev_server
  server = DevServer.new(event_queue: @event_queue, dirs: @dev_dirs)
  server.start
  @dev_server = server
end

#start_renderer_subscription(spec) ⇒ Hash[Symbol, untyped] Originally defined in module Subscriptions

Start a renderer subscription (send subscribe message to bridge).

Parameters:

Returns:

  • (Hash[Symbol, untyped])

#start_subscription(spec) ⇒ Hash[Symbol, untyped] Originally defined in module Subscriptions

Start a new subscription (timer or renderer).

Parameters:

Returns:

  • (Hash[Symbol, untyped])

#start_timer_subscription(spec) ⇒ Hash[Symbol, untyped] Originally defined in module Subscriptions

Start a timer subscription (runs locally, pushes to event queue).

Parameters:

Returns:

  • (Hash[Symbol, untyped])

#stop

This method returns an undefined value.

Stop a background runtime.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/plushie/runtime.rb', line 130

def stop
  @running = false
  enqueued = BoundedQueue.push(@event_queue, :shutdown, timeout: 1)
  thread = @loop_thread
  return unless thread
  return if thread == Thread.current

  unless enqueued
    @event_queue.close if @event_queue.respond_to?(:close)
    stop_thread(thread, timeout: 5)
    return
  end

  joined = thread.join(5)
  stop_thread(thread, timeout: 1) if joined.nil?
end

#stop_subscription(key) Originally defined in module Subscriptions

This method returns an undefined value.

Stop a subscription by key.

Parameters:

  • key (Object)

#stop_thread(thread, timeout: 0.5) Originally defined in module Commands

This method returns an undefined value.

Parameters:

  • thread (Thread, nil)
  • timeout: (Numeric) (defaults to: 0.5)

#sync_subscriptions Originally defined in module Subscriptions

This method returns an undefined value.

Synchronize subscriptions with the app's current subscribe output. Called after each update cycle.

#unregister_effect_stub(kind, timeout: 5) ⇒ :ok

Remove a previously registered effect stub. Blocks until the renderer confirms the stub is removed.

Parameters:

  • kind (String)

    effect kind

  • timeout (Numeric) (defaults to: 5)

    max wait in seconds

  • timeout: (Numeric) (defaults to: 5)

Returns:

  • (:ok)

Raises:



169
170
171
172
173
174
175
176
177
178
# File 'lib/plushie/runtime.rb', line 169

def unregister_effect_stub(kind, timeout: 5)
  ack_queue = Thread::Queue.new
  enqueued = BoundedQueue.push(@event_queue, [:unregister_effect_stub, kind, ack_queue], timeout: Float(timeout))
  raise Plushie::Error, "effect stub unregistration timed out for #{kind}" if enqueued.nil?

  result = ack_queue.pop(timeout: Float(timeout))
  raise Plushie::Error, "effect stub unregistration timed out for #{kind}" if result.nil?

  :ok
end

#unwrap_result(result) ⇒ [untyped, Command::Cmd]

Result validation

Parameters:

  • result (Object)

Returns:



722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/plushie/runtime.rb', line 722

def unwrap_result(result)
  case result
  in [model, Command::Cmd => cmd]
    [model, cmd]
  in [model, Array => cmds] if cmds.all? { |c| c.is_a?(Command::Cmd) }
    [model, Command.batch(cmds)]
  else
    if result.is_a?(Array) && result.length == 2
      raise ArgumentError, <<~MSG.chomp
        Invalid return from update/init: second element must be a Command or Array of Commands.
        Got: [#{result[0].class}, #{result[1].class}]

        Valid return shapes:
          model                        # bare model, no commands
          [model, Command.task(...)]  # model + single command
          [model, [cmd1, cmd2]]        # model + command list
      MSG
    end

    [result, Command.none]
  end
end

#update_max_rates(new_by_key) Originally defined in module Subscriptions

This method returns an undefined value.

Update max_rate on existing renderer subscriptions if changed.

Parameters:

#validate_app!(app)

This method returns an undefined value.

Init validation

Parameters:

  • app (Object)


925
926
927
928
929
930
931
932
# File 'lib/plushie/runtime.rb', line 925

def validate_app!(app)
  missing = %i[init update view].reject { |m| app.respond_to?(m) }
  return if missing.empty?

  raise ArgumentError,
    "app must respond to #{missing.join(", ")}. " \
    "Include Plushie::App or define init/update/view methods."
end

#validate_transport!(transport)

This method returns an undefined value.

Parameters:

  • transport (Object)


934
935
936
937
938
939
940
941
942
943
# File 'lib/plushie/runtime.rb', line 934

def validate_transport!(transport)
  case transport
  when :spawn, :stdio then nil
  when Array
    raise ArgumentError, "unsupported transport: #{transport.inspect}" unless transport[0] == :iostream
  else
    raise ArgumentError,
      "unsupported transport: #{transport.inspect}. Expected :spawn, :stdio, or [:iostream, adapter]"
  end
end

#view_error?Boolean

Returns true if the most recent view/render call failed, meaning the tree is stale and does not reflect the current model state.

Returns:

  • (Boolean)


207
208
209
# File 'lib/plushie/runtime.rb', line 207

def view_error?
  @consecutive_view_errors > 0
end