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,617 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+
5
+ module Terret
6
+ module Exec
7
+ # ctx[:subprocess] — the only place in Terret where an argv becomes a real
8
+ # process (plan §6.6; docs/exec.md §2). Two shapes: #spawn captures a
9
+ # one-shot command, #pty_spawn hands back a live terminal for
10
+ # ctx[:shell]/ctx[:terminals] to keep.
11
+ #
12
+ # Everything here is built to park the FIBER rather than the thread. One
13
+ # reactor, no user-facing threads (plan §8) means a call that blocked the
14
+ # thread would stall every other agent in the process on the first slow
15
+ # child, so the capture loop polls with non-blocking IO plus
16
+ # `Process.wait(WNOHANG)` and a `sleep` — all of which cooperate with the
17
+ # scheduler, verified on this Ruby. `Open3.capture3` was the obvious
18
+ # alternative and is rejected for two reasons: it spawns reader threads
19
+ # per call (measured: three extra threads while one capture runs), and it
20
+ # has no notion of a deadline, so cancellation would have to be bolted on
21
+ # from outside the call it is meant to bound.
22
+ class Subprocess < Hames::Service
23
+ service_key :subprocess
24
+ inject :sandbox
25
+ config_schema term_grace: { type: Numeric, default: 2,
26
+ doc: "seconds between SIGTERM and SIGKILL when stopping a process" }
27
+
28
+ # A capture that never exited on its own carries `status: nil` — an
29
+ # exit code we do not have is not reported as one — with what it managed
30
+ # to say before it was cancelled, and why, on stderr.
31
+ Result = Data.define(:status, :stdout, :stderr)
32
+
33
+ POLL = 0.01
34
+ CHUNK = 64 * 1024
35
+
36
+ def start(ctx)
37
+ @ctx = ctx
38
+ end
39
+
40
+ # Runs argv to completion (or to `timeout:`) and captures both streams.
41
+ # `env` merges into the inherited environment, `stdin` is written to the
42
+ # child and then closed, and a non-zero exit is a Result like any other
43
+ # rather than an exception — the caller asked to run a command, and a
44
+ # command that failed still ran.
45
+ def spawn(argv, cwd: Dir.pwd, env: {}, stdin: nil, timeout: nil)
46
+ # The §6.6 contract: every argv passes ctx[:sandbox].wrap before it
47
+ # becomes a process. This call site, and its twin in #pty_spawn, are
48
+ # the reason one config row swapping the sandbox provider moves every
49
+ # spawn in the harness inside a container without touching a tool.
50
+ argv = @ctx[:sandbox].wrap(argv, cwd: cwd)
51
+
52
+ in_r, in_w = IO.pipe
53
+ out_r, out_w = IO.pipe
54
+ err_r, err_w = IO.pipe
55
+ begin
56
+ pid = Process.spawn(stringify(env), *exec_form(argv),
57
+ chdir: cwd, in: in_r, out: out_w, err: err_w)
58
+ [in_r, out_w, err_w].each { |io| close!(io) }
59
+ capture(pid, in_w, out_r, err_r, stdin, timeout)
60
+ ensure
61
+ [in_r, in_w, out_r, out_w, err_r, err_w].each { |io| close!(io) }
62
+ end
63
+ end
64
+
65
+ # A live terminal. The handle is deliberately small — read, write, pid,
66
+ # close — because ctx[:terminals] holds these across turns and every
67
+ # method on it is something a tool call can end up driving.
68
+ def pty_spawn(argv, cwd: Dir.pwd, env: {})
69
+ # The §6.6 contract; see #spawn. `tty: true` is this path declaring
70
+ # what it is: a caller reaching for a pty wants terminal semantics on
71
+ # the far side of the sandbox as well, and a provider that can arrange
72
+ # one (docker, via `-t`) has to be told. Without it a container hands
73
+ # bash a pipe while the host pty carries on echoing, and the echoed
74
+ # request line — session sentinel and all — lands in what ctx[:shell]
75
+ # reads back as the command's own output. The bit rides this path
76
+ # only: `docker exec -i -t` against pipe stdin fails outright, so
77
+ # #spawn must never ask for it.
78
+ argv = @ctx[:sandbox].wrap(argv, cwd: cwd, tty: true)
79
+ reader, writer, pid = PTY.spawn(stringify(env), *exec_form(argv), chdir: cwd)
80
+ writer.sync = true
81
+ PTYHandle.new(reader: reader, writer: writer, pid: pid,
82
+ reaper: method(:reap!), grace: term_grace)
83
+ end
84
+
85
+ # A process the caller does not wait for. #spawn captures to completion
86
+ # and #pty_spawn hands back a terminal; this hands back a plain pipe and
87
+ # a pid, which is what ctx[:jobs] needs and neither of the others can be
88
+ # (docs/subagents.md §6). A job's whole point is output read while it is
89
+ # still running, so a capture loop is the wrong shape — and a pty is the
90
+ # wrong shape too, because a terminal rewrites the newlines of anything
91
+ # written through it (the CR-LF ctx[:shell] spends a `stty -onlcr` on)
92
+ # and there is nobody to run an stty in a buffer.
93
+ def pipe_spawn(argv, cwd: Dir.pwd, env: {})
94
+ # The §6.6 contract; see #spawn. No `tty:` — a job is not a terminal.
95
+ argv = @ctx[:sandbox].wrap(argv, cwd: cwd)
96
+
97
+ out_r, out_w = IO.pipe
98
+ begin
99
+ # One pipe for both streams, the way a terminal has one: a job's
100
+ # diagnostics are part of what it said, and a second pipe would need
101
+ # a second drain to stay deadlock-free for output that renders as a
102
+ # single stream anyway. stdin is /dev/null rather than inherited, so
103
+ # a background job that reads it sees EOF instead of racing the
104
+ # harness for the console. `pgroup: true` makes the child a process
105
+ # group leader, which is what lets the handle's close reach whatever
106
+ # the job spawned rather than only the job (the same reasoning as
107
+ # Shell#sweep: a surviving background child holds the agent's
108
+ # authority with nothing left in the harness able to name it).
109
+ pid = Process.spawn(stringify(env), *exec_form(argv), chdir: cwd,
110
+ in: File::NULL, out: out_w, err: out_w, pgroup: true)
111
+ rescue StandardError
112
+ close!(out_r)
113
+ raise
114
+ ensure
115
+ close!(out_w)
116
+ end
117
+ PipeHandle.new(reader: out_r, pid: pid, reaper: method(:reap!), grace: term_grace)
118
+ end
119
+
120
+ private
121
+
122
+ def term_grace = config[:term_grace] || 2
123
+
124
+ def capture(pid, in_w, out_r, err_r, stdin, timeout)
125
+ out = String.new(encoding: Encoding::BINARY)
126
+ err = String.new(encoding: Encoding::BINARY)
127
+ pending = stdin.nil? ? nil : String.new(stdin.to_s, encoding: Encoding::BINARY)
128
+ close!(in_w) if pending.nil?
129
+
130
+ deadline = timeout && monotonic + timeout
131
+ status = nil
132
+ ended = nil
133
+
134
+ loop do
135
+ pending = feed(in_w, pending)
136
+ drain(out_r, out)
137
+ drain(err_r, err)
138
+
139
+ if (reaped = Process.wait2(pid, Process::WNOHANG))
140
+ status = reaped.last.exitstatus
141
+ break
142
+ end
143
+
144
+ if deadline && monotonic >= deadline
145
+ ended = reap!(pid, term_grace)
146
+ break
147
+ end
148
+
149
+ sleep POLL
150
+ end
151
+
152
+ # The child's ends of the pipes are closed now it is reaped, so one
153
+ # more non-blocking pass collects whatever is still buffered. Reading
154
+ # to EOF instead would hang on a grandchild that inherited the pipe
155
+ # and outlived its parent.
156
+ drain(out_r, out)
157
+ drain(err_r, err)
158
+ note!(err, timeout, ended) if ended
159
+
160
+ Result.new(status: status, stdout: text(out), stderr: text(err))
161
+ end
162
+
163
+ # TERM, then KILL if the child is still there after the grace, then reap
164
+ # it. One escalation policy, shared by the spawn timeout and terminal
165
+ # close so the two cannot drift apart. Which signal actually ended the
166
+ # child is returned rather than swallowed: a caller reading a timed-out
167
+ # result deserves to know the process ignored the polite request.
168
+ def reap!(pid, grace)
169
+ signal(pid, "TERM")
170
+ deadline = monotonic + grace
171
+ loop do
172
+ return :terminated if Process.wait2(pid, Process::WNOHANG)
173
+ break if monotonic >= deadline
174
+
175
+ sleep POLL
176
+ end
177
+ signal(pid, "KILL")
178
+ Process.wait2(pid)
179
+ :killed
180
+ rescue Errno::ECHILD
181
+ # already reaped elsewhere; nothing left to end
182
+ :terminated
183
+ end
184
+
185
+ def note!(err, timeout, ended)
186
+ err << "\n" unless err.empty? || err.end_with?("\n")
187
+ err << if ended == :killed
188
+ "terret: timed out after #{timeout}s; sent SIGTERM, then SIGKILL after a #{term_grace}s grace\n"
189
+ else
190
+ "terret: timed out after #{timeout}s; sent SIGTERM\n"
191
+ end
192
+ end
193
+
194
+ # A child that has already exited but is not yet reaped is not an error
195
+ # here — the next wait collects it. Neither is EPERM, and that one is not
196
+ # a permission problem: this seam signals process GROUPS as well as pids
197
+ # (PipeHandle hands the reaper a `-pgid`), and Darwin answers EPERM
198
+ # rather than ESRCH for a group whose every remaining member is a zombie
199
+ # — which is exactly the state of a job that finished on its own and was
200
+ # never collected. Both errnos mean the same thing at this call site:
201
+ # nobody signalable of ours is left. The one case EPERM could hide is a
202
+ # live process under another uid, which no signal of ours could have
203
+ # ended anyway. Shell#sweep rescues the pair for the same reason.
204
+ def signal(pid, name)
205
+ Process.kill(name, pid)
206
+ rescue Errno::ESRCH, Errno::EPERM
207
+ nil
208
+ end
209
+
210
+ # Non-blocking, so a stdin payload larger than the pipe buffer cannot
211
+ # wedge the caller before the deadline loop starts checking it: whatever
212
+ # does not fit this pass is carried to the next. Returns the bytes still
213
+ # owed, or nil once the child's stdin is closed.
214
+ def feed(io, pending)
215
+ return nil if pending.nil?
216
+
217
+ if pending.empty?
218
+ close!(io)
219
+ return nil
220
+ end
221
+
222
+ written = io.write_nonblock(pending, exception: false)
223
+ return pending if written == :wait_writable
224
+
225
+ pending.byteslice(written..)
226
+ rescue Errno::EPIPE, IOError
227
+ close!(io)
228
+ nil
229
+ end
230
+
231
+ # Both streams every pass, never one to EOF: reading stdout to the end
232
+ # while stderr fills its pipe buffer is the classic capture deadlock.
233
+ def drain(io, buf)
234
+ return if io.closed?
235
+
236
+ loop do
237
+ chunk = io.read_nonblock(CHUNK, exception: false)
238
+ return close!(io) if chunk.nil? # EOF
239
+ return if chunk == :wait_readable
240
+
241
+ buf << chunk
242
+ end
243
+ rescue IOError
244
+ nil
245
+ end
246
+
247
+ # [cmd, argv0] forces the exec form even for a one-element argv, so a
248
+ # bare ["ls -la"] is a command named "ls -la" that fails to exec rather
249
+ # than a shell line. Nothing on this seam acquires a shell by accident;
250
+ # ctx[:shell] asks for one explicitly.
251
+ def exec_form(argv) = [[argv[0], argv[0]], *argv[1..]]
252
+
253
+ # Process.spawn's env hash merges into the inherited environment (only
254
+ # `unsetenv_others:` replaces it), which is what a caller passing one or
255
+ # two variables means.
256
+ def stringify(env) = (env || {}).to_h { |k, v| [k.to_s, v&.to_s] }
257
+
258
+ # Pipe bytes arrive as BINARY; everything downstream of this seam — tool
259
+ # results, the session log — is text. Forced rather than encoded so a
260
+ # child emitting invalid UTF-8 still round-trips its bytes instead of
261
+ # raising here.
262
+ def text(buf) = buf.force_encoding(Encoding::UTF_8)
263
+
264
+ def close!(io)
265
+ io.close unless io.nil? || io.closed?
266
+ rescue IOError
267
+ nil
268
+ end
269
+
270
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
271
+
272
+ # What a caller holds for a live terminal: the pty master on one side,
273
+ # the child on the other. These outlive a single tool call by design, so
274
+ # #close both drops the fds and ends the child rather than leaving it to
275
+ # the process's own exit.
276
+ class PTYHandle
277
+ attr_reader :pid
278
+
279
+ def initialize(reader:, writer:, pid:, reaper:, grace:)
280
+ @reader = reader
281
+ @writer = writer
282
+ @pid = pid
283
+ @reaper = reaper
284
+ @grace = grace
285
+ @closed = false
286
+ end
287
+
288
+ # Without a timeout this blocks until the child says something,
289
+ # parking the fiber. With one it polls to the deadline and returns ""
290
+ # empty-handed, which is what a tool call reading a terminal that has
291
+ # nothing to say needs — the blocking form would hold the turn open
292
+ # forever on an idle terminal. nil means end of stream: on a pty
293
+ # master a dead child surfaces as EIO rather than a clean EOF.
294
+ def read(max = 4096, timeout: nil)
295
+ return nil if @reader.closed?
296
+ return decode(@reader.readpartial(max)) if timeout.nil?
297
+
298
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
299
+ loop do
300
+ chunk = @reader.read_nonblock(max, exception: false)
301
+ return nil if chunk.nil?
302
+ return decode(chunk) unless chunk == :wait_readable
303
+ return "" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
304
+
305
+ sleep POLL
306
+ end
307
+ rescue Errno::EIO, EOFError, IOError
308
+ nil
309
+ end
310
+
311
+ def write(str) = @writer.write(str)
312
+
313
+ # Whether the child is still running, probed with a non-blocking wait.
314
+ # Write failure cannot be the only tell: macOS raises EIO writing to a
315
+ # master whose child died, but Linux queues the bytes into a pty no
316
+ # one will ever read and reports nothing — so a caller refusing input
317
+ # to a dead terminal has to ask the process table, not the fd. The
318
+ # probe reaps the child when it finds one exited; the status is kept
319
+ # so #close skips its own wait instead of hunting a pid that is gone
320
+ # (reap! tolerates that too, but not re-signaling a reaped pid is
321
+ # better than tolerating it).
322
+ def alive?
323
+ return false if @closed || @exited
324
+
325
+ if Process.wait2(@pid, Process::WNOHANG)
326
+ @exited = true
327
+ false
328
+ else
329
+ true
330
+ end
331
+ rescue Errno::ECHILD
332
+ @exited = true
333
+ false
334
+ end
335
+
336
+ # Idempotent: a terminal explicitly closed and then closed again by
337
+ # its owner's disposal must not raise, and must not wait on a child
338
+ # that is already reaped.
339
+ #
340
+ # The fds are dropped BEFORE the child is reaped, and that order is
341
+ # load-bearing rather than tidy. A child SIGKILLed while its terminal
342
+ # still holds bytes nobody read can stick in exit — measured on macOS
343
+ # with a shell and as little as a startup banner pending, the process
344
+ # sits in `E` state and the blocking wait that reaps it never returns.
345
+ # There is no rescuing that from inside the reactor either: the fiber
346
+ # parks in the scheduler's own process_wait hook, where its timers
347
+ # never get to preempt it, so one terminal closed in the wrong order
348
+ # takes every agent in the process with it. Closing the master first
349
+ # discards the pending output and hangs the child up, which also
350
+ # spares an interactive shell — one that ignores SIGTERM by design —
351
+ # the whole grace period it would otherwise sit out before the SIGKILL.
352
+ def close
353
+ return @ended if @closed
354
+
355
+ @closed = true
356
+ [@writer, @reader].each do |io|
357
+ io.close unless io.closed?
358
+ rescue IOError
359
+ nil
360
+ end
361
+ @ended = @exited ? :terminated : @reaper.call(@pid, @grace)
362
+ end
363
+
364
+ private
365
+
366
+ def decode(bytes) = bytes.force_encoding(Encoding::UTF_8)
367
+ end
368
+
369
+ # What a caller holds for a process it started and did not wait for. The
370
+ # surface is deliberately as small as PTYHandle's — read, status, close —
371
+ # because ctx[:jobs] keeps these across turns and every method on one is
372
+ # something a tool call can end up driving.
373
+ class PipeHandle
374
+ attr_reader :pid
375
+
376
+ # What one close may hold. The grace #end_group spends is a window the
377
+ # job goes on writing into, and a job that ignores TERM writes into all
378
+ # of it: draining that with nothing bounding it held 952MB at the
379
+ # default two-second grace, measured, to hand back output the owner's
380
+ # own buffer caps at a mebibyte. The bound being protected is the HOST
381
+ # PROCESS's — a handle cannot see ctx[:jobs]' cap, every agent on the
382
+ # box shares the memory an OOM would take, and this sits comfortably
383
+ # above that mebibyte so the cap that shapes a result stays the owner's.
384
+ #
385
+ # Past it the bytes are read and DISCARDED rather than the reading
386
+ # stopping, which is the trade Jobs::Buffer makes for the same reason:
387
+ # a reader that stops blocks the writer on its next write, and a job
388
+ # frozen inside its own grace period is a worse answer than a job whose
389
+ # last words were cut short.
390
+ MAX_PENDING = 2 << 20
391
+
392
+ # How much one drain may read before it yields the reactor. A child that
393
+ # keeps the pipe readable would otherwise spin the drain loop with no
394
+ # scheduler yield and no exit — starving every other fiber on the one
395
+ # reactor, which is exactly the case a job (running while nobody watches)
396
+ # invites. MAX_PENDING bounded retained memory, not reactor occupancy:
397
+ # read-and-discard still loops. This bounds the occupancy.
398
+ YIELD_BYTES = 1 << 20
399
+
400
+ def initialize(reader:, pid:, reaper:, grace:)
401
+ @reader = reader
402
+ @pid = pid
403
+ @reaper = reaper
404
+ @grace = grace
405
+ @eof = false
406
+ @exited = false
407
+ @status = nil
408
+ @closed = false
409
+ end
410
+
411
+ # Everything the process has written since the last read: "" while it
412
+ # is alive with nothing to say, nil once the stream has ended. Never
413
+ # blocks and never waits — the fiber reading this one has other jobs
414
+ # to drain — so a caller polls it rather than being pushed to.
415
+ #
416
+ # Bytes read in the same pass that hits EOF are returned; the nil
417
+ # comes on the pass after, so the end of a stream never swallows the
418
+ # last thing the process said.
419
+ def read(max = CHUNK)
420
+ buf = @pending || String.new(encoding: Encoding::BINARY)
421
+ @pending = nil
422
+ drain(buf, max) unless @eof
423
+ buf.empty? && @eof ? nil : decode(buf)
424
+ end
425
+
426
+ # Whether the stream has ended — the process is gone and the pipe has
427
+ # been read to its end. An owner asks this rather than #exited? when
428
+ # the question is "can anything else still arrive", because a job's
429
+ # own children can outlive it holding the write end.
430
+ def eof? = @eof
431
+
432
+ # Whether the process is gone, probed with a non-blocking wait that
433
+ # reaps it when it finds one exited. Asked rather than #exit_status
434
+ # because a signalled process HAS no exit status: `nil` there means
435
+ # "still running" and "killed" both, and only this tells them apart.
436
+ def exited?
437
+ return true if @exited
438
+
439
+ if (reaped = Process.wait2(@pid, Process::WNOHANG))
440
+ @exited = true
441
+ @status = reaped.last.exitstatus
442
+ end
443
+ @exited
444
+ rescue Errno::ECHILD
445
+ @exited = true
446
+ end
447
+
448
+ # nil until the process has exited, and nil afterwards too when a
449
+ # signal ended it rather than an `exit`.
450
+ def exit_status
451
+ exited?
452
+ @status
453
+ end
454
+
455
+ # Idempotent: a job explicitly stopped and then closed again by its
456
+ # owner's disposal must not raise, and must not wait on a child that
457
+ # is already reaped.
458
+ #
459
+ # The whole process GROUP goes, not just the pid. #pipe_spawn's child
460
+ # leads its own group, so a signal reaches whatever the job spawned as
461
+ # well as the job itself — a surviving background child would otherwise
462
+ # hold the agent's authority with nothing left in the harness able to
463
+ # name it — and the reaper, handed `-pgid`, collects the one member of
464
+ # that group that is our child. #end_group is the ordering; it is
465
+ # Shell#discard's, step for step, and for its reasons.
466
+ #
467
+ # The branch where the leader has ALREADY been reaped — by the drain
468
+ # fiber's probe, or by a collect that found it exited — signals
469
+ # nothing at all. Survivors of a reaped leader keep its pgid reserved
470
+ # only for as long as they live, so nothing here can tell "our group,
471
+ # now empty" from "a stranger's recycled pgid", and leaking a
472
+ # grandchild is the honest answer over killing somebody else's
473
+ # process. docs/subagents.md §6 says so out loud; plan §14 is where
474
+ # the fix would live.
475
+ #
476
+ # Unlike PTYHandle the fd is dropped last, because a pipe has no
477
+ # equivalent of the pty wedge: bytes nobody read are discarded by the
478
+ # kernel when the writer dies, so nothing here can stick in exit. What
479
+ # the process managed to say before it went is drained into the handle
480
+ # on the way past, so closing a job never swallows its last words —
481
+ # #read hands them over afterwards exactly as if it were still open.
482
+ def close
483
+ return @ended if @closed
484
+
485
+ @closed = true
486
+ begin
487
+ @ended = @exited ? :terminated : end_group
488
+ ensure
489
+ # A handle this call touched is a handle this call finishes. The
490
+ # reaper is somebody else's method and the fd is the only thing
491
+ # holding this pipe open, so a raise on the way through must not
492
+ # be able to leave the row half-closed: an fd still open, a child
493
+ # still in the process table, and an owner that thinks the job is
494
+ # over.
495
+ #
496
+ # The value is an ASSUMPTION recorded so a second close is
497
+ # idempotent, not an observation of the process table: if the
498
+ # reaper raised, what became of the child is exactly what we do not
499
+ # know. Nothing reads it today, and anything that starts to should
500
+ # be told that first.
501
+ @ended ||= :terminated
502
+ @exited = true
503
+ @pending ||= String.new(encoding: Encoding::BINARY)
504
+ drain(@pending, CHUNK, cap: MAX_PENDING) unless @eof
505
+ begin
506
+ @reader.close unless @reader.closed?
507
+ rescue IOError
508
+ nil
509
+ end
510
+ end
511
+ @ended
512
+ end
513
+
514
+ private
515
+
516
+ # Shell#discard's three steps, applied to a process group: ask it to
517
+ # leave, read it to EOF within the grace, then KILL what is still there
518
+ # — all before anything is reaped. Same escalation the reaper applies
519
+ # to a single pid (TERM, then SIGKILL after the grace), which is what
520
+ # the seam promises a `stop` does.
521
+ #
522
+ # The ask is a TERM to the whole group, because a job has no protocol
523
+ # to say `exit` down the way ctx[:shell] does, and the read is what
524
+ # tells us it worked. Waiting on the leader would be the obvious way to
525
+ # bound the wait, and it is the one thing that cannot happen here: a
526
+ # wait that collected it would free the pgid, and the KILL below would
527
+ # then be aimed at a pid the kernel is free to have handed to a
528
+ # stranger — the invariant Shell#sweep spells out. EOF answers the same
529
+ # question without reaping anything, and answers it better: it means
530
+ # the leader AND every child that inherited its output are gone. What
531
+ # the job said on its way out lands in @pending while we wait, where
532
+ # the next #read hands it over — under MAX_PENDING, because the same
533
+ # grace that lets a job leave politely lets a chatty one write for the
534
+ # whole of it.
535
+ def end_group
536
+ signal("TERM")
537
+ deadline = now + @grace
538
+ @pending ||= String.new(encoding: Encoding::BINARY)
539
+ loop do
540
+ # The deadline rides into the drain: a writer that keeps the pipe
541
+ # readable used to hold the drain here forever, so this loop's own
542
+ # deadline check was never reached and the SIGKILL below never ran.
543
+ # drain now surrenders at the deadline, and this break confirms it.
544
+ drain(@pending, CHUNK, cap: MAX_PENDING, deadline: deadline)
545
+ break if @eof || now >= deadline
546
+
547
+ sleep POLL
548
+ end
549
+ # `:killed` here says the STREAM outlived the grace, which is not
550
+ # what the reaper means by it: the leader may well have gone on the
551
+ # TERM while a child of its own held the pipe open. It is the honest
552
+ # answer for a close whose sweep did the ending, and #reap!'s own
553
+ # contract stays about the single pid its other callers hand it.
554
+ ended = @eof ? :terminated : :killed
555
+ sweep
556
+ @reaper.call(-@pid, @grace) # collects the leader; its own TERM is a no-op by now
557
+ ended
558
+ end
559
+
560
+ def drain(buf, max, cap: nil, deadline: nil)
561
+ since_yield = 0
562
+ loop do
563
+ # A deadline lets a caller (end_group) bound the whole drain: a
564
+ # continuously-readable pipe would otherwise never surrender this
565
+ # loop. Checked before the read so a passed deadline stops it at
566
+ # once rather than after one more chunk.
567
+ return if deadline && now >= deadline
568
+
569
+ chunk = @reader.read_nonblock(max, exception: false)
570
+ if chunk.nil?
571
+ @eof = true
572
+ break
573
+ end
574
+ break if chunk == :wait_readable
575
+
576
+ keep(buf, chunk, cap)
577
+ since_yield += chunk.bytesize
578
+ next if since_yield < YIELD_BYTES
579
+
580
+ # Cooperative yield so a pipe that stays readable cannot monopolize
581
+ # the reactor. sleep 0 parks the fiber briefly under a scheduler and
582
+ # is a no-op without one, so both deployments stay correct.
583
+ since_yield = 0
584
+ sleep 0
585
+ end
586
+ rescue IOError
587
+ @eof = true
588
+ end
589
+
590
+ # Appends what fits and drops the rest on the floor, having read it:
591
+ # past the cap the bytes still come off the pipe, so the writer stays
592
+ # unblocked while this process stops growing. An uncapped drain (#read,
593
+ # where the caller is asking for what is there) keeps everything.
594
+ def keep(buf, chunk, cap)
595
+ return buf << chunk if cap.nil?
596
+
597
+ room = cap - buf.bytesize
598
+ buf << chunk.byteslice(0, room) if room.positive?
599
+ end
600
+
601
+ def sweep = signal("KILL")
602
+
603
+ # Both refusals mean there was nothing of ours left to signal;
604
+ # Subprocess#signal says which is which and why neither is an error.
605
+ def signal(name)
606
+ Process.kill(name, -@pid)
607
+ rescue Errno::ESRCH, Errno::EPERM
608
+ nil
609
+ end
610
+
611
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
612
+
613
+ def decode(bytes) = bytes.force_encoding(Encoding::UTF_8)
614
+ end
615
+ end
616
+ end
617
+ end