Files
deepseek-harness/packages/code-runtime/code-runtime-python
Chinesezjc e6b547bef4 fix(code-runtime-python): guard teardown, log prefix, and settlement flush
Four independent corrections in the run lifecycle.

`killGroup` signalled `-child.pid` with a raw `process.kill`. Node keeps the
numeric `child.pid` after the leader is reaped and only clears its internal
handle, so `child.kill()` refuses while the raw call does not; `close` can
trail `exit` by seconds when a pipe-holding descendant keeps the streams open.
A recycled pgid could therefore receive this run's SIGTERM and armed SIGKILL.
`groupEmpty()` does not cover it: it reports whether the group has members, not
whether they are ours, and it first runs after the signal. The leader's start
time is now read at spawn and re-checked before each signal, matching the
position packages/subprocess/subprocess-local already states
("ProcessIdentity ... preventing teardown escalation after PID reuse"). Kept
local rather than depending on that package, which would add an architectural
edge. Linux reads /proc; Darwin has no /proc, so the reader reports undefined
and the guard degrades to the previous behavior instead of forking `ps` on a
teardown path.

`_push_bounded_prefix` built `(*self._pending, extra)`, copying every pending
reference into a same-size tuple before the bounded loop. For a
single-character drip that is a second pointer array as large as the list:
measured +80 MiB of tuple over a 40 MiB list for 5.2M chunks, the allocation
the bounded prefix exists to avoid. It now iterates the list in place and
handles `extra` in the loop's `else`; 4000 randomized inputs produce byte-identical
prefixes.

The settlement `flush_out()`/`flush_err()` ran outside any guard while `done`
was already decided, so a flush raising under memory pressure skipped
`send_done` and downgraded a child-classified `exception` into a host-side
`worker-exit`. Both are now wrapped, swallowing only the log tail.

The boot re-check's `if effective_soft != RLIM_INFINITY` was dead: `_clamped`
is asked for a finite `addr_bytes` on both sides and each branch returns that
value or a `min` with an inherited bound, so RLIM_INFINITY is unreachable. The
guard could only ever have skipped the re-check it claimed to protect.
2026-08-31 14:26:23 +08:00
..

description, kind
description kind
CPython subprocess implementation of the DeepSeek Harness code-execution seam, with fd-3 bindings, resource limits, log capture, and process-group teardown. package-reference

@deepseek-ai/dsh-code-runtime-python

English | 中文

CPython-subprocess implementation of the @deepseek-ai/dsh-code-runtime seam. Companion to @deepseek-ai/dsh-code-runtime-worker-thread; trades the Node worker thread for a fresh python3 subprocess so model code is Python instead of TypeScript.

The package ships PythonCodeRuntime as its default export. The plugin registers as codeRuntime with language: 'python' and isolation: 'process'. Each run() spawns a fresh python3 -I process, sends a boot frame and the program over fd 3, and resolves a CodeRunResult for every program outcome — rejecting only for seam misuse (a malformed binding namespace or non-positive config). The child runs the program as the body of an async function, so top-level await and return both work; binding calls travel back over fd 3 as JSON-lines. Containment (not a security boundary — model code has bash-equivalent trust) comes from an empty environment, RLIMIT_CPU/RLIMIT_AS, a wall-clock ceiling, and a SIGTERM→grace→SIGKILL teardown on the child's process group.

Wire protocol

The host and the CPython subprocess exchange a versionless, JSON-lines protocol on the child's fd 3 — one JSON object per line, leaving stdout/stderr free for the program's own output. src/protocol.ts is the host side; py/protocol.py mirrors its message shapes and the shared truncation-marker text on the Python side.

  • fd 3, not stdout — Node pins the channel positionally with stdio: ['pipe','pipe','pipe','pipe']; the Python bootstrap reads the same PROTOCOL_FD constant. JSON-lines framing.
  • Host treats every inbound frame as hostile — model code has full access to fd 3 and can post anything through it, so validateChildFrame shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to undefined rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled).
  • Lossless-JSON crossing — completion values and binding arguments cross as exact JSON. encodeJsonPlain serializes a JSON.parse-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on JSON.stringify's stack limit; checkDoneValue meters a forged completion value's byte length AND number losslessness in one bounded traversal that rejects an over-budget payload before enqueuing its children; hasUnsafeIntegerToken reads the raw frame text to catch an integer token that JSON.parse would silently round; hasNonLosslessNumber rejects a non-finite or negative-zero number in unbounded call.args. Beyond-safe-range integral doubles serialize through BigInt digits so the exact integer crosses, not the rounded String() form.
  • Shared truncation markerlogTruncationMarker(maxBytes) produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The log frame's truncated flag distinguishes the child ledger's own marker from program output.

Configuration

Every cap is a validated Config field with a default, changeable from cordis.yml (no hardcoded tunables). cpuSeconds (default 60) is the RLIMIT_CPU whole-second budget; the child sets the soft limit to cpuSeconds and the hard limit to cpuSeconds + 1, so the kernel's SIGXCPU at the soft limit classifies as a timeout while the +1s hard limit is a SIGKILL backstop. maxWallMs (default 600000) is the wall-clock ceiling that backstops CPU time for a program awaiting a promise nobody resolves. addressSpaceMb (default 512) is the RLIMIT_AS cap, not applied on Darwin (the dyld shared cache mapped into every process exceeds any practical cap there; cpuSeconds and maxWallMs still bound the run). maxLogBytes (default 65536) is the shared captured-log byte budget; maxValueBytes (default 32768) caps the completion value; graceMs (default 3000) is the SIGTERMSIGKILL grace window; pythonBin (default python3) is the interpreter, resolved against PATH before the child spawns with an empty environment.

Model Experience

Indirectly, through Code Mode in dsh-tools, which renders this backend's exact completion value when it fits (or an explicit invalid-output / output-limit failure), plus the exact [dsh-code-runtime-python] log capture truncated at <maxLogBytes> bytes log marker, into a retained run_code result.

KV Cache effect

No direct invalidation; the named consumer owns any request-prefix changes.

Known Limitations and Deferred Work

  • The cross-language guard covers executed values and frame field sets, not field typestests/protocol-mirror.e2e.ts compares PROTOCOL_FD, the log truncation marker, and each TypedDict's required and optional fields against a real python3. Comparing field types across TypeScript and Python has no mechanical equivalent here, so review plus the backend's real-subprocess suite owns type-level drift.
  • RLIMIT_AS is not enforced on macOS — the dyld shared cache mapped into every process at exec exceeds any practical address-space cap, and the kernel rejects the setrlimit call, so addressSpaceMb is skipped there. cpuSeconds and maxWallMs still bound every run.
  • A descendant that calls setsid() / start_new_session=True escapes teardown. Termination signals the child's process group with kill(-pid); a descendant that moves itself into a fresh session is no longer in that group and no signal reaches it. If it also releases the inherited stdout/stderr/fd-3 pipes, the leader's close still settles the run, and after the closeDeadline bound the fiber goes quiescent while that orphan keeps running. This is the containment boundary, not a security one — model code has bash-equivalent trust, and a bash tool can setsid away just the same. Reaching such an orphan would require tracking every descendant pid (as the bash-local backend's process-inspector does) and is deferred; the process-group teardown reaps everything that stays in the group.