Class: Echoes::EmbeddedShell

Inherits:
Object
  • Object
show all
Defined in:
lib/echoes/embedded_shell.rb

Overview

Drives a per-pane subprocess (‘embedded_shell_helper.rb`) that owns the pty as its controlling tty and runs a `Rubish::REPL` in its own session. Every external command rubish forks inherits the helper’s session/ctty, so Ctrl-C (ETX → SIGINT) works for any execution shape — single command, pipeline, loop, sequence, conditional.

The helper communicates with this class over a JSON-line control pipe. Standard rubish output (prompts, command stdout/stderr) flows through the pty in the usual way. The control pipe carries:

- synchronous queries: complete_at, prompt, prompt_segments,
  try_parse, tokenize, history, last_status, cwd, …
- async events from helper to host: command_done, …

API surface is identical to the previous in-process version so callers (Pane, etc.) don’t need to change.

Defined Under Namespace

Classes: TokenView

Constant Summary collapse

HELPER_SCRIPT =
File.expand_path('embedded_shell_helper.rb', __dir__)
DEFAULT_SYNC_TIMEOUT =
5.0
DONE_SENTINEL =

Mirror of EmbeddedShellHelper::DONE_SENTINEL — the helper writes this OSC sequence to the pty after a command’s last output bytes are flushed but before it sends the command_done event on the control pipe. The pty reader scans for it and strips it; the presence of the sentinel + the event together is what ‘reap_if_done` waits for.

"\e]7771\a"

Instance Method Summary collapse

Constructor Details

#initialize(no_rc: false) ⇒ EmbeddedShell

‘no_rc:` skips rubish’s startup-file loading (~/.rubishrc, /etc/bashrc, …) and history file restore in the helper. Tests pass true so the subprocess starts from a known-clean environment; production callers leave it false so the user’s config takes effect.



40
41
42
43
44
45
46
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/echoes/embedded_shell.rb', line 40

def initialize(no_rc: false)
  ENV['GIT_PAGER'] ||= 'cat'
  ENV['PAGER']     ||= 'cat'
  # Default $TERM so terminfo-aware tools (tput, less, vim, …)
  # have a profile to look up. CI runners launch the rake task
  # without TERM set; programs that read it bail with
  # "No value for $TERM and no -T specified".
  ENV['TERM']      ||= Echoes.config.term

  helper_env = {'ECHOES_HELPER_NO_RC' => no_rc ? '1' : nil}
  @master, slave = PTY.open
  # Bidirectional JSON-line control pipes. Helper reads requests on
  # fd 3 and writes responses/events on fd 4.
  ctl_in_r,  ctl_in_w  = IO.pipe   # parent → helper
  ctl_out_r, ctl_out_w = IO.pipe   # helper → parent
  @control_write = ctl_in_w
  @control_read  = ctl_out_r
  @control_write.sync = true

  @output_buffer  = +''
  @output_lock    = Mutex.new
  @pending_lock   = Mutex.new
  @pending        = {}            # id → Queue
  @running        = false
  @last_status    = 0
  @last_cwd       = Dir.pwd

  load_paths = $LOAD_PATH.flat_map { |p| ['-I', p] }
  @helper_pid = Process.spawn(
    helper_env, RbConfig.ruby, *load_paths, HELPER_SCRIPT,
    in: slave, out: slave, err: slave,
    3 => ctl_in_r, 4 => ctl_out_w,
    close_others: true,
  )
  slave.close
  ctl_in_r.close
  ctl_out_w.close

  @sentinel_seen = false
  @reader = Thread.new(@master) do |m|
    # Accumulator handles a sentinel that straddles two chunks.
    # After splitting on any complete sentinels, hold back ONLY the
    # bytes at the tail of `acc` that match a prefix of the sentinel
    # — anything else is safe to flush right away. Holding back
    # blindly (e.g. always the last N-1 bytes) corrupts the host's
    # parser when a long OSC sequence happens to end inside that
    # window: the trailing bytes get released much later, by which
    # time the parser has moved on and renders them as text.
    acc = +''
    begin
      loop do
        chunk = m.readpartial(4096)
        acc << chunk
        while (idx = acc.index(DONE_SENTINEL))
          before = acc[0...idx]
          @output_lock.synchronize { @output_buffer << before } unless before.empty?
          acc = acc[(idx + DONE_SENTINEL.bytesize)..] || +''
          @sentinel_seen = true
        end
        keep = pending_sentinel_prefix_len(acc)
        if acc.bytesize > keep
          flush_n = acc.bytesize - keep
          @output_lock.synchronize { @output_buffer << acc.byteslice(0, flush_n) }
          acc = acc.byteslice(flush_n, acc.bytesize - flush_n) || +''
        end
      end
    rescue EOFError, IOError, Errno::EIO
      # helper exited or was killed
    end
    @output_lock.synchronize { @output_buffer << acc } unless acc.empty?
  end

  @control_reader = Thread.new(@control_read) do |io|
    begin
      while (line = io.gets)
        handle_control_line(line)
      end
    rescue IOError, Errno::EIO
      # helper exited
    end
  end
end

Instance Method Details

#alive?Boolean

True until the helper subprocess has terminated (e.g. user typed ‘exit`). Reaps with WNOHANG so the call is non-blocking; once we observe the death we cache it so subsequent calls don’t ECHILD on an already-reaped pid.

