millforge 0.1.0__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.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
"""Pi 0.79.6-compatible local shell execution.
|
|
2
|
+
|
|
3
|
+
One ``execute_bash`` call owns its launch task, shell process, merged output
|
|
4
|
+
stream, raw-output writer, cancellation waits, and cleanup. In particular,
|
|
5
|
+
the post-exit drain is necessary because a shell can exit while a descendant
|
|
6
|
+
still holds an inherited output pipe open.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import codecs
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Callable, Iterable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import BinaryIO, Protocol, TypeVar, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from .contracts import (
|
|
26
|
+
PiCompatErrorKind,
|
|
27
|
+
PiCompatOperationResult,
|
|
28
|
+
PiCompatSideEffectState,
|
|
29
|
+
)
|
|
30
|
+
from .paths import _is_absolute_cwd
|
|
31
|
+
from .truncation import format_size, truncate_tail
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"PiCompatCancellation",
|
|
35
|
+
"PiCompatShellConfig",
|
|
36
|
+
"PiCompatShellResolutionError",
|
|
37
|
+
"execute_bash",
|
|
38
|
+
"resolve_pi_compat_shell",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_DEFAULT_MAX_LINES = 2_000
|
|
42
|
+
_DEFAULT_MAX_BYTES = 50 * 1024
|
|
43
|
+
_POST_EXIT_IDLE_SECONDS = 0.1
|
|
44
|
+
_PROCESS_POLL_SECONDS = 0.01
|
|
45
|
+
_CLEANUP_GRACE_SECONDS = 30.0
|
|
46
|
+
_CAPTURE_QUEUE_CHUNKS = 16
|
|
47
|
+
|
|
48
|
+
_T = TypeVar("_T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class PiCompatCancellation(Protocol):
|
|
53
|
+
"""Minimal cancellation surface supplied by the Millforge adapter."""
|
|
54
|
+
|
|
55
|
+
def is_cancelled(self) -> bool: ...
|
|
56
|
+
|
|
57
|
+
async def wait(self) -> None: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class PiCompatShellConfig:
|
|
62
|
+
"""One resolved host shell and the arguments preceding its command."""
|
|
63
|
+
|
|
64
|
+
executable: str
|
|
65
|
+
arguments: tuple[str, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PiCompatShellResolutionError(RuntimeError):
|
|
69
|
+
"""Raised when the host has no usable shell for Pi-compatible bash calls."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _LaunchInterrupted(RuntimeError):
|
|
73
|
+
"""The process was created after its caller had already interrupted launch."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _TailSnapshot:
|
|
78
|
+
content: str
|
|
79
|
+
truncated: bool
|
|
80
|
+
truncated_by: str | None
|
|
81
|
+
total_lines: int
|
|
82
|
+
total_bytes: int
|
|
83
|
+
output_lines: int
|
|
84
|
+
output_bytes: int
|
|
85
|
+
last_line_partial: bool
|
|
86
|
+
last_line_bytes: int
|
|
87
|
+
full_output_path: str | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class _CleanupOutcome:
|
|
92
|
+
tree_confirmed: bool
|
|
93
|
+
process_exited: bool
|
|
94
|
+
process_reaped: bool
|
|
95
|
+
diagnostics: tuple[str, ...] = ()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _OutputAccumulator:
|
|
99
|
+
"""Bounded Pi-style tail display plus one owned raw-output writer."""
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
103
|
+
self._raw_queue: asyncio.Queue[bytes | None] = asyncio.Queue(
|
|
104
|
+
maxsize=_CAPTURE_QUEUE_CHUNKS
|
|
105
|
+
)
|
|
106
|
+
self._writer_task = asyncio.create_task(
|
|
107
|
+
_write_raw_output(self._raw_queue),
|
|
108
|
+
name="millforge-pi-bash-output-writer",
|
|
109
|
+
)
|
|
110
|
+
self._writer_stop_requested = False
|
|
111
|
+
self._raw_output_path: Path | None = None
|
|
112
|
+
self._tail_text = ""
|
|
113
|
+
self._tail_bytes = 0
|
|
114
|
+
self._tail_starts_at_line_boundary = True
|
|
115
|
+
self._total_decoded_bytes = 0
|
|
116
|
+
self._completed_lines = 0
|
|
117
|
+
self._total_lines = 0
|
|
118
|
+
self._current_line_bytes = 0
|
|
119
|
+
self._has_open_line = False
|
|
120
|
+
self._finished = False
|
|
121
|
+
self._snapshot_result: _TailSnapshot | None = None
|
|
122
|
+
self._keep_raw_output = False
|
|
123
|
+
self._cleanup_diagnostics: list[str] = []
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def writer_task(self) -> asyncio.Task[Path]:
|
|
127
|
+
return self._writer_task
|
|
128
|
+
|
|
129
|
+
async def append(self, data: bytes) -> None:
|
|
130
|
+
if self._finished:
|
|
131
|
+
raise RuntimeError("cannot append to a finished output accumulator")
|
|
132
|
+
if not data:
|
|
133
|
+
return
|
|
134
|
+
await self._enqueue_raw_output(data)
|
|
135
|
+
self._append_decoded_text(self._decoder.decode(data, final=False))
|
|
136
|
+
|
|
137
|
+
async def finish(self) -> _TailSnapshot:
|
|
138
|
+
if self._snapshot_result is not None:
|
|
139
|
+
return self._snapshot_result
|
|
140
|
+
if not self._finished:
|
|
141
|
+
self._finished = True
|
|
142
|
+
self._append_decoded_text(self._decoder.decode(b"", final=True))
|
|
143
|
+
|
|
144
|
+
raw_output_path = await self._finish_writer()
|
|
145
|
+
snapshot = self._snapshot()
|
|
146
|
+
if snapshot.truncated:
|
|
147
|
+
self._keep_raw_output = True
|
|
148
|
+
snapshot = _TailSnapshot(
|
|
149
|
+
content=snapshot.content,
|
|
150
|
+
truncated=snapshot.truncated,
|
|
151
|
+
truncated_by=snapshot.truncated_by,
|
|
152
|
+
total_lines=snapshot.total_lines,
|
|
153
|
+
total_bytes=snapshot.total_bytes,
|
|
154
|
+
output_lines=snapshot.output_lines,
|
|
155
|
+
output_bytes=snapshot.output_bytes,
|
|
156
|
+
last_line_partial=snapshot.last_line_partial,
|
|
157
|
+
last_line_bytes=snapshot.last_line_bytes,
|
|
158
|
+
full_output_path=str(raw_output_path),
|
|
159
|
+
)
|
|
160
|
+
self._snapshot_result = snapshot
|
|
161
|
+
return snapshot
|
|
162
|
+
|
|
163
|
+
async def close(self) -> tuple[str, ...]:
|
|
164
|
+
"""Finish the writer before removing any unreturned raw-output file."""
|
|
165
|
+
|
|
166
|
+
cancellation: asyncio.CancelledError | None = None
|
|
167
|
+
diagnostics: list[str] = []
|
|
168
|
+
try:
|
|
169
|
+
await self._finish_writer()
|
|
170
|
+
except asyncio.CancelledError as exc:
|
|
171
|
+
cancellation = exc
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
174
|
+
|
|
175
|
+
if not self._keep_raw_output and self._raw_output_path is not None:
|
|
176
|
+
raw_output_path = self._raw_output_path
|
|
177
|
+
self._raw_output_path = None
|
|
178
|
+
try:
|
|
179
|
+
await _run_blocking(lambda: raw_output_path.unlink(missing_ok=True))
|
|
180
|
+
except asyncio.CancelledError as exc:
|
|
181
|
+
cancellation = exc
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
184
|
+
self._cleanup_diagnostics.extend(diagnostics)
|
|
185
|
+
if cancellation is not None:
|
|
186
|
+
raise cancellation
|
|
187
|
+
return tuple(diagnostics)
|
|
188
|
+
|
|
189
|
+
def snapshot_after_failure(self) -> _TailSnapshot:
|
|
190
|
+
"""Return the model tail after an already-observed capture failure."""
|
|
191
|
+
|
|
192
|
+
if not self._finished:
|
|
193
|
+
self._finished = True
|
|
194
|
+
self._append_decoded_text(self._decoder.decode(b"", final=True))
|
|
195
|
+
return self._snapshot()
|
|
196
|
+
|
|
197
|
+
async def _enqueue_raw_output(self, data: bytes | None) -> None:
|
|
198
|
+
if self._writer_task.done():
|
|
199
|
+
await _observe_task(self._writer_task)
|
|
200
|
+
raise RuntimeError("raw-output writer completed before capture finished")
|
|
201
|
+
try:
|
|
202
|
+
self._raw_queue.put_nowait(data)
|
|
203
|
+
return
|
|
204
|
+
except asyncio.QueueFull:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
put_task = asyncio.create_task(
|
|
208
|
+
self._raw_queue.put(data), name="millforge-pi-bash-output-queue-put"
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
done, _ = await asyncio.wait(
|
|
212
|
+
{put_task, self._writer_task}, return_when=asyncio.FIRST_COMPLETED
|
|
213
|
+
)
|
|
214
|
+
if self._writer_task in done:
|
|
215
|
+
if not put_task.done():
|
|
216
|
+
await _cancel_and_observe((put_task,))
|
|
217
|
+
else:
|
|
218
|
+
await _observe_task(put_task)
|
|
219
|
+
await _observe_task(self._writer_task)
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
"raw-output writer completed before capture finished"
|
|
222
|
+
)
|
|
223
|
+
await _observe_task(put_task)
|
|
224
|
+
finally:
|
|
225
|
+
if not put_task.done():
|
|
226
|
+
await _cancel_and_observe((put_task,))
|
|
227
|
+
|
|
228
|
+
async def _finish_writer(self) -> Path:
|
|
229
|
+
if not self._writer_stop_requested:
|
|
230
|
+
self._writer_stop_requested = True
|
|
231
|
+
await self._enqueue_raw_output(None)
|
|
232
|
+
raw_output_path = await _await_owned_task(self._writer_task)
|
|
233
|
+
self._raw_output_path = raw_output_path
|
|
234
|
+
return raw_output_path
|
|
235
|
+
|
|
236
|
+
def _append_decoded_text(self, text: str) -> None:
|
|
237
|
+
if not text:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
text_bytes = len(text.encode("utf-8"))
|
|
241
|
+
self._total_decoded_bytes += text_bytes
|
|
242
|
+
self._tail_text += text
|
|
243
|
+
self._tail_bytes += text_bytes
|
|
244
|
+
if self._tail_bytes > _DEFAULT_MAX_BYTES * 4:
|
|
245
|
+
self._trim_tail()
|
|
246
|
+
|
|
247
|
+
newline_count = text.count("\n")
|
|
248
|
+
if newline_count == 0:
|
|
249
|
+
self._current_line_bytes += text_bytes
|
|
250
|
+
self._has_open_line = True
|
|
251
|
+
else:
|
|
252
|
+
self._completed_lines += newline_count
|
|
253
|
+
trailing_text = text.rsplit("\n", maxsplit=1)[1]
|
|
254
|
+
self._current_line_bytes = len(trailing_text.encode("utf-8"))
|
|
255
|
+
self._has_open_line = bool(trailing_text)
|
|
256
|
+
self._total_lines = self._completed_lines + int(self._has_open_line)
|
|
257
|
+
|
|
258
|
+
def _trim_tail(self) -> None:
|
|
259
|
+
encoded_tail = self._tail_text.encode("utf-8")
|
|
260
|
+
max_rolling_bytes = _DEFAULT_MAX_BYTES * 2
|
|
261
|
+
if len(encoded_tail) <= max_rolling_bytes:
|
|
262
|
+
self._tail_bytes = len(encoded_tail)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
start = len(encoded_tail) - max_rolling_bytes
|
|
266
|
+
while start < len(encoded_tail) and encoded_tail[start] & 0xC0 == 0x80:
|
|
267
|
+
start += 1
|
|
268
|
+
self._tail_starts_at_line_boundary = (
|
|
269
|
+
self._tail_starts_at_line_boundary
|
|
270
|
+
if start == 0
|
|
271
|
+
else encoded_tail[start - 1] == 0x0A
|
|
272
|
+
)
|
|
273
|
+
self._tail_text = encoded_tail[start:].decode("utf-8", errors="replace")
|
|
274
|
+
self._tail_bytes = len(self._tail_text.encode("utf-8"))
|
|
275
|
+
|
|
276
|
+
def _snapshot(self) -> _TailSnapshot:
|
|
277
|
+
display_tail = self._tail_text
|
|
278
|
+
if not self._tail_starts_at_line_boundary:
|
|
279
|
+
first_newline = display_tail.find("\n")
|
|
280
|
+
display_tail = (
|
|
281
|
+
display_tail
|
|
282
|
+
if first_newline == -1
|
|
283
|
+
else display_tail[first_newline + 1 :]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
content, truncated_by, output_lines, output_bytes, last_line_partial = (
|
|
287
|
+
_truncate_tail(display_tail)
|
|
288
|
+
)
|
|
289
|
+
truncated = (
|
|
290
|
+
self._total_lines > _DEFAULT_MAX_LINES
|
|
291
|
+
or self._total_decoded_bytes > _DEFAULT_MAX_BYTES
|
|
292
|
+
)
|
|
293
|
+
if truncated and truncated_by is None:
|
|
294
|
+
truncated_by = (
|
|
295
|
+
"bytes" if self._total_decoded_bytes > _DEFAULT_MAX_BYTES else "lines"
|
|
296
|
+
)
|
|
297
|
+
return _TailSnapshot(
|
|
298
|
+
content=content,
|
|
299
|
+
truncated=truncated,
|
|
300
|
+
truncated_by=truncated_by if truncated else None,
|
|
301
|
+
total_lines=self._total_lines,
|
|
302
|
+
total_bytes=self._total_decoded_bytes,
|
|
303
|
+
output_lines=output_lines,
|
|
304
|
+
output_bytes=output_bytes,
|
|
305
|
+
last_line_partial=last_line_partial,
|
|
306
|
+
last_line_bytes=self._current_line_bytes,
|
|
307
|
+
full_output_path=None,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def resolve_pi_compat_shell() -> PiCompatShellConfig:
|
|
312
|
+
"""Resolve Pi's Bash-first shell policy using the host-native fallback."""
|
|
313
|
+
|
|
314
|
+
if os.name == "nt":
|
|
315
|
+
comspec = os.environ.get("COMSPEC")
|
|
316
|
+
candidate = comspec if comspec and comspec.strip() else "cmd.exe"
|
|
317
|
+
return PiCompatShellConfig(
|
|
318
|
+
executable=_resolve_shell_executable(candidate),
|
|
319
|
+
arguments=("/d", "/s", "/c"),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
bash_path = Path("/bin/bash")
|
|
323
|
+
if bash_path.is_file():
|
|
324
|
+
return PiCompatShellConfig(executable=str(bash_path), arguments=("-c",))
|
|
325
|
+
|
|
326
|
+
bash_from_path = shutil.which("bash")
|
|
327
|
+
if bash_from_path is not None:
|
|
328
|
+
return PiCompatShellConfig(
|
|
329
|
+
executable=_resolve_shell_executable(bash_from_path), arguments=("-c",)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return PiCompatShellConfig(
|
|
333
|
+
executable=_resolve_shell_executable("sh"), arguments=("-c",)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def execute_bash(
|
|
338
|
+
*,
|
|
339
|
+
cwd: Path,
|
|
340
|
+
command: str,
|
|
341
|
+
timeout_seconds: float,
|
|
342
|
+
cancellation: PiCompatCancellation,
|
|
343
|
+
shell_config: PiCompatShellConfig,
|
|
344
|
+
) -> PiCompatOperationResult:
|
|
345
|
+
"""Execute one shell command with Pi-compatible capture and result mapping."""
|
|
346
|
+
|
|
347
|
+
loop = asyncio.get_running_loop()
|
|
348
|
+
operation_started = loop.time()
|
|
349
|
+
validation_error = _validate_arguments(
|
|
350
|
+
cwd=cwd, command=command, timeout_seconds=timeout_seconds
|
|
351
|
+
)
|
|
352
|
+
if validation_error is not None:
|
|
353
|
+
return _result(
|
|
354
|
+
text=validation_error,
|
|
355
|
+
error_kind=PiCompatErrorKind.INVALID_ARGUMENTS,
|
|
356
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
deadline = operation_started + float(timeout_seconds)
|
|
360
|
+
if cancellation.is_cancelled():
|
|
361
|
+
return _result(
|
|
362
|
+
text="Command aborted",
|
|
363
|
+
error_kind=PiCompatErrorKind.CANCELLED,
|
|
364
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
accumulator = _OutputAccumulator()
|
|
368
|
+
cancellation_task = asyncio.create_task(
|
|
369
|
+
cancellation.wait(), name="millforge-pi-bash-cancellation-wait"
|
|
370
|
+
)
|
|
371
|
+
timeout_task = asyncio.create_task(
|
|
372
|
+
_sleep_until(deadline), name="millforge-pi-bash-timeout"
|
|
373
|
+
)
|
|
374
|
+
launch_task = asyncio.create_task(
|
|
375
|
+
_launch_process(
|
|
376
|
+
cwd=cwd,
|
|
377
|
+
command=command,
|
|
378
|
+
shell_config=shell_config,
|
|
379
|
+
),
|
|
380
|
+
name="millforge-pi-bash-launch",
|
|
381
|
+
)
|
|
382
|
+
process: asyncio.subprocess.Process | None = None
|
|
383
|
+
reader_task: asyncio.Task[None] | None = None
|
|
384
|
+
process_wait_task: asyncio.Task[int] | None = None
|
|
385
|
+
exit_task: asyncio.Task[int] | None = None
|
|
386
|
+
output_activity = asyncio.Event()
|
|
387
|
+
cleanup_task: asyncio.Task[_CleanupOutcome] | None = None
|
|
388
|
+
cleanup_diagnostics: list[str] = []
|
|
389
|
+
|
|
390
|
+
def start_interruption_cleanup() -> asyncio.Task[_CleanupOutcome]:
|
|
391
|
+
"""Transfer launch/process ownership to one shielded cleanup task."""
|
|
392
|
+
|
|
393
|
+
nonlocal cleanup_task
|
|
394
|
+
if cleanup_task is not None:
|
|
395
|
+
return cleanup_task
|
|
396
|
+
if (
|
|
397
|
+
process is not None
|
|
398
|
+
and reader_task is not None
|
|
399
|
+
and process_wait_task is not None
|
|
400
|
+
and exit_task is not None
|
|
401
|
+
):
|
|
402
|
+
cleanup = _finish_interrupted_process(
|
|
403
|
+
process=process,
|
|
404
|
+
exit_task=exit_task,
|
|
405
|
+
process_wait_task=process_wait_task,
|
|
406
|
+
reader_task=reader_task,
|
|
407
|
+
output_activity=output_activity,
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
cleanup = _recover_interrupted_launch(
|
|
411
|
+
launch_task=launch_task,
|
|
412
|
+
accumulator=accumulator,
|
|
413
|
+
output_activity=output_activity,
|
|
414
|
+
)
|
|
415
|
+
cleanup_task = asyncio.create_task(
|
|
416
|
+
cleanup, name="millforge-pi-bash-interruption-cleanup"
|
|
417
|
+
)
|
|
418
|
+
return cleanup_task
|
|
419
|
+
|
|
420
|
+
async def finish_interruption_cleanup() -> _CleanupOutcome:
|
|
421
|
+
outcome = await _await_owned_task(start_interruption_cleanup())
|
|
422
|
+
cleanup_diagnostics.extend(outcome.diagnostics)
|
|
423
|
+
return outcome
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
done, _ = await asyncio.wait(
|
|
427
|
+
{launch_task, cancellation_task, timeout_task},
|
|
428
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
429
|
+
)
|
|
430
|
+
if cancellation_task in done or cancellation.is_cancelled():
|
|
431
|
+
await finish_interruption_cleanup()
|
|
432
|
+
return await _interruption_result(
|
|
433
|
+
accumulator=accumulator,
|
|
434
|
+
status="Command aborted",
|
|
435
|
+
error_kind=PiCompatErrorKind.CANCELLED,
|
|
436
|
+
)
|
|
437
|
+
if timeout_task in done:
|
|
438
|
+
await finish_interruption_cleanup()
|
|
439
|
+
return await _interruption_result(
|
|
440
|
+
accumulator=accumulator,
|
|
441
|
+
status=(
|
|
442
|
+
"Command timed out after "
|
|
443
|
+
f"{_format_timeout_seconds(timeout_seconds)} seconds"
|
|
444
|
+
),
|
|
445
|
+
error_kind=PiCompatErrorKind.PROCESS_TIMEOUT,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
process = await _observe_task(launch_task)
|
|
450
|
+
except OSError as exc:
|
|
451
|
+
return _result(
|
|
452
|
+
text=str(exc),
|
|
453
|
+
error_kind=PiCompatErrorKind.PROCESS_LAUNCH_ERROR,
|
|
454
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
455
|
+
)
|
|
456
|
+
except _LaunchInterrupted as exc:
|
|
457
|
+
return _result(
|
|
458
|
+
text=str(exc),
|
|
459
|
+
error_kind=PiCompatErrorKind.PROCESS_LAUNCH_ERROR,
|
|
460
|
+
side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
reader_task, process_wait_task, exit_task = _start_process_tasks(
|
|
464
|
+
process, accumulator, output_activity
|
|
465
|
+
)
|
|
466
|
+
reader_observed = False
|
|
467
|
+
while True:
|
|
468
|
+
wait_set = {
|
|
469
|
+
cancellation_task,
|
|
470
|
+
timeout_task,
|
|
471
|
+
exit_task,
|
|
472
|
+
accumulator.writer_task,
|
|
473
|
+
}
|
|
474
|
+
if not reader_observed:
|
|
475
|
+
wait_set.add(reader_task)
|
|
476
|
+
# Typeshed requires one task result type although asyncio.wait
|
|
477
|
+
# supports heterogeneous futures and does not inspect results here.
|
|
478
|
+
done, _ = await asyncio.wait(
|
|
479
|
+
wait_set, # type: ignore[arg-type]
|
|
480
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if cancellation_task in done or cancellation.is_cancelled():
|
|
484
|
+
await finish_interruption_cleanup()
|
|
485
|
+
return await _interruption_result(
|
|
486
|
+
accumulator=accumulator,
|
|
487
|
+
status="Command aborted",
|
|
488
|
+
error_kind=PiCompatErrorKind.CANCELLED,
|
|
489
|
+
)
|
|
490
|
+
if timeout_task in done:
|
|
491
|
+
await finish_interruption_cleanup()
|
|
492
|
+
return await _interruption_result(
|
|
493
|
+
accumulator=accumulator,
|
|
494
|
+
status=(
|
|
495
|
+
"Command timed out after "
|
|
496
|
+
f"{_format_timeout_seconds(timeout_seconds)} seconds"
|
|
497
|
+
),
|
|
498
|
+
error_kind=PiCompatErrorKind.PROCESS_TIMEOUT,
|
|
499
|
+
)
|
|
500
|
+
if reader_task in done:
|
|
501
|
+
await _observe_task(reader_task)
|
|
502
|
+
reader_observed = True
|
|
503
|
+
continue
|
|
504
|
+
if accumulator.writer_task in done:
|
|
505
|
+
await _observe_task(accumulator.writer_task)
|
|
506
|
+
raise RuntimeError("raw-output writer completed before process exit")
|
|
507
|
+
|
|
508
|
+
exit_code = await _observe_task(exit_task)
|
|
509
|
+
await _finish_post_exit_output(
|
|
510
|
+
process=process,
|
|
511
|
+
process_wait_task=process_wait_task,
|
|
512
|
+
reader_task=reader_task,
|
|
513
|
+
output_activity=output_activity,
|
|
514
|
+
)
|
|
515
|
+
snapshot = await accumulator.finish()
|
|
516
|
+
text = _format_output(snapshot, empty_text="(no output)")
|
|
517
|
+
if exit_code != 0:
|
|
518
|
+
return _result(
|
|
519
|
+
text=_append_status(text, f"Command exited with code {exit_code}"),
|
|
520
|
+
truncated=snapshot.truncated,
|
|
521
|
+
error_kind=PiCompatErrorKind.PROCESS_EXIT_NONZERO,
|
|
522
|
+
exit_code=exit_code,
|
|
523
|
+
side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
|
|
524
|
+
)
|
|
525
|
+
return _result(
|
|
526
|
+
text=text,
|
|
527
|
+
truncated=snapshot.truncated,
|
|
528
|
+
exit_code=exit_code,
|
|
529
|
+
side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
|
|
530
|
+
)
|
|
531
|
+
except asyncio.CancelledError:
|
|
532
|
+
await finish_interruption_cleanup()
|
|
533
|
+
raise
|
|
534
|
+
except Exception as exc:
|
|
535
|
+
confirmed_complete = process is not None and (
|
|
536
|
+
process.returncode is not None or _exit_is_established(exit_task)
|
|
537
|
+
)
|
|
538
|
+
if (
|
|
539
|
+
process is not None
|
|
540
|
+
and reader_task is not None
|
|
541
|
+
and process_wait_task is not None
|
|
542
|
+
and exit_task is not None
|
|
543
|
+
):
|
|
544
|
+
if confirmed_complete:
|
|
545
|
+
cleanup_diagnostics.extend(
|
|
546
|
+
await _finish_after_exit_failure(
|
|
547
|
+
process=process,
|
|
548
|
+
process_wait_task=process_wait_task,
|
|
549
|
+
reader_task=reader_task,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
await finish_interruption_cleanup()
|
|
554
|
+
return _io_error_result(
|
|
555
|
+
accumulator=accumulator,
|
|
556
|
+
error=exc,
|
|
557
|
+
confirmed_complete=confirmed_complete,
|
|
558
|
+
)
|
|
559
|
+
finally:
|
|
560
|
+
if cleanup_task is not None and not cleanup_task.done():
|
|
561
|
+
try:
|
|
562
|
+
outcome = await _await_owned_task(cleanup_task)
|
|
563
|
+
cleanup_diagnostics.extend(outcome.diagnostics)
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
566
|
+
try:
|
|
567
|
+
await _cancel_and_observe(
|
|
568
|
+
task
|
|
569
|
+
for task in (
|
|
570
|
+
cancellation_task,
|
|
571
|
+
timeout_task,
|
|
572
|
+
launch_task if cleanup_task is None else None,
|
|
573
|
+
exit_task,
|
|
574
|
+
process_wait_task,
|
|
575
|
+
reader_task,
|
|
576
|
+
)
|
|
577
|
+
if task is not None and not task.done()
|
|
578
|
+
)
|
|
579
|
+
except Exception as exc:
|
|
580
|
+
cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
581
|
+
try:
|
|
582
|
+
cleanup_diagnostics.extend(await accumulator.close())
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _resolve_shell_executable(candidate: str) -> str:
|
|
588
|
+
candidate_path = Path(candidate)
|
|
589
|
+
if candidate_path.is_absolute():
|
|
590
|
+
if candidate_path.is_file():
|
|
591
|
+
return str(candidate_path.resolve())
|
|
592
|
+
raise PiCompatShellResolutionError(
|
|
593
|
+
f"shell executable is not a file: {candidate}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
resolved = shutil.which(candidate)
|
|
597
|
+
if resolved is None:
|
|
598
|
+
raise PiCompatShellResolutionError(
|
|
599
|
+
f"shell executable was not found: {candidate}"
|
|
600
|
+
)
|
|
601
|
+
return str(Path(resolved).resolve())
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _validate_arguments(
|
|
605
|
+
*, cwd: Path, command: str, timeout_seconds: float
|
|
606
|
+
) -> str | None:
|
|
607
|
+
try:
|
|
608
|
+
cwd_text = os.fspath(cwd)
|
|
609
|
+
except TypeError:
|
|
610
|
+
return "cwd must be a filesystem path"
|
|
611
|
+
if "\x00" in cwd_text:
|
|
612
|
+
return "cwd must not contain a NUL character"
|
|
613
|
+
try:
|
|
614
|
+
cwd_text.encode(sys.getfilesystemencoding(), "strict")
|
|
615
|
+
except UnicodeEncodeError:
|
|
616
|
+
return "cwd must be filesystem-encodable"
|
|
617
|
+
if not _is_absolute_cwd(cwd):
|
|
618
|
+
return "cwd must be an absolute path"
|
|
619
|
+
if not isinstance(command, str):
|
|
620
|
+
return "command must be a string"
|
|
621
|
+
if (
|
|
622
|
+
isinstance(timeout_seconds, bool)
|
|
623
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
624
|
+
or not math.isfinite(timeout_seconds)
|
|
625
|
+
or timeout_seconds <= 0
|
|
626
|
+
):
|
|
627
|
+
return "timeout_seconds must be a strictly positive finite number"
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def _launch_process(
|
|
632
|
+
*,
|
|
633
|
+
cwd: Path,
|
|
634
|
+
command: str,
|
|
635
|
+
shell_config: PiCompatShellConfig,
|
|
636
|
+
) -> asyncio.subprocess.Process:
|
|
637
|
+
"""Materialize a process and hand it to the caller without cleanup work."""
|
|
638
|
+
|
|
639
|
+
return await _create_process(cwd=cwd, command=command, shell_config=shell_config)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
async def _create_process(
|
|
643
|
+
*, cwd: Path, command: str, shell_config: PiCompatShellConfig
|
|
644
|
+
) -> asyncio.subprocess.Process:
|
|
645
|
+
if os.name == "nt":
|
|
646
|
+
return await asyncio.create_subprocess_exec(
|
|
647
|
+
shell_config.executable,
|
|
648
|
+
*shell_config.arguments,
|
|
649
|
+
command,
|
|
650
|
+
cwd=str(cwd),
|
|
651
|
+
stdin=subprocess.DEVNULL,
|
|
652
|
+
stdout=subprocess.PIPE,
|
|
653
|
+
# One kernel pipe gives a single ordered delivery boundary for
|
|
654
|
+
# bursty stdout/stderr instead of racing independent stream readers.
|
|
655
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
656
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
|
657
|
+
)
|
|
658
|
+
return await asyncio.create_subprocess_exec(
|
|
659
|
+
shell_config.executable,
|
|
660
|
+
*shell_config.arguments,
|
|
661
|
+
command,
|
|
662
|
+
cwd=str(cwd),
|
|
663
|
+
stdin=subprocess.DEVNULL,
|
|
664
|
+
stdout=subprocess.PIPE,
|
|
665
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
666
|
+
start_new_session=True,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _start_process_tasks(
|
|
671
|
+
process: asyncio.subprocess.Process,
|
|
672
|
+
accumulator: _OutputAccumulator,
|
|
673
|
+
output_activity: asyncio.Event,
|
|
674
|
+
) -> tuple[asyncio.Task[None], asyncio.Task[int], asyncio.Task[int]]:
|
|
675
|
+
assert process.stdout is not None
|
|
676
|
+
reader_task = asyncio.create_task(
|
|
677
|
+
_read_output(process.stdout, accumulator, output_activity),
|
|
678
|
+
name="millforge-pi-bash-output-reader",
|
|
679
|
+
)
|
|
680
|
+
process_wait_task = asyncio.create_task(
|
|
681
|
+
process.wait(), name="millforge-pi-bash-process-wait"
|
|
682
|
+
)
|
|
683
|
+
exit_task = asyncio.create_task(
|
|
684
|
+
_wait_for_process_exit(process), name="millforge-pi-bash-exit-watch"
|
|
685
|
+
)
|
|
686
|
+
return reader_task, process_wait_task, exit_task
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
async def _settle_interrupted_launch(
|
|
690
|
+
launch_task: asyncio.Task[asyncio.subprocess.Process],
|
|
691
|
+
cleanup_deadline: float,
|
|
692
|
+
) -> tuple[asyncio.subprocess.Process | None, tuple[str, ...]]:
|
|
693
|
+
"""Bound and observe launch handoff before an interruption result is emitted."""
|
|
694
|
+
|
|
695
|
+
diagnostics: list[str] = []
|
|
696
|
+
if not launch_task.done():
|
|
697
|
+
await _wait_for_task(launch_task, _cleanup_time_remaining(cleanup_deadline))
|
|
698
|
+
if not launch_task.done():
|
|
699
|
+
launch_task.cancel()
|
|
700
|
+
try:
|
|
701
|
+
await _await_owned_task(launch_task)
|
|
702
|
+
except asyncio.CancelledError:
|
|
703
|
+
diagnostics.append("launch did not hand off a process before cleanup grace")
|
|
704
|
+
return None, tuple(diagnostics)
|
|
705
|
+
except Exception as exc:
|
|
706
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
707
|
+
return None, tuple(diagnostics)
|
|
708
|
+
try:
|
|
709
|
+
return await _observe_task(launch_task), tuple(diagnostics)
|
|
710
|
+
except _LaunchInterrupted as exc:
|
|
711
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
712
|
+
except asyncio.CancelledError:
|
|
713
|
+
diagnostics.append("launch was cancelled before process handoff")
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
716
|
+
return None, tuple(diagnostics)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def _recover_interrupted_launch(
|
|
720
|
+
*,
|
|
721
|
+
launch_task: asyncio.Task[asyncio.subprocess.Process],
|
|
722
|
+
accumulator: _OutputAccumulator,
|
|
723
|
+
output_activity: asyncio.Event,
|
|
724
|
+
) -> _CleanupOutcome:
|
|
725
|
+
"""Own a racing launch until its process is reaped or handoff is bounded."""
|
|
726
|
+
|
|
727
|
+
cleanup_deadline = _cleanup_deadline()
|
|
728
|
+
process, diagnostics = await _settle_interrupted_launch(
|
|
729
|
+
launch_task, cleanup_deadline
|
|
730
|
+
)
|
|
731
|
+
if process is None:
|
|
732
|
+
return _CleanupOutcome(
|
|
733
|
+
tree_confirmed=False,
|
|
734
|
+
process_exited=False,
|
|
735
|
+
process_reaped=False,
|
|
736
|
+
diagnostics=diagnostics,
|
|
737
|
+
)
|
|
738
|
+
reader_task, process_wait_task, exit_task = _start_process_tasks(
|
|
739
|
+
process, accumulator, output_activity
|
|
740
|
+
)
|
|
741
|
+
outcome = await _finish_interrupted_process(
|
|
742
|
+
process=process,
|
|
743
|
+
exit_task=exit_task,
|
|
744
|
+
process_wait_task=process_wait_task,
|
|
745
|
+
reader_task=reader_task,
|
|
746
|
+
output_activity=output_activity,
|
|
747
|
+
cleanup_deadline=cleanup_deadline,
|
|
748
|
+
)
|
|
749
|
+
return _CleanupOutcome(
|
|
750
|
+
tree_confirmed=outcome.tree_confirmed,
|
|
751
|
+
process_exited=outcome.process_exited,
|
|
752
|
+
process_reaped=outcome.process_reaped,
|
|
753
|
+
diagnostics=diagnostics + outcome.diagnostics,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _cleanup_deadline() -> float:
|
|
758
|
+
return asyncio.get_running_loop().time() + _CLEANUP_GRACE_SECONDS
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _cleanup_time_remaining(deadline: float) -> float:
|
|
762
|
+
return max(0.0, deadline - asyncio.get_running_loop().time())
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
async def _sleep_until(deadline: float) -> None:
|
|
766
|
+
await asyncio.sleep(max(0.0, deadline - asyncio.get_running_loop().time()))
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _result(
|
|
770
|
+
*,
|
|
771
|
+
text: str,
|
|
772
|
+
truncated: bool = False,
|
|
773
|
+
error_kind: PiCompatErrorKind | None = None,
|
|
774
|
+
exit_code: int | None = None,
|
|
775
|
+
side_effect_state: PiCompatSideEffectState,
|
|
776
|
+
) -> PiCompatOperationResult:
|
|
777
|
+
return PiCompatOperationResult(
|
|
778
|
+
model_text=text,
|
|
779
|
+
truncated=truncated,
|
|
780
|
+
error_kind=error_kind,
|
|
781
|
+
exit_code=exit_code,
|
|
782
|
+
changed_path=None,
|
|
783
|
+
side_effect_state=side_effect_state,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
async def _read_output(
|
|
788
|
+
stream: asyncio.StreamReader,
|
|
789
|
+
accumulator: _OutputAccumulator,
|
|
790
|
+
output_activity: asyncio.Event,
|
|
791
|
+
) -> None:
|
|
792
|
+
while chunk := await stream.read(4_096):
|
|
793
|
+
await accumulator.append(chunk)
|
|
794
|
+
output_activity.set()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
async def _write_raw_output(queue: asyncio.Queue[bytes | None]) -> Path:
|
|
798
|
+
"""Write raw output off-loop and remove a partial destination on failure."""
|
|
799
|
+
|
|
800
|
+
output_path: Path | None = None
|
|
801
|
+
output_file: BinaryIO | None = None
|
|
802
|
+
try:
|
|
803
|
+
output_path = await _run_blocking(_create_raw_output_path)
|
|
804
|
+
output_file = await _run_blocking(lambda: output_path.open("wb"))
|
|
805
|
+
while True:
|
|
806
|
+
data = await queue.get()
|
|
807
|
+
if data is None:
|
|
808
|
+
break
|
|
809
|
+
assert output_file is not None
|
|
810
|
+
chunk_output_file: BinaryIO = output_file
|
|
811
|
+
await _run_blocking(lambda: _write_raw_chunk(chunk_output_file, data))
|
|
812
|
+
assert output_file is not None
|
|
813
|
+
await _run_blocking(output_file.flush)
|
|
814
|
+
await _run_blocking(output_file.close)
|
|
815
|
+
output_file = None
|
|
816
|
+
return output_path
|
|
817
|
+
except BaseException:
|
|
818
|
+
if output_file is not None:
|
|
819
|
+
try:
|
|
820
|
+
await _run_blocking(output_file.close)
|
|
821
|
+
except OSError:
|
|
822
|
+
pass
|
|
823
|
+
if output_path is not None:
|
|
824
|
+
try:
|
|
825
|
+
await _run_blocking(lambda: output_path.unlink(missing_ok=True))
|
|
826
|
+
except OSError:
|
|
827
|
+
pass
|
|
828
|
+
raise
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _create_raw_output_path() -> Path:
|
|
832
|
+
descriptor, path = tempfile.mkstemp(prefix="pi-bash-", suffix=".log")
|
|
833
|
+
os.close(descriptor)
|
|
834
|
+
return Path(path)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _write_raw_chunk(output_file: BinaryIO, data: bytes) -> None:
|
|
838
|
+
output_file.write(data)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
async def _run_blocking(operation: Callable[[], _T]) -> _T:
|
|
842
|
+
"""Await one thread-bound file operation before its handle may be closed."""
|
|
843
|
+
|
|
844
|
+
operation_task = asyncio.create_task(
|
|
845
|
+
asyncio.to_thread(operation), name="millforge-pi-bash-output-thread"
|
|
846
|
+
)
|
|
847
|
+
cancellation: asyncio.CancelledError | None = None
|
|
848
|
+
while not operation_task.done():
|
|
849
|
+
try:
|
|
850
|
+
await asyncio.shield(operation_task)
|
|
851
|
+
except asyncio.CancelledError as exc:
|
|
852
|
+
cancellation = exc
|
|
853
|
+
try:
|
|
854
|
+
result = operation_task.result()
|
|
855
|
+
except BaseException:
|
|
856
|
+
if cancellation is not None:
|
|
857
|
+
raise cancellation
|
|
858
|
+
raise
|
|
859
|
+
if cancellation is not None:
|
|
860
|
+
raise cancellation
|
|
861
|
+
return result
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
async def _wait_for_process_exit(process: asyncio.subprocess.Process) -> int:
|
|
865
|
+
while process.returncode is None:
|
|
866
|
+
await asyncio.sleep(_PROCESS_POLL_SECONDS)
|
|
867
|
+
return process.returncode
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
async def _wait_for_process_exit_with_grace(
|
|
871
|
+
process: asyncio.subprocess.Process,
|
|
872
|
+
exit_task: asyncio.Task[int],
|
|
873
|
+
cleanup_deadline: float,
|
|
874
|
+
) -> bool:
|
|
875
|
+
if process.returncode is not None:
|
|
876
|
+
return True
|
|
877
|
+
await _wait_for_task(exit_task, _cleanup_time_remaining(cleanup_deadline))
|
|
878
|
+
return process.returncode is not None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
async def _finish_interrupted_process(
|
|
882
|
+
*,
|
|
883
|
+
process: asyncio.subprocess.Process,
|
|
884
|
+
exit_task: asyncio.Task[int],
|
|
885
|
+
process_wait_task: asyncio.Task[int],
|
|
886
|
+
reader_task: asyncio.Task[None],
|
|
887
|
+
output_activity: asyncio.Event,
|
|
888
|
+
cleanup_deadline: float | None = None,
|
|
889
|
+
) -> _CleanupOutcome:
|
|
890
|
+
cleanup_deadline = cleanup_deadline or _cleanup_deadline()
|
|
891
|
+
outcome = await _terminate_process_tree(
|
|
892
|
+
process, exit_task, process_wait_task, cleanup_deadline
|
|
893
|
+
)
|
|
894
|
+
diagnostics = list(outcome.diagnostics)
|
|
895
|
+
if outcome.process_reaped:
|
|
896
|
+
try:
|
|
897
|
+
await _finish_post_exit_output(
|
|
898
|
+
process=process,
|
|
899
|
+
process_wait_task=process_wait_task,
|
|
900
|
+
reader_task=reader_task,
|
|
901
|
+
output_activity=output_activity,
|
|
902
|
+
cleanup_deadline=cleanup_deadline,
|
|
903
|
+
)
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
906
|
+
else:
|
|
907
|
+
_close_subprocess_pipes(process)
|
|
908
|
+
try:
|
|
909
|
+
await _cancel_and_observe((reader_task, process_wait_task, exit_task))
|
|
910
|
+
except Exception as exc:
|
|
911
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
912
|
+
return _CleanupOutcome(
|
|
913
|
+
tree_confirmed=outcome.tree_confirmed,
|
|
914
|
+
process_exited=outcome.process_exited,
|
|
915
|
+
process_reaped=outcome.process_reaped,
|
|
916
|
+
diagnostics=tuple(diagnostics),
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
async def _terminate_process_tree(
|
|
921
|
+
process: asyncio.subprocess.Process,
|
|
922
|
+
exit_task: asyncio.Task[int],
|
|
923
|
+
process_wait_task: asyncio.Task[int],
|
|
924
|
+
cleanup_deadline: float,
|
|
925
|
+
) -> _CleanupOutcome:
|
|
926
|
+
"""Request tree termination, then contain the direct shell on failure."""
|
|
927
|
+
|
|
928
|
+
diagnostics: list[str] = []
|
|
929
|
+
pid = process.pid
|
|
930
|
+
if pid is None:
|
|
931
|
+
return _CleanupOutcome(
|
|
932
|
+
tree_confirmed=False,
|
|
933
|
+
process_exited=process.returncode is not None,
|
|
934
|
+
process_reaped=False,
|
|
935
|
+
diagnostics=("process did not expose a PID for tree cleanup",),
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
tree_operation = (
|
|
939
|
+
_run_taskkill(pid) if os.name == "nt" else _kill_posix_process_group(pid)
|
|
940
|
+
)
|
|
941
|
+
tree_task = asyncio.create_task(
|
|
942
|
+
tree_operation, name="millforge-pi-bash-tree-cleanup"
|
|
943
|
+
)
|
|
944
|
+
try:
|
|
945
|
+
if await _wait_for_task(tree_task, _cleanup_time_remaining(cleanup_deadline)):
|
|
946
|
+
tree_confirmed = await _observe_task(tree_task)
|
|
947
|
+
else:
|
|
948
|
+
tree_confirmed = False
|
|
949
|
+
diagnostics.append("tree cleanup did not finish before cleanup grace")
|
|
950
|
+
await _cancel_and_observe((tree_task,))
|
|
951
|
+
except OSError as exc:
|
|
952
|
+
tree_confirmed = False
|
|
953
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
954
|
+
finally:
|
|
955
|
+
if not tree_task.done():
|
|
956
|
+
await _cancel_and_observe((tree_task,))
|
|
957
|
+
|
|
958
|
+
process_exited = await _wait_for_process_exit_with_grace(
|
|
959
|
+
process, exit_task, cleanup_deadline
|
|
960
|
+
)
|
|
961
|
+
if process_exited:
|
|
962
|
+
_close_subprocess_pipes(process)
|
|
963
|
+
process_reaped, wait_diagnostics = await _settle_process_wait(
|
|
964
|
+
process, process_wait_task, cleanup_deadline
|
|
965
|
+
)
|
|
966
|
+
diagnostics.extend(wait_diagnostics)
|
|
967
|
+
if tree_confirmed and process_reaped:
|
|
968
|
+
return _CleanupOutcome(
|
|
969
|
+
tree_confirmed=True,
|
|
970
|
+
process_exited=True,
|
|
971
|
+
process_reaped=True,
|
|
972
|
+
diagnostics=tuple(diagnostics),
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
direct_exited, direct_reaped, direct_diagnostics = await _terminate_direct_child(
|
|
976
|
+
process, exit_task, process_wait_task, cleanup_deadline
|
|
977
|
+
)
|
|
978
|
+
diagnostics.extend(direct_diagnostics)
|
|
979
|
+
return _CleanupOutcome(
|
|
980
|
+
tree_confirmed=tree_confirmed,
|
|
981
|
+
process_exited=direct_exited,
|
|
982
|
+
process_reaped=direct_reaped,
|
|
983
|
+
diagnostics=tuple(diagnostics),
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
async def _kill_posix_process_group(pid: int) -> bool:
|
|
988
|
+
try:
|
|
989
|
+
os.killpg(pid, signal.SIGKILL)
|
|
990
|
+
except ProcessLookupError:
|
|
991
|
+
return not _posix_process_group_exists(pid)
|
|
992
|
+
except OSError:
|
|
993
|
+
return False
|
|
994
|
+
return await _wait_for_posix_process_group_death(pid)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _posix_process_group_exists(pid: int) -> bool:
|
|
998
|
+
try:
|
|
999
|
+
os.killpg(pid, 0)
|
|
1000
|
+
except ProcessLookupError:
|
|
1001
|
+
return False
|
|
1002
|
+
except OSError:
|
|
1003
|
+
return True
|
|
1004
|
+
return True
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
async def _wait_for_posix_process_group_death(pid: int) -> bool:
|
|
1008
|
+
deadline = asyncio.get_running_loop().time() + _CLEANUP_GRACE_SECONDS
|
|
1009
|
+
while _posix_process_group_exists(pid):
|
|
1010
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
1011
|
+
return False
|
|
1012
|
+
await asyncio.sleep(_PROCESS_POLL_SECONDS)
|
|
1013
|
+
return True
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
async def _run_taskkill(pid: int) -> bool:
|
|
1017
|
+
try:
|
|
1018
|
+
taskkill = await asyncio.create_subprocess_exec(
|
|
1019
|
+
"taskkill",
|
|
1020
|
+
"/F",
|
|
1021
|
+
"/T",
|
|
1022
|
+
"/PID",
|
|
1023
|
+
str(pid),
|
|
1024
|
+
stdin=subprocess.DEVNULL,
|
|
1025
|
+
stdout=subprocess.DEVNULL,
|
|
1026
|
+
stderr=subprocess.DEVNULL,
|
|
1027
|
+
)
|
|
1028
|
+
except OSError:
|
|
1029
|
+
return False
|
|
1030
|
+
|
|
1031
|
+
wait_task = asyncio.create_task(
|
|
1032
|
+
taskkill.wait(), name="millforge-pi-bash-taskkill-wait"
|
|
1033
|
+
)
|
|
1034
|
+
try:
|
|
1035
|
+
if not await _wait_for_task(wait_task, _CLEANUP_GRACE_SECONDS):
|
|
1036
|
+
try:
|
|
1037
|
+
taskkill.kill()
|
|
1038
|
+
except (OSError, ProcessLookupError):
|
|
1039
|
+
pass
|
|
1040
|
+
if await _wait_for_task(wait_task, _CLEANUP_GRACE_SECONDS):
|
|
1041
|
+
await _observe_task(wait_task)
|
|
1042
|
+
else:
|
|
1043
|
+
await _cancel_and_observe((wait_task,))
|
|
1044
|
+
return False
|
|
1045
|
+
return await _observe_task(wait_task) == 0
|
|
1046
|
+
finally:
|
|
1047
|
+
await _cancel_and_observe(task for task in (wait_task,) if not task.done())
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def _terminate_direct_child(
|
|
1051
|
+
process: asyncio.subprocess.Process,
|
|
1052
|
+
exit_task: asyncio.Task[int],
|
|
1053
|
+
process_wait_task: asyncio.Task[int],
|
|
1054
|
+
cleanup_deadline: float,
|
|
1055
|
+
) -> tuple[bool, bool, tuple[str, ...]]:
|
|
1056
|
+
diagnostics: list[str] = []
|
|
1057
|
+
if process.returncode is None:
|
|
1058
|
+
try:
|
|
1059
|
+
process.kill()
|
|
1060
|
+
except (OSError, ProcessLookupError) as exc:
|
|
1061
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
1062
|
+
process_exited = await _wait_for_process_exit_with_grace(
|
|
1063
|
+
process, exit_task, cleanup_deadline
|
|
1064
|
+
)
|
|
1065
|
+
_close_subprocess_pipes(process)
|
|
1066
|
+
process_reaped, wait_diagnostics = await _settle_process_wait(
|
|
1067
|
+
process, process_wait_task, cleanup_deadline
|
|
1068
|
+
)
|
|
1069
|
+
diagnostics.extend(wait_diagnostics)
|
|
1070
|
+
return process_exited, process_reaped, tuple(diagnostics)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
async def _finish_post_exit_output(
|
|
1074
|
+
*,
|
|
1075
|
+
process: asyncio.subprocess.Process,
|
|
1076
|
+
process_wait_task: asyncio.Task[int],
|
|
1077
|
+
reader_task: asyncio.Task[None],
|
|
1078
|
+
output_activity: asyncio.Event,
|
|
1079
|
+
cleanup_deadline: float | None = None,
|
|
1080
|
+
) -> None:
|
|
1081
|
+
reader_error: Exception | None = None
|
|
1082
|
+
try:
|
|
1083
|
+
reader_finished = await _drain_post_exit_output(reader_task, output_activity)
|
|
1084
|
+
if not reader_finished:
|
|
1085
|
+
_close_subprocess_pipes(process)
|
|
1086
|
+
await _cancel_and_observe((reader_task,))
|
|
1087
|
+
else:
|
|
1088
|
+
await _observe_task(reader_task)
|
|
1089
|
+
except Exception as exc:
|
|
1090
|
+
reader_error = exc
|
|
1091
|
+
finally:
|
|
1092
|
+
await _settle_process_wait(
|
|
1093
|
+
process,
|
|
1094
|
+
process_wait_task,
|
|
1095
|
+
cleanup_deadline or _cleanup_deadline(),
|
|
1096
|
+
)
|
|
1097
|
+
if reader_error is not None:
|
|
1098
|
+
raise reader_error
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
async def _finish_after_exit_failure(
|
|
1102
|
+
*,
|
|
1103
|
+
process: asyncio.subprocess.Process,
|
|
1104
|
+
process_wait_task: asyncio.Task[int],
|
|
1105
|
+
reader_task: asyncio.Task[None],
|
|
1106
|
+
) -> tuple[str, ...]:
|
|
1107
|
+
diagnostics: list[str] = []
|
|
1108
|
+
cleanup_deadline = _cleanup_deadline()
|
|
1109
|
+
_close_subprocess_pipes(process)
|
|
1110
|
+
try:
|
|
1111
|
+
await _cancel_and_observe((reader_task,))
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
1114
|
+
_, wait_diagnostics = await _settle_process_wait(
|
|
1115
|
+
process, process_wait_task, cleanup_deadline
|
|
1116
|
+
)
|
|
1117
|
+
diagnostics.extend(wait_diagnostics)
|
|
1118
|
+
return tuple(diagnostics)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
async def _settle_process_wait(
|
|
1122
|
+
process: asyncio.subprocess.Process,
|
|
1123
|
+
process_wait_task: asyncio.Task[int],
|
|
1124
|
+
cleanup_deadline: float,
|
|
1125
|
+
) -> tuple[bool, tuple[str, ...]]:
|
|
1126
|
+
diagnostics: list[str] = []
|
|
1127
|
+
if await _wait_for_task(
|
|
1128
|
+
process_wait_task, _cleanup_time_remaining(cleanup_deadline)
|
|
1129
|
+
):
|
|
1130
|
+
try:
|
|
1131
|
+
await _observe_task(process_wait_task)
|
|
1132
|
+
except Exception as exc:
|
|
1133
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
1134
|
+
return False, tuple(diagnostics)
|
|
1135
|
+
return True, tuple(diagnostics)
|
|
1136
|
+
_close_subprocess_pipes(process)
|
|
1137
|
+
if await _wait_for_task(
|
|
1138
|
+
process_wait_task, _cleanup_time_remaining(cleanup_deadline)
|
|
1139
|
+
):
|
|
1140
|
+
try:
|
|
1141
|
+
await _observe_task(process_wait_task)
|
|
1142
|
+
except Exception as exc:
|
|
1143
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
1144
|
+
return False, tuple(diagnostics)
|
|
1145
|
+
return True, tuple(diagnostics)
|
|
1146
|
+
try:
|
|
1147
|
+
await _cancel_and_observe((process_wait_task,))
|
|
1148
|
+
except Exception as exc:
|
|
1149
|
+
diagnostics.append(_format_cleanup_diagnostic(exc))
|
|
1150
|
+
return False, tuple(diagnostics)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
async def _drain_post_exit_output(
|
|
1154
|
+
reader_task: asyncio.Task[None], output_activity: asyncio.Event
|
|
1155
|
+
) -> bool:
|
|
1156
|
+
"""Drain output until EOF or Pi's re-armed 100-ms idle window expires."""
|
|
1157
|
+
|
|
1158
|
+
loop = asyncio.get_running_loop()
|
|
1159
|
+
idle_deadline = loop.time() + _POST_EXIT_IDLE_SECONDS
|
|
1160
|
+
output_activity.clear()
|
|
1161
|
+
while not reader_task.done():
|
|
1162
|
+
remaining = idle_deadline - loop.time()
|
|
1163
|
+
if remaining <= 0:
|
|
1164
|
+
return False
|
|
1165
|
+
activity_task = asyncio.create_task(
|
|
1166
|
+
output_activity.wait(), name="millforge-pi-bash-post-exit-activity"
|
|
1167
|
+
)
|
|
1168
|
+
try:
|
|
1169
|
+
done, _ = await asyncio.wait(
|
|
1170
|
+
{reader_task, activity_task},
|
|
1171
|
+
timeout=remaining,
|
|
1172
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1173
|
+
)
|
|
1174
|
+
if reader_task.done():
|
|
1175
|
+
return True
|
|
1176
|
+
if activity_task in done:
|
|
1177
|
+
await _observe_task(activity_task)
|
|
1178
|
+
output_activity.clear()
|
|
1179
|
+
idle_deadline = loop.time() + _POST_EXIT_IDLE_SECONDS
|
|
1180
|
+
finally:
|
|
1181
|
+
if not activity_task.done():
|
|
1182
|
+
await _cancel_and_observe((activity_task,))
|
|
1183
|
+
return True
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _close_subprocess_pipes(process: asyncio.subprocess.Process) -> None:
|
|
1187
|
+
"""Close inherited pipe transports once Pi's idle window has elapsed."""
|
|
1188
|
+
|
|
1189
|
+
transport = getattr(process, "_transport", None)
|
|
1190
|
+
if transport is None:
|
|
1191
|
+
return
|
|
1192
|
+
pipe_transport = transport.get_pipe_transport(1)
|
|
1193
|
+
if pipe_transport is not None:
|
|
1194
|
+
pipe_transport.close()
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
async def _wait_for_task(task: asyncio.Task[object], timeout: float) -> bool:
|
|
1198
|
+
done, _ = await asyncio.wait({task}, timeout=timeout)
|
|
1199
|
+
return task in done
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
async def _observe_task(task: asyncio.Task[_T]) -> _T:
|
|
1203
|
+
return await task
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
async def _await_owned_task(task: asyncio.Task[_T]) -> _T:
|
|
1207
|
+
"""Observe a task even if its parent receives cancellation mid-cleanup."""
|
|
1208
|
+
|
|
1209
|
+
cancellation: asyncio.CancelledError | None = None
|
|
1210
|
+
while not task.done():
|
|
1211
|
+
try:
|
|
1212
|
+
await asyncio.shield(task)
|
|
1213
|
+
except asyncio.CancelledError as exc:
|
|
1214
|
+
cancellation = exc
|
|
1215
|
+
try:
|
|
1216
|
+
result = task.result()
|
|
1217
|
+
except BaseException:
|
|
1218
|
+
if cancellation is not None:
|
|
1219
|
+
raise cancellation
|
|
1220
|
+
raise
|
|
1221
|
+
if cancellation is not None:
|
|
1222
|
+
raise cancellation
|
|
1223
|
+
return result
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
async def _cancel_and_observe(tasks: Iterable[asyncio.Task[object]]) -> None:
|
|
1227
|
+
pending = tuple(tasks)
|
|
1228
|
+
for task in pending:
|
|
1229
|
+
if not task.done():
|
|
1230
|
+
task.cancel()
|
|
1231
|
+
error: Exception | None = None
|
|
1232
|
+
for task in pending:
|
|
1233
|
+
if not task.done() and not await _wait_for_task(task, _CLEANUP_GRACE_SECONDS):
|
|
1234
|
+
continue
|
|
1235
|
+
try:
|
|
1236
|
+
task.result()
|
|
1237
|
+
except asyncio.CancelledError:
|
|
1238
|
+
pass
|
|
1239
|
+
except Exception as exc:
|
|
1240
|
+
if error is None:
|
|
1241
|
+
error = exc
|
|
1242
|
+
if error is not None:
|
|
1243
|
+
raise error
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
async def _interruption_result(
|
|
1247
|
+
*,
|
|
1248
|
+
accumulator: _OutputAccumulator,
|
|
1249
|
+
status: str,
|
|
1250
|
+
error_kind: PiCompatErrorKind,
|
|
1251
|
+
) -> PiCompatOperationResult:
|
|
1252
|
+
try:
|
|
1253
|
+
snapshot = await accumulator.finish()
|
|
1254
|
+
except Exception:
|
|
1255
|
+
snapshot = accumulator.snapshot_after_failure()
|
|
1256
|
+
return _result(
|
|
1257
|
+
text=_append_status(_format_output(snapshot, empty_text=""), status),
|
|
1258
|
+
truncated=snapshot.truncated,
|
|
1259
|
+
error_kind=error_kind,
|
|
1260
|
+
side_effect_state=PiCompatSideEffectState.COMPLETION_UNKNOWN,
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _io_error_result(
|
|
1265
|
+
*,
|
|
1266
|
+
accumulator: _OutputAccumulator,
|
|
1267
|
+
error: Exception,
|
|
1268
|
+
confirmed_complete: bool,
|
|
1269
|
+
) -> PiCompatOperationResult:
|
|
1270
|
+
snapshot = accumulator.snapshot_after_failure()
|
|
1271
|
+
return _result(
|
|
1272
|
+
text=_append_status(_format_output(snapshot, empty_text=""), str(error)),
|
|
1273
|
+
truncated=snapshot.truncated,
|
|
1274
|
+
error_kind=PiCompatErrorKind.IO_ERROR,
|
|
1275
|
+
side_effect_state=(
|
|
1276
|
+
PiCompatSideEffectState.CONFIRMED_COMPLETE
|
|
1277
|
+
if confirmed_complete
|
|
1278
|
+
else PiCompatSideEffectState.COMPLETION_UNKNOWN
|
|
1279
|
+
),
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _exit_is_established(exit_task: asyncio.Task[int] | None) -> bool:
|
|
1284
|
+
if exit_task is None or not exit_task.done() or exit_task.cancelled():
|
|
1285
|
+
return False
|
|
1286
|
+
return exit_task.exception() is None
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _format_cleanup_diagnostic(error: Exception) -> str:
|
|
1290
|
+
message = str(error).replace("\x00", "\\0")
|
|
1291
|
+
return f"{type(error).__name__}: {message}"
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _format_timeout_seconds(timeout_seconds: float) -> str:
|
|
1295
|
+
"""Render a finite IEEE-754 value using JavaScript Number#toString rules."""
|
|
1296
|
+
|
|
1297
|
+
timeout = float(timeout_seconds)
|
|
1298
|
+
if timeout == 0:
|
|
1299
|
+
return "0"
|
|
1300
|
+
|
|
1301
|
+
text = repr(timeout).lower()
|
|
1302
|
+
magnitude = abs(timeout)
|
|
1303
|
+
if 1e-6 <= magnitude < 1e21:
|
|
1304
|
+
return _scientific_to_decimal(text) if "e" in text else text.removesuffix(".0")
|
|
1305
|
+
|
|
1306
|
+
if "e" not in text:
|
|
1307
|
+
return text.removesuffix(".0")
|
|
1308
|
+
mantissa, exponent_text = text.split("e", maxsplit=1)
|
|
1309
|
+
mantissa = mantissa.removesuffix(".0")
|
|
1310
|
+
exponent = int(exponent_text)
|
|
1311
|
+
sign = "+" if exponent >= 0 else "-"
|
|
1312
|
+
return f"{mantissa}e{sign}{abs(exponent)}"
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _scientific_to_decimal(text: str) -> str:
|
|
1316
|
+
mantissa, exponent_text = text.split("e", maxsplit=1)
|
|
1317
|
+
exponent = int(exponent_text)
|
|
1318
|
+
sign = ""
|
|
1319
|
+
if mantissa.startswith("-"):
|
|
1320
|
+
sign, mantissa = "-", mantissa[1:]
|
|
1321
|
+
whole, _, fraction = mantissa.partition(".")
|
|
1322
|
+
digits = whole + fraction
|
|
1323
|
+
decimal_index = len(whole) + exponent
|
|
1324
|
+
if decimal_index <= 0:
|
|
1325
|
+
return f"{sign}0.{('0' * -decimal_index)}{digits}"
|
|
1326
|
+
if decimal_index >= len(digits):
|
|
1327
|
+
return f"{sign}{digits}{('0' * (decimal_index - len(digits)))}"
|
|
1328
|
+
return f"{sign}{digits[:decimal_index]}.{digits[decimal_index:]}"
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _format_output(snapshot: _TailSnapshot, *, empty_text: str) -> str:
|
|
1332
|
+
text = snapshot.content or empty_text
|
|
1333
|
+
if not snapshot.truncated or snapshot.full_output_path is None:
|
|
1334
|
+
return text
|
|
1335
|
+
|
|
1336
|
+
start_line = snapshot.total_lines - snapshot.output_lines + 1
|
|
1337
|
+
end_line = snapshot.total_lines
|
|
1338
|
+
if snapshot.last_line_partial:
|
|
1339
|
+
last_line_size = _format_size(snapshot.last_line_bytes)
|
|
1340
|
+
return _append_status(
|
|
1341
|
+
text,
|
|
1342
|
+
f"[Showing last {_format_size(snapshot.output_bytes)} of line {end_line} "
|
|
1343
|
+
f"(line is {last_line_size}). Full output: {snapshot.full_output_path}]",
|
|
1344
|
+
)
|
|
1345
|
+
if snapshot.truncated_by == "lines":
|
|
1346
|
+
notice = f"[Showing lines {start_line}-{end_line} of {snapshot.total_lines}. Full output: {snapshot.full_output_path}]"
|
|
1347
|
+
else:
|
|
1348
|
+
notice = (
|
|
1349
|
+
f"[Showing lines {start_line}-{end_line} of {snapshot.total_lines} "
|
|
1350
|
+
f"({_format_size(_DEFAULT_MAX_BYTES)} limit). Full output: {snapshot.full_output_path}]"
|
|
1351
|
+
)
|
|
1352
|
+
return _append_status(text, notice)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def _append_status(text: str, status: str) -> str:
|
|
1356
|
+
return f"{text}\n\n{status}" if text else status
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _truncate_tail(content: str) -> tuple[str, str | None, int, int, bool]:
|
|
1360
|
+
truncation = truncate_tail(
|
|
1361
|
+
content,
|
|
1362
|
+
max_lines=_DEFAULT_MAX_LINES,
|
|
1363
|
+
max_bytes=_DEFAULT_MAX_BYTES,
|
|
1364
|
+
)
|
|
1365
|
+
return (
|
|
1366
|
+
truncation.content,
|
|
1367
|
+
truncation.truncated_by,
|
|
1368
|
+
truncation.output_lines,
|
|
1369
|
+
truncation.output_bytes,
|
|
1370
|
+
truncation.last_line_partial,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _format_size(byte_count: int) -> str:
|
|
1375
|
+
return format_size(byte_count)
|