From 2b3b7f87dc77bab7bbb174b82b553283b56cd676 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 02:39:39 +0800 Subject: [PATCH] fix(code-runtime-python): send the run frame after boot-ack; reject directories in pythonBin resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's two behavior items: the run frame was written back-to-back with the boot frame (the seam contract puts run after boot-ack, which confirms the namespaces were accepted); it now goes out from the boot-ack handler, so a boot failure cannot race the run frame. resolvePythonBin now requires the candidate to be a regular file — a directory passes X_OK and would otherwise shadow a later real interpreter. Doc spots: the load-time overflow message says worker-exit (not stranding to the wall clock), the run JSDoc spells out the resolve-with-error contract, the PATH-stub test removes the stale v8 ignore, and the README's binding-value bullet names serialization cost. --- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/src/index.ts | 43 ++++++++++++++++--- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 34651facd9..35f7700e97 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-python/README.md -README.md: 7b116c4d51df44e0db519fdf0fd73a1776dfb4f1 -README.zh.md: 2f367d6bde6d79163be9e45e2bdea61ab0f62958 +README.md: eab1adebcf3189d7bec812dd4b1a16a3e9d0a78c +README.zh.md: d52545f3fcc8884341f3fdfad0d387808eafcd62 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 7b116c4d51..eab1adebcf 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -116,7 +116,7 @@ These limits define what the package does and does not cover; they are current p - **An fd-3 frame whose raw length exceeds 64 MiB settles the run as a worker-exit** — `maxLogBytes`/`maxValueBytes` are load-bounded to the same parser cap so an honest child's frames always fit; a model-constructed binding ARGUMENT above 64 MiB (a value with no seam-level budget) trips the same cap — an accepted residual of the OOM guard. - **A combined log-and-value peak is not modelled by the load gate** — a model daemon thread that keeps writing while the completion value is metered and framed can add the two peaks in a way no gate admits or rejects; the run dies as `worker-exit`, containment holds, and only the failure classification is degraded. - **A 1-second dual-limit `ulimit -t 1` CPU overrun is reported as `worker-exit`, not a timeout** — when the host starts under a hard CPU limit equal to the soft and that limit is 1, `_clamped` cannot lower the soft, so the kernel SIGKILLs the busy loop and SIGXCPU is never delivered; containment holds, only the classification is degraded. -- **No byte cap on intermediate binding values** — the implementation remains bounded by structured-clone cost and process memory, and a provider or executor may apply its own fetch cap. +- **No byte cap on intermediate binding values** — the implementation remains bounded by the lossless-JSON serialization cost and process memory, and a provider or executor may apply its own fetch cap. ### Dev Note diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 2f367d6bde..d52545f3fc 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -116,7 +116,7 @@ kind: "package-reference" - **原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 worker-exit 结算**——`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。 - **组合日志与值的峰值不被加载门建模**——持续写入的模型 daemon 线程与完成值计量、分帧相加的峰值没有任何门会放行或拒绝;运行以 `worker-exit` 告终,隔离成立,只有失败分类降级。 - **1 秒双限 `ulimit -t 1` CPU 超限被报告为 `worker-exit` 而非 timeout**——当宿主在一个与软限相等的硬 CPU 限下启动且该限为 1 时,`_clamped` 无法下调软限,内核在同一 tick SIGKILL 忙循环,SIGXCPU 永远不会送达;隔离成立,只有分类降级。 -- **中间 binding 值没有字节上限**——实现仍受 structured-clone 成本与进程内存约束,提供方或执行器可能应用自己的获取上限。 +- **中间 binding 值没有字节上限**——实现仍受无损 JSON 序列化成本与进程内存约束,提供方或执行器可能应用自己的获取上限。 ### 开发备注 diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 85e1320fdf..d1f795ff15 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -14,7 +14,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { once } from 'node:events' -import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -403,11 +403,14 @@ function resolvePythonBin(bin: string): string { // the working directory, and the returned candidate must be an absolute // path — spawn() resolves a relative pythonBin against the host CWD, which // is outside the seam contract. - /* v8 ignore next -- normal PATHs carry no empty or relative segment. */ if (dir === '' || !isAbsolute(dir)) continue const candidate = join(dir, bin) try { accessSync(candidate, fsConstants.X_OK) + // A directory passes X_OK too, so require a regular file: a PATH entry + // named like the interpreter (e.g. a `python3` directory) must not be + // chosen over a later real interpreter. + if (!statSync(candidate).isFile()) continue return candidate } catch { // Not executable here; try the next PATH entry. @@ -790,7 +793,7 @@ export class PythonCodeRuntime extends CodeRuntime { } const limit = FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES if (this.config[key] > limit) { - throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which drops raw frames past ${FRAME_PARSE_CAP_BYTES} bytes before decoding to bound host memory — a larger budget would admit a config whose honest child frames the host then silently discards, stranding the run to the wall clock), got ${String(this.config[key])}`) + throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${FRAME_PARSE_CAP_BYTES} bytes before decoding to bound host memory — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`) } // Reject a log budget too small to honor: the truncation marker alone // must serialize within the budget, or a marker-only truncated run @@ -871,8 +874,11 @@ export class PythonCodeRuntime extends CodeRuntime { } /** - * Execute one program in a fresh Python subprocess. Program outcomes resolve - * with `result.error`; the method rejects only for seam misuse. + * Execute one program in a fresh Python subprocess. Every program outcome — + * parse failure, thrown exception, invalid completion, output overflow, + * budget expiry, abort, or substrate death — resolves with `result.error` set + * (classified by `CodeRunFailure.kind`); the method rejects only for seam + * misuse. */ async run(request: CodeRunRequest): Promise { if (this.disposed) throw new Error('dsh-code-runtime-python: run() after disposal') @@ -1448,12 +1454,21 @@ export class PythonCodeRuntime extends CodeRuntime { // retained state to one number and cannot be poisoned by a forgery. let nextCallId = 0 + // Set by run() when the boot frame is written; the fd-3 handler calls it + // on boot-ack to send the run frame (see the seam's boot->boot-ack->run + // order). scoped per run. An object holder so the cross-closure + // assignment is a property write (eslint's prefer-const cannot see the + // reassignment through the closure). + const bootAckGate: { run?: () => void } = {} const handleFrame = (message: ChildToHost): void => { /* v8 ignore next -- late frame after settlement; defensive against forged post-settlement traffic. */ if (settled) return switch (message.type) { case 'boot-ack': - return // Presently informational. + // The child accepted the boot frame (namespaces built); the run + // frame goes out now, not with the boot frame. + bootAckGate.run?.() + return case 'log': if (message.truncated === true) { // The CHILD ledger hit its cap. Its marker is the last log text @@ -1967,13 +1982,27 @@ export class PythonCodeRuntime extends CodeRuntime { ...namespace.errorClass ? { errorClass: namespace.errorClass } : {}, })), } + // The run frame is sent only after the child's boot-ack: the seam + // contract puts `run` after `boot-ack` (the ack confirms the namespaces + // were accepted), and sending it earlier would let a boot failure race + // the run frame. The ack handler below writes it. + let runSent = false try { proto.write(`${JSON.stringify(boot)}\n`) - proto.write(`${JSON.stringify({ type: 'run', program: request.program })}\n`) } catch (error: unknown) { finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } }) return } + // Register the ack gate with the frame handler before any data arrives. + bootAckGate.run = (): void => { + if (runSent) return + runSent = true + try { + proto.write(`${JSON.stringify({ type: 'run', program: request.program })}\n`) + } catch (error: unknown) { + finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } }) + } + } }) } }