terret-exec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Terret
6
+ module Exec
7
+ # There is no shell to run anything in: bash could not be spawned, or it
8
+ # started and never answered its startup handshake. Raised rather than
9
+ # returned, because every other outcome on this seam describes a command
10
+ # that actually ran, and this one is the absence of the thing that runs
11
+ # them.
12
+ ShellUnavailable = Class.new(Terret::Tools::Failure)
13
+
14
+ # A second command arrived for a session that is still running one.
15
+ # Refused rather than queued: one bash per key can only run one thing at a
16
+ # time, and a queue would either make the waiting caller's `timeout:` a lie
17
+ # (its clock would start before its command did) or need a scheduler this
18
+ # seam has no business owning. The guarantee worth keeping is narrow and
19
+ # absolute — the result a caller gets back is that caller's command's
20
+ # result — and a refusal keeps it without inventing machinery.
21
+ ShellBusy = Class.new(Terret::Tools::Failure)
22
+
23
+ # ctx[:shell] — one persistent bash per session key (plan §6.6;
24
+ # docs/exec.md §2). The whole reason this seam exists next to
25
+ # ctx[:subprocess]'s one-shot #spawn is that the same process serves every
26
+ # call for a key, so `cd` and `export` from one run are visible to the
27
+ # next, exactly as a human's terminal session behaves.
28
+ #
29
+ # The protocol: write the command on its own line, then a `printf` line
30
+ # that emits a per-session random sentinel followed by `$?`, and read
31
+ # until that marker. The sentinel is generated here and never exported, so
32
+ # a command cannot forge one; the marker regexp additionally demands
33
+ # digits and a newline immediately after it, which is what makes the parse
34
+ # exact rather than lucky — the only other place the sentinel can appear
35
+ # in the stream is a terminal echo of the request line, where `%s` follows
36
+ # it instead of a status.
37
+ #
38
+ # There is deliberately no cap on the number of sessions, where
39
+ # ctx[:terminals] caps names hard. The difference is who supplies the key:
40
+ # a session key is an agent id the harness hands in, so the count is
41
+ # bounded by the agents the harness chose to run, while a terminal name
42
+ # comes from a tool call the model wrote and is therefore something a model
43
+ # can mint without limit. Capping the harness against itself would only
44
+ # move the failure somewhere less honest.
45
+ class Shell < Hames::Service
46
+ service_key :shell
47
+ inject :subprocess
48
+ config_schema timeout: { type: Numeric, default: 120, doc: "seconds a shell command may run" },
49
+ max_output: { type: Integer, default: 1 << 20,
50
+ doc: "bytes of command output retained before truncation" },
51
+ cwd: { type: String, doc: "working directory for the shell (default: Dir.pwd)" },
52
+ env: { type: Hash, default: {}, doc: "environment overlay for the shell" }
53
+
54
+ # `status` is nil when the command did not report one — it was
55
+ # interrupted, or the shell ended underneath it. An exit code we do not
56
+ # have is not invented. `stdout` is the terminal's stream: a pty has one,
57
+ # so a command's stderr arrives interleaved here rather than separately.
58
+ # `notice` is nil unless something happened that the caller did not ask
59
+ # for (a restart, a truncation); it is a field rather than text appended
60
+ # to stdout so that stdout stays exactly what the terminal carried.
61
+ #
62
+ # "Exactly" is worth pinning down: no echo of the request, no prompt, no
63
+ # separator this file injected. It does not mean only the command's own
64
+ # bytes — the shell's own lines (`[1] 1234` when a job is backgrounded, a
65
+ # syntax error it complains about) are written to the same terminal at
66
+ # the same time, and reporting them is more honest than guessing which
67
+ # lines a caller did not mean to ask for.
68
+ Result = Data.define(:status, :stdout, :notice)
69
+
70
+ Session = Data.define(:handle, :sentinel, :marker)
71
+
72
+ DEFAULT_SESSION = :default
73
+ DEFAULT_TIMEOUT = 120
74
+ HANDSHAKE_TIMEOUT = 10
75
+ CHUNK = 64 * 1024
76
+
77
+ # What one run may buffer. A command that writes without pause fills this
78
+ # process's memory at whatever rate the terminal will carry — measured
79
+ # here at 11.4MB in three seconds, so the default 120s timeout puts
80
+ # roughly 450MB within reach of a single `yes`. One mebibyte is far more
81
+ # than a model can read (the Bash tool caps what it shows at a fraction
82
+ # of it) and far less than a runaway command needs to hurt the host; the
83
+ # limit is a memory bound, not a display decision, which is why the tool
84
+ # layer still applies its own smaller one.
85
+ DEFAULT_MAX_OUTPUT = 1 << 20
86
+
87
+ # Once the cap is reached the tail of the stream is still scanned for the
88
+ # marker, in a window this size. It only has to be longer than a marker
89
+ # (a 38-byte sentinel, a status, a newline) for a marker split across two
90
+ # reads to survive the trim.
91
+ MARKER_WINDOW = 256
92
+
93
+ # UTF-8 lead bytes and the character length each one declares: [mask,
94
+ # value, bytes]. Read as "if (byte & mask) == value, the character is
95
+ # `bytes` long".
96
+ CHARACTER_LENGTHS = [
97
+ [0x80, 0x00, 1],
98
+ [0xE0, 0xC0, 2],
99
+ [0xF0, 0xE0, 3],
100
+ [0xF8, 0xF0, 4]
101
+ ].freeze
102
+
103
+ # A session that is not reused is never left half-drained, so the budget
104
+ # only bounds the pathological case: a background job spewing without
105
+ # pause between two runs. When it does lose that race the remainder is
106
+ # not lost, it simply arrives inside the next run's output — the same
107
+ # thing a human sees when a background job prints over their next
108
+ # command, and a better answer than reading a spewing terminal forever.
109
+ DRAIN_BUDGET = 0.1
110
+
111
+ # How long a session gets to leave on its own before the handle's reaper
112
+ # takes over. Bounded, because a shell still busy with a command will not
113
+ # read the request at all — and in exactly that case disposal costs this
114
+ # budget plus the reaper's own grace before the SIGKILL lands (measured:
115
+ # a close that would take 3s takes 6.01s when a child is still writing),
116
+ # because bash never gets to the `exit`.
117
+ FAREWELL_BUDGET = 1.0
118
+
119
+ # ETX — what a human's ^C is on the wire. The line discipline turns it
120
+ # into SIGINT for the terminal's foreground process group; with job
121
+ # control off (see HANDSHAKE) that group is the session's own, so the
122
+ # signal reaches the command's children as well as bash. It is the
123
+ # gentler half of ending a run: #sweep is what guarantees the rest.
124
+ INTERRUPT = "\u0003"
125
+
126
+ # `--noediting` is load-bearing, not tidiness: on a pty bash is
127
+ # interactive, and readline echoes the line it is reading no matter what
128
+ # the terminal's own echo flag says. Disabling line editing puts the
129
+ # echo back under the terminal's control, where the handshake's `stty
130
+ # -echo` can turn it off. `--norc --noprofile` keep a developer's dotfiles
131
+ # from deciding what an agent's shell prints.
132
+ BASH_ARGV = ["bash", "--norc", "--noprofile", "--noediting", "-s"].freeze
133
+
134
+ # Run once per session before any command. `-echo` so the request lines
135
+ # do not come back as output; `-onlcr` so the terminal stops rewriting
136
+ # the child's newlines as CR-LF; `-icanon min 1 time 0` because a
137
+ # canonical-mode terminal caps one input line at MAX_CANON (1024 bytes on
138
+ # macOS) and would silently lose the tail of a longer command; `-ixon` so
139
+ # a stray ^S in a command cannot wedge the stream. Failure is tolerated
140
+ # (`2>/dev/null`): under a sandbox whose exec has no tty there is nothing
141
+ # to configure, and bash is then non-interactive, which needs none of it.
142
+ #
143
+ # `set +m` turns job control off, and that is a disposal decision rather
144
+ # than a cosmetic one. With job control on, bash puts every job in a
145
+ # process group of its own, so a `&` job's group id is one nothing here
146
+ # ever learns — and a background job that outlives its session is a
147
+ # process holding the agent's authority that no part of the harness can
148
+ # still name. Without job control every child stays in the session's own
149
+ # process group, which #sweep can end as a unit. The trade-off, stated
150
+ # rather than discovered: the session has no `fg`, `bg`, or `%1`. What it
151
+ # does NOT buy is a quieter terminal — bash still prints its "[1] 1234"
152
+ # notice when a job is backgrounded (measured, not assumed), and that
153
+ # line lands in the run's output like anything else the shell says.
154
+ HANDSHAKE = "stty -echo -onlcr -icanon -ixon min 1 time 0 2>/dev/null; set +m; PS1=; PS2="
155
+
156
+ def start(ctx)
157
+ @ctx = ctx
158
+ @sessions = {} # key (String) => Session
159
+ @running = {} # key (String) => true while a command is in flight
160
+ # When the agent that owns a session key is disposed, its bash (and the
161
+ # background jobs in its process group) must go with it — fork disposal
162
+ # never reaches this root-mounted process. Registered via ctx.on, so it
163
+ # reverses when this service unloads.
164
+ ctx.on("agent/disposed") { |session_id| close(session: session_id) }
165
+ end
166
+
167
+ # The loader calls this on unload. A persistent shell is a process the
168
+ # harness owns, so dropping the reference without reaping it would leak a
169
+ # bash per agent for the life of the host process.
170
+ def stop(_ctx) = close_all
171
+
172
+ # Every knob is read where it is used, so a hot config swap needs nothing
173
+ # re-derived here. A live bash keeps the cwd and environment it was
174
+ # spawned with — it is a running process, not a value — and the new
175
+ # settings govern the next session.
176
+ def reconfigure(_config); end
177
+
178
+ # Runs one command in this key's session, spawning the session on first
179
+ # use. Never raises for a command's own failure: a non-zero status is a
180
+ # Result like any other, because a command that failed still ran.
181
+ def run(cmd, session: DEFAULT_SESSION, timeout: nil)
182
+ key = session.to_s
183
+ raise ShellBusy, "the #{key} shell session is already running a command" if @running[key]
184
+
185
+ @running[key] = true
186
+ begin
187
+ run!(key, cmd, timeout || default_timeout)
188
+ ensure
189
+ # released even when the run raised, so a session is never left
190
+ # permanently unusable by a failure it already reported
191
+ @running.delete(key)
192
+ end
193
+ end
194
+
195
+ # The pid of this key's live bash, or nil if it has never run anything.
196
+ # An owner (and a test) can see whether a session is real without asking
197
+ # it to run something.
198
+ def pid(session: DEFAULT_SESSION) = @sessions[session.to_s]&.handle&.pid
199
+
200
+ # Reaps one session's bash. Closing a key that has none is not an error:
201
+ # disposal must be safe to call over a set of keys that may or may not
202
+ # have run anything.
203
+ def close(session: DEFAULT_SESSION) = discard(session.to_s)
204
+
205
+ def close_all = @sessions.keys.each { |key| discard(key) }
206
+
207
+ private
208
+
209
+ def run!(key, cmd, timeout)
210
+ notices = []
211
+ if stale?(key)
212
+ discard(key)
213
+ notices << "the shell session had exited; a fresh one was started, " \
214
+ "so the cwd and variables from earlier runs are gone"
215
+ end
216
+ s = (@sessions[key] ||= open_session)
217
+
218
+ return ended(key, "", notices) unless write(s, request(s, cmd))
219
+
220
+ outcome, out, dropped = collect(s, monotonic + timeout)
221
+ if dropped.positive?
222
+ notices << "output truncated at max_output: kept the first #{out.bytesize} bytes " \
223
+ "and dropped #{dropped} more"
224
+ end
225
+
226
+ case outcome
227
+ when :timeout then timed_out(key, s, out, notices, timeout)
228
+ when :eof then ended(key, out, notices)
229
+ else Result.new(status: outcome, stdout: out, notice: join(notices))
230
+ end
231
+ end
232
+
233
+ def default_timeout = config[:timeout] || DEFAULT_TIMEOUT
234
+ def max_output = config[:max_output] || DEFAULT_MAX_OUTPUT
235
+ def cwd = config[:cwd] || Dir.pwd
236
+ def env = config[:env] || {}
237
+
238
+ # The command goes on its own line rather than joined to the marker line
239
+ # with `;`, so a multi-line command runs as written. printf emits no
240
+ # separator of its own before the sentinel, which is what makes stdout
241
+ # exact: there is no injected newline to strip back off, and a command
242
+ # whose output ends without one (or ends with a bare CR) is reported as
243
+ # it was written.
244
+ #
245
+ # The cost of this shape is honest and worth stating: a command that
246
+ # leaves bash waiting for more input (an unclosed quote, a trailing `\`)
247
+ # swallows the marker line, and the run ends at its timeout with the
248
+ # session restarted.
249
+ # `builtin printf`, not bare `printf`: a command that redefines printf as
250
+ # a shell function would otherwise intercept this line and forge both the
251
+ # sentinel and the status. `builtin` bypasses any function of that name,
252
+ # so the marker always carries the shell's real `$?`.
253
+ def request(s, cmd) = "#{cmd}\nbuiltin printf '%s%s\\n' '#{s.sentinel}' \"$?\"\n"
254
+
255
+ def open_session
256
+ sentinel = "TERRET#{SecureRandom.hex(16)}"
257
+ handle = @ctx[:subprocess].pty_spawn(BASH_ARGV, cwd: cwd, env: env)
258
+ s = Session.new(handle: handle, sentinel: sentinel,
259
+ marker: Regexp.new("#{sentinel}(\\d+)\\r?\\n"))
260
+ handshake!(s)
261
+ s
262
+ end
263
+
264
+ # Reading to the first marker also synchronises the session: everything
265
+ # bash said before it — the login banner some systems print, the default
266
+ # prompt, the echo of the handshake line itself before `stty -echo` took
267
+ # effect — is discarded, so the first command's output starts clean.
268
+ def handshake!(s)
269
+ outcome = if write(s, request(s, HANDSHAKE))
270
+ collect(s, monotonic + HANDSHAKE_TIMEOUT).first
271
+ else
272
+ :closed
273
+ end
274
+ return if outcome.is_a?(Integer)
275
+
276
+ # nothing else holds this handle yet, so a shell that never became
277
+ # usable is reaped here rather than left for the caller to dispose
278
+ s.handle.close
279
+ raise ShellUnavailable, "the shell did not answer its startup handshake (#{outcome})"
280
+ end
281
+
282
+ # Reads until the marker, the deadline, or the end of the shell. Returns
283
+ # [status, stdout, dropped] for a command that reported one, or
284
+ # [:timeout, partial, dropped] / [:eof, partial, dropped] for the two
285
+ # ways it may not have.
286
+ #
287
+ # Past `max_output` the kept buffer stops growing, but reading does not:
288
+ # the marker arrives at the very end of a command's output, so a run that
289
+ # simply stopped reading would lose the status of every command that ever
290
+ # exceeded the cap, and would leave the unread bytes to be charged to the
291
+ # next run. Instead the overflow is counted and discarded, with a window
292
+ # of it kept so the marker is still found when it comes.
293
+ #
294
+ # The buffers stay BINARY until they are sliced: terminal bytes are not
295
+ # guaranteed to be valid UTF-8, and matching against a BINARY string is
296
+ # the one form that cannot raise on a child emitting whatever it likes.
297
+ def collect(s, deadline)
298
+ kept = String.new(encoding: Encoding::BINARY)
299
+ tail = nil # the rolling window, once the cap is reached
300
+ seen = 0 # every output byte the command produced, kept or not
301
+ cap = max_output
302
+
303
+ loop do
304
+ scan = tail || kept
305
+ if (m = s.marker.match(scan))
306
+ # Where the marker starts in the STREAM, not in whichever buffer
307
+ # found it: the window always ends at the stream's end, so its own
308
+ # offsets are relative. Everything before that point is the
309
+ # command's output and everything from it on is protocol — which
310
+ # is why the cut is taken here rather than at the cap. A command
311
+ # whose output stops within a marker's length of the cap leaves the
312
+ # marker BEGINNING inside the kept bytes, and returning those
313
+ # verbatim would hand the caller the session's sentinel, the one
314
+ # value the protocol's forgery resistance rests on.
315
+ marker_at = seen - scan.bytesize + m.begin(0)
316
+ out = kept.byteslice(0, marker_at) # byteslice clamps, so this is
317
+ # all of `kept` in the ordinary
318
+ # over-the-cap case
319
+ return [Integer(m[1]), text(out), dropped!(marker_at - out.bytesize)]
320
+ end
321
+
322
+ remaining = deadline - monotonic
323
+ return [:timeout, *partial(kept, seen)] if remaining <= 0
324
+
325
+ chunk = s.handle.read(CHUNK, timeout: remaining)
326
+ return [:eof, *partial(kept, seen)] if chunk.nil?
327
+
328
+ seen += chunk.bytesize
329
+ if tail
330
+ window!(tail << chunk.b)
331
+ else
332
+ kept << chunk.b
333
+ next if kept.bytesize <= cap
334
+
335
+ # keep the first `cap` bytes; carry a window across the cut so a
336
+ # marker straddling it is still whole in the tail
337
+ tail = window!(kept.byteslice([cap - MARKER_WINDOW, 0].max..))
338
+ kept = whole_characters(kept.byteslice(0, cap))
339
+ end
340
+ end
341
+ end
342
+
343
+ # A run that ended without its marker: everything read is the command's
344
+ # output, and the cut is wherever the deadline or the shell's end landed
345
+ # — a place this file chose, so it gets the same character-boundary
346
+ # treatment the cap does.
347
+ def partial(kept, seen)
348
+ out = whole_characters(kept)
349
+ [text(out), dropped!(seen - out.bytesize)]
350
+ end
351
+
352
+ def window!(buf)
353
+ buf.slice!(0, buf.bytesize - MARKER_WINDOW) if buf.bytesize > MARKER_WINDOW
354
+ buf
355
+ end
356
+
357
+ # Back a byte-offset cut off to the last whole UTF-8 character. Cutting
358
+ # at a byte offset can split a character in half, and the halves are not
359
+ # the child's bytes — this seam made them. That matters beyond tidiness:
360
+ # a durable append JSON-encodes the payload, so a manufactured half
361
+ # character raises at the append boundary, one layer away from the code
362
+ # that broke it.
363
+ #
364
+ # Only an INCOMPLETE trailing character moves, and never more than three
365
+ # bytes, because that is the longest tail a split UTF-8 character can
366
+ # leave. Bytes a child emitted that were never valid UTF-8 are left
367
+ # exactly as they arrived — a stray continuation byte with no lead, an
368
+ # 0xFF — since preserving what the child actually wrote is the whole
369
+ # reason nothing here re-encodes. The rule is narrow on purpose: never
370
+ # manufacture invalid bytes out of valid ones.
371
+ def whole_characters(bytes)
372
+ seen = 0
373
+ index = bytes.bytesize - 1
374
+ while index >= 0 && seen < 4
375
+ byte = bytes.getbyte(index)
376
+ if (byte & 0xC0) == 0x80 # a continuation byte; keep walking back
377
+ index -= 1
378
+ seen += 1
379
+ next
380
+ end
381
+
382
+ need = CHARACTER_LENGTHS.find { |mask, value, _| (byte & mask) == value }&.last
383
+ return bytes if need.nil? || seen + 1 >= need # complete, or not ours to fix
384
+
385
+ return bytes.byteslice(0, index) # an incomplete tail: drop it
386
+ end
387
+ bytes
388
+ end
389
+
390
+ # Every byte dropped is a byte the command produced that the caller will
391
+ # not see, so the count is non-negative by construction — `out` is always
392
+ # a prefix of the output that preceded the marker. A negative count would
393
+ # mean protocol bytes were being counted as output, which is exactly the
394
+ # arithmetic that leaks a sentinel, so it fails loudly here rather than
395
+ # being rounded away by a `.positive?` check downstream.
396
+ def dropped!(count)
397
+ raise "shell: dropped byte count went negative (#{count}); the cap arithmetic is wrong" if count.negative?
398
+
399
+ count
400
+ end
401
+
402
+ # Bytes sitting on the terminal between two runs belong to whatever wrote
403
+ # them — a backgrounded job, a job-control notice — and never to the
404
+ # command about to run, so they are drained rather than charged to the
405
+ # next result. The same pass is the liveness check: a session whose bash
406
+ # is gone reads as EOF, and an idle live one answers "" on the first
407
+ # non-blocking read, so this costs a syscall in the ordinary case.
408
+ def stale?(key)
409
+ s = @sessions[key] or return false
410
+
411
+ deadline = monotonic + DRAIN_BUDGET
412
+ loop do
413
+ chunk = s.handle.read(CHUNK, timeout: 0)
414
+ return true if chunk.nil?
415
+ return false if chunk.empty? || monotonic >= deadline
416
+ end
417
+ end
418
+
419
+ # The decided semantics (docs/exec.md §2): kill and respawn rather than
420
+ # try to recover. The interrupt is what ends the command's own children;
421
+ # the session goes with it because after an interrupt its input queue and
422
+ # the output still in flight are in a state we cannot account for — the
423
+ # next run gets a shell we can, and the caller is told so rather than
424
+ # left to discover it through a lost `cd`.
425
+ def timed_out(key, s, out, notices, timeout)
426
+ interrupt(s)
427
+ discard(key)
428
+ notices << "timed out after #{timeout}s; the command was interrupted and the shell " \
429
+ "session killed, so the next run starts a fresh session"
430
+ Result.new(status: nil, stdout: out, notice: join(notices))
431
+ end
432
+
433
+ def ended(key, out, notices)
434
+ discard(key)
435
+ notices << "the shell session ended while this command ran; the next run starts a " \
436
+ "fresh session, so the cwd and variables from earlier runs are gone"
437
+ Result.new(status: nil, stdout: out, notice: join(notices))
438
+ end
439
+
440
+ def interrupt(s) = write(s, INTERRUPT)
441
+
442
+ # A write to a terminal whose child is gone is not an exception here: it
443
+ # is how we learn the session ended, and the caller asked to run a
444
+ # command, not to be told about a file descriptor.
445
+ def write(s, str)
446
+ s.handle.write(str)
447
+ true
448
+ rescue Errno::EIO, Errno::EPIPE, IOError
449
+ false
450
+ end
451
+
452
+ def discard(key)
453
+ s = @sessions.delete(key) or return nil
454
+ farewell(s)
455
+ sweep(s.handle.pid)
456
+ s.handle.close
457
+ end
458
+
459
+ # End everything still running in the session's process group. Reaping
460
+ # bash is not enough on its own: a `&` job is not bash's business once
461
+ # started, and it survives the shell's exit to be reparented to init —
462
+ # a process with the agent's authority, outside the sandbox's lifecycle,
463
+ # that nothing in the harness can name any more. `set +m` (see HANDSHAKE)
464
+ # is what makes one signal reach all of them: without job control every
465
+ # child stays in the group bash leads, and PTY.spawn made bash a session
466
+ # leader, so its pid IS that group's id.
467
+ #
468
+ # Deliberately before the handle is closed: at this point bash is either
469
+ # alive or an unreaped zombie, so the pid is still ours and cannot have
470
+ # been recycled into somebody else's process group by the time the signal
471
+ # lands. KILL rather than TERM because this runs after the session has
472
+ # already been asked to leave politely.
473
+ #
474
+ # Both refusals mean the same thing here — there was nothing left to end.
475
+ # ESRCH is an empty group; EPERM is what Darwin answers when every member
476
+ # left is a zombie (measured: a group holding one live child signals
477
+ # fine, the same group once only the exited leader remains raises EPERM).
478
+ # The one case EPERM could hide is a live child running under another
479
+ # uid, which a setuid command could produce and which no signal of ours
480
+ # could have ended anyway.
481
+ def sweep(pgid)
482
+ Process.kill("KILL", -pgid)
483
+ rescue Errno::ESRCH, Errno::EPERM
484
+ nil
485
+ end
486
+
487
+ # Ask the shell to leave, and read it to EOF before the handle is closed.
488
+ # Both halves are load-bearing, and neither is politeness:
489
+ #
490
+ # An interactive bash ignores SIGTERM — that is what keeps a stray kill
491
+ # from taking down a human's terminal — so a close that went straight to
492
+ # the reaper would always spend the full grace period before the SIGKILL
493
+ # landed. `exit` is the only cheap way out.
494
+ #
495
+ # And a bash SIGKILLed while its terminal still holds bytes nobody read
496
+ # gets stuck in exit: measured on macOS, with as little as a startup
497
+ # banner pending, the process sits in `E` state indefinitely and the
498
+ # reaper's blocking wait never returns — a wedge that would take the
499
+ # whole reactor with it. Reading to EOF (which is also what draining
500
+ # does) is what keeps that from being reachable.
501
+ def farewell(s)
502
+ write(s, "exit\n")
503
+ deadline = monotonic + FAREWELL_BUDGET
504
+ loop do
505
+ return if s.handle.read(CHUNK, timeout: 0.01).nil? # EOF: the shell is gone
506
+ return if monotonic >= deadline
507
+ end
508
+ end
509
+
510
+ def join(notices) = notices.empty? ? nil : notices.join(" ")
511
+
512
+ # Terminal bytes arrive as BINARY; everything downstream of this seam is
513
+ # text. Forced rather than encoded, so a command emitting invalid UTF-8
514
+ # still round-trips its bytes instead of raising here.
515
+ def text(buf) = buf.force_encoding(Encoding::UTF_8)
516
+
517
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
518
+ end
519
+ end
520
+ end