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
# 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.

Returns:

  • (Boolean)


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_promptObject



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

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

#cwdObject



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

#historyObject



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

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.



190
191
192
# File 'lib/echoes/embedded_shell.rb', line 190

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

#last_statusObject



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

def last_status
  @last_status
end

#promptObject



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

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

#prompt_segmentsObject



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_outputObject

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_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).



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_segmentsObject

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

Returns:

  • (Boolean)


146
147
148
# File 'lib/echoes/embedded_shell.rb', line 146

def running?
  @running
end

#shutdownObject

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