mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -682,6 +682,35 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
expect(result.logs).toEqual(['partial'])
|
||||
})
|
||||
|
||||
it('aggregates a large newline-free native write into one log entry, not one per pipe chunk', async () => {
|
||||
// A single `os.write` larger than one pipe read arrives as several Node
|
||||
// `data` chunks. `logs` entries are joined with `\n` downstream, so pushing
|
||||
// one entry per transport chunk would insert model-visible newlines at
|
||||
// arbitrary pipe boundaries inside one native write. Stray capture holds a
|
||||
// per-stream residual and admits only on a real `\n`, so a 200 KiB blast
|
||||
// with no newline reads back as exactly one entry with no interior breaks.
|
||||
const { runtime } = await setup({ maxLogBytes: 300_000 })
|
||||
const size = 200_000
|
||||
const result = await runtime.run({
|
||||
program: ['import os', `os.write(1, b"A" * ${size})`, 'return None'].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toEqual(['A'.repeat(size)])
|
||||
})
|
||||
|
||||
it('splits native output on its own newlines, one entry per line', async () => {
|
||||
// The complement of the aggregation case: real newlines in a native write
|
||||
// still delimit entries, matching the child's line-granular `log` frames.
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: ['import os', 'os.write(1, b"one\\ntwo\\nthree")', 'return None'].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
it('fails a completion dict with a non-string key as invalid-output (no key coercion)', async () => {
|
||||
// json.dumps would coerce {1: "a", "1": "b"} to a single "1" key, silently
|
||||
// dropping data. The shape validator rejects it before encoding.
|
||||
@@ -2116,6 +2145,10 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
|
||||
await fiber.dispose()
|
||||
const mtime = (): number => { try { return statSync(heartbeat).mtimeMs } catch { return 0 } }
|
||||
const afterDispose = mtime()
|
||||
// Pin the assertion to a heartbeat that actually ran: mtime() returns 0 when
|
||||
// the file never existed, so without this the `toBe` below would pass
|
||||
// vacuously (0 === 0) if the survivor never wrote a heartbeat at all.
|
||||
expect(afterDispose).toBeGreaterThan(0)
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
expect(mtime()).toBe(afterDispose)
|
||||
}, 20_000)
|
||||
|
||||
Reference in New Issue
Block a user