Class: Echoes::EmbeddedShell
- Inherits:
-
Object
- Object
- Echoes::EmbeddedShell
- 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.('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
-
#alive? ⇒ Boolean
True until the helper subprocess has terminated (e.g. user typed ‘exit`).
-
#complete_at(line:, point:) ⇒ Object
—- queries to the helper (synchronous) —-.
- #continuation_prompt ⇒ Object
- #cwd ⇒ Object
-
#forward_input(bytes) ⇒ Object
Write keystrokes to the pty master.
- #history ⇒ Object
-
#initialize(no_rc: false) ⇒ EmbeddedShell
constructor
‘no_rc:` skips rubish’s startup-file loading (~/.rubishrc, /etc/bashrc, …) and history file restore in the helper.
-
#interrupt ⇒ Object
ETX.
- #last_status ⇒ Object
- #prompt ⇒ Object
- #prompt_segments ⇒ Object
-
#read_available_output ⇒ Object
Drain any output bytes the pty reader has buffered.
-
#reap_if_done ⇒ Object
Compatibility method the host calls each tick.
-
#resize(rows:, cols:) ⇒ Object
Updates the pty master’s winsize.
-
#right_prompt_segments ⇒ Object
Right-aligned prompt (rubish’s RPROMPT).
- #running? ⇒ Boolean
-
#shutdown ⇒ Object
Returns when the helper has exited and pipes are closed.
-
#submit_and_wait(line, rows: 24, cols: 80, timeout: 30) ⇒ Object
Synchronous wrapper for scripts and tests that don’t have a dispatch loop.
-
#submit_line(line, rows: 24, cols: 80) ⇒ Object
Submit a complete line of input.
- #tokenize(line) ⇒ Object
- #try_parse(line) ⇒ Object
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 |
# File 'lib/echoes/embedded_shell.rb', line 40 def initialize(no_rc: false) ENV['GIT_PAGER'] ||= 'cat' ENV['PAGER'] ||= 'cat' 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.
154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/echoes/embedded_shell.rb', line 154 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) —-
219 220 221 |
# File 'lib/echoes/embedded_shell.rb', line 219 def complete_at(line:, point:) rpc_sync('complete', line: line, point: point) || [] end |
#continuation_prompt ⇒ Object
237 238 239 |
# File 'lib/echoes/embedded_shell.rb', line 237 def continuation_prompt rpc_sync('continuation_prompt') || '> ' end |
#cwd ⇒ Object
257 258 259 260 261 262 |
# File 'lib/echoes/embedded_shell.rb', line 257 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).
181 182 183 184 |
# File 'lib/echoes/embedded_shell.rb', line 181 def forward_input(bytes) @master.write(bytes) rescue IOError, Errno::EIO, Errno::EPIPE end |
#history ⇒ Object
249 250 251 |
# File 'lib/echoes/embedded_shell.rb', line 249 def history rpc_sync('history') || [] end |
#interrupt ⇒ Object
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.
190 191 192 |
# File 'lib/echoes/embedded_shell.rb', line 190 def interrupt @master.write("\x03") rescue nil end |
#last_status ⇒ Object
253 254 255 |
# File 'lib/echoes/embedded_shell.rb', line 253 def last_status @last_status end |
#prompt ⇒ Object
223 224 225 |
# File 'lib/echoes/embedded_shell.rb', line 223 def prompt rpc_sync('prompt') || '' end |
#prompt_segments ⇒ Object
227 228 229 |
# File 'lib/echoes/embedded_shell.rb', line 227 def prompt_segments (rpc_sync('prompt_segments') || []).map { |s| symbolize_segment(s) } end |
#read_available_output ⇒ Object
Drain any output bytes the pty reader has buffered. Empty String if none. Never blocks.
170 171 172 173 174 175 176 |
# File 'lib/echoes/embedded_shell.rb', line 170 def read_available_output @output_lock.synchronize do data = @output_buffer.dup @output_buffer.clear data end end |
#reap_if_done ⇒ Object
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).
210 211 212 213 214 215 |
# File 'lib/echoes/embedded_shell.rb', line 210 def reap_if_done return false unless @done_pending && @sentinel_seen @done_pending = false @sentinel_seen = false true end |
#resize(rows:, cols:) ⇒ 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.
197 198 199 200 |
# File 'lib/echoes/embedded_shell.rb', line 197 def resize(rows:, cols:) @master.winsize = [rows, cols] rescue IOError, Errno::EIO end |
#right_prompt_segments ⇒ Object
Right-aligned prompt (rubish’s RPROMPT). Returns an Array of segments — possibly empty — same shape as ‘prompt_segments`.
233 234 235 |
# File 'lib/echoes/embedded_shell.rb', line 233 def right_prompt_segments (rpc_sync('right_prompt_segments') || []).map { |s| symbolize_segment(s) } end |
#running? ⇒ Boolean
146 147 148 |
# File 'lib/echoes/embedded_shell.rb', line 146 def running? @running end |
#shutdown ⇒ Object
Returns when the helper has exited and pipes are closed. For tests / shutdown.
266 267 268 269 270 271 272 273 274 |
# File 'lib/echoes/embedded_shell.rb', line 266 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).
132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/echoes/embedded_shell.rb', line 132 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) ⇒ 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.
122 123 124 125 126 127 |
# File 'lib/echoes/embedded_shell.rb', line 122 def submit_line(line, rows: 24, cols: 80) return if running? @master.winsize = [rows, cols] rescue nil @running = true rpc_async('execute', line: line) end |
#tokenize(line) ⇒ Object
245 246 247 |
# File 'lib/echoes/embedded_shell.rb', line 245 def tokenize(line) (rpc_sync('tokenize', line: line) || []).map { |t| TokenView.new(t['type'].to_sym, t['value']) } end |
#try_parse(line) ⇒ Object
241 242 243 |
# File 'lib/echoes/embedded_shell.rb', line 241 def try_parse(line) (rpc_sync('try_parse', line: line) || 'error').to_sym end |