iowap-node 2.3.20__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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Handler runner: executes a capability handler as a subprocess.
|
|
3
|
+
|
|
4
|
+
A *handler* is an external executable (script, binary, or shell command)
|
|
5
|
+
that performs the actual work for a claimed stage. The runner sets up a
|
|
6
|
+
well-defined environment, feeds the stage ``payload`` as JSON on stdin,
|
|
7
|
+
captures stdout/stderr, enforces a timeout, and returns a result dict
|
|
8
|
+
ready to be POSTed to the ``/complete`` endpoint.
|
|
9
|
+
|
|
10
|
+
Contract (see NODE_CLI_SPEC.md §4 and docs/node/capabilities.md):
|
|
11
|
+
|
|
12
|
+
* Environment variables set before execution::
|
|
13
|
+
|
|
14
|
+
RELAY_STAGE_ID Stage ID from claim
|
|
15
|
+
RELAY_TASK_ID Task ID from claim
|
|
16
|
+
RELAY_CAPABILITY Capability name
|
|
17
|
+
RELAY_NODE_ID Assigned node ID
|
|
18
|
+
RELAY_BASE_URL Relay server URL
|
|
19
|
+
RELAY_TOKEN_FILE Path to runtime token file
|
|
20
|
+
|
|
21
|
+
* Stdin: Request Envelope as a JSON string (T-005b Envelope-Contract):
|
|
22
|
+
``{"task_id": ..., "capability": ..., "input": {...payload...}}``.
|
|
23
|
+
Since T-005d (strict phase) the envelope is sent alone; a legacy
|
|
24
|
+
top-level mirror of the payload keys can be re-enabled per capability
|
|
25
|
+
via ``config.envelope_request_mirror: true`` (rollback lever).
|
|
26
|
+
* Stdout: on exit 0 MUST be valid JSON — normalized to the Response
|
|
27
|
+
Envelope (T-005b): a conforming ``{"status": "completed", "result":
|
|
28
|
+
{...}, "error": null}`` passes through verbatim, ``status: "error"``
|
|
29
|
+
fails the stage fail-fast via its ``error`` message, and bare result
|
|
30
|
+
dicts are wrapped into the envelope (without an ``error`` key — the
|
|
31
|
+
daemon counts failures via ``"error" in result`` key presence).
|
|
32
|
+
* Stderr: captured and included in the error result on non-zero exit.
|
|
33
|
+
On exit 0 it is attached to the result under ``_handler.stderr`` for
|
|
34
|
+
debugging but is never interpreted as the result.
|
|
35
|
+
* Exit codes:
|
|
36
|
+
0 -> stdout parsed as result dict
|
|
37
|
+
non-zero -> {"error": "handler exited with code N", "stderr": ...}
|
|
38
|
+
Handlers MUST exit non-zero whenever they could not produce a valid
|
|
39
|
+
result. Exit 0 with non-JSON or an ``error``-keyed payload completes
|
|
40
|
+
the stage silently and skips the scheduler's retry budget, losing the
|
|
41
|
+
failed work.
|
|
42
|
+
* Timeout: kills the whole handler process tree (T-188) and returns
|
|
43
|
+
``{"error": "handler timeout after Ns"}``.
|
|
44
|
+
* Output cap (T-191): stdout/stderr are buffered up to
|
|
45
|
+
``MAX_HANDLER_STDOUT_BYTES``/``MAX_HANDLER_STDERR_BYTES``; exceeding the
|
|
46
|
+
cap fails the stage instead of letting a runaway handler OOM the daemon.
|
|
47
|
+
|
|
48
|
+
Retry behaviour (T-060): non-zero exit, timeout, invalid-JSON-on-exit-0
|
|
49
|
+
and complete-endpoint failures all increment the daemon-side per-task
|
|
50
|
+
failure counter (and the server-side ``retry_count`` via the released
|
|
51
|
+
claim). Once ``max_retries`` (default 2 → 3 attempts total) is exceeded
|
|
52
|
+
the scheduler marks the stage as ``failed`` permanently.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import signal
|
|
60
|
+
import subprocess
|
|
61
|
+
import threading
|
|
62
|
+
import time
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from nodes.common import node_config
|
|
66
|
+
|
|
67
|
+
# Environment variables passed to every handler.
|
|
68
|
+
HANDLER_ENV_KEYS = (
|
|
69
|
+
"RELAY_STAGE_ID",
|
|
70
|
+
"RELAY_TASK_ID",
|
|
71
|
+
"RELAY_CAPABILITY",
|
|
72
|
+
"RELAY_NODE_ID",
|
|
73
|
+
"RELAY_BASE_URL",
|
|
74
|
+
"RELAY_TOKEN_FILE",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# T-191 (F4): the handler shares the daemon process, so an unbounded stdout
|
|
78
|
+
# buffer is an OOM vector — a handler that accidentally dumps a huge file to
|
|
79
|
+
# stdout would take down the process holding the agent, file-serve and
|
|
80
|
+
# heartbeat threads. Cap what we buffer and fail the stage loudly instead.
|
|
81
|
+
MAX_HANDLER_STDOUT_BYTES = 10 * 1024 * 1024
|
|
82
|
+
MAX_HANDLER_STDERR_BYTES = 1 * 1024 * 1024
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _kill_process_tree(proc: subprocess.Popen) -> None:
|
|
86
|
+
"""Kill the handler and every process it spawned (T-188, F1).
|
|
87
|
+
|
|
88
|
+
The handler runs through ``shell=True`` in its own session
|
|
89
|
+
(``start_new_session=True`` at spawn), so the child is a process-group
|
|
90
|
+
leader and ``os.killpg`` reaches the whole tree. ``proc.kill()`` alone
|
|
91
|
+
only reaps the direct shell — the actual handler (a grandchild) would
|
|
92
|
+
survive a timeout and keep burning CPU on the host.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
pgid = os.getpgid(proc.pid)
|
|
96
|
+
except (ProcessLookupError, PermissionError):
|
|
97
|
+
pgid = None
|
|
98
|
+
if pgid is not None:
|
|
99
|
+
try:
|
|
100
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
101
|
+
except (ProcessLookupError, PermissionError):
|
|
102
|
+
pass
|
|
103
|
+
try:
|
|
104
|
+
proc.kill()
|
|
105
|
+
except (ProcessLookupError, PermissionError):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_stream_bounded(stream: Any, limit: int) -> tuple[bytes, bool]:
|
|
110
|
+
"""Read a pipe up to ``limit`` bytes; return ``(data, overflowed)``.
|
|
111
|
+
|
|
112
|
+
Stops reading as soon as the cap is hit — the caller kills the process
|
|
113
|
+
tree on overflow, so the truncated remainder never matters. Returns
|
|
114
|
+
``overflowed=True`` exactly when more than ``limit`` bytes were offered.
|
|
115
|
+
"""
|
|
116
|
+
buf = bytearray()
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
chunk = stream.read(65536)
|
|
120
|
+
except (ValueError, OSError):
|
|
121
|
+
break
|
|
122
|
+
if not chunk:
|
|
123
|
+
break
|
|
124
|
+
remaining = limit - len(buf)
|
|
125
|
+
if len(chunk) > remaining:
|
|
126
|
+
buf += chunk[: max(0, remaining)]
|
|
127
|
+
return bytes(buf), True
|
|
128
|
+
buf += chunk
|
|
129
|
+
return bytes(buf), False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_env(stage: dict[str, Any], context: dict[str, Any]) -> dict[str, str]:
|
|
134
|
+
"""Build the environment dict for the handler subprocess.
|
|
135
|
+
|
|
136
|
+
Inherits the current process environment and overlays the relay
|
|
137
|
+
context variables. Stage-derived values (``RELAY_STAGE_ID``,
|
|
138
|
+
``RELAY_TASK_ID``, ``RELAY_CAPABILITY``) are populated from the
|
|
139
|
+
claimed stage when not already supplied by ``context``; node-level
|
|
140
|
+
values (``RELAY_NODE_ID``, ``RELAY_BASE_URL``, ``RELAY_TOKEN_FILE``)
|
|
141
|
+
always come from ``context``. Missing values are set to the empty
|
|
142
|
+
string so the handler can rely on the keys always being present.
|
|
143
|
+
"""
|
|
144
|
+
env = dict(os.environ)
|
|
145
|
+
# Stage-derived defaults (context may override).
|
|
146
|
+
stage_defaults = {
|
|
147
|
+
"RELAY_STAGE_ID": stage.get("stage_id"),
|
|
148
|
+
"RELAY_TASK_ID": stage.get("task_id"),
|
|
149
|
+
"RELAY_CAPABILITY": stage.get("capability"),
|
|
150
|
+
}
|
|
151
|
+
for key in HANDLER_ENV_KEYS:
|
|
152
|
+
if key in stage_defaults and key not in context:
|
|
153
|
+
value = stage_defaults[key]
|
|
154
|
+
else:
|
|
155
|
+
value = context.get(key)
|
|
156
|
+
env[key] = "" if value is None else str(value)
|
|
157
|
+
return env
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _stdin_payload(stage: dict[str, Any]) -> bytes:
|
|
161
|
+
"""Serialize the stage payload for handler stdin.
|
|
162
|
+
|
|
163
|
+
T-005b (design.md §3.2): delegates to _build_stdin_payload with the
|
|
164
|
+
per-capability rollout toggle (``config.envelope_request_mirror``).
|
|
165
|
+
Signature kept for stability — daemon and tests call this unchanged.
|
|
166
|
+
T-005d strict phase: the toggle defaults to False (envelope-only
|
|
167
|
+
stdin); True re-enables the legacy top-level mirror as a rollback
|
|
168
|
+
lever for a single capability.
|
|
169
|
+
"""
|
|
170
|
+
return _build_stdin_payload(
|
|
171
|
+
stage, mirrored=_envelope_request_mirror(stage)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _envelope_request_mirror(stage: dict[str, Any]) -> bool:
|
|
176
|
+
"""Resolve the per-capability request-mirror toggle for ``stage``.
|
|
177
|
+
|
|
178
|
+
Reads the active profile's capability ``config.envelope_request_mirror``
|
|
179
|
+
(T-005b design.md §3.2, D5: per-capability flip beats a global flag).
|
|
180
|
+
|
|
181
|
+
T-005d (strict phase): the default is now **False** — the fleet has
|
|
182
|
+
migrated to the envelope contract, so the strict envelope-only stdin
|
|
183
|
+
is the normal case. The config key survives as the per-capability
|
|
184
|
+
ROLLBACK lever: setting ``envelope_request_mirror: true`` re-enables
|
|
185
|
+
the legacy top-level mirror for a single capability whose handler
|
|
186
|
+
still reads flat keys.
|
|
187
|
+
|
|
188
|
+
Any lookup problem (profile unreadable, capability unknown, config
|
|
189
|
+
not a mapping) resolves to the default as well (strict) — a config
|
|
190
|
+
hiccup must not silently re-enable the rollout mirror; a legacy
|
|
191
|
+
handler reading flat keys then fails loudly instead of being kept
|
|
192
|
+
alive by a stale profile.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
for cap in node_config.load_active_profile():
|
|
196
|
+
if cap.get("name") == stage.get("capability"):
|
|
197
|
+
cfg = cap.get("config")
|
|
198
|
+
if isinstance(cfg, dict):
|
|
199
|
+
return bool(cfg.get("envelope_request_mirror", False))
|
|
200
|
+
break
|
|
201
|
+
except Exception: # noqa: BLE001 — resolve to strict default, no mirror
|
|
202
|
+
return False
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_stdin_payload(stage: dict[str, Any], *, mirrored: bool) -> bytes:
|
|
207
|
+
"""Build the Request Envelope for handler stdin (T-005b, design.md §3.2).
|
|
208
|
+
|
|
209
|
+
FROZEN contract: ``{"task_id": str, "capability": str, "input": dict}``
|
|
210
|
+
— the capability-specific payload moves under ``input``.
|
|
211
|
+
|
|
212
|
+
mirrored=True (rollout phase): payload keys are additionally placed at
|
|
213
|
+
the top level so legacy handlers reading flat keys keep working.
|
|
214
|
+
Payload keys are written FIRST and the contract keys LAST, so
|
|
215
|
+
``task_id``/``capability``/``input`` always win on collision.
|
|
216
|
+
mirrored=False (strict phase, post-migration): envelope only.
|
|
217
|
+
"""
|
|
218
|
+
payload = stage.get("payload")
|
|
219
|
+
if payload is None:
|
|
220
|
+
payload = {}
|
|
221
|
+
if not isinstance(payload, dict):
|
|
222
|
+
payload = {"payload": payload}
|
|
223
|
+
envelope: dict[str, Any] = {}
|
|
224
|
+
if mirrored:
|
|
225
|
+
envelope.update(payload)
|
|
226
|
+
envelope["task_id"] = str(stage.get("task_id") or "")
|
|
227
|
+
envelope["capability"] = str(stage.get("capability") or "")
|
|
228
|
+
envelope["input"] = payload
|
|
229
|
+
return json.dumps(envelope).encode("utf-8")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _normalize_result(stdout_text: str) -> dict[str, Any]:
|
|
233
|
+
"""Normalize handler stdout to the Response Envelope (T-005b §3.1).
|
|
234
|
+
|
|
235
|
+
Contract (FROZEN, T-005a design.md §3.1 + ## Deviations):
|
|
236
|
+
|
|
237
|
+
- Parsed dict WITH ``status == "completed"``: requires a ``result``
|
|
238
|
+
object; ``error`` must be None if the key exists. Passed through
|
|
239
|
+
verbatim (conforming handlers are never rewritten). Note the
|
|
240
|
+
verified live shape omits ``error`` entirely on success — the
|
|
241
|
+
normalize layer does NOT inject ``error: None`` because the daemon
|
|
242
|
+
counts failures via ``"error" in result`` (key presence).
|
|
243
|
+
- ``status == "error"``: pass through verbatim — the daemon's
|
|
244
|
+
``"error" in result`` check then fails the stage (fail-fast,
|
|
245
|
+
retry budget applies, no exit-code guessing).
|
|
246
|
+
- Any other ``status`` value: error result.
|
|
247
|
+
- Bare dict (no ``status``) WITH ``error`` key: pass through
|
|
248
|
+
unchanged (legacy error convention, D3).
|
|
249
|
+
- Bare dict otherwise: wrapped as ``{"status": "completed",
|
|
250
|
+
"result": <d>}`` — deliberately WITHOUT an ``error`` key (daemon
|
|
251
|
+
failure counting is key-presence-based; D4/D10 with the verified
|
|
252
|
+
daemon read).
|
|
253
|
+
- Non-object JSON: error result (bare scalars were always ambiguous;
|
|
254
|
+
the contract requires an object).
|
|
255
|
+
|
|
256
|
+
Raises nothing; returns an error-result dict on normalize failures.
|
|
257
|
+
Idempotent by construction: envelopes are detected by the ``status``
|
|
258
|
+
key and passed through verbatim, so double-wrapping is impossible.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
parsed = json.loads(stdout_text)
|
|
262
|
+
except json.JSONDecodeError as exc:
|
|
263
|
+
return {"error": f"handler stdout is not valid JSON: {exc.msg}"}
|
|
264
|
+
if not isinstance(parsed, dict):
|
|
265
|
+
kind = type(parsed).__name__
|
|
266
|
+
return {
|
|
267
|
+
"error": (
|
|
268
|
+
"handler stdout must be a JSON object (envelope or bare "
|
|
269
|
+
f"result), got {kind}"
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if "status" not in parsed:
|
|
273
|
+
# Bare result (legacy). Error-keyed bare dicts pass through
|
|
274
|
+
# unchanged; everything else is wrapped into the envelope.
|
|
275
|
+
if "error" in parsed:
|
|
276
|
+
return parsed
|
|
277
|
+
return {"status": "completed", "result": parsed}
|
|
278
|
+
status = parsed.get("status")
|
|
279
|
+
if status == "completed":
|
|
280
|
+
if not isinstance(parsed.get("result"), dict):
|
|
281
|
+
return {
|
|
282
|
+
"error": (
|
|
283
|
+
"handler envelope status 'completed' missing 'result' "
|
|
284
|
+
"object"
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
if "error" in parsed and parsed.get("error") is not None:
|
|
288
|
+
return {
|
|
289
|
+
"error": (
|
|
290
|
+
"handler envelope status 'completed' carries non-null "
|
|
291
|
+
"'error'"
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
return parsed
|
|
295
|
+
if status == "error":
|
|
296
|
+
if not isinstance(parsed.get("error"), str) or not parsed.get("error"):
|
|
297
|
+
return {
|
|
298
|
+
"error": "handler envelope status 'error' missing 'error' message"
|
|
299
|
+
}
|
|
300
|
+
return parsed
|
|
301
|
+
return {
|
|
302
|
+
"error": (
|
|
303
|
+
f"handler envelope has invalid status {status!r} "
|
|
304
|
+
"(expected 'completed' or 'error')"
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_handler(
|
|
310
|
+
handler: str,
|
|
311
|
+
stage: dict[str, Any],
|
|
312
|
+
*,
|
|
313
|
+
context: dict[str, Any] | None = None,
|
|
314
|
+
timeout: int = 300,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
"""Execute a handler subprocess and return a result dict.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
handler:
|
|
321
|
+
Executable path or shell command. Always run via the shell
|
|
322
|
+
(``shell=True``) so profiles can use pipelines / env-aware
|
|
323
|
+
commands. Profiles are trusted operator-only files.
|
|
324
|
+
stage:
|
|
325
|
+
The stage dict returned by the claim endpoint. Only its
|
|
326
|
+
``payload`` is forwarded to stdin; ``stage_id`` / ``task_id``
|
|
327
|
+
are exposed via environment variables.
|
|
328
|
+
context:
|
|
329
|
+
Mapping with at least ``RELAY_NODE_ID``, ``RELAY_BASE_URL``,
|
|
330
|
+
``RELAY_TOKEN_FILE`` and (optionally) the other env vars. Any
|
|
331
|
+
missing key is sent to the handler as the empty string.
|
|
332
|
+
timeout:
|
|
333
|
+
Subprocess timeout in seconds. Defaults to 300.
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
dict
|
|
338
|
+
On success (exit 0): the parsed JSON from stdout, augmented with
|
|
339
|
+
an ``_handler`` debug dict (``stderr``, ``stdout_length``,
|
|
340
|
+
``exit_code``). If the stdout is not valid JSON, an error dict is
|
|
341
|
+
returned instead.
|
|
342
|
+
On failure (non-zero exit or timeout): an error dict of the
|
|
343
|
+
form ``{"error": "...", "stderr": "..."}``.
|
|
344
|
+
"""
|
|
345
|
+
context = context or {}
|
|
346
|
+
env = _build_env(stage, context)
|
|
347
|
+
stdin_bytes = _stdin_payload(stage)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
proc = subprocess.Popen( # noqa: S602 — shell=True by design
|
|
351
|
+
handler,
|
|
352
|
+
shell=True,
|
|
353
|
+
stdin=subprocess.PIPE,
|
|
354
|
+
stdout=subprocess.PIPE,
|
|
355
|
+
stderr=subprocess.PIPE,
|
|
356
|
+
env=env,
|
|
357
|
+
start_new_session=True, # T-188: own process group → killpg reaches the tree
|
|
358
|
+
)
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
return {"error": f"handler failed to start: {exc}", "stderr": ""}
|
|
361
|
+
|
|
362
|
+
out_holder: list[tuple[bytes, bool]] = []
|
|
363
|
+
err_holder: list[tuple[bytes, bool]] = []
|
|
364
|
+
overflow_evt = threading.Event()
|
|
365
|
+
|
|
366
|
+
def _drain(stream: Any, limit: int, holder: list[tuple[bytes, bool]]) -> None:
|
|
367
|
+
data, overflowed = _read_stream_bounded(stream, limit)
|
|
368
|
+
holder.append((data, overflowed))
|
|
369
|
+
if overflowed:
|
|
370
|
+
# Stop waiting for the handler to exit — it is blocked writing
|
|
371
|
+
# into the now-unread pipe. The main loop kills the tree.
|
|
372
|
+
overflow_evt.set()
|
|
373
|
+
|
|
374
|
+
def _feed_stdin() -> None:
|
|
375
|
+
stdin_pipe = proc.stdin
|
|
376
|
+
if stdin_pipe is None: # pragma: no cover — PIPE guarantees a handle
|
|
377
|
+
return
|
|
378
|
+
try:
|
|
379
|
+
stdin_pipe.write(stdin_bytes)
|
|
380
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
381
|
+
pass
|
|
382
|
+
finally:
|
|
383
|
+
try:
|
|
384
|
+
stdin_pipe.close()
|
|
385
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
# Drain stdout/stderr on separate threads: a single-threaded read would
|
|
389
|
+
# deadlock the moment one pipe fills while we block on the other.
|
|
390
|
+
t_out = threading.Thread(
|
|
391
|
+
target=_drain,
|
|
392
|
+
args=(proc.stdout, MAX_HANDLER_STDOUT_BYTES, out_holder),
|
|
393
|
+
daemon=True,
|
|
394
|
+
)
|
|
395
|
+
t_err = threading.Thread(
|
|
396
|
+
target=_drain,
|
|
397
|
+
args=(proc.stderr, MAX_HANDLER_STDERR_BYTES, err_holder),
|
|
398
|
+
daemon=True,
|
|
399
|
+
)
|
|
400
|
+
t_in = threading.Thread(target=_feed_stdin, daemon=True)
|
|
401
|
+
t_out.start()
|
|
402
|
+
t_err.start()
|
|
403
|
+
t_in.start()
|
|
404
|
+
|
|
405
|
+
timed_out = False
|
|
406
|
+
deadline = time.monotonic() + timeout
|
|
407
|
+
while proc.poll() is None:
|
|
408
|
+
if overflow_evt.is_set():
|
|
409
|
+
# T-191 (F4): kill now, the handler cannot finish (blocked write).
|
|
410
|
+
_kill_process_tree(proc)
|
|
411
|
+
break
|
|
412
|
+
if time.monotonic() >= deadline:
|
|
413
|
+
# T-188 (F1): kill the whole tree, not just the direct shell.
|
|
414
|
+
timed_out = True
|
|
415
|
+
_kill_process_tree(proc)
|
|
416
|
+
break
|
|
417
|
+
time.sleep(0.05)
|
|
418
|
+
try:
|
|
419
|
+
proc.wait(timeout=5)
|
|
420
|
+
except subprocess.TimeoutExpired: # pragma: no cover — SIGKILL is final
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
t_out.join(timeout=5)
|
|
424
|
+
t_err.join(timeout=5)
|
|
425
|
+
t_in.join(timeout=5)
|
|
426
|
+
|
|
427
|
+
stdout_bytes, stdout_overflowed = out_holder[0] if out_holder else (b"", False)
|
|
428
|
+
stderr_bytes, stderr_overflowed = err_holder[0] if err_holder else (b"", False)
|
|
429
|
+
|
|
430
|
+
stdout = _safe_decode(stdout_bytes)
|
|
431
|
+
stderr = _safe_decode(stderr_bytes)
|
|
432
|
+
|
|
433
|
+
if timed_out:
|
|
434
|
+
return {"error": f"handler timeout after {timeout}s", "stderr": stderr}
|
|
435
|
+
|
|
436
|
+
# T-191 (F4): a handler must not be able to OOM the daemon through stdout.
|
|
437
|
+
if stdout_overflowed or stderr_overflowed:
|
|
438
|
+
return {
|
|
439
|
+
"error": (
|
|
440
|
+
"handler output exceeded the size limit "
|
|
441
|
+
f"(stdout>{MAX_HANDLER_STDOUT_BYTES}B / "
|
|
442
|
+
f"stderr>{MAX_HANDLER_STDERR_BYTES}B)"
|
|
443
|
+
),
|
|
444
|
+
"stdout": stdout,
|
|
445
|
+
"stderr": stderr,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if proc.returncode != 0:
|
|
449
|
+
return {
|
|
450
|
+
"error": f"handler exited with code {proc.returncode}",
|
|
451
|
+
"stderr": stderr,
|
|
452
|
+
"stdout": stdout,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# Exit 0: stdout must be valid JSON result.
|
|
456
|
+
if not stdout.strip():
|
|
457
|
+
return {"error": "handler produced no stdout output", "stderr": stderr}
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
# Guard kept separate from normalize so the non-JSON error keeps
|
|
461
|
+
# its pre-existing shape (stdout/stderr attached) byte-identical.
|
|
462
|
+
json.loads(stdout)
|
|
463
|
+
except json.JSONDecodeError as exc:
|
|
464
|
+
return {
|
|
465
|
+
"error": f"handler stdout is not valid JSON: {exc.msg}",
|
|
466
|
+
"stdout": stdout,
|
|
467
|
+
"stderr": stderr,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# T-005b Envelope-Contract: normalize handler stdout at this single
|
|
471
|
+
# choke-point — conforming envelopes pass through verbatim, bare
|
|
472
|
+
# results are wrapped, error shapes fail the stage fail-fast. All
|
|
473
|
+
# normalize failures return an error-result dict (daemon failure
|
|
474
|
+
# accounting untouched).
|
|
475
|
+
parsed = _normalize_result(stdout)
|
|
476
|
+
|
|
477
|
+
# Always attach handler diagnostics so callers can debug empty
|
|
478
|
+
# responses without having to download artifacts. The CLI surfaces
|
|
479
|
+
# these in `node-cli task result` (see _print_task_result).
|
|
480
|
+
# T-193 (F6): a handler may ship a non-dict ``_handler`` in its own
|
|
481
|
+
# envelope — normalize it instead of raising a TypeError that would
|
|
482
|
+
# abort the stage before ``client.complete()``.
|
|
483
|
+
if not isinstance(parsed.get("_handler"), dict):
|
|
484
|
+
parsed["_handler"] = {}
|
|
485
|
+
parsed["_handler"]["stderr"] = stderr
|
|
486
|
+
parsed["_handler"]["stdout_length"] = len(stdout)
|
|
487
|
+
parsed["_handler"]["exit_code"] = proc.returncode
|
|
488
|
+
return parsed
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _safe_decode(data: bytes | None) -> str:
|
|
492
|
+
if not data:
|
|
493
|
+
return ""
|
|
494
|
+
try:
|
|
495
|
+
return data.decode("utf-8")
|
|
496
|
+
except UnicodeDecodeError:
|
|
497
|
+
return data.decode("utf-8", errors="replace")
|