autoforge-cli 0.1.0a0__py3-none-any.whl
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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/executor.py
ADDED
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
"""CLI execution abstraction over subprocess.
|
|
2
|
+
|
|
3
|
+
Represents every external invocation (Claude Code, OpenCode, gh, git) as a
|
|
4
|
+
structured ExecutionRequest -> ExecutionResult.
|
|
5
|
+
|
|
6
|
+
Safety properties:
|
|
7
|
+
- argv lists only, never ``shell=True`` — prompt text containing quotes,
|
|
8
|
+
``$``, ``;``, ``|`` or ``$(...)`` is passed as one opaque argument;
|
|
9
|
+
- stdin is ``/dev/null`` so a CLI that expects an interactive TTY cannot
|
|
10
|
+
hang waiting for input, unless the request carries ``stdin_data``: that
|
|
11
|
+
is written in full by a non-blocking feeder and stdin is then closed, so
|
|
12
|
+
the child sees EOF, a child that never reads costs the controller
|
|
13
|
+
nothing past the invocation, and what the child did not read is counted
|
|
14
|
+
(:class:`_StdinFeeder`, :attr:`ExecutionResult.stdin_unread`);
|
|
15
|
+
- the child runs in its own session/process group, and nothing in that
|
|
16
|
+
group outlives the invocation: on timeout the whole group is terminated
|
|
17
|
+
(SIGTERM, then SIGKILL), and after a normal exit a group that still has
|
|
18
|
+
members or pipes still open past a short grace is terminated the same
|
|
19
|
+
way, so grandchildren spawned by an agent (test runners, editors, servers)
|
|
20
|
+
do not linger into the next phase, whether or not they hold the agent's
|
|
21
|
+
pipes. The child's own exit status and output are kept in the second
|
|
22
|
+
case; the result records that descendants were killed. The kill is
|
|
23
|
+
complete only when no process is left in the group, and every wait in it
|
|
24
|
+
is bounded: what survives SIGKILL, or left the group (``setsid``) and so
|
|
25
|
+
holds the pipes out of reach, is abandoned rather than waited for, and the
|
|
26
|
+
result says so (:attr:`ExecutionResult.group_survived_kill`,
|
|
27
|
+
:attr:`ExecutionResult.capture_abandoned`) so an operator looks for the
|
|
28
|
+
leftover instead of assuming the kill was clean;
|
|
29
|
+
- capture is bounded: each stream keeps at most ``max_output_bytes`` (the
|
|
30
|
+
first half and the last half of what the child wrote) in a buffer whose
|
|
31
|
+
memory is that bound plus a constant, so a runaway or adversarial child
|
|
32
|
+
costs the controller a bounded amount of memory, not the size of its
|
|
33
|
+
output, however it chunks it. The tail is kept because the CONTROL_RESULT
|
|
34
|
+
block is the last thing on stdout; :attr:`ExecutionResult.stdout_tail` is
|
|
35
|
+
the part known to be contiguous up to EOF, so a caller that parses a
|
|
36
|
+
truncated stream never sees text that spans the cut;
|
|
37
|
+
- the environment is allow-listed on request: a request carrying
|
|
38
|
+
``env_allowlist`` starts the child from only the named variables of the
|
|
39
|
+
controller's environment (:func:`select_environment`) instead of a copy
|
|
40
|
+
of all of it, so an agent or a repository-defined command never sees a
|
|
41
|
+
credential that was in the operator's shell but has nothing to do with
|
|
42
|
+
the run. Which names are allowed is policy and belongs to the caller; the
|
|
43
|
+
executor only applies the selection;
|
|
44
|
+
- on request (``contain_orphans``), what left the group is caught too: on
|
|
45
|
+
Linux the controller is a child subreaper for the invocation
|
|
46
|
+
(``prctl(PR_SET_CHILD_SUBREAPER)``), so a process that called ``setsid``
|
|
47
|
+
(or was started detached) and whose parent then died is re-parented to the
|
|
48
|
+
controller instead of to init; the ones that die while the child runs are
|
|
49
|
+
reaped as they die, so they cannot pile up as zombies against the
|
|
50
|
+
session's process limit, and at teardown every orphan still alive is
|
|
51
|
+
killed (SIGTERM, then SIGKILL, its own process group with it) and reported
|
|
52
|
+
(:attr:`ExecutionResult.orphans_killed`,
|
|
53
|
+
:attr:`ExecutionResult.orphan_survived_kill`). Where no subreaper exists
|
|
54
|
+
the result says the check could not be made
|
|
55
|
+
(:attr:`ExecutionResult.orphans_unchecked`). See the ADR 0002 amendment;
|
|
56
|
+
- the child runs under two limits (:class:`_Limits`, #193): a wall-clock
|
|
57
|
+
ceiling (``timeout_seconds``, measured from the spawn) and, on request, an
|
|
58
|
+
idle limit (``idle_timeout_seconds``) that every chunk the child writes
|
|
59
|
+
on stdout or stderr restarts, so a child that keeps producing output is
|
|
60
|
+
never killed for being slow and a silent one is killed once it has been
|
|
61
|
+
silent that long. The wait runs in slices, each bounded by whichever
|
|
62
|
+
limit is due first, and the kill on either is the group kill above. The
|
|
63
|
+
result says which limit fired (:attr:`ExecutionResult.timeout_limit`) and
|
|
64
|
+
when the child last wrote anything (:attr:`ExecutionResult.last_activity_at`).
|
|
65
|
+
Only the child's output is activity: what the controller writes to it is
|
|
66
|
+
not, and neither is time spent waiting on anyone else;
|
|
67
|
+
- on request (``sandbox``), the command runs inside the outer sandbox a
|
|
68
|
+
:class:`~autoforge.sandbox.LaunchDescription` describes (ADR 0005): the
|
|
69
|
+
Linux backend (:mod:`autoforge.sandbox_bwrap`) is spawned in its place,
|
|
70
|
+
with the same session, pipes, limits, containment and group kill, and a
|
|
71
|
+
host that cannot establish the sandbox refuses the launch
|
|
72
|
+
(:class:`~autoforge.errors.SandboxLaunchRefusedError`) instead of running
|
|
73
|
+
it without one. A request without a description takes exactly the path
|
|
74
|
+
it took before (ADR 0002 §4e).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
import ctypes
|
|
80
|
+
import math
|
|
81
|
+
import os
|
|
82
|
+
import re
|
|
83
|
+
import selectors
|
|
84
|
+
import signal
|
|
85
|
+
import subprocess
|
|
86
|
+
import sys
|
|
87
|
+
import threading
|
|
88
|
+
import time
|
|
89
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
90
|
+
from dataclasses import dataclass
|
|
91
|
+
from datetime import UTC, datetime
|
|
92
|
+
from typing import IO
|
|
93
|
+
|
|
94
|
+
from . import sandbox_bwrap
|
|
95
|
+
from .errors import ExecutionError, ExecutionTimeoutError, SandboxLaunchRefusedError
|
|
96
|
+
from .sandbox import LaunchDescription, SandboxFacts
|
|
97
|
+
from .sandbox_bwrap import BwrapLaunch
|
|
98
|
+
|
|
99
|
+
_KILL_GRACE_SECONDS = 5.0
|
|
100
|
+
# After the child has exited on its own, how long its pipes may stay open
|
|
101
|
+
# and its group may keep a member before the group is killed. A child that
|
|
102
|
+
# left nothing behind clears both at once; the grace is for a helper the
|
|
103
|
+
# child is shutting down as it exits, not for a server it meant to leave.
|
|
104
|
+
_EXIT_GRACE_SECONDS = 2.0
|
|
105
|
+
_GROUP_POLL_SECONDS = 0.02
|
|
106
|
+
_READ_CHUNK_BYTES = 64 * 1024
|
|
107
|
+
# How often the wait for the child's exit looks at :attr:`ExecutionRequest.stop`,
|
|
108
|
+
# and so how late after it is set the group kill can begin; and what the wait
|
|
109
|
+
# returns when it was set.
|
|
110
|
+
_STOP_POLL_SECONDS = 0.1
|
|
111
|
+
_STOPPED = "stopped"
|
|
112
|
+
|
|
113
|
+
# Per-stream capture bound. A Claude Code ``-p`` transcript is kilobytes and
|
|
114
|
+
# an OpenCode ``run`` transcript at most a few megabytes, so the default is
|
|
115
|
+
# well above any legitimate agent output; ``gh``/``git`` plumbing output is
|
|
116
|
+
# smaller still. It is a request field rather than configuration because
|
|
117
|
+
# nothing legitimate should reach it.
|
|
118
|
+
DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
119
|
+
|
|
120
|
+
# The backstop under every agent limit: an agent with no wall-clock ceiling
|
|
121
|
+
# of its own still ends here. Every wait is bounded by the remaining time,
|
|
122
|
+
# and the wait primitives cannot take an arbitrarily large timeout (epoll's
|
|
123
|
+
# is an int of milliseconds, about 24.8 days); a week is far above any run.
|
|
124
|
+
MAX_DEADLINE_SECONDS = 7 * 24 * 3600
|
|
125
|
+
|
|
126
|
+
# The names :attr:`ExecutionResult.timeout_limit` takes when a limit fired.
|
|
127
|
+
LIMIT_IDLE = "idle"
|
|
128
|
+
LIMIT_MAX_RUNTIME = "max_runtime"
|
|
129
|
+
|
|
130
|
+
# An environment allow-list entry: a variable name, or a name prefix followed
|
|
131
|
+
# by ``*`` (``LC_*``, ``ANTHROPIC_*``). Nothing else -- no other glob
|
|
132
|
+
# characters, no empty prefix -- so an entry cannot quietly widen into
|
|
133
|
+
# "everything".
|
|
134
|
+
ENV_PATTERN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\*?$")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_env_pattern(name: str) -> bool:
|
|
138
|
+
"""Whether ``name`` is a valid environment allow-list entry."""
|
|
139
|
+
return bool(ENV_PATTERN_RE.match(name))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def select_environment(
|
|
143
|
+
names: Iterable[str], source: Mapping[str, str] | None = None
|
|
144
|
+
) -> dict[str, str]:
|
|
145
|
+
"""The variables of ``source`` (default: this process's environment) named by ``names``.
|
|
146
|
+
|
|
147
|
+
An entry is an exact variable name or a prefix ending in ``*``; the
|
|
148
|
+
result is the subset of ``source`` some entry names, in ``source``'s
|
|
149
|
+
order, and a name that is absent from ``source`` is simply not there
|
|
150
|
+
(the child gets no empty placeholder). An entry that is not a valid
|
|
151
|
+
pattern raises :class:`ExecutionError`, so a typo in a configured list
|
|
152
|
+
is a refusal to launch, never a silently narrower or wider environment.
|
|
153
|
+
"""
|
|
154
|
+
env = os.environ if source is None else source
|
|
155
|
+
exact: set[str] = set()
|
|
156
|
+
prefixes: list[str] = []
|
|
157
|
+
for name in names:
|
|
158
|
+
if not is_env_pattern(name):
|
|
159
|
+
raise ExecutionError(
|
|
160
|
+
f"invalid environment allow-list entry {name!r}: expected a variable name "
|
|
161
|
+
"or a name prefix followed by '*'"
|
|
162
|
+
)
|
|
163
|
+
if name.endswith("*"):
|
|
164
|
+
prefixes.append(name[:-1])
|
|
165
|
+
else:
|
|
166
|
+
exact.add(name)
|
|
167
|
+
return {
|
|
168
|
+
key: value
|
|
169
|
+
for key, value in env.items()
|
|
170
|
+
if key in exact or any(key.startswith(prefix) for prefix in prefixes)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class ExecutionRequest:
|
|
176
|
+
command: list[str]
|
|
177
|
+
cwd: str | None = None
|
|
178
|
+
# Layered over the inherited (or allow-listed) environment when given.
|
|
179
|
+
env: dict[str, str] | None = None
|
|
180
|
+
# The wall-clock ceiling, measured from the spawn; 0 or less: none.
|
|
181
|
+
timeout_seconds: int = 1800
|
|
182
|
+
# Kill the child once it has written nothing on stdout or stderr for
|
|
183
|
+
# this many seconds; every chunk restarts it (#193). ``None``: no idle
|
|
184
|
+
# limit, only the ceiling. For an agent; plumbing leaves it off.
|
|
185
|
+
idle_timeout_seconds: float | None = None
|
|
186
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
|
|
187
|
+
# ``None``: the child inherits the controller's whole environment (the
|
|
188
|
+
# controller's own git/gh plumbing). A tuple of names/prefixes: the child
|
|
189
|
+
# starts from only those variables (see :func:`select_environment`); an
|
|
190
|
+
# empty tuple is an empty environment.
|
|
191
|
+
env_allowlist: tuple[str, ...] | None = None
|
|
192
|
+
# Catch and kill what the child started outside its process group (see
|
|
193
|
+
# :class:`_Containment`). For agents and repository-defined commands; the
|
|
194
|
+
# controller's own git/gh plumbing leaves it off, so a ``git gc --auto``
|
|
195
|
+
# that git daemonized on purpose is left alone.
|
|
196
|
+
contain_orphans: bool = False
|
|
197
|
+
# Written to the child's stdin, which is then closed; ``None`` keeps
|
|
198
|
+
# stdin on ``/dev/null``. For a CLI whose argv cannot carry a payload
|
|
199
|
+
# verbatim (the OpenCode v2 message). Bounded by the timeout, not by size.
|
|
200
|
+
stdin_data: bytes | None = None
|
|
201
|
+
# Called on the reader thread with the stream's name (``"stdout"`` or
|
|
202
|
+
# ``"stderr"``) each time a chunk of it arrives: a liveness signal for an
|
|
203
|
+
# agent that streams nothing parseable (#192). It never sees the bytes,
|
|
204
|
+
# so what the child wrote stays in the bounded capture alone. Calls from
|
|
205
|
+
# the two streams are serialized; one that raises is not called again
|
|
206
|
+
# for this invocation, on either stream, and both drains go on.
|
|
207
|
+
on_output: Callable[[str], None] | None = None
|
|
208
|
+
# Called the same way, under the same lock, with the stream's name and
|
|
209
|
+
# the chunk itself: for a caller that digests what it reads as it
|
|
210
|
+
# arrives (the loop detector's line repetition, #194) and keeps none of
|
|
211
|
+
# it. The capture is not affected; one that raises is unhooked like
|
|
212
|
+
# ``on_output``, independently of it.
|
|
213
|
+
on_chunk: Callable[[str, bytes], None] | None = None
|
|
214
|
+
# Set by the caller, from any thread (an ``on_chunk`` hook included), to
|
|
215
|
+
# end the invocation: the group is killed as on a timeout, through the
|
|
216
|
+
# same path and within the same bounds, and the result says ``stopped``.
|
|
217
|
+
# Why the caller stopped it is the caller's; the executor never sets it.
|
|
218
|
+
stop: threading.Event | None = None
|
|
219
|
+
# Run the command inside the outer sandbox this describes (ADR 0005);
|
|
220
|
+
# ``None`` runs it as every launch ran before the sandbox existed. A
|
|
221
|
+
# sandboxed launch is spawned on the calling thread, which waits for it,
|
|
222
|
+
# as ``bwrap --die-with-parent`` requires (D10.6). A host that cannot
|
|
223
|
+
# establish the sandbox raises :class:`SandboxLaunchRefusedError`; it never
|
|
224
|
+
# runs the command without it.
|
|
225
|
+
sandbox: LaunchDescription | None = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class ExecutionResult:
|
|
230
|
+
command: list[str]
|
|
231
|
+
cwd: str | None
|
|
232
|
+
exit_code: int
|
|
233
|
+
stdout: str
|
|
234
|
+
stderr: str
|
|
235
|
+
started_at: str
|
|
236
|
+
finished_at: str
|
|
237
|
+
timed_out: bool = False
|
|
238
|
+
# True when the stream exceeded the capture bound: ``stdout``/``stderr``
|
|
239
|
+
# then hold its head, an omission marker and its tail.
|
|
240
|
+
stdout_truncated: bool = False
|
|
241
|
+
stderr_truncated: bool = False
|
|
242
|
+
# Index into ``stdout`` where the contiguous-to-EOF tail begins (0 when
|
|
243
|
+
# nothing was omitted, so ``stdout_tail`` is then the whole stream).
|
|
244
|
+
stdout_tail_offset: int = 0
|
|
245
|
+
# How many bytes the child wrote on each stream, the omitted ones
|
|
246
|
+
# included: the capture keeps at most ``max_output_bytes`` of them.
|
|
247
|
+
stdout_bytes: int = 0
|
|
248
|
+
stderr_bytes: int = 0
|
|
249
|
+
# The child exited on its own but left processes behind (its pipes were
|
|
250
|
+
# still open or its group still had a member past the exit grace), so
|
|
251
|
+
# the group was killed; ``exit_code`` and the streams are the child's own.
|
|
252
|
+
# Never set together with ``timed_out``.
|
|
253
|
+
descendants_killed: bool = False
|
|
254
|
+
# A process was still in the group after the SIGKILL grace (stuck in the
|
|
255
|
+
# kernel, or not signallable from here): the kill was not clean and a
|
|
256
|
+
# leftover may still be running.
|
|
257
|
+
group_survived_kill: bool = False
|
|
258
|
+
# A pipe never reached EOF: a writer the group kill could not reach
|
|
259
|
+
# (one that left the group) still holds it; what was read before the
|
|
260
|
+
# capture was abandoned is what the streams contain.
|
|
261
|
+
capture_abandoned: bool = False
|
|
262
|
+
# ``contain_orphans`` only. A process the invocation started had left the
|
|
263
|
+
# group and outlived its parent (re-parented to the controller), so it
|
|
264
|
+
# was killed; set on any path, a timeout included.
|
|
265
|
+
orphans_killed: bool = False
|
|
266
|
+
# Such an orphan was still alive after the SIGKILL grace.
|
|
267
|
+
orphan_survived_kill: bool = False
|
|
268
|
+
# Containment was requested but this platform has no child subreaper, so
|
|
269
|
+
# a process that left the group was neither seen nor killed.
|
|
270
|
+
orphans_unchecked: bool = False
|
|
271
|
+
# Bytes of ``stdin_data`` the child never read: never written because
|
|
272
|
+
# the invocation ended first, or written and still in the pipe when it
|
|
273
|
+
# ended. Always the payload's tail, since the child reads a prefix. Read
|
|
274
|
+
# by any process holding the child's stdin counts as read; whether the
|
|
275
|
+
# child then used what it read is beyond what the executor can see.
|
|
276
|
+
stdin_unread: int = 0
|
|
277
|
+
# Which limit killed the child when ``timed_out``: ``"idle"`` (it wrote
|
|
278
|
+
# nothing for ``idle_timeout_seconds``) or ``"max_runtime"`` (the
|
|
279
|
+
# wall-clock ceiling); empty otherwise.
|
|
280
|
+
timeout_limit: str = ""
|
|
281
|
+
# When the child last wrote anything on stdout or stderr (UTC, ISO
|
|
282
|
+
# 8601); ``None`` when it wrote nothing at all.
|
|
283
|
+
last_activity_at: str | None = None
|
|
284
|
+
# The caller set :attr:`ExecutionRequest.stop` before the child exited,
|
|
285
|
+
# so the group was killed; ``exit_code`` is then -1. Never set together
|
|
286
|
+
# with ``timed_out``.
|
|
287
|
+
stopped: bool = False
|
|
288
|
+
# What the outer sandbox recorded for a request that carried a
|
|
289
|
+
# description (ADR 0005 D10.4); ``None`` for one that did not.
|
|
290
|
+
sandbox: SandboxFacts | None = None
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def stdout_tail(self) -> str:
|
|
294
|
+
"""The part of stdout captured contiguously up to EOF.
|
|
295
|
+
|
|
296
|
+
This is what a CONTROL_RESULT parser should search: a block found
|
|
297
|
+
here is one the child actually wrote whole, never one assembled
|
|
298
|
+
across the omission marker or a stale earlier block from the head.
|
|
299
|
+
"""
|
|
300
|
+
return self.stdout[self.stdout_tail_offset :]
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def truncated(self) -> bool:
|
|
304
|
+
return self.stdout_truncated or self.stderr_truncated
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def leftovers(self) -> str:
|
|
308
|
+
"""What the invocation left behind, for a log line; empty when nothing."""
|
|
309
|
+
return describe_leftovers(
|
|
310
|
+
descendants_killed=self.descendants_killed,
|
|
311
|
+
group_survived_kill=self.group_survived_kill,
|
|
312
|
+
capture_abandoned=self.capture_abandoned,
|
|
313
|
+
orphans_killed=self.orphans_killed,
|
|
314
|
+
orphan_survived_kill=self.orphan_survived_kill,
|
|
315
|
+
orphans_unchecked=self.orphans_unchecked,
|
|
316
|
+
sandbox_teardown=self.sandbox is not None and self.sandbox.enforced,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def ok(self) -> bool:
|
|
321
|
+
return (
|
|
322
|
+
not self.timed_out and not self.stopped and self.exit_code == 0 and not self.truncated
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def raise_if_failed(self) -> ExecutionResult:
|
|
326
|
+
if self.timed_out:
|
|
327
|
+
limit = f" ({self.timeout_limit} limit)" if self.timeout_limit else ""
|
|
328
|
+
raise ExecutionTimeoutError(f"command timed out{limit}: {' '.join(self.command)}")
|
|
329
|
+
if self.stopped:
|
|
330
|
+
raise ExecutionError(f"command was stopped: {' '.join(self.command)}")
|
|
331
|
+
if self.exit_code != 0:
|
|
332
|
+
raise ExecutionError(
|
|
333
|
+
f"command exited {self.exit_code}: {' '.join(self.command)}\n"
|
|
334
|
+
f"stderr (tail): {self.stderr[-2000:]}"
|
|
335
|
+
)
|
|
336
|
+
if self.truncated:
|
|
337
|
+
raise ExecutionError(
|
|
338
|
+
f"command output was truncated at the capture bound: {' '.join(self.command)}"
|
|
339
|
+
)
|
|
340
|
+
return self
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def describe_leftovers(
|
|
344
|
+
*,
|
|
345
|
+
descendants_killed: bool,
|
|
346
|
+
group_survived_kill: bool,
|
|
347
|
+
capture_abandoned: bool,
|
|
348
|
+
orphans_killed: bool = False,
|
|
349
|
+
orphan_survived_kill: bool = False,
|
|
350
|
+
orphans_unchecked: bool = False,
|
|
351
|
+
sandbox_teardown: bool = False,
|
|
352
|
+
) -> str:
|
|
353
|
+
"""One sentence naming what an invocation left behind; empty when nothing.
|
|
354
|
+
|
|
355
|
+
The facts are the executor's (see :class:`ExecutionResult`); the
|
|
356
|
+
sentence is for a run log or an error, so an operator is pointed at a
|
|
357
|
+
leftover process rather than at a slow agent or a clean kill.
|
|
358
|
+
``sandbox_teardown`` is every enforced sandboxed launch: what it left
|
|
359
|
+
inside the sandbox was killed with the namespace and never counted, so
|
|
360
|
+
the sentence says so rather than reporting nothing (ADR 0005 D10.4).
|
|
361
|
+
"""
|
|
362
|
+
parts: list[str] = []
|
|
363
|
+
if descendants_killed:
|
|
364
|
+
parts.append(
|
|
365
|
+
"the child exited but left processes behind (its output pipes stayed open or "
|
|
366
|
+
"its process group still had a member past the exit grace), so the group was killed"
|
|
367
|
+
)
|
|
368
|
+
if group_survived_kill:
|
|
369
|
+
parts.append(
|
|
370
|
+
"its process group still had a member after SIGKILL, so a leftover process "
|
|
371
|
+
"may still be running"
|
|
372
|
+
)
|
|
373
|
+
if capture_abandoned:
|
|
374
|
+
parts.append(
|
|
375
|
+
"its output pipes never reached EOF, so a process outside its group (one that "
|
|
376
|
+
"called setsid) still holds them and may still be running"
|
|
377
|
+
)
|
|
378
|
+
if orphans_killed:
|
|
379
|
+
parts.append(
|
|
380
|
+
"it left processes outside its process group (re-parented to the controller), "
|
|
381
|
+
"so they were killed"
|
|
382
|
+
)
|
|
383
|
+
if orphan_survived_kill:
|
|
384
|
+
parts.append(
|
|
385
|
+
"a process it left outside its process group was still alive after SIGKILL, so "
|
|
386
|
+
"it may still be running"
|
|
387
|
+
)
|
|
388
|
+
if orphans_unchecked:
|
|
389
|
+
parts.append(
|
|
390
|
+
"processes it may have left outside its process group could not be checked on "
|
|
391
|
+
"this platform (no child subreaper), so one may still be running"
|
|
392
|
+
)
|
|
393
|
+
if sandbox_teardown:
|
|
394
|
+
parts.append(
|
|
395
|
+
"whatever it left inside the sandbox was killed when the sandbox's namespace was "
|
|
396
|
+
"torn down, uncounted"
|
|
397
|
+
)
|
|
398
|
+
return "; ".join(parts)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass
|
|
402
|
+
class _Captured:
|
|
403
|
+
text: str
|
|
404
|
+
truncated: bool
|
|
405
|
+
tail_offset: int
|
|
406
|
+
# Every byte the stream carried, kept or not.
|
|
407
|
+
total: int = 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _BoundedBuffer:
|
|
411
|
+
"""Keep at most ``limit`` bytes of a stream: its head and its tail.
|
|
412
|
+
|
|
413
|
+
The first ``limit - limit // 2`` bytes are the head and the last
|
|
414
|
+
``limit // 2`` the tail; everything between is counted and dropped. The
|
|
415
|
+
tail is a ring buffer (one ``bytearray``, filled once and then
|
|
416
|
+
overwritten in place), so the memory the buffer holds is the limit plus a
|
|
417
|
+
constant, whatever the stream's chunking: a writer that produces one byte
|
|
418
|
+
per read costs no per-chunk objects. The tail is trimmed to the byte, so
|
|
419
|
+
what is retained never exceeds ``limit``, and a bound smaller than one
|
|
420
|
+
read is honoured too.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def __init__(self, limit: int, name: str) -> None:
|
|
424
|
+
self._name = name
|
|
425
|
+
self._limit = limit
|
|
426
|
+
self._head_limit = limit - limit // 2
|
|
427
|
+
self._tail_limit = limit // 2
|
|
428
|
+
self._head = bytearray()
|
|
429
|
+
# The ring grows by appending until it holds ``_tail_limit`` bytes and
|
|
430
|
+
# is overwritten in place from ``_pos`` after that; ``_pos`` is the
|
|
431
|
+
# oldest byte once the ring is full and 0 before.
|
|
432
|
+
self._ring = bytearray()
|
|
433
|
+
self._pos = 0
|
|
434
|
+
self._total = 0
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def retained(self) -> int:
|
|
438
|
+
"""Bytes currently held; never more than the limit."""
|
|
439
|
+
return len(self._head) + len(self._ring)
|
|
440
|
+
|
|
441
|
+
def feed(self, chunk: bytes) -> None:
|
|
442
|
+
self._total += len(chunk)
|
|
443
|
+
room = self._head_limit - len(self._head)
|
|
444
|
+
if room > 0:
|
|
445
|
+
self._head += chunk[:room]
|
|
446
|
+
chunk = chunk[room:]
|
|
447
|
+
size = self._tail_limit
|
|
448
|
+
if not chunk or size == 0:
|
|
449
|
+
return
|
|
450
|
+
if len(self._ring) < size:
|
|
451
|
+
fill = size - len(self._ring)
|
|
452
|
+
self._ring += chunk[:fill]
|
|
453
|
+
chunk = chunk[fill:]
|
|
454
|
+
if not chunk:
|
|
455
|
+
return
|
|
456
|
+
if len(chunk) >= size:
|
|
457
|
+
self._ring[:] = chunk[len(chunk) - size :]
|
|
458
|
+
self._pos = 0
|
|
459
|
+
return
|
|
460
|
+
end = self._pos + len(chunk)
|
|
461
|
+
if end <= size:
|
|
462
|
+
self._ring[self._pos : end] = chunk
|
|
463
|
+
else:
|
|
464
|
+
split = size - self._pos
|
|
465
|
+
self._ring[self._pos :] = chunk[:split]
|
|
466
|
+
self._ring[: len(chunk) - split] = chunk[split:]
|
|
467
|
+
self._pos = end % size
|
|
468
|
+
|
|
469
|
+
def _tail(self) -> bytes:
|
|
470
|
+
return bytes(self._ring[self._pos :] + self._ring[: self._pos])
|
|
471
|
+
|
|
472
|
+
def captured(self) -> _Captured:
|
|
473
|
+
tail = self._tail()
|
|
474
|
+
omitted = self._total - len(self._head) - len(tail)
|
|
475
|
+
if omitted <= 0:
|
|
476
|
+
# Nothing was dropped, so the split between head and tail is an
|
|
477
|
+
# internal detail: decode the stream whole, or a multi-byte
|
|
478
|
+
# character that straddles it would come out as replacements.
|
|
479
|
+
return _Captured(
|
|
480
|
+
(self._head + tail).decode("utf-8", errors="replace"), False, 0, self._total
|
|
481
|
+
)
|
|
482
|
+
# Past the bound the head and the tail are separate pieces of the
|
|
483
|
+
# stream; a character cut by the bound decodes to replacements on
|
|
484
|
+
# either side of the marker, which is what was captured.
|
|
485
|
+
head_text = self._head.decode("utf-8", errors="replace")
|
|
486
|
+
tail_text = tail.decode("utf-8", errors="replace")
|
|
487
|
+
marker = (
|
|
488
|
+
f"\n[autoforge: {omitted} bytes of {self._name} omitted; the capture bound is "
|
|
489
|
+
f"{self._limit} bytes, the first {len(self._head)} and last {len(tail)} "
|
|
490
|
+
"bytes were kept]\n"
|
|
491
|
+
)
|
|
492
|
+
return _Captured(
|
|
493
|
+
head_text + marker + tail_text, True, len(head_text) + len(marker), self._total
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class _Limits:
|
|
498
|
+
"""The limits one invocation runs under, and the activity that moves one (#193).
|
|
499
|
+
|
|
500
|
+
``idle`` (seconds, or ``None``): the invocation is over once the child
|
|
501
|
+
has written nothing on stdout or stderr for that long, counted from the
|
|
502
|
+
spawn until the first chunk and from the latest chunk after it.
|
|
503
|
+
``ceiling`` (seconds, or ``None``): measured from the spawn and never
|
|
504
|
+
moved. :meth:`deadline` is whichever is due first, recomputed on every
|
|
505
|
+
call, so a waiter that wakes at a deadline activity has since moved
|
|
506
|
+
simply waits again.
|
|
507
|
+
|
|
508
|
+
:meth:`touch` is called on the reader threads; it publishes the time of
|
|
509
|
+
the chunk in one assignment, so a waiter on another thread reads either
|
|
510
|
+
the previous chunk's time or this one's, never a mixture. Only output
|
|
511
|
+
touches it: what the controller writes to the child is not activity,
|
|
512
|
+
and neither is any time spent waiting on someone else.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(self, idle: float | None, ceiling: float | None) -> None:
|
|
516
|
+
self.idle = idle
|
|
517
|
+
self.ceiling = ceiling
|
|
518
|
+
self._started = time.monotonic()
|
|
519
|
+
# (monotonic, wall clock) of the latest chunk; None before the first.
|
|
520
|
+
self._last: tuple[float, float] | None = None
|
|
521
|
+
# (deadline, limit) once :meth:`pin` has fixed them.
|
|
522
|
+
self._pinned: tuple[float, str] | None = None
|
|
523
|
+
|
|
524
|
+
def touch(self) -> None:
|
|
525
|
+
"""A chunk of output arrived."""
|
|
526
|
+
self._last = (time.monotonic(), time.time())
|
|
527
|
+
|
|
528
|
+
def _due(self) -> tuple[float, str] | None:
|
|
529
|
+
idle_at = None
|
|
530
|
+
if self.idle is not None:
|
|
531
|
+
last = self._last
|
|
532
|
+
idle_at = (self._started if last is None else last[0]) + self.idle
|
|
533
|
+
ceiling_at = None if self.ceiling is None else self._started + self.ceiling
|
|
534
|
+
if ceiling_at is not None and (idle_at is None or ceiling_at <= idle_at):
|
|
535
|
+
return ceiling_at, LIMIT_MAX_RUNTIME
|
|
536
|
+
if idle_at is not None:
|
|
537
|
+
return idle_at, LIMIT_IDLE
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
def deadline(self) -> float | None:
|
|
541
|
+
"""When the invocation is over unless output arrives first
|
|
542
|
+
(``time.monotonic()``); ``None`` when no limit applies."""
|
|
543
|
+
due = self._pinned or self._due()
|
|
544
|
+
return None if due is None else due[0]
|
|
545
|
+
|
|
546
|
+
def limit(self) -> str:
|
|
547
|
+
"""The limit :meth:`deadline` belongs to; empty when none applies."""
|
|
548
|
+
due = self._pinned or self._due()
|
|
549
|
+
return "" if due is None else due[1]
|
|
550
|
+
|
|
551
|
+
def expired(self) -> bool:
|
|
552
|
+
deadline = self.deadline()
|
|
553
|
+
return deadline is not None and time.monotonic() >= deadline
|
|
554
|
+
|
|
555
|
+
def pin(self) -> None:
|
|
556
|
+
"""Fix the current deadline: output from here on no longer moves it.
|
|
557
|
+
|
|
558
|
+
For a caller that has decided to end the invocation and is giving
|
|
559
|
+
the child a bounded chance to shut down in order. Idempotent.
|
|
560
|
+
"""
|
|
561
|
+
if self._pinned is None:
|
|
562
|
+
self._pinned = self._due()
|
|
563
|
+
|
|
564
|
+
def last_activity_at(self) -> str | None:
|
|
565
|
+
"""When the latest chunk arrived (UTC, ISO 8601); ``None`` before any."""
|
|
566
|
+
last = self._last
|
|
567
|
+
return None if last is None else datetime.fromtimestamp(last[1], UTC).isoformat()
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _wait_within(
|
|
571
|
+
proc: subprocess.Popen, limits: _Limits, stop: threading.Event | None = None
|
|
572
|
+
) -> str:
|
|
573
|
+
"""Wait for the child's exit while it stays within ``limits`` and ``stop``
|
|
574
|
+
is unset.
|
|
575
|
+
|
|
576
|
+
Returns ``""`` once the child has exited, the limit that ran out
|
|
577
|
+
(:data:`LIMIT_IDLE`, :data:`LIMIT_MAX_RUNTIME`), or :data:`_STOPPED` when
|
|
578
|
+
``stop`` was set. Each slice waits until the deadline due at its start;
|
|
579
|
+
one that ends without the exit looks again, since output may have moved
|
|
580
|
+
the deadline meanwhile, and only a deadline still past when looked at
|
|
581
|
+
ends the wait. With ``stop`` no slice is longer than
|
|
582
|
+
:data:`_STOP_POLL_SECONDS`.
|
|
583
|
+
"""
|
|
584
|
+
while True:
|
|
585
|
+
# A child that has exited on its own by then is not stopped.
|
|
586
|
+
if stop is not None and stop.is_set() and proc.poll() is None:
|
|
587
|
+
return _STOPPED
|
|
588
|
+
deadline = limits.deadline()
|
|
589
|
+
timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
590
|
+
if stop is not None:
|
|
591
|
+
timeout = _STOP_POLL_SECONDS if timeout is None else min(timeout, _STOP_POLL_SECONDS)
|
|
592
|
+
try:
|
|
593
|
+
proc.wait(timeout=timeout)
|
|
594
|
+
except subprocess.TimeoutExpired:
|
|
595
|
+
if limits.expired():
|
|
596
|
+
return limits.limit()
|
|
597
|
+
continue
|
|
598
|
+
return ""
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class _PipeDrain(threading.Thread):
|
|
602
|
+
"""Drain one pipe until EOF or abandoned, handing each chunk to :meth:`_feed`.
|
|
603
|
+
|
|
604
|
+
The pipe is drained whatever the child writes, so the child never blocks
|
|
605
|
+
on a full pipe the way it would if the controller stopped reading. EOF
|
|
606
|
+
arrives only once every writer is gone; :meth:`abandon` ends the read
|
|
607
|
+
without it, for a writer nothing here can kill. What a chunk becomes
|
|
608
|
+
(bounded capture here, LF-framed records in ``executor_duplex``) is the
|
|
609
|
+
subclass's. ``activity``, when given, is touched before each chunk is
|
|
610
|
+
handed on (see :class:`_Limits`), so whoever is woken by the chunk
|
|
611
|
+
already sees the deadline it moved.
|
|
612
|
+
|
|
613
|
+
Building a reader takes nothing that must be given back: the wake pipe
|
|
614
|
+
is opened by :meth:`start` and released by :meth:`close`. A launch holds
|
|
615
|
+
each reader before it starts it, so its cleanup reaches every pipe
|
|
616
|
+
taken, and a reader that fails while it is built (its buffer cannot be
|
|
617
|
+
allocated, say) holds nothing for it to reach.
|
|
618
|
+
"""
|
|
619
|
+
|
|
620
|
+
def __init__(self, stream: IO[bytes], name: str, activity: _Limits | None = None) -> None:
|
|
621
|
+
super().__init__(name=f"autoforge-capture-{name}", daemon=True)
|
|
622
|
+
self._fd = stream.fileno()
|
|
623
|
+
self._activity = activity
|
|
624
|
+
# (read end, write end): opened by :meth:`start`, closed by :meth:`close`.
|
|
625
|
+
self._wake: tuple[int, int] | None = None
|
|
626
|
+
self.error: OSError | None = None
|
|
627
|
+
|
|
628
|
+
def start(self) -> None:
|
|
629
|
+
"""Open the wake pipe and start the thread. A thread that cannot be
|
|
630
|
+
started leaves the pipe open for :meth:`close`."""
|
|
631
|
+
self._wake = os.pipe()
|
|
632
|
+
super().start()
|
|
633
|
+
|
|
634
|
+
def run(self) -> None:
|
|
635
|
+
assert self._wake is not None # opened by start() before the thread began
|
|
636
|
+
wake_r = self._wake[0]
|
|
637
|
+
eof = False
|
|
638
|
+
try:
|
|
639
|
+
with selectors.DefaultSelector() as sel:
|
|
640
|
+
sel.register(self._fd, selectors.EVENT_READ)
|
|
641
|
+
sel.register(wake_r, selectors.EVENT_READ)
|
|
642
|
+
while True:
|
|
643
|
+
ready = {key.fd for key, _ in sel.select()}
|
|
644
|
+
if wake_r in ready:
|
|
645
|
+
return
|
|
646
|
+
chunk = os.read(self._fd, _READ_CHUNK_BYTES)
|
|
647
|
+
if not chunk:
|
|
648
|
+
eof = True
|
|
649
|
+
return
|
|
650
|
+
if self._activity is not None:
|
|
651
|
+
self._activity.touch()
|
|
652
|
+
self._feed(chunk)
|
|
653
|
+
except OSError as exc:
|
|
654
|
+
self.error = exc
|
|
655
|
+
finally:
|
|
656
|
+
self._ended(eof)
|
|
657
|
+
|
|
658
|
+
def _feed(self, chunk: bytes) -> None:
|
|
659
|
+
raise NotImplementedError
|
|
660
|
+
|
|
661
|
+
def _ended(self, eof: bool) -> None:
|
|
662
|
+
"""Called once on the reader thread when the read ends; ``eof`` is
|
|
663
|
+
False when it was abandoned or failed."""
|
|
664
|
+
|
|
665
|
+
def wait(self, deadline: float | None) -> bool:
|
|
666
|
+
"""Join until EOF or ``deadline`` (``time.monotonic()``); True on EOF."""
|
|
667
|
+
self.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
|
|
668
|
+
return not self.is_alive()
|
|
669
|
+
|
|
670
|
+
def abandon(self) -> None:
|
|
671
|
+
"""Stop reading without EOF; what was read so far is what is captured."""
|
|
672
|
+
assert self._wake is not None # only a started reader is abandoned
|
|
673
|
+
os.write(self._wake[1], b"\0")
|
|
674
|
+
self.join()
|
|
675
|
+
|
|
676
|
+
def close(self) -> None:
|
|
677
|
+
"""Release the wake pipe, if :meth:`start` opened one. Idempotent."""
|
|
678
|
+
if self._wake is None:
|
|
679
|
+
return
|
|
680
|
+
for fd in self._wake:
|
|
681
|
+
try:
|
|
682
|
+
os.close(fd)
|
|
683
|
+
except OSError:
|
|
684
|
+
pass
|
|
685
|
+
self._wake = None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class _OutputObserver:
|
|
689
|
+
""":attr:`ExecutionRequest.on_output` and :attr:`~ExecutionRequest.on_chunk`,
|
|
690
|
+
shared by both readers of one invocation.
|
|
691
|
+
|
|
692
|
+
Calls are serialized, and the first exception a hook raises unhooks that
|
|
693
|
+
hook for both streams: a call the other reader was waiting to make finds
|
|
694
|
+
it gone, so a failing hook is called once and can never stop either
|
|
695
|
+
drain, nor the other hook.
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
def __init__(
|
|
699
|
+
self,
|
|
700
|
+
on_output: Callable[[str], None] | None,
|
|
701
|
+
on_chunk: Callable[[str, bytes], None] | None = None,
|
|
702
|
+
) -> None:
|
|
703
|
+
self._on_output = on_output
|
|
704
|
+
self._on_chunk = on_chunk
|
|
705
|
+
self._lock = threading.Lock()
|
|
706
|
+
|
|
707
|
+
def __call__(self, stream: str, chunk: bytes) -> None:
|
|
708
|
+
with self._lock:
|
|
709
|
+
if self._on_chunk is not None:
|
|
710
|
+
try:
|
|
711
|
+
self._on_chunk(stream, chunk)
|
|
712
|
+
except Exception:
|
|
713
|
+
self._on_chunk = None
|
|
714
|
+
if self._on_output is not None:
|
|
715
|
+
try:
|
|
716
|
+
self._on_output(stream)
|
|
717
|
+
except Exception:
|
|
718
|
+
self._on_output = None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class _BoundedReader(_PipeDrain):
|
|
722
|
+
"""Drain one pipe into a :class:`_BoundedBuffer` until EOF or abandoned.
|
|
723
|
+
|
|
724
|
+
``observer``, when given, is handed the stream's name and the chunk
|
|
725
|
+
after each chunk is captured.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
def __init__(
|
|
729
|
+
self,
|
|
730
|
+
stream: IO[bytes],
|
|
731
|
+
limit: int,
|
|
732
|
+
name: str,
|
|
733
|
+
observer: _OutputObserver | None = None,
|
|
734
|
+
activity: _Limits | None = None,
|
|
735
|
+
) -> None:
|
|
736
|
+
super().__init__(stream, name, activity)
|
|
737
|
+
self.buffer = _BoundedBuffer(limit, name)
|
|
738
|
+
self._stream_name = name
|
|
739
|
+
self._observer = observer
|
|
740
|
+
|
|
741
|
+
def _feed(self, chunk: bytes) -> None:
|
|
742
|
+
self.buffer.feed(chunk)
|
|
743
|
+
if self._observer is not None:
|
|
744
|
+
self._observer(self._stream_name, chunk)
|
|
745
|
+
|
|
746
|
+
def captured(self) -> _Captured:
|
|
747
|
+
"""Decode what was kept; call only once the thread has ended."""
|
|
748
|
+
return self.buffer.captured()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
class _StdinFeeder(threading.Thread):
|
|
752
|
+
"""Write ``data`` to the child's stdin, close it so the child sees EOF,
|
|
753
|
+
and count what the child never read.
|
|
754
|
+
|
|
755
|
+
The child's stdin is :attr:`child_end`, the read end of a pipe the
|
|
756
|
+
feeder owns. The feeder holds that read end too, until :meth:`stop`:
|
|
757
|
+
written is not read, and a pipe discards what is in it once its last
|
|
758
|
+
reader is gone, so only a read end kept past the child's exit can show
|
|
759
|
+
that the child left part of the payload unread (:attr:`unread`). With a
|
|
760
|
+
reader always present a write never fails with EPIPE: a child that
|
|
761
|
+
stops reading leaves the pipe full and the thread parked.
|
|
762
|
+
|
|
763
|
+
The write end is non-blocking and the thread waits on it and on a wake
|
|
764
|
+
pipe, so a child that never reads its stdin (or a descendant that holds
|
|
765
|
+
the pipe and never reads) costs a parked thread that :meth:`stop` ends
|
|
766
|
+
at once, never a hung invocation.
|
|
767
|
+
"""
|
|
768
|
+
|
|
769
|
+
def __init__(self, data: bytes) -> None:
|
|
770
|
+
super().__init__(name="autoforge-feed-stdin", daemon=True)
|
|
771
|
+
self._data = data
|
|
772
|
+
self.child_end, self._write_end = os.pipe()
|
|
773
|
+
try:
|
|
774
|
+
self._wake_r, self._wake_w = os.pipe()
|
|
775
|
+
except BaseException:
|
|
776
|
+
os.close(self.child_end)
|
|
777
|
+
os.close(self._write_end)
|
|
778
|
+
raise
|
|
779
|
+
self._unwritten = len(data)
|
|
780
|
+
# Final once :meth:`stop` has returned.
|
|
781
|
+
self.unread = len(data)
|
|
782
|
+
self.error: OSError | None = None
|
|
783
|
+
|
|
784
|
+
def run(self) -> None:
|
|
785
|
+
view = memoryview(self._data)
|
|
786
|
+
try:
|
|
787
|
+
os.set_blocking(self._write_end, False)
|
|
788
|
+
with selectors.DefaultSelector() as sel:
|
|
789
|
+
sel.register(self._write_end, selectors.EVENT_WRITE)
|
|
790
|
+
sel.register(self._wake_r, selectors.EVENT_READ)
|
|
791
|
+
while view:
|
|
792
|
+
ready = {key.fd for key, _ in sel.select()}
|
|
793
|
+
if self._wake_r in ready:
|
|
794
|
+
return
|
|
795
|
+
try:
|
|
796
|
+
view = view[os.write(self._write_end, view) :]
|
|
797
|
+
except BlockingIOError:
|
|
798
|
+
continue
|
|
799
|
+
except OSError as exc:
|
|
800
|
+
self.error = exc
|
|
801
|
+
finally:
|
|
802
|
+
self._unwritten = len(view)
|
|
803
|
+
os.close(self._write_end)
|
|
804
|
+
|
|
805
|
+
def stop(self) -> None:
|
|
806
|
+
"""End the feed if it is still writing, wait for the thread, count
|
|
807
|
+
what was left unread and release the pipes.
|
|
808
|
+
|
|
809
|
+
Called once the invocation is over (or its spawn failed, in which
|
|
810
|
+
case the thread never started and nothing was written).
|
|
811
|
+
"""
|
|
812
|
+
if self.ident is None:
|
|
813
|
+
os.close(self._write_end)
|
|
814
|
+
else:
|
|
815
|
+
os.write(self._wake_w, b"\0")
|
|
816
|
+
self.join()
|
|
817
|
+
written = len(self._data) - self._unwritten
|
|
818
|
+
self.unread = self._unwritten + _drain(self.child_end, written)
|
|
819
|
+
for fd in (self.child_end, self._wake_r, self._wake_w):
|
|
820
|
+
os.close(fd)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _drain(fd: int, limit: int) -> int:
|
|
824
|
+
"""Read and count what is left in the pipe ``fd`` reads from, at most
|
|
825
|
+
``limit`` bytes (all that was ever written to it).
|
|
826
|
+
|
|
827
|
+
Every write end is closed by then, so the pipe reads to EOF; the
|
|
828
|
+
descriptor is non-blocking anyway, so a writer that is somehow still
|
|
829
|
+
there ends the count rather than hanging it.
|
|
830
|
+
"""
|
|
831
|
+
os.set_blocking(fd, False)
|
|
832
|
+
count = 0
|
|
833
|
+
while count < limit:
|
|
834
|
+
try:
|
|
835
|
+
chunk = os.read(fd, min(limit - count, 1 << 16))
|
|
836
|
+
except BlockingIOError:
|
|
837
|
+
break
|
|
838
|
+
if not chunk:
|
|
839
|
+
break
|
|
840
|
+
count += len(chunk)
|
|
841
|
+
return count
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _now() -> str:
|
|
845
|
+
return datetime.now(UTC).isoformat()
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _signal_group(pgid: int, sig: signal.Signals) -> bool:
|
|
849
|
+
"""Signal a process group; False when no process is left in it."""
|
|
850
|
+
try:
|
|
851
|
+
os.killpg(pgid, sig)
|
|
852
|
+
except ProcessLookupError:
|
|
853
|
+
return False
|
|
854
|
+
return True
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _reaped(proc: subprocess.Popen, deadline: float) -> bool:
|
|
858
|
+
try:
|
|
859
|
+
proc.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
860
|
+
except subprocess.TimeoutExpired:
|
|
861
|
+
return False
|
|
862
|
+
return True
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _eof(readers: tuple[_PipeDrain, ...], deadline: float | None) -> bool:
|
|
866
|
+
return all(reader.wait(deadline) for reader in readers)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _group_alive(pgid: int) -> bool:
|
|
870
|
+
"""True while any process still belongs to the group.
|
|
871
|
+
|
|
872
|
+
The group id stays reserved while a member lives, however the member was
|
|
873
|
+
started and wherever its output goes; ``EPERM`` names a member that
|
|
874
|
+
cannot be signalled from here, which is still a member.
|
|
875
|
+
"""
|
|
876
|
+
try:
|
|
877
|
+
os.killpg(pgid, 0)
|
|
878
|
+
except ProcessLookupError:
|
|
879
|
+
return False
|
|
880
|
+
except PermissionError:
|
|
881
|
+
return True
|
|
882
|
+
return True
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _group_gone(pgid: int, deadline: float, contained: _Containment | None = None) -> bool:
|
|
886
|
+
"""Poll until the group has no member left or ``deadline``; True when gone.
|
|
887
|
+
|
|
888
|
+
A ``contained`` invocation that adopts its group's dead
|
|
889
|
+
(:attr:`_Containment.adopts_group`) has them reaped as the poll goes,
|
|
890
|
+
since a zombie still counts as a member until it is reaped.
|
|
891
|
+
"""
|
|
892
|
+
while _group_alive(pgid):
|
|
893
|
+
if contained is not None and contained.adopts_group:
|
|
894
|
+
contained.reap_dead()
|
|
895
|
+
if not _group_alive(pgid):
|
|
896
|
+
break
|
|
897
|
+
if time.monotonic() >= deadline:
|
|
898
|
+
return False
|
|
899
|
+
time.sleep(_GROUP_POLL_SECONDS)
|
|
900
|
+
return True
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
# prctl(2) options; Linux only.
|
|
904
|
+
_PR_SET_CHILD_SUBREAPER = 36
|
|
905
|
+
_PR_GET_CHILD_SUBREAPER = 37
|
|
906
|
+
# How often a contained invocation reaps the orphans that died while its
|
|
907
|
+
# child runs. Each zombie holds a pid against the session's pids.max until it
|
|
908
|
+
# is reaped, so this bounds the backlog to what the child's tools can orphan
|
|
909
|
+
# in one interval.
|
|
910
|
+
_REAP_INTERVAL_SECONDS = 0.25
|
|
911
|
+
|
|
912
|
+
# One contained invocation at a time per controller process: the orphans of
|
|
913
|
+
# an invocation are told apart from the controller's other children by being
|
|
914
|
+
# new, which holds only while nothing else spawns meanwhile. The engine runs
|
|
915
|
+
# its invocations one after another, so this never waits; a second one is a
|
|
916
|
+
# refusal, not a queue.
|
|
917
|
+
_CONTAINMENT_LOCK = threading.Lock()
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _prctl() -> Callable[..., int] | None:
|
|
921
|
+
"""libc's ``prctl``, or None where there is none (not Linux)."""
|
|
922
|
+
if not sys.platform.startswith("linux"):
|
|
923
|
+
return None
|
|
924
|
+
try:
|
|
925
|
+
return ctypes.CDLL(None, use_errno=True).prctl # type: ignore[no-any-return]
|
|
926
|
+
except (OSError, AttributeError):
|
|
927
|
+
return None
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def orphan_containment_available() -> bool:
|
|
931
|
+
"""Whether a contained invocation can catch its orphans here (ADR 0002 §4b).
|
|
932
|
+
|
|
933
|
+
True where the controller can make itself a child subreaper, as
|
|
934
|
+
:meth:`_Containment.begin` does: the setting is read, set and restored,
|
|
935
|
+
so a host whose security policy permits reading it but denies setting it
|
|
936
|
+
(a seccomp filter, say) is not taken for one that can contain. Elsewhere
|
|
937
|
+
a contained request runs uncontained and its result carries
|
|
938
|
+
``orphans_unchecked``: a process that left the child's group may outlive
|
|
939
|
+
the invocation.
|
|
940
|
+
"""
|
|
941
|
+
prctl = _prctl()
|
|
942
|
+
if prctl is None:
|
|
943
|
+
return False
|
|
944
|
+
current = ctypes.c_int(0)
|
|
945
|
+
if not _CONTAINMENT_LOCK.acquire(blocking=False):
|
|
946
|
+
# A contained invocation of this process owns the setting, which is
|
|
947
|
+
# only read here: it is set when that invocation could set it.
|
|
948
|
+
return (
|
|
949
|
+
prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(current), 0, 0, 0) == 0
|
|
950
|
+
and current.value == 1
|
|
951
|
+
)
|
|
952
|
+
try:
|
|
953
|
+
if prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(current), 0, 0, 0) != 0:
|
|
954
|
+
return False
|
|
955
|
+
if prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
|
|
956
|
+
return False
|
|
957
|
+
prctl(_PR_SET_CHILD_SUBREAPER, current.value, 0, 0, 0)
|
|
958
|
+
return True
|
|
959
|
+
finally:
|
|
960
|
+
_CONTAINMENT_LOCK.release()
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _children() -> dict[tuple[int, int], bool]:
|
|
964
|
+
"""This process's children as ``(pid, start time) -> is a zombie``.
|
|
965
|
+
|
|
966
|
+
The start time (``/proc/<pid>/stat`` field 22) makes the key unique across
|
|
967
|
+
a pid reuse. Children are listed through ``/proc/self/task/*/children``
|
|
968
|
+
when the kernel provides it and by a scan of ``/proc`` otherwise; a
|
|
969
|
+
process that is gone by the time it is read is simply not listed.
|
|
970
|
+
"""
|
|
971
|
+
own = os.getpid()
|
|
972
|
+
pids: set[int] = set()
|
|
973
|
+
try:
|
|
974
|
+
for task in os.scandir(f"/proc/{own}/task"):
|
|
975
|
+
with open(f"{task.path}/children") as fh:
|
|
976
|
+
pids.update(int(pid) for pid in fh.read().split())
|
|
977
|
+
scan = False
|
|
978
|
+
except OSError:
|
|
979
|
+
scan = True
|
|
980
|
+
if scan:
|
|
981
|
+
pids = {int(entry.name) for entry in os.scandir("/proc") if entry.name.isdigit()}
|
|
982
|
+
found: dict[tuple[int, int], bool] = {}
|
|
983
|
+
for pid in pids:
|
|
984
|
+
try:
|
|
985
|
+
with open(f"/proc/{pid}/stat", "rb") as fh:
|
|
986
|
+
stat = fh.read()
|
|
987
|
+
except OSError:
|
|
988
|
+
continue
|
|
989
|
+
# ``pid (comm) state ppid ...``: comm may hold spaces and parentheses,
|
|
990
|
+
# so the fields are counted from the last ``)``.
|
|
991
|
+
fields = stat[stat.rfind(b")") + 2 :].split()
|
|
992
|
+
if len(fields) < 20 or int(fields[1]) != own:
|
|
993
|
+
continue
|
|
994
|
+
found[(pid, int(fields[19]))] = fields[0] == b"Z"
|
|
995
|
+
return found
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
class _Containment:
|
|
999
|
+
"""Catch the orphans of one invocation (the ADR 0002 amendment, #132).
|
|
1000
|
+
|
|
1001
|
+
A process that calls ``setsid`` (or is spawned detached, as Pi's bash tool
|
|
1002
|
+
spawns every command) leaves the child's process group, and the group kill
|
|
1003
|
+
cannot reach it. On Linux the controller marks itself a child subreaper
|
|
1004
|
+
for the invocation, so when such a process's parent dies it is
|
|
1005
|
+
re-parented to the controller rather than to init: the orphans are then
|
|
1006
|
+
exactly the controller's children that were not there before the
|
|
1007
|
+
invocation and are not the child itself. Being the controller's unreaped
|
|
1008
|
+
children, their pids cannot be reused, so signalling them is safe.
|
|
1009
|
+
|
|
1010
|
+
Each orphan is signalled with its process group (unless that is the
|
|
1011
|
+
controller's own), which reaches what it started in turn; whatever is
|
|
1012
|
+
re-parented as its parents die is signalled when it appears. Dead orphans
|
|
1013
|
+
are reaped here, since nothing else waits for them, and from the start:
|
|
1014
|
+
a reaper thread reaps them every ``_REAP_INTERVAL_SECONDS`` while the
|
|
1015
|
+
child runs. An agent's tools orphan short-lived processes all along (a
|
|
1016
|
+
test suite's helpers, a shell's background jobs), and every zombie
|
|
1017
|
+
holds a pid against the session's ``pids.max``, so over an hour-long
|
|
1018
|
+
run they pile up until no fork in the session succeeds and the agent
|
|
1019
|
+
itself aborts. The reaper never reaps the child (its exit status
|
|
1020
|
+
belongs to its ``Popen``) or a child the controller had before. The
|
|
1021
|
+
first look at the orphans after the child's run (the exit grace, the
|
|
1022
|
+
kill) stops it, so from there the caller's own polling reaps and
|
|
1023
|
+
signals alone. :meth:`release` restores the previous subreaper setting;
|
|
1024
|
+
an orphan still alive then stays the controller's child, and is
|
|
1025
|
+
reported, not hidden.
|
|
1026
|
+
"""
|
|
1027
|
+
|
|
1028
|
+
def __init__(self, prctl: Callable[..., int], previous: int, adopts_group: bool) -> None:
|
|
1029
|
+
self._prctl = prctl
|
|
1030
|
+
self._previous = previous
|
|
1031
|
+
# A sandboxed launch (ADR 0005): when the backend is killed, its
|
|
1032
|
+
# process inside the PID namespace is re-parented here and dies at
|
|
1033
|
+
# once (the namespace's teardown), a zombie still in the child's
|
|
1034
|
+
# group. The group checks reap it as they poll (:func:`_group_gone`)
|
|
1035
|
+
# instead of waiting out a grace for a member that is already dead.
|
|
1036
|
+
self.adopts_group = adopts_group
|
|
1037
|
+
self._before = frozenset(_children())
|
|
1038
|
+
self.child: int | None = None
|
|
1039
|
+
self.found = False
|
|
1040
|
+
self._signalled: dict[signal.Signals, set[tuple[int, int]]] = {}
|
|
1041
|
+
self._released = False
|
|
1042
|
+
self._stop_reaper = threading.Event()
|
|
1043
|
+
self._reaper = threading.Thread(
|
|
1044
|
+
target=self._reap_until_stopped, name="autoforge-reaper", daemon=True
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
@classmethod
|
|
1048
|
+
def begin(cls, *, adopts_group: bool = False) -> _Containment | None:
|
|
1049
|
+
"""Start containing; None when this platform has no child subreaper.
|
|
1050
|
+
|
|
1051
|
+
Raises :class:`ExecutionError` when another contained invocation is
|
|
1052
|
+
running in this process, or the reaper thread cannot be started
|
|
1053
|
+
(nothing has been spawned yet, so nothing is left behind).
|
|
1054
|
+
"""
|
|
1055
|
+
prctl = _prctl()
|
|
1056
|
+
if prctl is None:
|
|
1057
|
+
return None
|
|
1058
|
+
if not _CONTAINMENT_LOCK.acquire(blocking=False):
|
|
1059
|
+
raise ExecutionError(
|
|
1060
|
+
"another orphan-contained invocation is already running in this process"
|
|
1061
|
+
)
|
|
1062
|
+
try:
|
|
1063
|
+
previous = ctypes.c_int(0)
|
|
1064
|
+
if prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(previous), 0, 0, 0) != 0:
|
|
1065
|
+
_CONTAINMENT_LOCK.release()
|
|
1066
|
+
return None
|
|
1067
|
+
contained = cls(prctl, previous.value, adopts_group)
|
|
1068
|
+
if prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
|
|
1069
|
+
_CONTAINMENT_LOCK.release()
|
|
1070
|
+
return None
|
|
1071
|
+
except BaseException:
|
|
1072
|
+
_CONTAINMENT_LOCK.release()
|
|
1073
|
+
raise
|
|
1074
|
+
try:
|
|
1075
|
+
contained._reaper.start()
|
|
1076
|
+
except BaseException as exc:
|
|
1077
|
+
contained.release()
|
|
1078
|
+
if isinstance(exc, RuntimeError):
|
|
1079
|
+
raise ExecutionError(f"failed to start the orphan reaper: {exc}") from exc
|
|
1080
|
+
raise
|
|
1081
|
+
return contained
|
|
1082
|
+
|
|
1083
|
+
def _reap_until_stopped(self) -> None:
|
|
1084
|
+
"""The reaper thread: reap the dead orphans until told to stop."""
|
|
1085
|
+
while not self._stop_reaper.wait(_REAP_INTERVAL_SECONDS):
|
|
1086
|
+
child = self.child
|
|
1087
|
+
if child is None:
|
|
1088
|
+
continue # not spawned yet: a zombie now could be the child itself
|
|
1089
|
+
try:
|
|
1090
|
+
found = _children()
|
|
1091
|
+
except OSError:
|
|
1092
|
+
continue # /proc unreadable for a moment; the next pass looks again
|
|
1093
|
+
for key, zombie in found.items():
|
|
1094
|
+
if zombie and key not in self._before and key[0] != child:
|
|
1095
|
+
try:
|
|
1096
|
+
os.waitpid(key[0], os.WNOHANG)
|
|
1097
|
+
except ChildProcessError:
|
|
1098
|
+
pass
|
|
1099
|
+
|
|
1100
|
+
def _stop_reaping(self) -> None:
|
|
1101
|
+
"""Stop the reaper thread and wait for it; from here the caller reaps.
|
|
1102
|
+
|
|
1103
|
+
Only the thread that signals the orphans reaps them from then on, so
|
|
1104
|
+
an orphan it is about to signal cannot be reaped, and its pid reused,
|
|
1105
|
+
in between.
|
|
1106
|
+
"""
|
|
1107
|
+
self._stop_reaper.set()
|
|
1108
|
+
if self._reaper.ident is not None:
|
|
1109
|
+
self._reaper.join()
|
|
1110
|
+
|
|
1111
|
+
def _live(self) -> set[tuple[int, int]]:
|
|
1112
|
+
"""The orphans still alive; the dead ones are reaped on the way.
|
|
1113
|
+
|
|
1114
|
+
A process still in the child's group was re-parented here too when
|
|
1115
|
+
its parent died, but it is the group kill's, not an orphan: it is
|
|
1116
|
+
reaped once dead and otherwise left to the group checks. The first
|
|
1117
|
+
call stops the reaper thread.
|
|
1118
|
+
"""
|
|
1119
|
+
self._stop_reaping()
|
|
1120
|
+
live: set[tuple[int, int]] = set()
|
|
1121
|
+
for key, zombie in _children().items():
|
|
1122
|
+
pid = key[0]
|
|
1123
|
+
if key in self._before or pid == self.child:
|
|
1124
|
+
continue
|
|
1125
|
+
if zombie:
|
|
1126
|
+
try:
|
|
1127
|
+
os.waitpid(pid, os.WNOHANG)
|
|
1128
|
+
except ChildProcessError:
|
|
1129
|
+
pass
|
|
1130
|
+
for sent in self._signalled.values():
|
|
1131
|
+
sent.discard(key)
|
|
1132
|
+
continue
|
|
1133
|
+
try:
|
|
1134
|
+
in_group = os.getpgid(pid) == self.child
|
|
1135
|
+
except OSError:
|
|
1136
|
+
continue # gone meanwhile; reaped on the next look
|
|
1137
|
+
if not in_group:
|
|
1138
|
+
live.add(key)
|
|
1139
|
+
return live
|
|
1140
|
+
|
|
1141
|
+
def _send(self, key: tuple[int, int], sig: signal.Signals) -> None:
|
|
1142
|
+
pid = key[0]
|
|
1143
|
+
self._signalled.setdefault(sig, set()).add(key)
|
|
1144
|
+
self.found = True
|
|
1145
|
+
try:
|
|
1146
|
+
pgid = os.getpgid(pid)
|
|
1147
|
+
if pgid != os.getpgrp():
|
|
1148
|
+
os.killpg(pgid, sig)
|
|
1149
|
+
os.kill(pid, sig)
|
|
1150
|
+
except OSError:
|
|
1151
|
+
pass # exited meanwhile; it is reaped on the next look
|
|
1152
|
+
|
|
1153
|
+
def send_all(self, sig: signal.Signals) -> bool:
|
|
1154
|
+
"""Send ``sig`` to every live orphan; False when there is none."""
|
|
1155
|
+
live = self._live()
|
|
1156
|
+
for key in live:
|
|
1157
|
+
self._send(key, sig)
|
|
1158
|
+
return bool(live)
|
|
1159
|
+
|
|
1160
|
+
def gone(self, deadline: float, sig: signal.Signals) -> bool:
|
|
1161
|
+
"""Poll until no orphan is alive or ``deadline``; True when none is.
|
|
1162
|
+
|
|
1163
|
+
An orphan that appears meanwhile (re-parented as its parent died) is
|
|
1164
|
+
sent ``sig`` too.
|
|
1165
|
+
"""
|
|
1166
|
+
while True:
|
|
1167
|
+
live = self._live()
|
|
1168
|
+
for key in live - self._signalled.get(sig, set()):
|
|
1169
|
+
self._send(key, sig)
|
|
1170
|
+
if not live:
|
|
1171
|
+
return True
|
|
1172
|
+
if time.monotonic() >= deadline:
|
|
1173
|
+
return False
|
|
1174
|
+
time.sleep(_GROUP_POLL_SECONDS)
|
|
1175
|
+
|
|
1176
|
+
def settled(self, deadline: float) -> bool:
|
|
1177
|
+
"""Poll until no orphan is alive or ``deadline``, signalling nothing."""
|
|
1178
|
+
while self._live():
|
|
1179
|
+
if time.monotonic() >= deadline:
|
|
1180
|
+
return False
|
|
1181
|
+
time.sleep(_GROUP_POLL_SECONDS)
|
|
1182
|
+
return True
|
|
1183
|
+
|
|
1184
|
+
def alive(self) -> bool:
|
|
1185
|
+
return bool(self._live())
|
|
1186
|
+
|
|
1187
|
+
def reap_dead(self) -> None:
|
|
1188
|
+
"""Reap the dead among the invocation's new children now (:meth:`_live`)."""
|
|
1189
|
+
self._live()
|
|
1190
|
+
|
|
1191
|
+
def release(self) -> None:
|
|
1192
|
+
"""Stop the reaper, reap what has died and restore the previous
|
|
1193
|
+
subreaper setting. Idempotent."""
|
|
1194
|
+
if self._released:
|
|
1195
|
+
return
|
|
1196
|
+
self._released = True
|
|
1197
|
+
try:
|
|
1198
|
+
self._live()
|
|
1199
|
+
finally:
|
|
1200
|
+
self._prctl(_PR_SET_CHILD_SUBREAPER, self._previous, 0, 0, 0)
|
|
1201
|
+
_CONTAINMENT_LOCK.release()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@dataclass(frozen=True)
|
|
1205
|
+
class _Termination:
|
|
1206
|
+
"""What a group kill left behind: nothing, when both are False."""
|
|
1207
|
+
|
|
1208
|
+
# A process was still in the group after the SIGKILL grace.
|
|
1209
|
+
group_survived: bool
|
|
1210
|
+
# A pipe never reached EOF and the capture was abandoned.
|
|
1211
|
+
capture_abandoned: bool
|
|
1212
|
+
# An orphan (see :class:`_Containment`) was still alive after SIGKILL.
|
|
1213
|
+
orphan_survived: bool = False
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _terminate_group(
|
|
1217
|
+
pgid: int,
|
|
1218
|
+
proc: subprocess.Popen,
|
|
1219
|
+
readers: tuple[_PipeDrain, ...],
|
|
1220
|
+
contained: _Containment | None = None,
|
|
1221
|
+
) -> _Termination:
|
|
1222
|
+
"""SIGTERM the child's process group, escalate to SIGKILL, and stop reading.
|
|
1223
|
+
|
|
1224
|
+
``pgid`` is signalled rather than ``proc``: the child leads its own group
|
|
1225
|
+
(``start_new_session``), so its descendants can be reached after the
|
|
1226
|
+
child itself has exited and been reaped, and the group id stays reserved
|
|
1227
|
+
while any of them lives. The group is dead when the child is reaped, its
|
|
1228
|
+
pipes reached EOF *and* no process is left in the group; the last check
|
|
1229
|
+
is what catches a descendant that closed its inherited pipes and ignores
|
|
1230
|
+
SIGTERM, which the first two cannot see. Every wait here is bounded by
|
|
1231
|
+
the kill grace, so the whole function returns within two grace periods
|
|
1232
|
+
(SIGTERM, then SIGKILL) whatever the group left behind. What survives
|
|
1233
|
+
the SIGKILL grace cannot be dealt with from here and is not waited for:
|
|
1234
|
+
a member stuck in the kernel (uninterruptible sleep) or not signallable,
|
|
1235
|
+
the direct child included, and a writer that left the group (``setsid``)
|
|
1236
|
+
and so was never reached. The capture is then abandoned, and a child
|
|
1237
|
+
still unreaped is left to the ``subprocess`` module, which reaps it when
|
|
1238
|
+
it finally dies. The return value names what was left: a member still
|
|
1239
|
+
in the group, and a capture that had to be abandoned, so the caller can
|
|
1240
|
+
report an unclean kill instead of a clean one.
|
|
1241
|
+
|
|
1242
|
+
With ``contained``, the invocation's orphans get each signal with the
|
|
1243
|
+
group, and the kill is complete only once none is alive either; a
|
|
1244
|
+
``setsid`` writer is then one of them, so its pipes reach EOF instead of
|
|
1245
|
+
being abandoned.
|
|
1246
|
+
"""
|
|
1247
|
+
deadline = time.monotonic() + _KILL_GRACE_SECONDS
|
|
1248
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
1249
|
+
signalled = _signal_group(pgid, sig)
|
|
1250
|
+
if contained is not None and contained.send_all(sig):
|
|
1251
|
+
signalled = True
|
|
1252
|
+
if not signalled:
|
|
1253
|
+
break
|
|
1254
|
+
deadline = time.monotonic() + _KILL_GRACE_SECONDS
|
|
1255
|
+
if (
|
|
1256
|
+
_reaped(proc, deadline)
|
|
1257
|
+
and _eof(readers, deadline)
|
|
1258
|
+
and _group_gone(pgid, deadline, contained)
|
|
1259
|
+
and (contained is None or contained.gone(deadline, sig))
|
|
1260
|
+
):
|
|
1261
|
+
return _Termination(group_survived=False, capture_abandoned=False)
|
|
1262
|
+
# The group is empty (the signal found nobody) or its remains are past
|
|
1263
|
+
# help; the remaining EOF wait runs out the deadline already in hand,
|
|
1264
|
+
# never a fresh one, so the bound above holds.
|
|
1265
|
+
abandoned = not _eof(readers, deadline)
|
|
1266
|
+
if abandoned:
|
|
1267
|
+
for reader in readers:
|
|
1268
|
+
reader.abandon()
|
|
1269
|
+
return _Termination(
|
|
1270
|
+
group_survived=_group_alive(pgid),
|
|
1271
|
+
capture_abandoned=abandoned,
|
|
1272
|
+
orphan_survived=contained is not None and contained.alive(),
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _child_environment(
|
|
1277
|
+
env_allowlist: tuple[str, ...] | None, env: Mapping[str, str] | None
|
|
1278
|
+
) -> dict[str, str]:
|
|
1279
|
+
"""The child's environment: the whole of this process's, or only the
|
|
1280
|
+
allow-listed names (:func:`select_environment`), with ``env`` layered over."""
|
|
1281
|
+
child = dict(os.environ) if env_allowlist is None else select_environment(env_allowlist)
|
|
1282
|
+
if env:
|
|
1283
|
+
child.update(env)
|
|
1284
|
+
return child
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _spawn(
|
|
1288
|
+
command: list[str],
|
|
1289
|
+
*,
|
|
1290
|
+
cwd: str | None,
|
|
1291
|
+
env: dict[str, str],
|
|
1292
|
+
stdin: int,
|
|
1293
|
+
launch: BwrapLaunch | None = None,
|
|
1294
|
+
) -> subprocess.Popen[bytes]:
|
|
1295
|
+
"""Start ``command`` in a session of its own with stdout and stderr piped.
|
|
1296
|
+
|
|
1297
|
+
``start_new_session`` makes the child lead a process group the executor
|
|
1298
|
+
can reach and kill after the child itself is gone; the terminal is never
|
|
1299
|
+
inherited. A spawn failure is an :class:`ExecutionError`. With
|
|
1300
|
+
``launch`` the sandbox backend is started in the command's place
|
|
1301
|
+
(:func:`_spawn_sandboxed`) and ``env`` is not used: the launch carries
|
|
1302
|
+
the command's environment.
|
|
1303
|
+
"""
|
|
1304
|
+
if launch is not None:
|
|
1305
|
+
return _spawn_sandboxed(launch, cwd=cwd, stdin=stdin)
|
|
1306
|
+
try:
|
|
1307
|
+
return subprocess.Popen( # noqa: S603 - argv list, no shell
|
|
1308
|
+
command,
|
|
1309
|
+
cwd=cwd,
|
|
1310
|
+
env=env,
|
|
1311
|
+
stdin=stdin,
|
|
1312
|
+
stdout=subprocess.PIPE,
|
|
1313
|
+
stderr=subprocess.PIPE,
|
|
1314
|
+
start_new_session=True,
|
|
1315
|
+
)
|
|
1316
|
+
except FileNotFoundError as exc:
|
|
1317
|
+
raise ExecutionError(f"executable not found: {command[0]} ({exc})") from exc
|
|
1318
|
+
except OSError as exc:
|
|
1319
|
+
raise ExecutionError(f"failed to spawn {' '.join(command)}: {exc}") from exc
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _spawn_sandboxed(
|
|
1323
|
+
launch: BwrapLaunch, *, cwd: str | None, stdin: int
|
|
1324
|
+
) -> subprocess.Popen[bytes]:
|
|
1325
|
+
"""Start the sandbox backend in place of the command, as :func:`_spawn` does.
|
|
1326
|
+
|
|
1327
|
+
The same session and pipes, plus the descriptors only ``bwrap`` reads
|
|
1328
|
+
(its options, its status stream) and the launch's own process scope,
|
|
1329
|
+
applied between fork and exec (:class:`BwrapLaunch`). The
|
|
1330
|
+
fork happens on this thread, which is the thread ``--die-with-parent``
|
|
1331
|
+
watches, so the caller must outlive the invocation (ADR 0005 D10.6). A
|
|
1332
|
+
failure of that scope, or a backend gone since it was prepared, is a
|
|
1333
|
+
:class:`SandboxLaunchRefusedError`.
|
|
1334
|
+
"""
|
|
1335
|
+
try:
|
|
1336
|
+
return subprocess.Popen( # noqa: S603 - argv list, no shell
|
|
1337
|
+
launch.argv,
|
|
1338
|
+
cwd=cwd,
|
|
1339
|
+
env=launch.spawn_env,
|
|
1340
|
+
stdin=stdin,
|
|
1341
|
+
stdout=subprocess.PIPE,
|
|
1342
|
+
stderr=subprocess.PIPE,
|
|
1343
|
+
start_new_session=True,
|
|
1344
|
+
pass_fds=launch.pass_fds,
|
|
1345
|
+
preexec_fn=launch.preexec_fn, # noqa: PLW1509 - prepared foreign calls only
|
|
1346
|
+
)
|
|
1347
|
+
except FileNotFoundError as exc:
|
|
1348
|
+
raise SandboxLaunchRefusedError(
|
|
1349
|
+
f"the sandbox cannot be established: {launch.argv[0]} is gone ({exc})"
|
|
1350
|
+
) from exc
|
|
1351
|
+
except subprocess.SubprocessError as exc:
|
|
1352
|
+
raise launch.spawn_error(exc) from exc
|
|
1353
|
+
except OSError as exc:
|
|
1354
|
+
raise ExecutionError(f"failed to spawn {' '.join(launch.argv)}: {exc}") from exc
|
|
1355
|
+
finally:
|
|
1356
|
+
launch.spawned()
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _prepare_sandbox(
|
|
1360
|
+
description: LaunchDescription,
|
|
1361
|
+
command: list[str],
|
|
1362
|
+
*,
|
|
1363
|
+
cwd: str | None,
|
|
1364
|
+
env_allowlist: tuple[str, ...] | None,
|
|
1365
|
+
env: Mapping[str, str] | None,
|
|
1366
|
+
) -> BwrapLaunch:
|
|
1367
|
+
"""The backend's launch for ``command`` inside ``description``, or a refusal.
|
|
1368
|
+
|
|
1369
|
+
The command's environment is selected as for any launch
|
|
1370
|
+
(:func:`_child_environment`) and handed to the backend, which applies the
|
|
1371
|
+
description's overrides over it. Done before an invocation's containment
|
|
1372
|
+
begins, since the backend may read its own version first.
|
|
1373
|
+
"""
|
|
1374
|
+
return sandbox_bwrap.prepare(
|
|
1375
|
+
description, command, cwd=cwd, env=_child_environment(env_allowlist, env)
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def execute(req: ExecutionRequest) -> ExecutionResult:
|
|
1380
|
+
"""Run one subprocess to completion, capturing output.
|
|
1381
|
+
|
|
1382
|
+
Completion is the child's exit, EOF on both pipes and an empty process
|
|
1383
|
+
group. The child's exit is bounded by ``timeout_seconds`` from the spawn
|
|
1384
|
+
and, with ``idle_timeout_seconds``, by that long after the last chunk
|
|
1385
|
+
the child wrote on either stream (see :class:`_Limits`); past either the
|
|
1386
|
+
whole group is killed and the result is a timeout naming the limit
|
|
1387
|
+
(``timeout_limit``). A caller that sets ``stop`` ends it the same way,
|
|
1388
|
+
and the result is then ``stopped``. Once the child has
|
|
1389
|
+
exited on its own, the other two are given ``_EXIT_GRACE_SECONDS``: a
|
|
1390
|
+
descendant that outlives the child (a server it left running, holding
|
|
1391
|
+
the inherited pipes or with its stdio redirected) is then killed with
|
|
1392
|
+
the rest of the group, the child's own exit status and output are
|
|
1393
|
+
returned, and ``descendants_killed`` records the kill. Nothing an agent
|
|
1394
|
+
starts outlives its invocation; the next invocation begins with no
|
|
1395
|
+
process of the previous one holding a port or writing into the tree.
|
|
1396
|
+
|
|
1397
|
+
Every wait past the child's exit is bounded, so what a kill cannot
|
|
1398
|
+
remove is reported (``group_survived_kill``, ``capture_abandoned``)
|
|
1399
|
+
rather than waited for, and ``execute()`` returns within the limit that
|
|
1400
|
+
fires (the idle limit after the last output, or the ceiling after the
|
|
1401
|
+
spawn, whichever comes first) plus the exit grace plus two kill grace
|
|
1402
|
+
periods whatever the child left behind.
|
|
1403
|
+
|
|
1404
|
+
Timeouts, non-zero exits and truncated output are *returned* (not
|
|
1405
|
+
raised) so callers can log stdout/stderr first; use ``raise_if_failed()``
|
|
1406
|
+
to convert. ExecutionError is raised only when the process cannot be
|
|
1407
|
+
spawned, the threads that feed, read and reap for it cannot be started
|
|
1408
|
+
(the group is killed first once it runs), or its output cannot be read.
|
|
1409
|
+
|
|
1410
|
+
With ``contain_orphans`` the invocation's orphans (:class:`_Containment`)
|
|
1411
|
+
count as part of what must be gone: they get the exit grace and the kill
|
|
1412
|
+
the group gets, within the same bounds. Those that die while the child
|
|
1413
|
+
runs are reaped meanwhile.
|
|
1414
|
+
|
|
1415
|
+
With ``sandbox`` the backend runs in the command's place
|
|
1416
|
+
(:func:`_prepare_sandbox`) and everything above applies to it: its group
|
|
1417
|
+
is the command's group, and the kernel removes what is left inside the
|
|
1418
|
+
sandbox when the command or the backend ends. A host that cannot
|
|
1419
|
+
establish the sandbox, before or after the spawn, raises
|
|
1420
|
+
:class:`SandboxLaunchRefusedError`, and nothing the backend wrote is
|
|
1421
|
+
returned as the command's; a backend ended by a signal the executor did
|
|
1422
|
+
not send is the command's abnormal end, as for any command
|
|
1423
|
+
(:meth:`BwrapLaunch.outcome`).
|
|
1424
|
+
"""
|
|
1425
|
+
if not req.command:
|
|
1426
|
+
raise ExecutionError("empty command")
|
|
1427
|
+
if req.max_output_bytes <= 0:
|
|
1428
|
+
raise ExecutionError(f"max_output_bytes must be > 0, got {req.max_output_bytes}")
|
|
1429
|
+
check_limit("idle_timeout_seconds", req.idle_timeout_seconds)
|
|
1430
|
+
launch = None
|
|
1431
|
+
if req.sandbox is not None:
|
|
1432
|
+
launch = _prepare_sandbox(
|
|
1433
|
+
req.sandbox,
|
|
1434
|
+
req.command,
|
|
1435
|
+
cwd=req.cwd,
|
|
1436
|
+
env_allowlist=req.env_allowlist,
|
|
1437
|
+
env=req.env,
|
|
1438
|
+
)
|
|
1439
|
+
try:
|
|
1440
|
+
contained = (
|
|
1441
|
+
_Containment.begin(adopts_group=launch is not None) if req.contain_orphans else None
|
|
1442
|
+
)
|
|
1443
|
+
try:
|
|
1444
|
+
return _execute(req, contained, launch)
|
|
1445
|
+
finally:
|
|
1446
|
+
if contained is not None:
|
|
1447
|
+
contained.release()
|
|
1448
|
+
finally:
|
|
1449
|
+
if launch is not None:
|
|
1450
|
+
launch.close()
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def check_limit(name: str, value: float | None) -> None:
|
|
1454
|
+
"""Raise :class:`ExecutionError` unless ``value`` is ``None`` or a finite
|
|
1455
|
+
number of seconds in ``(0, MAX_DEADLINE_SECONDS]``."""
|
|
1456
|
+
if value is None:
|
|
1457
|
+
return
|
|
1458
|
+
if not (math.isfinite(value) and 0 < value <= MAX_DEADLINE_SECONDS):
|
|
1459
|
+
raise ExecutionError(
|
|
1460
|
+
f"{name} must be finite, > 0 and <= {MAX_DEADLINE_SECONDS}, got {value}"
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def _execute(
|
|
1465
|
+
req: ExecutionRequest,
|
|
1466
|
+
contained: _Containment | None,
|
|
1467
|
+
launch: BwrapLaunch | None = None,
|
|
1468
|
+
) -> ExecutionResult:
|
|
1469
|
+
started = _now()
|
|
1470
|
+
ceiling = req.timeout_seconds if req.timeout_seconds > 0 else None
|
|
1471
|
+
limits = _Limits(req.idle_timeout_seconds, ceiling)
|
|
1472
|
+
try:
|
|
1473
|
+
feeder = None if req.stdin_data is None else _StdinFeeder(req.stdin_data)
|
|
1474
|
+
except OSError as exc:
|
|
1475
|
+
raise ExecutionError(f"failed to spawn {' '.join(req.command)}: {exc}") from exc
|
|
1476
|
+
readers: tuple[_BoundedReader, ...] = ()
|
|
1477
|
+
timed_out = False
|
|
1478
|
+
stopped = False
|
|
1479
|
+
timeout_limit = ""
|
|
1480
|
+
descendants_killed = False
|
|
1481
|
+
left = _Termination(group_survived=False, capture_abandoned=False)
|
|
1482
|
+
try:
|
|
1483
|
+
proc = _spawn(
|
|
1484
|
+
req.command,
|
|
1485
|
+
cwd=req.cwd,
|
|
1486
|
+
env={} if launch is not None else _child_environment(req.env_allowlist, req.env),
|
|
1487
|
+
stdin=subprocess.DEVNULL if feeder is None else feeder.child_end,
|
|
1488
|
+
launch=launch,
|
|
1489
|
+
)
|
|
1490
|
+
except BaseException:
|
|
1491
|
+
if feeder is not None:
|
|
1492
|
+
feeder.stop()
|
|
1493
|
+
raise
|
|
1494
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
1495
|
+
if contained is not None:
|
|
1496
|
+
contained.child = proc.pid
|
|
1497
|
+
pgid = proc.pid # start_new_session: the child leads a group of its own
|
|
1498
|
+
try:
|
|
1499
|
+
# The child is running from here on, so everything that serves it
|
|
1500
|
+
# is set up under the same guard as the wait, and nothing that can
|
|
1501
|
+
# fail runs before it: a thread the system refuses (a thread or
|
|
1502
|
+
# descriptor limit) kills the group and fails the launch as a
|
|
1503
|
+
# refused spawn would, and anything else (an allocation, Ctrl-C)
|
|
1504
|
+
# kills it too, never leaving the child behind.
|
|
1505
|
+
try:
|
|
1506
|
+
if feeder is not None:
|
|
1507
|
+
feeder.start()
|
|
1508
|
+
# Output is read as bytes and decoded with replacement: agent
|
|
1509
|
+
# output is untrusted (a dumped binary, a mis-encoded file the
|
|
1510
|
+
# agent cats), and a decode error is not an AutoForgeError, so it
|
|
1511
|
+
# would leave the invocation unlogged (#17).
|
|
1512
|
+
observer = (
|
|
1513
|
+
None
|
|
1514
|
+
if req.on_output is None and req.on_chunk is None
|
|
1515
|
+
else _OutputObserver(req.on_output, req.on_chunk)
|
|
1516
|
+
)
|
|
1517
|
+
for stream, name in ((proc.stdout, "stdout"), (proc.stderr, "stderr")):
|
|
1518
|
+
readers += (_BoundedReader(stream, req.max_output_bytes, name, observer, limits),)
|
|
1519
|
+
readers[-1].start()
|
|
1520
|
+
except (OSError, RuntimeError) as exc:
|
|
1521
|
+
raise ExecutionError(f"failed to start {' '.join(req.command)}: {exc}") from exc
|
|
1522
|
+
ended = _wait_within(proc, limits, req.stop)
|
|
1523
|
+
stopped = ended == _STOPPED
|
|
1524
|
+
timed_out = bool(ended) and not stopped
|
|
1525
|
+
timeout_limit = ended if timed_out else ""
|
|
1526
|
+
if timed_out or stopped:
|
|
1527
|
+
left = _terminate_group(pgid, proc, readers, contained)
|
|
1528
|
+
else:
|
|
1529
|
+
# The child has exited, but the invocation is over only at EOF
|
|
1530
|
+
# and once no process is left in its group. A child that left
|
|
1531
|
+
# nothing behind clears both at once; a descendant that holds
|
|
1532
|
+
# the inherited pipes or merely stays in the group is given the
|
|
1533
|
+
# exit grace and then killed with the group, the child's own
|
|
1534
|
+
# result kept. The group id cannot name an unrelated process
|
|
1535
|
+
# while a member lives; once the group is empty the id is free
|
|
1536
|
+
# for reuse, which only a pid wrap-around inside this window
|
|
1537
|
+
# could bring about.
|
|
1538
|
+
# An orphan still alive past the same grace is killed with
|
|
1539
|
+
# them, and reported as an orphan, not as a group member.
|
|
1540
|
+
grace = time.monotonic() + _EXIT_GRACE_SECONDS
|
|
1541
|
+
group_settled = _eof(readers, grace) and _group_gone(pgid, grace, contained)
|
|
1542
|
+
if not (group_settled and (contained is None or contained.settled(grace))):
|
|
1543
|
+
descendants_killed = not group_settled
|
|
1544
|
+
left = _terminate_group(pgid, proc, readers, contained)
|
|
1545
|
+
except BaseException:
|
|
1546
|
+
# A reader whose thread never started has nothing to stop.
|
|
1547
|
+
started_readers = tuple(reader for reader in readers if reader.ident is not None)
|
|
1548
|
+
_terminate_group(pgid, proc, started_readers, contained)
|
|
1549
|
+
raise
|
|
1550
|
+
finally:
|
|
1551
|
+
if feeder is not None:
|
|
1552
|
+
feeder.stop()
|
|
1553
|
+
for reader in readers:
|
|
1554
|
+
reader.close()
|
|
1555
|
+
proc.stdout.close()
|
|
1556
|
+
proc.stderr.close()
|
|
1557
|
+
for reader in readers:
|
|
1558
|
+
if reader.error is not None:
|
|
1559
|
+
raise ExecutionError(
|
|
1560
|
+
f"failed to read {reader.name.removeprefix('autoforge-capture-')} of "
|
|
1561
|
+
f"{' '.join(req.command)}: {reader.error}"
|
|
1562
|
+
) from reader.error
|
|
1563
|
+
if feeder is not None and feeder.error is not None:
|
|
1564
|
+
raise ExecutionError(
|
|
1565
|
+
f"failed to write the stdin of {' '.join(req.command)}: {feeder.error}"
|
|
1566
|
+
) from feeder.error
|
|
1567
|
+
out, err = (reader.captured() for reader in readers)
|
|
1568
|
+
facts = None
|
|
1569
|
+
if launch is not None:
|
|
1570
|
+
# The backend has been reaped (or abandoned past SIGKILL), so what it
|
|
1571
|
+
# recorded about the command is complete; a setup failure raises
|
|
1572
|
+
# here, after the teardown, with nothing returned as the command's.
|
|
1573
|
+
facts = launch.outcome(
|
|
1574
|
+
returncode=proc.returncode, ended_by_executor=timed_out or stopped, stderr=err.text
|
|
1575
|
+
)
|
|
1576
|
+
return ExecutionResult(
|
|
1577
|
+
command=list(req.command),
|
|
1578
|
+
cwd=req.cwd,
|
|
1579
|
+
exit_code=-1 if timed_out or stopped else proc.returncode,
|
|
1580
|
+
stdout=out.text,
|
|
1581
|
+
stderr=err.text,
|
|
1582
|
+
started_at=started,
|
|
1583
|
+
finished_at=_now(),
|
|
1584
|
+
timed_out=timed_out,
|
|
1585
|
+
stdout_truncated=out.truncated,
|
|
1586
|
+
stderr_truncated=err.truncated,
|
|
1587
|
+
stdout_tail_offset=out.tail_offset,
|
|
1588
|
+
stdout_bytes=out.total,
|
|
1589
|
+
stderr_bytes=err.total,
|
|
1590
|
+
descendants_killed=descendants_killed,
|
|
1591
|
+
group_survived_kill=left.group_survived,
|
|
1592
|
+
capture_abandoned=left.capture_abandoned,
|
|
1593
|
+
orphans_killed=contained is not None and contained.found,
|
|
1594
|
+
orphan_survived_kill=left.orphan_survived,
|
|
1595
|
+
orphans_unchecked=req.contain_orphans and contained is None,
|
|
1596
|
+
stdin_unread=0 if feeder is None else feeder.unread,
|
|
1597
|
+
timeout_limit=timeout_limit,
|
|
1598
|
+
last_activity_at=limits.last_activity_at(),
|
|
1599
|
+
stopped=stopped,
|
|
1600
|
+
sandbox=facts,
|
|
1601
|
+
)
|