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
@@ -3832,6 +3832,37 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect(result.value).toBe(reply.length)
}, 90_000)
it('drops a late binding resolution before snapshotting it', async () => {
// `sendReply` checks `settled`, but only after the resolution has been walked
// and copied by `snapshotJsonValue`. Binding resolution carries no seam-level
// byte cap, so a binding that resolves a wide value AFTER the run already
// settled (here on `maxWallMs`) spent host heap building a frame that is then
// discarded. The check now runs before the snapshot.
//
// The binding resolves well after the 1s wall clock with a 2M-element array;
// the run must still report `timeout`, and the late value must not appear.
let resolvedLate = false
const { runtime } = await setup({ maxWallMs: 1_000 })
const result = await runtime.run({
program: 'return await tools.slow({})',
bindings: [{
global: 'tools',
functions: {
slow: async () => {
await new Promise(resolve => setTimeout(resolve, 2_500))
resolvedLate = true
return Array.from({ length: 2_000_000 }, () => 0)
},
},
}],
})
expect(result.error?.kind).toBe('timeout')
expect(result.value).toBeUndefined()
// Pin that the late path actually ran, so the assertion above is not vacuous.
await new Promise(resolve => setTimeout(resolve, 2_000))
expect(resolvedLate).toBe(true)
}, 90_000)
it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
// Blank print() lines carry zero content bytes; without the +1 separator
// charge they would bypass maxLogBytes entirely and grow the retained