fix(code-runtime-python): drop a late binding resolution before snapshotting it

`sendReply` already refuses to write after the run settled, but only after
`snapshotJsonValue` walked and copied the resolution. Binding resolution carries
no seam-level byte cap, so a binding resolving a wide value after `maxWallMs`,
an abort, or dispose settled the run spent host heap building a frame that was
then discarded. The check moves ahead of the snapshot.

Also in this change:

- `readProcessStart` moved after `messageOf`. Inserting it between `messageOf`'s
  JSDoc and its body left that function undocumented and the orphaned block
  reading as a second doc for the reader; `verify-export-jsdoc` does not catch it
  because `messageOf` is not exported.
- The README pair adds the disposed-runtime rejection to `run()`'s public
  contract, which `src/index.ts` has enforced all along.
- Known Limitations records three deferred constraints that until now existed
  only in review discussion: the combined log-and-value peak the load gate does
  not model, the host-side per-member expansion of a wide binding reply (owned by
  `packages/core/session`, and shared with the worker-thread backend), and the
  absence of fd-3 backpressure for concurrent replies.
- The Agent Note's same-group section records the teardown identity guard and its
  two rulings, including why an ABSENT start-time reading proceeds rather than
  withholding the signal, and that reading it as a mismatch is what turned the
  three same-group heartbeat cases red on Linux.
This commit is contained in:
Chinesezjc
2026-08-31 14:31:48 +08:00
committed by Tianyi Cui
parent 0a46bb3414
commit 2a9a917853
8 changed files with 66 additions and 17 deletions
@@ -316,6 +316,17 @@ const GROUP_REAP_POLL_MS = 50
* @returns The value's message or string form; a fixed placeholder when its own
* conversion throws.
*/
function messageOf(error: unknown): string {
try {
return String(error instanceof Error ? error.message : error)
} catch {
// Swallows only a throw from the value's own `message` getter or string
// conversion. Nothing else runs inside the try, and the placeholder is a
// literal, so this cannot throw again.
return '<unrenderable rejection value>'
}
}
/**
* A process's start time, as the identity half of (pid, started).
*
@@ -347,17 +358,6 @@ export function readProcessStart(pid: number): string | undefined {
}
}
function messageOf(error: unknown): string {
try {
return String(error instanceof Error ? error.message : error)
} catch {
// Swallows only a throw from the value's own `message` getter or string
// conversion. Nothing else runs inside the try, and the placeholder is a
// literal, so this cannot throw again.
return '<unrenderable rejection value>'
}
}
/**
* Resolve `pythonBin` to an absolute path against the CURRENT process `PATH`,
* BEFORE the child spawns with an empty environment. A basename (the default
@@ -1347,6 +1347,14 @@ export class PythonCodeRuntime extends CodeRuntime {
void (async () => {
try {
const resolved = await fn(message.args)
// Drop a reply the run no longer needs BEFORE snapshotting it.
// `sendReply` also checks `settled`, but only after this value has
// been walked and copied: a binding that resolves a wide value
// after `maxWallMs`, an abort, or dispose already settled the run
// would spend host heap on a frame that is then discarded, and
// binding resolution carries no seam-level byte cap to bound it.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the run can settle while this binding is awaited.
if (settled) return
// The seam requires a lossy resolution to REJECT descriptively,
// not silently coerce: a raw JSON.stringify would turn NaN/
// Infinity into null and drop undefined fields. Snapshot through