Returns:

  • (Boolean)


159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/echoes/embedded_shell.rb', line 159

def alive?
  return false if @helper_dead
  return true unless @helper_pid
  pid, _ = Process.waitpid2(@helper_pid, Process::WNOHANG)
  if pid
    @helper_dead = true
    return false
  end
  true
rescue Errno::ECHILD
  @helper_dead = true
  false
end

#complete_at(line:, point:) ⇒ Object

—- queries to the helper (synchronous) —-



224
225
226
# File 'lib/echoes/embedded_shell.rb', line 224

def complete_at(line:, point:)
  rpc_sync('complete', line: line, point: point) || []
end

#continuation_promptObject



242
243
244
# File 'lib/echoes/embedded_shell.rb', line 242

def continuation_prompt
  rpc_sync('continuation_prompt') || '> '
end

#cwdObject



262
263
264
265
266
267
# File 'lib/echoes/embedded_shell.rb', line 262

def cwd
  # Cached from command_done events; only refresh on demand if
  # nothing has been run yet.
  @last_cwd ||= rpc_sync('cwd')
  @last_cwd
end

#forward_input(bytes) ⇒ Object

Write keystrokes to the pty master. The kernel’s line discipline routes them to whatever is reading on the slave (the running command’s stdin).



186
187
188
189
# File 'lib/echoes/embedded_shell.rb', line 186

def forward_input(bytes)
  @master.write(bytes)
rescue IOError, Errno::EIO, Errno::EPIPE
end

#historyObject



254
255
256
# File 'lib/echoes/embedded_shell.rb', line 254

def history
  rpc_sync('history') || []
end

#interruptObject

ETX. The line discipline converts to SIGINT delivered to the foreground process group of the slave’s controlling session —which is the helper’s session, so any rubish-forked child receives it.



195
196
197
# File 'lib/echoes/embedded_shell.rb', line 195

def interrupt
  @master.write("\x03") rescue nil
end

#last_statusObject



258
259
260
# File 'lib/echoes/embedded_shell.rb', line 258

def last_status
  @last_status
end

#promptObject



228
229
230
# File 'lib/echoes/embedded_shell.rb', line 228

def prompt
  rpc_sync('prompt') || ''
end

#prompt_segmentsObject



232
233
234
# File 'lib/echoes/embedded_shell.rb', line 232

def prompt_segments
  (rpc_sync('prompt_segments') || []).map { |s| symbolize_segment(s) }
end

#read_available_outputObject

Drain any output bytes the pty reader has buffered. Empty String if none. Never blocks.



175
176
177
178
179
180
181
# File 'lib/echoes/embedded_shell.rb', line 175

