fix(code-runtime-python): resolve worker-exit on sync spawn failure; aggregate stray output by line

Wrap spawn and the fd-3 narrowing so a synchronous throw (ENAMETOOLONG on
an over-PATH_MAX pythonBin, EMFILE) removes the run's staging directory and
resolves the same worker-exit class as the async error event, instead of
rejecting run() and leaking the directory.

Aggregate native stdout/stderr by real newline rather than by Node data
chunk: logs entries are joined with "\n" downstream, so a newline-free
write larger than one pipe read no longer reads back with spurious breaks.
The ledger still bounds a newline-free flood.

Track a running scan offset in both frame readers so a large frame
accumulated across chunks is scanned once, not re-scanned from 0 per chunk.

Reword the deadline hard-bound v8-ignore to state its real environment
dependence (PID-1-doesn't-reap container, zombie survivor) and cross-ref
the note's rejected signal-0 alternative; fix settle comments that quoted
the pre-qualification teardown contract; document the capMessage vs
_cap_message billing split on both sides; guard the dispose-after-resolve
heartbeat assertion against a vacuous 0===0 pass; reuse
_TRUNCATION_MARKER_BYTES; note the abandoned-call pending-entry bound.

Update the Agent Note Decision/Testing/Alternatives/Consequences for the
above and record the confirmed-empty finalize as a second honest
fail-before exception; sync the zh pair.
This commit is contained in:
Chinesezjc
2026-08-31 14:21:57 +08:00
committed by Tianyi Cui
parent 1103e36c22
commit 44203f3fa7
7 changed files with 262 additions and 81 deletions
@@ -1,5 +1,7 @@
import { EventEmitter } from 'node:events'
import { readdirSync } from 'node:fs'
import { PassThrough } from 'node:stream'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
@@ -64,4 +66,26 @@ describe('PythonCodeRuntime — boot-write failure', () => {
expect(result.error?.message).toContain('failed to boot python subprocess')
await fiber.dispose()
})
it('resolves a worker-exit and removes the staging dir when spawn throws synchronously', async () => {
// `spawn` can throw same-tick — EMFILE on a descriptor-exhausted host, or a
// libuv-level failure — before the Promise executor and its settlement path
// exist. Left uncaught it rejected run() (the seam permits rejection only for
// misuse) and stranded the staging directory materializePyScripts had just
// written, which only settle() removes. The fix catches it, unlinks the
// directory, and resolves the same `worker-exit` class as an async ENOENT.
const before = readdirSync(tmpdir()).filter(name => name.startsWith('dsh-code-runtime-python-'))
spawnMock.mockImplementation(() => { throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' }) })
const ctx = new Context()
const fiber = await ctx.plugin(PythonCodeRuntime)
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
const result = await runtime.run({ program: 'return 1', bindings: [] })
expect(result.error?.kind).toBe('worker-exit')
expect(result.error?.message).toContain('python spawn error')
const after = readdirSync(tmpdir()).filter(name => name.startsWith('dsh-code-runtime-python-'))
expect(after).toEqual(before)
await fiber.dispose()
})
})