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
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"""Duplex child handle: a subprocess the controller writes to while it runs.
|
|
2
|
+
|
|
3
|
+
:func:`autoforge.executor.execute` is one-shot: the child's stdin is
|
|
4
|
+
``/dev/null``, or a payload fixed before the spawn followed by EOF
|
|
5
|
+
(``ExecutionRequest.stdin_data``), and nothing the child writes back changes
|
|
6
|
+
what it is sent. An RPC transport needs a child it can keep writing records
|
|
7
|
+
to while reading records back. This module is the narrowest primitive that
|
|
8
|
+
does that and keeps every guarantee ``execute()`` gives (ADR 0002: nothing
|
|
9
|
+
the child starts outlives the invocation). It deals in bytes and LF-framed
|
|
10
|
+
records only: nothing here knows an encoding, a provider, a phase or
|
|
11
|
+
controller state; decoding a record is the caller's.
|
|
12
|
+
|
|
13
|
+
Guarantees:
|
|
14
|
+
|
|
15
|
+
- the spawn is ``execute()``'s: an argv list, ``cwd``, the allow-listed
|
|
16
|
+
environment plus ``env``, a session (and so a process group) of its own,
|
|
17
|
+
the terminal never inherited, a spawn failure an :class:`ExecutionError`,
|
|
18
|
+
and so is a setup failure after the spawn (the group is killed first and
|
|
19
|
+
nothing taken for the invocation is kept);
|
|
20
|
+
- stdout is split into records on LF (0x0A) **only**, one trailing CR
|
|
21
|
+
stripped per record; no other byte or Unicode line separator (U+2028,
|
|
22
|
+
U+2029, NUL, VT, FF, NEL) is a boundary. A record longer than
|
|
23
|
+
``max_record_bytes`` is reported as :class:`Oversize` with at most its
|
|
24
|
+
first :data:`OVERSIZE_HEAD_BYTES` bytes, so that the caller can tell what
|
|
25
|
+
was dropped, and dropped through its LF; an unterminated fragment at EOF
|
|
26
|
+
is reported as :class:`Fragment`, never as a record;
|
|
27
|
+
- stdout and stderr are drained continuously on reader threads, whether or
|
|
28
|
+
not the caller reads, so the child never blocks on a full pipe. Complete
|
|
29
|
+
records wait in a queue bounded by ``max_pending_records`` and
|
|
30
|
+
``max_pending_bytes``; exceeding it is :class:`Overflow`, which ends the
|
|
31
|
+
invocation. stderr goes to a bounded head/tail capture and never becomes
|
|
32
|
+
a record. Memory is bounded by those limits plus a constant;
|
|
33
|
+
- writes are whole records, serialized by one lock and non-blocking under a
|
|
34
|
+
selector, so a child that stops reading stdin cannot hold the controller
|
|
35
|
+
past the deadline; a child that closed its stdin raises
|
|
36
|
+
:class:`ChildStdinClosedError`;
|
|
37
|
+
- one deadline bounds every :meth:`DuplexChild.send_line`,
|
|
38
|
+
:meth:`DuplexChild.read_line` and :meth:`DuplexChild.finish`: the
|
|
39
|
+
``execute()`` limits (#193), an idle limit that every chunk the child
|
|
40
|
+
writes on stdout or stderr restarts (``idle_timeout_seconds``) and a
|
|
41
|
+
wall-clock ceiling from the spawn (``max_runtime_seconds``, or
|
|
42
|
+
:data:`MAX_DEADLINE_SECONDS` when unset), whichever is due first. A
|
|
43
|
+
record written to the child is not activity. Past the deadline the whole
|
|
44
|
+
group is killed exactly as ``execute()`` kills it on timeout, and the
|
|
45
|
+
result is ``timed_out`` and names the limit; except that, as in
|
|
46
|
+
``execute()``, the child is looked at first, and a ``read_line`` or
|
|
47
|
+
``finish()`` that meets the deadline after the child has exited settles
|
|
48
|
+
the group with the exit grace instead, the child's status kept. A
|
|
49
|
+
caller that winds a child down in order once a limit falls due asks for
|
|
50
|
+
``wind_down_seconds``: the limit still falls due on time
|
|
51
|
+
(:meth:`DuplexChild.limit_due`) and the deadline, the kill, comes that
|
|
52
|
+
much later. :meth:`DuplexChild.pin_deadline` stops output from moving
|
|
53
|
+
either, for a caller winding the child down;
|
|
54
|
+
- EOF is not the child's exit: a descendant holding stdout keeps EOF away,
|
|
55
|
+
and one that keeps writing keeps every read busy.
|
|
56
|
+
:meth:`DuplexChild.exited` says whether the child itself has exited,
|
|
57
|
+
without waiting, so a caller reading records until EOF asks it after
|
|
58
|
+
every read and calls :meth:`DuplexChild.finish` once it has (ADR 0002's
|
|
59
|
+
exit grace, then the kill, the child's status kept). After a
|
|
60
|
+
``finish()`` that did not time out, :meth:`DuplexChild.read_line`
|
|
61
|
+
returns the records still queued and then the end item, never waiting;
|
|
62
|
+
- teardown runs on every exit from the ``with`` block, an exception or
|
|
63
|
+
``KeyboardInterrupt`` included: stdin is closed, the child is given a
|
|
64
|
+
bounded wait to exit, then the ADR 0002 exit grace and empty-group check,
|
|
65
|
+
then the group kill if anything is left. The leftover facts
|
|
66
|
+
(``descendants_killed``, ``group_survived_kill``, ``capture_abandoned``)
|
|
67
|
+
mean what they mean in :class:`autoforge.executor.ExecutionResult`. With a
|
|
68
|
+
hostile child the whole invocation takes at most the deadline (the idle
|
|
69
|
+
limit after the last output, or the ceiling, plus any wind-down) plus the
|
|
70
|
+
exit grace plus two kill graces, the bound ``execute()`` has;
|
|
71
|
+
- ``contain_orphans`` catches what left the group exactly as in
|
|
72
|
+
``execute()`` (the subreaper is held from before the spawn to the end of
|
|
73
|
+
the teardown), with the same three orphan facts;
|
|
74
|
+
- ``sandbox`` runs the child inside the outer sandbox exactly as in
|
|
75
|
+
``execute()`` (ADR 0005): the backend is spawned in the command's place,
|
|
76
|
+
every guarantee above applies to it, and a sandbox that could not be
|
|
77
|
+
established raises :class:`~autoforge.errors.SandboxLaunchRefusedError`
|
|
78
|
+
from :func:`start_duplex` or, for a setup failure the backend reports
|
|
79
|
+
after the spawn, from :meth:`DuplexChild.finish`.
|
|
80
|
+
|
|
81
|
+
A server child that takes no input and is not spoken to over stdout (#126)
|
|
82
|
+
uses the same handle with ``stdin_pipe=False`` and
|
|
83
|
+
``stdout_mode=StdoutMode.CAPTURE``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import enum
|
|
89
|
+
import os
|
|
90
|
+
import selectors
|
|
91
|
+
import subprocess
|
|
92
|
+
import threading
|
|
93
|
+
import time
|
|
94
|
+
from collections import deque
|
|
95
|
+
from dataclasses import dataclass
|
|
96
|
+
from types import TracebackType
|
|
97
|
+
from typing import IO
|
|
98
|
+
|
|
99
|
+
from . import executor
|
|
100
|
+
from .errors import ChildStdinClosedError, ExecutionError, ExecutionTimeoutError
|
|
101
|
+
from .executor import (
|
|
102
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
103
|
+
LIMIT_IDLE,
|
|
104
|
+
MAX_DEADLINE_SECONDS,
|
|
105
|
+
BwrapLaunch,
|
|
106
|
+
LaunchDescription,
|
|
107
|
+
SandboxFacts,
|
|
108
|
+
_BoundedReader,
|
|
109
|
+
_child_environment,
|
|
110
|
+
_Containment,
|
|
111
|
+
_eof,
|
|
112
|
+
_group_gone,
|
|
113
|
+
_Limits,
|
|
114
|
+
_now,
|
|
115
|
+
_PipeDrain,
|
|
116
|
+
_prepare_sandbox,
|
|
117
|
+
_reaped,
|
|
118
|
+
_spawn,
|
|
119
|
+
_terminate_group,
|
|
120
|
+
_Termination,
|
|
121
|
+
check_limit,
|
|
122
|
+
describe_leftovers,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# A single record above this is not a protocol message but a runaway; the
|
|
126
|
+
# bound is per record, the queue below bounds what waits to be read.
|
|
127
|
+
DEFAULT_MAX_RECORD_BYTES = 4 * 1024 * 1024
|
|
128
|
+
DEFAULT_MAX_PENDING_RECORDS = 1024
|
|
129
|
+
DEFAULT_MAX_PENDING_BYTES = 16 * 1024 * 1024
|
|
130
|
+
# How much of a dropped record an :class:`Oversize` keeps: enough for a
|
|
131
|
+
# caller to read what kind of record it was from its leading bytes (#144),
|
|
132
|
+
# never its content. Never more than the record bound itself.
|
|
133
|
+
OVERSIZE_HEAD_BYTES = 64
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class StdoutMode(enum.Enum):
|
|
137
|
+
"""What the handle does with the child's stdout."""
|
|
138
|
+
|
|
139
|
+
# LF-framed records, read with :meth:`DuplexChild.read_line`.
|
|
140
|
+
RECORDS = "records"
|
|
141
|
+
# Bounded head/tail capture, returned in :attr:`DuplexResult.stdout`.
|
|
142
|
+
CAPTURE = "capture"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Record:
|
|
147
|
+
"""One complete record: the bytes before an LF, one trailing CR stripped."""
|
|
148
|
+
|
|
149
|
+
data: bytes
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Oversize:
|
|
154
|
+
"""A record exceeded ``limit`` bytes; it was dropped through its LF.
|
|
155
|
+
|
|
156
|
+
``head`` is its first ``min(OVERSIZE_HEAD_BYTES, limit)`` bytes, for
|
|
157
|
+
the caller to recognise the record by; the rest was never kept.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
limit: int
|
|
161
|
+
head: bytes
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class Fragment:
|
|
166
|
+
"""Bytes after the last LF when stdout reached EOF: not a record."""
|
|
167
|
+
|
|
168
|
+
data: bytes
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class Eof:
|
|
173
|
+
"""stdout ended and every record before it was read. Sticky."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class Overflow:
|
|
178
|
+
"""The pending-record queue exceeded its bound; records after the ones
|
|
179
|
+
already returned were dropped and the invocation is over. Sticky."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Timeout:
|
|
184
|
+
"""No record arrived in time. ``deadline_exceeded``: the invocation's
|
|
185
|
+
deadline passed and the group was killed (sticky); otherwise only the
|
|
186
|
+
``read_line`` timeout ran out and the child is still running."""
|
|
187
|
+
|
|
188
|
+
deadline_exceeded: bool
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
ReadOutcome = Record | Oversize | Fragment | Eof | Overflow | Timeout
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class DuplexRequest:
|
|
196
|
+
command: list[str]
|
|
197
|
+
cwd: str | None = None
|
|
198
|
+
# Layered over the inherited (or allow-listed) environment when given.
|
|
199
|
+
env: dict[str, str] | None = None
|
|
200
|
+
# As :attr:`autoforge.executor.ExecutionRequest.env_allowlist`.
|
|
201
|
+
env_allowlist: tuple[str, ...] | None = None
|
|
202
|
+
# Kill the child once it has written nothing on stdout or stderr for
|
|
203
|
+
# this long (every chunk restarts it); ``None``: no idle limit.
|
|
204
|
+
idle_timeout_seconds: float | None = None
|
|
205
|
+
# The wall-clock ceiling, measured from the spawn; ``None``:
|
|
206
|
+
# :data:`MAX_DEADLINE_SECONDS`. With the idle limit it bounds every call
|
|
207
|
+
# on the handle.
|
|
208
|
+
max_runtime_seconds: float | None = None
|
|
209
|
+
# How long after a limit falls due a child still running is given before
|
|
210
|
+
# its group is killed, for a caller that winds it down in order (Pi's
|
|
211
|
+
# `abort`): the limit falls due on time (:meth:`DuplexChild.limit_due`),
|
|
212
|
+
# the kill comes this much later (:meth:`DuplexChild.deadline`). 0: as
|
|
213
|
+
# the limit falls due.
|
|
214
|
+
wind_down_seconds: float = 0.0
|
|
215
|
+
max_record_bytes: int = DEFAULT_MAX_RECORD_BYTES
|
|
216
|
+
max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS
|
|
217
|
+
max_pending_bytes: int = DEFAULT_MAX_PENDING_BYTES
|
|
218
|
+
max_stderr_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
|
|
219
|
+
# False: the child's stdin is ``/dev/null`` and ``send_line`` refuses.
|
|
220
|
+
stdin_pipe: bool = True
|
|
221
|
+
stdout_mode: StdoutMode = StdoutMode.RECORDS
|
|
222
|
+
# The capture bound in ``StdoutMode.CAPTURE``; unused for records.
|
|
223
|
+
max_stdout_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
|
|
224
|
+
# As :attr:`autoforge.executor.ExecutionRequest.contain_orphans`.
|
|
225
|
+
contain_orphans: bool = False
|
|
226
|
+
# As :attr:`autoforge.executor.ExecutionRequest.sandbox`. The thread that
|
|
227
|
+
# calls :func:`start_duplex` is the one ``bwrap --die-with-parent``
|
|
228
|
+
# watches: it must outlive the handle (ADR 0005 D10.6), whichever
|
|
229
|
+
# threads then send, read and finish.
|
|
230
|
+
sandbox: LaunchDescription | None = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class DuplexResult:
|
|
235
|
+
command: list[str]
|
|
236
|
+
cwd: str | None
|
|
237
|
+
# The child's own exit status; -1 on a timeout (as in ``execute()``) and
|
|
238
|
+
# when the controller killed the child before it exited (an overflow, or
|
|
239
|
+
# an exception in the ``with``).
|
|
240
|
+
exit_code: int
|
|
241
|
+
stderr: str
|
|
242
|
+
started_at: str
|
|
243
|
+
finished_at: str
|
|
244
|
+
timed_out: bool = False
|
|
245
|
+
stderr_truncated: bool = False
|
|
246
|
+
# The pending-record queue overflowed (see :class:`Overflow`).
|
|
247
|
+
records_overflowed: bool = False
|
|
248
|
+
# ``StdoutMode.CAPTURE`` only: the bounded capture, as in ``execute()``.
|
|
249
|
+
stdout: str = ""
|
|
250
|
+
stdout_truncated: bool = False
|
|
251
|
+
# The ADR 0002 facts, as in :class:`autoforge.executor.ExecutionResult`.
|
|
252
|
+
descendants_killed: bool = False
|
|
253
|
+
group_survived_kill: bool = False
|
|
254
|
+
capture_abandoned: bool = False
|
|
255
|
+
orphans_killed: bool = False
|
|
256
|
+
orphan_survived_kill: bool = False
|
|
257
|
+
orphans_unchecked: bool = False
|
|
258
|
+
# As in :class:`autoforge.executor.ExecutionResult`: the limit that
|
|
259
|
+
# killed the child when ``timed_out`` (``"idle"`` or ``"max_runtime"``),
|
|
260
|
+
# and when it last wrote anything.
|
|
261
|
+
timeout_limit: str = ""
|
|
262
|
+
last_activity_at: str | None = None
|
|
263
|
+
# The caller ended the invocation with :meth:`DuplexChild.stop` before
|
|
264
|
+
# the child exited; ``exit_code`` is then -1. Never set with ``timed_out``.
|
|
265
|
+
stopped: bool = False
|
|
266
|
+
# As in :class:`autoforge.executor.ExecutionResult`.
|
|
267
|
+
sandbox: SandboxFacts | None = None
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def leftovers(self) -> str:
|
|
271
|
+
"""What the invocation left behind, for a log line; empty when nothing."""
|
|
272
|
+
return describe_leftovers(
|
|
273
|
+
descendants_killed=self.descendants_killed,
|
|
274
|
+
group_survived_kill=self.group_survived_kill,
|
|
275
|
+
capture_abandoned=self.capture_abandoned,
|
|
276
|
+
orphans_killed=self.orphans_killed,
|
|
277
|
+
orphan_survived_kill=self.orphan_survived_kill,
|
|
278
|
+
orphans_unchecked=self.orphans_unchecked,
|
|
279
|
+
sandbox_teardown=self.sandbox is not None and self.sandbox.enforced,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _LineFramer:
|
|
284
|
+
"""Split a byte stream into records on LF only, holding at most one record.
|
|
285
|
+
|
|
286
|
+
The partial record is held in one ``bytearray`` of at most ``limit + 1``
|
|
287
|
+
bytes (the extra byte is room for the CR an LF ending strips). Past
|
|
288
|
+
that, the record is reported once as :class:`Oversize`, with its head,
|
|
289
|
+
the moment it crosses the bound and its remaining bytes are counted out
|
|
290
|
+
up to the next LF, so a stream with no LF at all costs the bound, not
|
|
291
|
+
its length.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, limit: int) -> None:
|
|
295
|
+
self._limit = limit
|
|
296
|
+
self._head = min(OVERSIZE_HEAD_BYTES, limit)
|
|
297
|
+
self._buf = bytearray()
|
|
298
|
+
self._dropping = False
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def retained(self) -> int:
|
|
302
|
+
return len(self._buf)
|
|
303
|
+
|
|
304
|
+
def feed(self, chunk: bytes) -> list[Record | Oversize]:
|
|
305
|
+
out: list[Record | Oversize] = []
|
|
306
|
+
start = 0
|
|
307
|
+
while True:
|
|
308
|
+
lf = chunk.find(b"\n", start)
|
|
309
|
+
if lf < 0:
|
|
310
|
+
self._take(chunk[start:], out)
|
|
311
|
+
return out
|
|
312
|
+
self._take(chunk[start:lf], out)
|
|
313
|
+
if self._dropping:
|
|
314
|
+
self._dropping = False
|
|
315
|
+
else:
|
|
316
|
+
record = bytes(self._buf)
|
|
317
|
+
self._buf.clear()
|
|
318
|
+
if record.endswith(b"\r"):
|
|
319
|
+
record = record[:-1]
|
|
320
|
+
if len(record) > self._limit:
|
|
321
|
+
out.append(Oversize(self._limit, record[: self._head]))
|
|
322
|
+
else:
|
|
323
|
+
out.append(Record(record))
|
|
324
|
+
start = lf + 1
|
|
325
|
+
|
|
326
|
+
def _take(self, piece: bytes, out: list[Record | Oversize]) -> None:
|
|
327
|
+
if self._dropping or not piece:
|
|
328
|
+
return
|
|
329
|
+
if len(self._buf) + len(piece) > self._limit + 1:
|
|
330
|
+
head = bytes(self._buf[: self._head])
|
|
331
|
+
head += piece[: self._head - len(head)]
|
|
332
|
+
self._buf.clear()
|
|
333
|
+
self._dropping = True
|
|
334
|
+
out.append(Oversize(self._limit, head))
|
|
335
|
+
return
|
|
336
|
+
self._buf += piece
|
|
337
|
+
|
|
338
|
+
def finish(self) -> list[Fragment]:
|
|
339
|
+
"""At EOF: the unterminated tail, if one is held."""
|
|
340
|
+
if self._dropping or not self._buf:
|
|
341
|
+
return []
|
|
342
|
+
fragment = Fragment(bytes(self._buf))
|
|
343
|
+
self._buf.clear()
|
|
344
|
+
return [fragment]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _RecordQueue:
|
|
348
|
+
"""Records waiting to be read, bounded by count and bytes; never blocks a put.
|
|
349
|
+
|
|
350
|
+
A put past either bound ends the queue with :class:`Overflow` instead
|
|
351
|
+
of waiting, so the reader thread keeps draining the pipe. The end item
|
|
352
|
+
(:class:`Eof` or :class:`Overflow`) is returned after every queued item,
|
|
353
|
+
and then on every later get. An :class:`Oversize` counts its head.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self, max_items: int, max_bytes: int) -> None:
|
|
357
|
+
self._max_items = max_items
|
|
358
|
+
self._max_bytes = max_bytes
|
|
359
|
+
self._items: deque[Record | Oversize | Fragment] = deque()
|
|
360
|
+
self._bytes = 0
|
|
361
|
+
self._end: Eof | Overflow | None = None
|
|
362
|
+
self._cond = threading.Condition()
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def ended(self) -> bool:
|
|
366
|
+
with self._cond:
|
|
367
|
+
return self._end is not None
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def overflowed(self) -> bool:
|
|
371
|
+
with self._cond:
|
|
372
|
+
return isinstance(self._end, Overflow)
|
|
373
|
+
|
|
374
|
+
def put(self, item: Record | Oversize | Fragment) -> bool:
|
|
375
|
+
"""Queue ``item``; False once the queue has ended (overflow included)."""
|
|
376
|
+
size = _held_bytes(item)
|
|
377
|
+
with self._cond:
|
|
378
|
+
if self._end is not None:
|
|
379
|
+
return False
|
|
380
|
+
if len(self._items) >= self._max_items or self._bytes + size > self._max_bytes:
|
|
381
|
+
self._end = Overflow()
|
|
382
|
+
self._cond.notify_all()
|
|
383
|
+
return False
|
|
384
|
+
self._items.append(item)
|
|
385
|
+
self._bytes += size
|
|
386
|
+
self._cond.notify_all()
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
def end(self) -> None:
|
|
390
|
+
with self._cond:
|
|
391
|
+
if self._end is None:
|
|
392
|
+
self._end = Eof()
|
|
393
|
+
self._cond.notify_all()
|
|
394
|
+
|
|
395
|
+
def get(self, until: float) -> Record | Oversize | Fragment | Eof | Overflow | None:
|
|
396
|
+
"""The next item, or None when ``until`` (``time.monotonic()``) passes first."""
|
|
397
|
+
with self._cond:
|
|
398
|
+
while True:
|
|
399
|
+
if self._items:
|
|
400
|
+
item = self._items.popleft()
|
|
401
|
+
self._bytes -= _held_bytes(item)
|
|
402
|
+
return item
|
|
403
|
+
if self._end is not None:
|
|
404
|
+
return self._end
|
|
405
|
+
remaining = until - time.monotonic()
|
|
406
|
+
if remaining <= 0:
|
|
407
|
+
return None
|
|
408
|
+
self._cond.wait(remaining)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _held_bytes(item: Record | Oversize | Fragment) -> int:
|
|
412
|
+
"""The bytes ``item`` holds while it waits in the queue."""
|
|
413
|
+
return len(item.head) if isinstance(item, Oversize) else len(item.data)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class _RecordReader(_PipeDrain):
|
|
417
|
+
"""Drain stdout through a :class:`_LineFramer` into a :class:`_RecordQueue`.
|
|
418
|
+
|
|
419
|
+
After an overflow the pipe is still drained, and what it carries
|
|
420
|
+
dropped, so the child never blocks on it and the teardown sees EOF the
|
|
421
|
+
way it does for any other child.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(
|
|
425
|
+
self,
|
|
426
|
+
stream: IO[bytes],
|
|
427
|
+
max_record_bytes: int,
|
|
428
|
+
queue: _RecordQueue,
|
|
429
|
+
activity: _Limits | None = None,
|
|
430
|
+
) -> None:
|
|
431
|
+
super().__init__(stream, "stdout", activity)
|
|
432
|
+
self.framer = _LineFramer(max_record_bytes)
|
|
433
|
+
self._queue = queue
|
|
434
|
+
|
|
435
|
+
def _feed(self, chunk: bytes) -> None:
|
|
436
|
+
if self._queue.ended:
|
|
437
|
+
return
|
|
438
|
+
for item in self.framer.feed(chunk):
|
|
439
|
+
if not self._queue.put(item):
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
def _ended(self, eof: bool) -> None:
|
|
443
|
+
if eof:
|
|
444
|
+
for fragment in self.framer.finish():
|
|
445
|
+
self._queue.put(fragment)
|
|
446
|
+
self._queue.end()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _validate(req: DuplexRequest) -> None:
|
|
450
|
+
if not req.command:
|
|
451
|
+
raise ExecutionError("empty command")
|
|
452
|
+
check_limit("idle_timeout_seconds", req.idle_timeout_seconds)
|
|
453
|
+
check_limit("max_runtime_seconds", req.max_runtime_seconds)
|
|
454
|
+
if req.wind_down_seconds != 0:
|
|
455
|
+
check_limit("wind_down_seconds", req.wind_down_seconds)
|
|
456
|
+
for name in (
|
|
457
|
+
"max_record_bytes",
|
|
458
|
+
"max_pending_records",
|
|
459
|
+
"max_pending_bytes",
|
|
460
|
+
"max_stderr_bytes",
|
|
461
|
+
"max_stdout_bytes",
|
|
462
|
+
):
|
|
463
|
+
value = getattr(req, name)
|
|
464
|
+
if value <= 0:
|
|
465
|
+
raise ExecutionError(f"{name} must be > 0, got {value}")
|
|
466
|
+
if req.max_record_bytes > req.max_pending_bytes:
|
|
467
|
+
raise ExecutionError(
|
|
468
|
+
f"max_record_bytes ({req.max_record_bytes}) must not exceed "
|
|
469
|
+
f"max_pending_bytes ({req.max_pending_bytes}): one record would overflow the queue"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def start_duplex(req: DuplexRequest) -> DuplexChild:
|
|
474
|
+
"""Spawn the child and start draining it; use the result as a context manager.
|
|
475
|
+
|
|
476
|
+
``with start_duplex(req) as child: ... result = child.finish()``. The
|
|
477
|
+
``with`` guarantees the teardown whatever happens inside it. A child
|
|
478
|
+
that cannot be spawned, or whose readers cannot be set up, raises
|
|
479
|
+
:class:`ExecutionError`; anything else that fails or interrupts the
|
|
480
|
+
launch after the spawn is raised as itself. Either way the group is
|
|
481
|
+
killed first. A sandbox that cannot be established is a
|
|
482
|
+
:class:`~autoforge.errors.SandboxLaunchRefusedError`, before anything is
|
|
483
|
+
spawned or taken.
|
|
484
|
+
"""
|
|
485
|
+
_validate(req)
|
|
486
|
+
launch = None
|
|
487
|
+
if req.sandbox is not None:
|
|
488
|
+
launch = _prepare_sandbox(
|
|
489
|
+
req.sandbox,
|
|
490
|
+
req.command,
|
|
491
|
+
cwd=req.cwd,
|
|
492
|
+
env_allowlist=req.env_allowlist,
|
|
493
|
+
env=req.env,
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
return _start(req, launch)
|
|
497
|
+
except BaseException:
|
|
498
|
+
if launch is not None:
|
|
499
|
+
launch.close()
|
|
500
|
+
raise
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _start(req: DuplexRequest, launch: BwrapLaunch | None) -> DuplexChild:
|
|
504
|
+
""":func:`start_duplex` once the launch is prepared; the handle owns it
|
|
505
|
+
from its return, and the caller closes it on a failure."""
|
|
506
|
+
started = _now()
|
|
507
|
+
contained = _Containment.begin(adopts_group=launch is not None) if req.contain_orphans else None
|
|
508
|
+
try:
|
|
509
|
+
proc = _spawn(
|
|
510
|
+
req.command,
|
|
511
|
+
cwd=req.cwd,
|
|
512
|
+
env={} if launch is not None else _child_environment(req.env_allowlist, req.env),
|
|
513
|
+
stdin=subprocess.PIPE if req.stdin_pipe else subprocess.DEVNULL,
|
|
514
|
+
launch=launch,
|
|
515
|
+
)
|
|
516
|
+
except BaseException:
|
|
517
|
+
if contained is not None:
|
|
518
|
+
contained.release()
|
|
519
|
+
raise
|
|
520
|
+
if contained is not None:
|
|
521
|
+
contained.child = proc.pid
|
|
522
|
+
# The child is running from here on, and the caller has no handle to
|
|
523
|
+
# tear down until this returns, so every step from here to the return
|
|
524
|
+
# runs under one guard, as in ``execute()``: whatever fails or interrupts it
|
|
525
|
+
# (a thread or descriptor limit, an allocation, Ctrl-C) kills the group
|
|
526
|
+
# and releases the containment and the pipes before the launch fails,
|
|
527
|
+
# never leaving the child, the orphan reaper or the subreaper setting
|
|
528
|
+
# behind.
|
|
529
|
+
child: DuplexChild | None = None
|
|
530
|
+
try:
|
|
531
|
+
try:
|
|
532
|
+
child = DuplexChild(req, proc, started, contained, launch)
|
|
533
|
+
child._start()
|
|
534
|
+
except (OSError, RuntimeError) as exc:
|
|
535
|
+
raise ExecutionError(f"failed to start {' '.join(req.command)}: {exc}") from exc
|
|
536
|
+
except BaseException:
|
|
537
|
+
_abort_start(proc, () if child is None else child._readers, contained)
|
|
538
|
+
raise
|
|
539
|
+
return child
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _abort_start(
|
|
543
|
+
proc: subprocess.Popen[bytes],
|
|
544
|
+
readers: tuple[_PipeDrain, ...],
|
|
545
|
+
contained: _Containment | None,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""The launch failed after the spawn: kill the group at once and release
|
|
548
|
+
everything taken so far, since no teardown will run."""
|
|
549
|
+
# A reader whose thread never started has nothing to stop.
|
|
550
|
+
started = tuple(reader for reader in readers if reader.ident is not None)
|
|
551
|
+
try:
|
|
552
|
+
_terminate_group(proc.pid, proc, started, contained)
|
|
553
|
+
finally:
|
|
554
|
+
if contained is not None:
|
|
555
|
+
contained.release()
|
|
556
|
+
if proc.stdin is not None:
|
|
557
|
+
try:
|
|
558
|
+
proc.stdin.close()
|
|
559
|
+
except OSError:
|
|
560
|
+
pass # nothing was written; the fd is closed either way
|
|
561
|
+
for reader in readers:
|
|
562
|
+
reader.close()
|
|
563
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
564
|
+
proc.stdout.close()
|
|
565
|
+
proc.stderr.close()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class DuplexChild:
|
|
569
|
+
"""A running child with a writable stdin and a framed stdout.
|
|
570
|
+
|
|
571
|
+
``send_line`` and ``read_line`` may be called from different threads;
|
|
572
|
+
writes are serialized, so a record is never interleaved with another.
|
|
573
|
+
Every call is bounded by the request's deadline. Made by
|
|
574
|
+
:func:`start_duplex`, which starts it under its launch guard.
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
def __init__(
|
|
578
|
+
self,
|
|
579
|
+
req: DuplexRequest,
|
|
580
|
+
proc: subprocess.Popen[bytes],
|
|
581
|
+
started: str,
|
|
582
|
+
contained: _Containment | None = None,
|
|
583
|
+
launch: BwrapLaunch | None = None,
|
|
584
|
+
) -> None:
|
|
585
|
+
self._req = req
|
|
586
|
+
self._contained = contained
|
|
587
|
+
self._launch = launch
|
|
588
|
+
self._proc = proc
|
|
589
|
+
self._started = started
|
|
590
|
+
ceiling = req.max_runtime_seconds
|
|
591
|
+
if ceiling is None:
|
|
592
|
+
ceiling = MAX_DEADLINE_SECONDS
|
|
593
|
+
self._limits = _Limits(req.idle_timeout_seconds, ceiling)
|
|
594
|
+
self._pgid = proc.pid # start_new_session: the child leads a group of its own
|
|
595
|
+
self._write_lock = threading.RLock()
|
|
596
|
+
self._lifecycle_lock = threading.RLock()
|
|
597
|
+
self._stdin_open = proc.stdin is not None
|
|
598
|
+
self._queue: _RecordQueue | None = None
|
|
599
|
+
self._capture: _BoundedReader | None = None
|
|
600
|
+
self._readers: tuple[_PipeDrain, ...] = ()
|
|
601
|
+
# Set once the group has been dealt with: killed, or found empty after
|
|
602
|
+
# the child's own exit.
|
|
603
|
+
self._left: _Termination | None = None
|
|
604
|
+
self._timed_out = False
|
|
605
|
+
self._timeout_limit = ""
|
|
606
|
+
self._stopped = False
|
|
607
|
+
self._child_killed = False
|
|
608
|
+
self._descendants_killed = False
|
|
609
|
+
self._closed = False
|
|
610
|
+
self._result: DuplexResult | None = None
|
|
611
|
+
|
|
612
|
+
def _start(self) -> None:
|
|
613
|
+
"""Start draining the child's pipes. A reader takes its wake pipe
|
|
614
|
+
only as it starts, and each is in ``_readers`` before it starts, so
|
|
615
|
+
a failure part-way leaves everything taken where
|
|
616
|
+
:func:`start_duplex`'s guard releases it."""
|
|
617
|
+
proc, req = self._proc, self._req
|
|
618
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
619
|
+
if proc.stdin is not None:
|
|
620
|
+
os.set_blocking(proc.stdin.fileno(), False)
|
|
621
|
+
stdout_reader: _PipeDrain
|
|
622
|
+
if req.stdout_mode is StdoutMode.RECORDS:
|
|
623
|
+
self._queue = _RecordQueue(req.max_pending_records, req.max_pending_bytes)
|
|
624
|
+
stdout_reader = _RecordReader(
|
|
625
|
+
proc.stdout, req.max_record_bytes, self._queue, self._limits
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
self._capture = _BoundedReader(
|
|
629
|
+
proc.stdout, req.max_stdout_bytes, "stdout", activity=self._limits
|
|
630
|
+
)
|
|
631
|
+
stdout_reader = self._capture
|
|
632
|
+
self._readers = (stdout_reader,)
|
|
633
|
+
self._stderr = _BoundedReader(
|
|
634
|
+
proc.stderr, req.max_stderr_bytes, "stderr", activity=self._limits
|
|
635
|
+
)
|
|
636
|
+
self._readers += (self._stderr,)
|
|
637
|
+
for reader in self._readers:
|
|
638
|
+
reader.start()
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def pid(self) -> int:
|
|
642
|
+
"""The child's pid, which is also its process group id."""
|
|
643
|
+
return self._proc.pid
|
|
644
|
+
|
|
645
|
+
def limit_due(self) -> float:
|
|
646
|
+
"""When a limit falls due unless the child writes first
|
|
647
|
+
(``time.monotonic()``): the idle limit after its last output or the
|
|
648
|
+
ceiling, whichever is due first. Recomputed on every call."""
|
|
649
|
+
due = self._limits.deadline()
|
|
650
|
+
assert due is not None # the ceiling always applies
|
|
651
|
+
return due
|
|
652
|
+
|
|
653
|
+
def deadline(self) -> float:
|
|
654
|
+
"""When the invocation is over unless the child writes first
|
|
655
|
+
(``time.monotonic()``): :meth:`limit_due` plus the request's
|
|
656
|
+
``wind_down_seconds``. Recomputed on every call."""
|
|
657
|
+
return self.limit_due() + self._req.wind_down_seconds
|
|
658
|
+
|
|
659
|
+
@property
|
|
660
|
+
def deadline_limit(self) -> str:
|
|
661
|
+
"""The limit :meth:`deadline` belongs to: ``"idle"`` or ``"max_runtime"``."""
|
|
662
|
+
return self._limits.limit()
|
|
663
|
+
|
|
664
|
+
def pin_deadline(self) -> None:
|
|
665
|
+
"""Fix the deadline where it is: output from here on no longer moves it.
|
|
666
|
+
|
|
667
|
+
For a caller that has decided to end the invocation (an abort, an
|
|
668
|
+
orderly shutdown) and gives the child a bounded chance to settle
|
|
669
|
+
within the deadline it had; a child that keeps writing meanwhile
|
|
670
|
+
cannot stretch that chance. :meth:`limit_due` is fixed with it.
|
|
671
|
+
Idempotent.
|
|
672
|
+
"""
|
|
673
|
+
self._limits.pin()
|
|
674
|
+
|
|
675
|
+
def _expired(self) -> bool:
|
|
676
|
+
return time.monotonic() >= self.deadline()
|
|
677
|
+
|
|
678
|
+
def __enter__(self) -> DuplexChild:
|
|
679
|
+
return self
|
|
680
|
+
|
|
681
|
+
def __exit__(
|
|
682
|
+
self,
|
|
683
|
+
exc_type: type[BaseException] | None,
|
|
684
|
+
exc: BaseException | None,
|
|
685
|
+
tb: TracebackType | None,
|
|
686
|
+
) -> None:
|
|
687
|
+
if exc_type is None:
|
|
688
|
+
self.finish()
|
|
689
|
+
return
|
|
690
|
+
# An exception (KeyboardInterrupt included) is in flight: the child is
|
|
691
|
+
# given a short chance to exit on stdin EOF and run its own cleanup,
|
|
692
|
+
# never the rest of the deadline, and then the group goes.
|
|
693
|
+
exit_by = min(self.deadline(), time.monotonic() + executor._EXIT_GRACE_SECONDS)
|
|
694
|
+
try:
|
|
695
|
+
self._teardown(exit_by)
|
|
696
|
+
except ExecutionError:
|
|
697
|
+
pass # the exception in flight is the one to report
|
|
698
|
+
|
|
699
|
+
def send_line(self, data: bytes) -> None:
|
|
700
|
+
"""Write ``data`` and an LF as one whole record, bounded by the deadline.
|
|
701
|
+
|
|
702
|
+
``data`` must not contain an LF: one call is one record. A child
|
|
703
|
+
that closed its stdin raises :class:`ChildStdinClosedError`; the
|
|
704
|
+
deadline passing mid-write kills the group and raises
|
|
705
|
+
:class:`ExecutionTimeoutError` (the stream is then over, so a partly
|
|
706
|
+
written record is never continued).
|
|
707
|
+
"""
|
|
708
|
+
if b"\n" in data:
|
|
709
|
+
raise ExecutionError("a record must not contain LF (0x0A)")
|
|
710
|
+
with self._write_lock:
|
|
711
|
+
if self._timed_out:
|
|
712
|
+
raise self._timeout_error()
|
|
713
|
+
if not self._stdin_open:
|
|
714
|
+
raise ExecutionError("the child's stdin is not open for writing")
|
|
715
|
+
assert self._proc.stdin is not None
|
|
716
|
+
fd = self._proc.stdin.fileno()
|
|
717
|
+
view = memoryview(data + b"\n")
|
|
718
|
+
with selectors.DefaultSelector() as sel:
|
|
719
|
+
sel.register(fd, selectors.EVENT_WRITE)
|
|
720
|
+
while view:
|
|
721
|
+
remaining = self.deadline() - time.monotonic()
|
|
722
|
+
if remaining <= 0:
|
|
723
|
+
self._expire()
|
|
724
|
+
raise self._timeout_error()
|
|
725
|
+
if not sel.select(remaining):
|
|
726
|
+
continue
|
|
727
|
+
try:
|
|
728
|
+
written = os.write(fd, view)
|
|
729
|
+
except BlockingIOError:
|
|
730
|
+
continue
|
|
731
|
+
except BrokenPipeError as exc:
|
|
732
|
+
raise ChildStdinClosedError(
|
|
733
|
+
f"the child closed its stdin: {' '.join(self._req.command)}"
|
|
734
|
+
) from exc
|
|
735
|
+
view = view[written:]
|
|
736
|
+
|
|
737
|
+
def exited(self) -> bool:
|
|
738
|
+
"""Whether the child itself has exited; never waits.
|
|
739
|
+
|
|
740
|
+
Its group may live on and hold its stdout, so EOF can come long
|
|
741
|
+
after this, or never; and a descendant that keeps writing keeps
|
|
742
|
+
every read busy, so no read going quiet says so either. A caller
|
|
743
|
+
that reads records until EOF asks this after every read, a record
|
|
744
|
+
included, and once it is True calls :meth:`finish`, which gives
|
|
745
|
+
what is left ADR 0002's exit grace and then kills it, keeping the
|
|
746
|
+
child's own status; :meth:`read_line` then returns what stdout
|
|
747
|
+
carried up to its end.
|
|
748
|
+
"""
|
|
749
|
+
return self._proc.poll() is not None
|
|
750
|
+
|
|
751
|
+
def read_line(self, timeout: float | None = None) -> ReadOutcome:
|
|
752
|
+
"""The next stdout outcome, waiting at most ``timeout`` seconds and
|
|
753
|
+
never past the deadline (see :data:`ReadOutcome`). After a
|
|
754
|
+
:meth:`finish` that did not time out it never waits: the records
|
|
755
|
+
still queued, then the end item, whatever the deadline. When the
|
|
756
|
+
deadline comes, a child still running is an overrun; one that has
|
|
757
|
+
exited by then is not, and is settled as :meth:`finish` settles it
|
|
758
|
+
(see :meth:`_deadline_reached`)."""
|
|
759
|
+
if self._queue is None:
|
|
760
|
+
raise ExecutionError("stdout is captured, not framed into records")
|
|
761
|
+
if self._timed_out:
|
|
762
|
+
return Timeout(deadline_exceeded=True)
|
|
763
|
+
if self._closed:
|
|
764
|
+
return self._rest()
|
|
765
|
+
now = time.monotonic()
|
|
766
|
+
give_up = None if timeout is None else now + max(0.0, timeout)
|
|
767
|
+
while True:
|
|
768
|
+
# Output (stderr included) moves the deadline while this waits,
|
|
769
|
+
# so a wait that ends at the deadline it started with looks again.
|
|
770
|
+
deadline = self.deadline()
|
|
771
|
+
if now >= deadline:
|
|
772
|
+
return self._deadline_reached()
|
|
773
|
+
item = self._queue.get(deadline if give_up is None else min(deadline, give_up))
|
|
774
|
+
if item is not None:
|
|
775
|
+
return item
|
|
776
|
+
now = time.monotonic()
|
|
777
|
+
if give_up is not None and now >= give_up and now < self.deadline():
|
|
778
|
+
return Timeout(deadline_exceeded=False)
|
|
779
|
+
|
|
780
|
+
def _rest(self) -> ReadOutcome:
|
|
781
|
+
"""Torn down: every reader has ended, so the rest of stdout is queued
|
|
782
|
+
up to its end item, and there is nothing left to wait for and no
|
|
783
|
+
deadline left to enforce."""
|
|
784
|
+
assert self._queue is not None
|
|
785
|
+
rest = self._queue.get(time.monotonic())
|
|
786
|
+
assert rest is not None
|
|
787
|
+
return rest
|
|
788
|
+
|
|
789
|
+
def _deadline_reached(self) -> ReadOutcome:
|
|
790
|
+
"""A read met the deadline. The child is looked at first, as
|
|
791
|
+
``execute()``'s last look at it is at the deadline: one
|
|
792
|
+
still running overran it, and the group is killed and the read is
|
|
793
|
+
``Timeout``. One that has exited ran within it, whatever still
|
|
794
|
+
holds its stdout, so the group gets ADR 0002's exit grace and the
|
|
795
|
+
kill exactly as in :meth:`finish`, the child's status is kept, and
|
|
796
|
+
the read returns the rest of stdout."""
|
|
797
|
+
if not self.exited():
|
|
798
|
+
self._expire()
|
|
799
|
+
return Timeout(deadline_exceeded=True)
|
|
800
|
+
self._teardown(time.monotonic())
|
|
801
|
+
return self._rest()
|
|
802
|
+
|
|
803
|
+
def close_stdin(self) -> None:
|
|
804
|
+
"""Close the child's stdin (its orderly-shutdown request). Idempotent."""
|
|
805
|
+
with self._write_lock:
|
|
806
|
+
if not self._stdin_open:
|
|
807
|
+
return
|
|
808
|
+
self._stdin_open = False
|
|
809
|
+
assert self._proc.stdin is not None
|
|
810
|
+
try:
|
|
811
|
+
self._proc.stdin.close()
|
|
812
|
+
except OSError:
|
|
813
|
+
pass # nothing was buffered; the fd is closed either way
|
|
814
|
+
|
|
815
|
+
def finish(self) -> DuplexResult:
|
|
816
|
+
"""Close stdin, wait for the child within the deadline, tear down, report.
|
|
817
|
+
|
|
818
|
+
Legal at any time: a running child is asked to stop by stdin EOF and
|
|
819
|
+
given until the deadline; past it the group is killed and the
|
|
820
|
+
result is ``timed_out``. Once the child has exited on its own, ADR
|
|
821
|
+
0002's exit grace applies exactly as in ``execute()``. After an
|
|
822
|
+
:class:`Overflow` a child still running is killed at once. A second
|
|
823
|
+
call returns the same result. A sandboxed child whose backend
|
|
824
|
+
reports that the sandbox was never set up raises
|
|
825
|
+
:class:`~autoforge.errors.SandboxLaunchRefusedError` here, after the
|
|
826
|
+
teardown, as ``execute()`` does.
|
|
827
|
+
"""
|
|
828
|
+
if self._result is not None:
|
|
829
|
+
return self._result
|
|
830
|
+
self._teardown(None)
|
|
831
|
+
for reader in self._readers:
|
|
832
|
+
if reader.error is not None:
|
|
833
|
+
raise ExecutionError(
|
|
834
|
+
f"failed to read {reader.name.removeprefix('autoforge-capture-')} of "
|
|
835
|
+
f"{' '.join(self._req.command)}: {reader.error}"
|
|
836
|
+
) from reader.error
|
|
837
|
+
err = self._stderr.captured()
|
|
838
|
+
out = self._capture.captured() if self._capture is not None else None
|
|
839
|
+
assert self._left is not None
|
|
840
|
+
facts = None
|
|
841
|
+
if self._launch is not None:
|
|
842
|
+
facts = self._launch.outcome(
|
|
843
|
+
returncode=self._proc.returncode,
|
|
844
|
+
ended_by_executor=self._timed_out or self._stopped or self._child_killed,
|
|
845
|
+
stderr=err.text,
|
|
846
|
+
)
|
|
847
|
+
self._result = DuplexResult(
|
|
848
|
+
command=list(self._req.command),
|
|
849
|
+
cwd=self._req.cwd,
|
|
850
|
+
exit_code=-1 if self._timed_out or self._child_killed else self._proc.returncode,
|
|
851
|
+
stderr=err.text,
|
|
852
|
+
started_at=self._started,
|
|
853
|
+
finished_at=_now(),
|
|
854
|
+
timed_out=self._timed_out,
|
|
855
|
+
stderr_truncated=err.truncated,
|
|
856
|
+
records_overflowed=self._queue is not None and self._queue.overflowed,
|
|
857
|
+
stdout=out.text if out is not None else "",
|
|
858
|
+
stdout_truncated=out.truncated if out is not None else False,
|
|
859
|
+
descendants_killed=self._descendants_killed,
|
|
860
|
+
group_survived_kill=self._left.group_survived,
|
|
861
|
+
capture_abandoned=self._left.capture_abandoned,
|
|
862
|
+
orphans_killed=self._contained is not None and self._contained.found,
|
|
863
|
+
orphan_survived_kill=self._left.orphan_survived,
|
|
864
|
+
orphans_unchecked=self._req.contain_orphans and self._contained is None,
|
|
865
|
+
timeout_limit=self._timeout_limit,
|
|
866
|
+
last_activity_at=self._limits.last_activity_at(),
|
|
867
|
+
stopped=self._stopped,
|
|
868
|
+
sandbox=facts,
|
|
869
|
+
)
|
|
870
|
+
return self._result
|
|
871
|
+
|
|
872
|
+
def _timeout_error(self) -> ExecutionTimeoutError:
|
|
873
|
+
if (self._timeout_limit or self._limits.limit()) == LIMIT_IDLE:
|
|
874
|
+
what = f"wrote nothing for {self._limits.idle:g}s"
|
|
875
|
+
else:
|
|
876
|
+
what = f"reached its {self._limits.ceiling:g}s maximum runtime"
|
|
877
|
+
return ExecutionTimeoutError(
|
|
878
|
+
f"command {what} and was killed: {' '.join(self._req.command)}"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
def _mark_timed_out(self) -> None:
|
|
882
|
+
"""Record the timeout and the limit it was; caller holds the lock."""
|
|
883
|
+
self._timed_out = True
|
|
884
|
+
self._timeout_limit = self._limits.limit()
|
|
885
|
+
|
|
886
|
+
def _kill(self) -> None:
|
|
887
|
+
"""Kill the group before the child exited on its own; caller holds the lock."""
|
|
888
|
+
self._child_killed = self._proc.poll() is None
|
|
889
|
+
self._left = _terminate_group(self._pgid, self._proc, self._readers, self._contained)
|
|
890
|
+
|
|
891
|
+
def stop(self) -> bool:
|
|
892
|
+
"""End the invocation now, for a reason of the caller's (#194's loop
|
|
893
|
+
detector): the group is killed as on a timeout, through the same
|
|
894
|
+
path and within the same bounds, and the result says ``stopped``.
|
|
895
|
+
False, and nothing done, once the child has exited on its own or the
|
|
896
|
+
group has been dealt with (a limit fired first): the caller then
|
|
897
|
+
settles it as usual. The caller follows a stop with :meth:`finish`."""
|
|
898
|
+
with self._lifecycle_lock:
|
|
899
|
+
if self._left is not None or self._proc.poll() is not None:
|
|
900
|
+
return False
|
|
901
|
+
self._stopped = True
|
|
902
|
+
self._kill()
|
|
903
|
+
return True
|
|
904
|
+
|
|
905
|
+
def _expire(self) -> None:
|
|
906
|
+
"""The deadline passed: kill the group as ``execute()`` does on timeout."""
|
|
907
|
+
with self._lifecycle_lock:
|
|
908
|
+
if self._left is not None:
|
|
909
|
+
return
|
|
910
|
+
self._mark_timed_out()
|
|
911
|
+
self._kill()
|
|
912
|
+
|
|
913
|
+
def _teardown(self, exit_by: float | None) -> None:
|
|
914
|
+
"""Close stdin, give the child until ``exit_by`` to exit (``None``:
|
|
915
|
+
until the deadline, which output still moves), apply the exit grace
|
|
916
|
+
or kill the group, and release the pipes. Idempotent."""
|
|
917
|
+
self.close_stdin()
|
|
918
|
+
with self._lifecycle_lock:
|
|
919
|
+
try:
|
|
920
|
+
self._settle(exit_by)
|
|
921
|
+
except BaseException:
|
|
922
|
+
# Interrupted (Ctrl-C) before the group was dealt with: kill it
|
|
923
|
+
# and the orphans while the subreaper still catches them, as
|
|
924
|
+
# ``execute()`` does, and only then restore the setting.
|
|
925
|
+
try:
|
|
926
|
+
if self._left is None:
|
|
927
|
+
self._kill()
|
|
928
|
+
finally:
|
|
929
|
+
if self._contained is not None:
|
|
930
|
+
self._contained.release()
|
|
931
|
+
if self._launch is not None:
|
|
932
|
+
self._launch.collect()
|
|
933
|
+
self._launch.close()
|
|
934
|
+
raise
|
|
935
|
+
if self._closed:
|
|
936
|
+
return
|
|
937
|
+
self._closed = True
|
|
938
|
+
if self._contained is not None:
|
|
939
|
+
self._contained.release()
|
|
940
|
+
if self._launch is not None:
|
|
941
|
+
# The backend has been reaped or killed: what it recorded is
|
|
942
|
+
# in the status pipe, read now for :meth:`finish`.
|
|
943
|
+
self._launch.collect()
|
|
944
|
+
self._launch.close()
|
|
945
|
+
for reader in self._readers:
|
|
946
|
+
reader.close()
|
|
947
|
+
assert self._proc.stdout is not None and self._proc.stderr is not None
|
|
948
|
+
self._proc.stdout.close()
|
|
949
|
+
self._proc.stderr.close()
|
|
950
|
+
|
|
951
|
+
def _exits_by(self, exit_by: float | None) -> bool:
|
|
952
|
+
"""Wait for the child's exit until ``exit_by``, or (``None``) until the
|
|
953
|
+
deadline as output moves it; True when it exited."""
|
|
954
|
+
if exit_by is not None:
|
|
955
|
+
return _reaped(self._proc, exit_by)
|
|
956
|
+
while not _reaped(self._proc, self.deadline()):
|
|
957
|
+
if self._expired():
|
|
958
|
+
return False
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
def _settle(self, exit_by: float | None) -> None:
|
|
962
|
+
"""Deal with the group once: the exit grace, or the kill; caller holds the lock."""
|
|
963
|
+
if self._left is None:
|
|
964
|
+
overflowed = self._queue is not None and self._queue.overflowed
|
|
965
|
+
if overflowed and self._proc.poll() is None:
|
|
966
|
+
self._kill()
|
|
967
|
+
elif not self._exits_by(exit_by):
|
|
968
|
+
if self._expired():
|
|
969
|
+
self._mark_timed_out()
|
|
970
|
+
self._kill()
|
|
971
|
+
else:
|
|
972
|
+
# The child exited on its own: ADR 0002's exit grace, then
|
|
973
|
+
# whatever is still in the group (or holds the pipes) is
|
|
974
|
+
# killed and the child's own status kept.
|
|
975
|
+
grace = time.monotonic() + executor._EXIT_GRACE_SECONDS
|
|
976
|
+
contained = self._contained
|
|
977
|
+
group_settled = _eof(self._readers, grace) and _group_gone(
|
|
978
|
+
self._pgid, grace, contained
|
|
979
|
+
)
|
|
980
|
+
if group_settled and (contained is None or contained.settled(grace)):
|
|
981
|
+
self._left = _Termination(group_survived=False, capture_abandoned=False)
|
|
982
|
+
else:
|
|
983
|
+
# Only an orphan left over is an orphan kill, not a group one.
|
|
984
|
+
self._descendants_killed = not group_settled
|
|
985
|
+
self._left = _terminate_group(self._pgid, self._proc, self._readers, contained)
|