def read_available_output
  @output_lock.synchronize do
    data = @output_buffer.dup
    @output_buffer.clear
    data
  end
end

#reap_if_doneObject

Compatibility method the host calls each tick. With the helper architecture the pty/ctty live for the pane’s lifetime, but the host still needs a one-shot “command finished” signal so it can emit the next prompt. Returns true on the tick where BOTH the control_out command_done event has arrived AND the pty sentinel has been observed by the reader thread (so the caller’s next ‘read_available_output` is guaranteed to have drained the command’s last bytes).



215
216
217
218
219
220
# File 'lib/echoes/embedded_shell.rb', line 215

def reap_if_done
  return false unless @done_pending && @sentinel_seen
  @done_pending = false
  @sentinel_seen = false
  true
end

#resize(rows:, cols:, px_width: 0, px_height: 0) ⇒ Object

Updates the pty master’s winsize. The kernel propagates to the slave (which is the helper’s ctty) and emits SIGWINCH to the foreground process group, so vim/less/etc. repaint.



202
203
204
205
# File 'lib/echoes/embedded_shell.rb', line 202

def resize(rows:, cols:, px_width: 0, px_height: 0)
  @master.winsize = [rows, cols, px_width.to_i, px_height.to_i]
rescue IOError, Errno::EIO
end

#right_prompt_segmentsObject

Right-aligned prompt (rubish’s RPROMPT). Returns an Array of segments — possibly empty — same shape as ‘prompt_segments`.



238
239
240
# File 'lib/echoes/embedded_shell.rb', line 238

def right_prompt_segments
  (rpc_sync('right_prompt_segments') || []).map { |s| symbolize_segment(s) }
end

#running?Boolean

Returns:

  • (Boolean)


151
152
153
# File 'lib/echoes/embedded_shell.rb', line 151

def running?
  @running
end

#shutdownObject

Returns when the helper has exited and pipes are closed. For tests / shutdown.



271
272
273
274
275
276
277
278
279
# File 'lib/echoes/embedded_shell.rb', line 271

def shutdown
  rpc_async('shutdown') rescue nil
  Process.waitpid(@helper_pid) rescue nil
  @master.close rescue nil
  @control_write.close rescue nil
  @control_read.close rescue nil
  @reader.join(1) rescue nil
  @control_reader.join(1) rescue nil
end

#submit_and_wait(line, rows: 24, cols: 80, timeout: 30) ⇒ Object

Synchronous wrapper for scripts and tests that don’t have a dispatch loop. Submits and blocks until the command completes (or ‘timeout` seconds pass — in which case ETX is sent).



137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/echoes/embedded_shell.rb', line 137

def submit_and_wait(line, rows: 24, cols: 80, timeout: 30)
  submit_line(line, rows: rows, cols: cols)
  deadline = Time.now + timeout
  while running?
    if Time.now > deadline
      interrupt
      break
    end
    sleep 0.01
  end
  reap_if_done
  nil
end

#submit_line(line, rows: 24, cols: 80, px_width: 0, px_height: 0) ⇒ Object

Submit a complete line of input. Returns immediately; output flows through the pty asynchronously and ‘command_done` will set by the helper before execution.



127
128
129
130
131
132
# File 'lib/echoes/embedded_shell.rb', line 127

def submit_line(line, rows: 24, cols: 80, px_width: 0, px_height: 0)
  return if running?
  @master.winsize = [rows, cols, px_width.to_i, px_height.to_i] rescue nil
  @running = true
  rpc_async('execute', line: line)
end

#tokenize(line) ⇒ Object



250
251
252
# File 'lib/echoes/embedded_shell.rb', line 250

def tokenize(line)
  (rpc_sync('tokenize', line: line) || []).map { |t| TokenView.new(t['type'].to_sym, t['value']) }
end

#try_parse(line) ⇒ Object



246
247
248
# File 'lib/echoes/embedded_shell.rb', line 246

def try_parse(line)
  (rpc_sync('try_parse', line: line) || 'error').to_sym
end