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.
- checksums.yaml +7 -0
- data/lib/terret/exec/fs.rb +186 -0
- data/lib/terret/exec/jobs.rb +304 -0
- data/lib/terret/exec/sandbox_none.rb +31 -0
- data/lib/terret/exec/shell.rb +520 -0
- data/lib/terret/exec/subprocess.rb +617 -0
- data/lib/terret/exec/terminals.rb +180 -0
- data/lib/terret/exec.rb +14 -0
- metadata +72 -0
|
@@ -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
|