mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
Adding a published Python backend and reordering `flush_line` left several owning documents stating things that are no longer true. `src/invariant.ts` justified its empty installer with "ships only the fd-3 wire-protocol codec", which the subprocess execution path contradicts. The reason now states the actual one: every relation this backend maintains lives in the CPython child or on the fd-3 wire, so no same-process event sequence is observable from a listener -- the same shape the sibling worker-thread backend uses. The seam's `PORTABLE_RESERVED_WORDS` and `language` JSDoc, the code-runtime README pair, and docs/subsystems/code-runtime both said only TypeScript has a published backend. Corrected in all four, with the generated cordis catalog regenerated for the `language` change. The note attributed the 12x multiple to the settlement flush holding three copies. That stopped being true when `flush_line` was reordered to drop the pending chunks before its push: the binding worst case is the newline path's single near-budget write. Corrected in the note (both sides) and in the test comment that repeated it. The note's Testing section now registers the cases this stack added, and the Chinese side receives the O(depth) entry it never got plus the new ones -- it had drifted from the English. `INTERPRETER_BASELINE_BYTES` argued 64 MiB from a RESIDENT set while RLIMIT_AS bounds address space. It now cites the bootstrap's own measurement (30.23 MiB of mappings for `python3 -I`), making 64 MiB roughly twice the measured baseline. Also: a hardcoded `(:232-235)` comment reference becomes a reference by name, a "which now walks in O(depth) too" change narrative becomes a current-state statement, and a stray double blank line is removed.
4267 lines
206 KiB
TypeScript
4267 lines
206 KiB
TypeScript
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { basename, dirname, join } from 'node:path'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { Context } from '@deepseek-ai/cordis'
|
||
import { PythonCodeRuntime, readProcessStart } from '../src/index.ts'
|
||
import { logTruncationMarker } from '../src/protocol.ts'
|
||
import type { Config } from '../src/index.ts'
|
||
import type { CodeBindingFunction, CodeJsonValue, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||
|
||
/**
|
||
* Names one `py/` script whose `copyFileSync` must fail, for the partial-staging
|
||
* case. A real disk-full or missing-asset failure mid-copy cannot be produced
|
||
* from a test, and the leak only shows when `mkdtempSync` has already succeeded.
|
||
*/
|
||
const { failNextCopyOf } = vi.hoisted(() => ({ failNextCopyOf: { value: undefined as string | undefined } }))
|
||
vi.mock('node:fs', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('node:fs')>()
|
||
return {
|
||
...actual,
|
||
copyFileSync(source: string, destination: string): void {
|
||
if (failNextCopyOf.value !== undefined && basename(source) === failNextCopyOf.value) {
|
||
failNextCopyOf.value = undefined
|
||
throw Object.assign(new Error('simulated ENOSPC on copy'), { code: 'ENOSPC' })
|
||
}
|
||
actual.copyFileSync(source, destination)
|
||
},
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Integration suite over REAL python3 subprocesses (no subprocess mocks — it is
|
||
* cheap and local, per docs/testing.md's real-over-mock policy; the only mock is
|
||
* `node:fs.copyFileSync` for the staging-failure cases). Each test builds a fresh
|
||
* runtime so budgets can be tuned per case.
|
||
*/
|
||
async function setup(config: Config = {}) {
|
||
const ctx = new Context()
|
||
const fiber = await ctx.plugin(PythonCodeRuntime, config)
|
||
const runtime = ctx.codeRuntime as PythonCodeRuntime
|
||
return { ctx, fiber, runtime }
|
||
}
|
||
|
||
/** Convenience: one namespace `tools` with the given functions. */
|
||
function tools(functions: Record<string, CodeBindingFunction>) {
|
||
return [{ global: 'tools', functions }]
|
||
}
|
||
|
||
describe('PythonCodeRuntime — seam descriptors and misuse', () => {
|
||
it('registers the seam descriptors', async () => {
|
||
const { runtime } = await setup()
|
||
expect(runtime.language).toBe('python')
|
||
expect(runtime.isolation).toBe('process')
|
||
})
|
||
|
||
it('rejects non-positive config as seam misuse', async () => {
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { cpuSeconds: 0 }))
|
||
.rejects.toThrow(/cpuSeconds must be a positive number/)
|
||
await expect(ctx.plugin(PythonCodeRuntime, { maxWallMs: -1 }))
|
||
.rejects.toThrow(/maxWallMs must be a positive number/)
|
||
})
|
||
|
||
it('rejects a non-integer cpuSeconds at load (setrlimit needs an int)', async () => {
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { cpuSeconds: 1.5 }))
|
||
.rejects.toThrow(/cpuSeconds must be a positive integer, got 1.5/)
|
||
})
|
||
|
||
it('rejects a non-integer byte budget at load (the child int()-truncates it)', async () => {
|
||
// maxLogBytes/maxValueBytes cross to the child, which reads them through
|
||
// int(...): a float would floor there while the host meters the fraction, so
|
||
// the two sides would enforce different public config. Reject at load.
|
||
const ctxLog = new Context()
|
||
await expect(ctxLog.plugin(PythonCodeRuntime, { maxLogBytes: 3.5 }))
|
||
.rejects.toThrow(/maxLogBytes must be a positive integer/)
|
||
const ctxValue = new Context()
|
||
await expect(ctxValue.plugin(PythonCodeRuntime, { maxValueBytes: 1024.5 }))
|
||
.rejects.toThrow(/maxValueBytes must be a positive integer/)
|
||
})
|
||
|
||
it('rejects finite numeric config that cannot cross as an exact rlimit integer', async () => {
|
||
// `Number.isFinite` and `Number.isInteger` both admit values that cannot
|
||
// round-trip. `addressSpaceMb: 1e308` overflows to `Infinity` once multiplied
|
||
// by 1 MiB, and `encodeJsonPlain` renders that as `null`, so the child gets no
|
||
// limit at all; `cpuSeconds: 1e100` clears `Number.isInteger` while sitting
|
||
// far past the safe range, so `setrlimit` receives a different number than was
|
||
// configured. Both used to end every run in a bootstrap exception instead of
|
||
// failing at load, where a self-contained configuration error belongs.
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { addressSpaceMb: 1e308 }))
|
||
.rejects.toThrow(/addressSpaceMb must be at most \d+ .*exact integer/)
|
||
await expect(ctx.plugin(PythonCodeRuntime, { cpuSeconds: 1e100 }))
|
||
.rejects.toThrow(/cpuSeconds must be at most \d+ .*exact integers/)
|
||
// The boundary values still load: the bound rejects what cannot be encoded,
|
||
// not everything large.
|
||
const okMb = await ctx.plugin(PythonCodeRuntime, { addressSpaceMb: Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024)) })
|
||
await okMb.dispose()
|
||
const okCpu = await ctx.plugin(PythonCodeRuntime, { cpuSeconds: Number.MAX_SAFE_INTEGER - 1 })
|
||
await okCpu.dispose()
|
||
})
|
||
|
||
it('rejects an output cap whose payload could not cross the frame ceiling', async () => {
|
||
// The caps budget a payload that must arrive inside ONE fd-3 frame, and the
|
||
// 256 MiB framing ceiling is fixed. A larger cap is unsatisfiable rather
|
||
// than generous: a completion the cap admits arrives as an over-ceiling
|
||
// frame and fails the run as `worker-exit`, inverting the `output-limit`
|
||
// the cap describes. Both budgets are metered in already-escaped serialized
|
||
// bytes, so a payload occupies at most `cap + envelope` on the wire; the
|
||
// bound is `ceiling - envelope`, not `(ceiling - envelope) / 6` (that
|
||
// divided in escape expansion the charge already counts).
|
||
const admissible = 256 * 1024 * 1024 - 64
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: admissible + 1 }))
|
||
.rejects.toThrow(/maxLogBytes must not exceed 268435392 .*fd-3 frame ceiling/)
|
||
await expect(ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible + 1 }))
|
||
.rejects.toThrow(/maxValueBytes must not exceed 268435392 .*fd-3 frame ceiling/)
|
||
// The boundary value itself loads: the bound is the largest cap a frame can
|
||
// still carry, not one below it. It needs an address space large enough to
|
||
// clear the separate maxValueBytes/addressSpaceMb worst-case gate (the cap
|
||
// times the 12x Unicode expansion must fit), so this pairs it with a 4 GiB
|
||
// addressSpaceMb — the two load-time bounds are independent.
|
||
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible, addressSpaceMb: 4096 })
|
||
await boundary.dispose()
|
||
})
|
||
|
||
it('rejects a pythonBin that spawn() would throw on, at load', async () => {
|
||
// Both values pass the string schema and both make `spawn` throw
|
||
// SYNCHRONOUSLY from inside run() — ERR_INVALID_ARG_VALUE for the empty
|
||
// path, ERR_INVALID_ARG_TYPE for the NUL — so run() would REJECT instead of
|
||
// resolving the worker-exit the seam promises for a child that cannot
|
||
// start. Both are self-contained configuration errors, so they fail here.
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { pythonBin: '' }))
|
||
.rejects.toThrow(/pythonBin must be a non-empty path without NUL bytes/)
|
||
await expect(ctx.plugin(PythonCodeRuntime, { pythonBin: 'py\u0000thon3' }))
|
||
.rejects.toThrow(/pythonBin must be a non-empty path without NUL bytes/)
|
||
})
|
||
|
||
it('rejects a timer budget setTimeout would silently clamp to 1 ms', async () => {
|
||
// Node stores a setTimeout delay as a signed 32-bit value and substitutes
|
||
// 1 ms for anything larger, inverting the knob's meaning: a huge maxWallMs
|
||
// would time every run out at once, and a huge graceMs would SIGKILL one
|
||
// millisecond after SIGTERM. Both must fail at load instead.
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, { maxWallMs: 2_147_483_648 }))
|
||
.rejects.toThrow(/maxWallMs must not exceed 2147483647/)
|
||
// graceMs is bounded by the close deadline's added margin, not by the raw
|
||
// timer maximum, because that sum is what gets armed.
|
||
await expect(ctx.plugin(PythonCodeRuntime, { graceMs: 2_147_481_648 }))
|
||
.rejects.toThrow(/graceMs must not exceed 2147481647/)
|
||
// The exact maxima still load.
|
||
await expect(ctx.plugin(PythonCodeRuntime, { maxWallMs: 2_147_483_647, graceMs: 2_147_481_647 }))
|
||
.resolves.toBeDefined()
|
||
})
|
||
|
||
it('rejects loading this Unix-only backend on Windows', async () => {
|
||
// The bootstrap needs the POSIX `resource` module, a positional fd 3, and
|
||
// negative-PID process-group signals — none on Windows. The constructor
|
||
// must throw at load rather than register ctx.codeRuntime and defer the
|
||
// failure to the first run.
|
||
const original = process.platform
|
||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||
try {
|
||
const ctx = new Context()
|
||
await expect(ctx.plugin(PythonCodeRuntime, {})).rejects.toThrow(/requires a Unix platform/)
|
||
} finally {
|
||
Object.defineProperty(process, 'platform', { value: original, configurable: true })
|
||
}
|
||
})
|
||
|
||
it('rejects a binding global that is not a Python identifier or is reserved', async () => {
|
||
const { runtime } = await setup()
|
||
await expect(runtime.run({
|
||
program: 'return 1',
|
||
bindings: [{ global: '1bad', functions: {} }],
|
||
})).rejects.toThrow(/is not a usable Python identifier/)
|
||
await expect(runtime.run({
|
||
program: 'return 1',
|
||
bindings: [{ global: 'class', functions: {} }],
|
||
})).rejects.toThrow(/is not a usable Python identifier/)
|
||
})
|
||
|
||
it('rejects duplicate binding namespaces', async () => {
|
||
const { runtime } = await setup()
|
||
await expect(runtime.run({
|
||
program: 'return 1',
|
||
bindings: [
|
||
{ global: 'tools', functions: {} },
|
||
{ global: 'tools', functions: {} },
|
||
],
|
||
})).rejects.toThrow(/duplicate binding global/)
|
||
})
|
||
|
||
it('rejects run() after disposal, and unregisters ctx.codeRuntime', async () => {
|
||
const { ctx, fiber, runtime } = await setup()
|
||
await fiber.dispose()
|
||
await expect(runtime.run({ program: 'return 1', bindings: [] }))
|
||
.rejects.toThrow(/after disposal/)
|
||
expect(ctx.get('codeRuntime')).toBeUndefined()
|
||
})
|
||
|
||
it('short-circuits when the request signal is already aborted', async () => {
|
||
const { runtime } = await setup()
|
||
const signal = AbortSignal.abort('already-cancelled')
|
||
const result = await runtime.run({ program: 'return 1', bindings: [], signal })
|
||
expect(result.error?.kind).toBe('abort')
|
||
expect(result.error?.message).toContain('already-cancelled')
|
||
expect(result.logs).toEqual([])
|
||
})
|
||
|
||
it('short-circuits on an already-aborted signal whose reason cannot be converted', async () => {
|
||
// The pre-flight arm converted the reason with a bare `String()`, so a
|
||
// hostile reason threw out of `run()` — the seam promises to reject only for
|
||
// misuse, and a caller's cancellation token is not misuse.
|
||
const { runtime } = await setup()
|
||
const signal = AbortSignal.abort({
|
||
[Symbol.toPrimitive]() { throw new Error('reason blew up') },
|
||
})
|
||
const result = await runtime.run({ program: 'return 1', bindings: [], signal })
|
||
expect(result.error?.kind).toBe('abort')
|
||
expect(result.error?.message).toBe('<unrenderable rejection value>')
|
||
expect(result.logs).toEqual([])
|
||
})
|
||
|
||
it('runs the interpreter from materialized scripts outside the package, and removes them per run', async () => {
|
||
// The interpreter is an EXTERNAL process, so it can only open paths the OS
|
||
// resolves. Inside the single-file Python-SDK executable the packaged `py/`
|
||
// directory lives in pkg's virtual filesystem, which Node reads through its
|
||
// patched `fs` but `python3` cannot see, so spawning from that path fails
|
||
// with ENOENT. The scripts are therefore copied to a real directory first.
|
||
//
|
||
// The path is read from the child's own `__main__` module, so it proves
|
||
// where the interpreter actually loaded the entry script — asserting on a
|
||
// host-side constant would only restate the source. The program namespace
|
||
// seeds `__name__` but no `__file__`, hence the module lookup.
|
||
// `protocol.py` must land in the SAME directory, since `bootstrap.py` puts
|
||
// its own directory on `sys.path` to import it; the run completing at all
|
||
// already exercises that import.
|
||
const { runtime } = await setup()
|
||
const entryOf = async (): Promise<string> => {
|
||
const result = await runtime.run({ program: 'import sys\nreturn sys.modules["__main__"].__file__', bindings: [] })
|
||
expect(result.error).toBeUndefined()
|
||
return result.value as string
|
||
}
|
||
const entry = await entryOf()
|
||
expect(entry.endsWith('/bootstrap.py')).toBe(true)
|
||
const dir = dirname(entry)
|
||
expect(dir.startsWith(realpathSync(tmpdir()))).toBe(true)
|
||
expect(dir).not.toContain('/packages/')
|
||
// Staging is per RUN and removed at settlement, so by the time `run()`
|
||
// resolved the directory is already gone — nothing survives to be rewritten
|
||
// by a later run. `protocol.py` had to be beside the entry script for the run
|
||
// to complete at all, since `bootstrap.py` imports it off `sys.path`.
|
||
expect(existsSync(dir)).toBe(false)
|
||
// A second run stages its own copy rather than reusing the first.
|
||
expect(dirname(await entryOf())).not.toBe(dir)
|
||
})
|
||
|
||
it('contains a program that rewrites its own bootstrap to the run that did it', async () => {
|
||
// The child runs as the same UID as the host, so `0o700` does not stop model
|
||
// code from rewriting the scripts it was started from —
|
||
// `sys.modules['__main__'].__file__` names them. While all runs shared one
|
||
// staged copy, a program that overwrote `bootstrap.py` broke the NEXT run
|
||
// (measured: it settled as `worker-exit`), and substituted code would have
|
||
// run before the resource limits were applied.
|
||
const { runtime } = await setup({ maxWallMs: 10_000 })
|
||
const sabotage = await runtime.run({
|
||
program: [
|
||
'import sys',
|
||
'path = sys.modules["__main__"].__file__',
|
||
'open(path, "w").write("raise SystemExit(1)\\n")',
|
||
'return path',
|
||
].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(sabotage.error).toBeUndefined()
|
||
// The damage stayed inside the run that caused it.
|
||
const after = await runtime.run({ program: 'return 1 + 1', bindings: [] })
|
||
expect(after.error).toBeUndefined()
|
||
expect(after.value).toBe(2)
|
||
}, 20_000)
|
||
|
||
it('leaves no subprocess or scripts behind when disposal races the first run', async () => {
|
||
// Staging runs SYNCHRONOUSLY so no async boundary opens between `run()` and
|
||
// the point where `execute` registers the run in `live` and installs the
|
||
// abort listener. With an `await` there, a disposal landing in that window
|
||
// saw an empty `live`, returned, removed the script directory, and let the
|
||
// continuation spawn a subprocess after the fiber was gone.
|
||
//
|
||
// `dispose()` is called in the same synchronous turn as `run()`, with no
|
||
// `await` between them, so it lands exactly in that window.
|
||
//
|
||
// The leak assertion compares before and after rather than requiring an
|
||
// empty tmpdir: other tests in this file build runtimes they never dispose,
|
||
// so only the directories this test adds are its own evidence.
|
||
const staged = (): string[] =>
|
||
readdirSync(realpathSync(tmpdir())).filter(name => name.startsWith('dsh-code-runtime-python-'))
|
||
const before = new Set(staged())
|
||
const { fiber, runtime } = await setup({ maxWallMs: 8_000 })
|
||
const pending = runtime.run({ program: 'import time\nwhile True: time.sleep(0.1)', bindings: [] })
|
||
const disposed = fiber.dispose()
|
||
const result = await pending
|
||
await disposed
|
||
// Whatever the run reports, it must be terminal and must not be a success.
|
||
expect(result.value).toBeUndefined()
|
||
expect(['abort', 'worker-exit', 'timeout']).toContain(result.error?.kind)
|
||
// Disposal is to quiescence, so this run's directory is gone once it
|
||
// resolves, and nothing recreated it afterwards.
|
||
expect(staged().filter(name => !before.has(name))).toEqual([])
|
||
}, 15_000)
|
||
|
||
it('settles as abort when the signal fires in the same turn as the first run', async () => {
|
||
// Same window, the other listener. `addEventListener('abort')` does not
|
||
// replay an event that already fired, so an abort landing before the
|
||
// listener was installed used to be missed entirely and the program ran to
|
||
// success or the wall ceiling instead of resolving as `abort`. Synchronous
|
||
// staging keeps the pre-flight check and the listener in one turn, leaving
|
||
// no gap for the signal to slip through.
|
||
const { runtime } = await setup({ maxWallMs: 4_000, graceMs: 200 })
|
||
const controller = new AbortController()
|
||
const pending = runtime.run({
|
||
program: 'import time\nwhile True: time.sleep(0.1)',
|
||
bindings: [],
|
||
signal: controller.signal,
|
||
})
|
||
controller.abort('same-turn-abort')
|
||
const result = await pending
|
||
expect(result.error?.kind).toBe('abort')
|
||
expect(result.error?.message).toContain('same-turn-abort')
|
||
}, 15_000)
|
||
|
||
it('reports a staging failure as worker-exit instead of rejecting run()', async () => {
|
||
// Staging touches the filesystem, so it can fail for reasons that are not
|
||
// the caller's doing: a full or read-only temp filesystem, or a deployment
|
||
// that failed to ship the packaged scripts. Those are SUBSTRATE failures,
|
||
// the same class as a child that cannot start, and the seam reserves
|
||
// rejection for misuse — so `run()` must resolve, not throw.
|
||
//
|
||
// `TMPDIR` is the honest lever: `mkdtempSync` builds its path from
|
||
// `os.tmpdir()`, so pointing it at a path that is not a directory makes the
|
||
// real call fail without stubbing the module under test.
|
||
const previous = process.env.TMPDIR
|
||
const notADirectory = join(await mkdtemp(join(tmpdir(), 'dsh-staging-')), 'file')
|
||
await writeFile(notADirectory, '')
|
||
process.env.TMPDIR = notADirectory
|
||
try {
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||
expect(result.error?.kind).toBe('worker-exit')
|
||
expect(result.error?.message).toContain('failed to stage the python bootstrap')
|
||
expect(result.logs).toEqual([])
|
||
} finally {
|
||
if (previous === undefined) delete process.env.TMPDIR
|
||
else process.env.TMPDIR = previous
|
||
}
|
||
})
|
||
|
||
it('leaves no staging directory behind when a script copy fails', async () => {
|
||
// `mkdtempSync` succeeding and a later `copyFileSync` failing is its own
|
||
// case: the directory exists but is only partially populated. Recording it
|
||
// before the copies would leak it, because `run` retries staging on the next
|
||
// call and overwrites the single recorded path — teardown could then remove
|
||
// only the newest attempt. Staging must clean up its own partial directory.
|
||
//
|
||
// Only `copyFileSync` is stubbed, and only for the second script, so
|
||
// `mkdtempSync` really runs and the directory under assertion is real.
|
||
const staged = (): string[] =>
|
||
readdirSync(realpathSync(tmpdir())).filter(name => name.startsWith('dsh-code-runtime-python-'))
|
||
const before = new Set(staged())
|
||
failNextCopyOf.value = 'protocol.py'
|
||
try {
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||
expect(result.error?.kind).toBe('worker-exit')
|
||
expect(result.error?.message).toContain('failed to stage the python bootstrap')
|
||
// The partial directory is gone, so nothing accumulates across retries.
|
||
expect(staged().filter(name => !before.has(name))).toEqual([])
|
||
} finally {
|
||
failNextCopyOf.value = undefined
|
||
}
|
||
}, 15_000)
|
||
})
|
||
|
||
describe('PythonCodeRuntime — process identity', () => {
|
||
it('reads a live process start time and distinguishes it from an absent pid', () => {
|
||
// The teardown guard signals `-child.pid` with a RAW `process.kill`, which
|
||
// (unlike `child.kill()`) has no handle check, so it would reach a recycled
|
||
// pgid during the window between the leader being reaped and `close` firing.
|
||
// A pid alone cannot separate the original from its replacement -- both
|
||
// answer `kill(pid, 0)` -- so the guard compares START TIME, and this pins
|
||
// that the reading is stable for one process and absent for a pid that
|
||
// cannot be read.
|
||
const own = readProcessStart(process.pid)
|
||
if (process.platform === 'linux') {
|
||
// Same process, two reads: the identity must be stable, or the guard would
|
||
// refuse to signal its own live group.
|
||
expect(own).toBeDefined()
|
||
expect(readProcessStart(process.pid)).toBe(own)
|
||
// Pid 0 is never a readable /proc entry, so the guard degrades to
|
||
// undefined rather than throwing on a teardown path.
|
||
expect(readProcessStart(0)).toBeUndefined()
|
||
} else {
|
||
// Darwin has no /proc: the reader reports undefined, and `killGroup` then
|
||
// keeps its pre-existing behavior instead of paying a `ps` fork per signal.
|
||
expect(own).toBeUndefined()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('PythonCodeRuntime — inherited resource limits', () => {
|
||
it('runs under an inherited hard limit tighter than addressSpaceMb', async () => {
|
||
// An unprivileged process may lower a hard rlimit but never raise it. Under
|
||
// a harness started with `ulimit -v` below `addressSpaceBytes`, requesting
|
||
// the configured cap made `setrlimit` raise `ValueError` and every run
|
||
// returned a bootstrap exception — even though the inherited limit is
|
||
// STRONGER than the one asked for. The bootstrap clamps to the inherited
|
||
// hard limit instead, so the run proceeds under the stricter bound.
|
||
//
|
||
// `pythonBin` is the honest lever: a wrapper that lowers RLIMIT_AS and then
|
||
// execs the real interpreter reproduces the inherited-limit condition
|
||
// without touching this test process's own limits.
|
||
const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-'))
|
||
const wrapper = join(dir, 'python3-capped')
|
||
// 256 MiB, half the 512 MiB addressSpaceMb default, so the requested cap is
|
||
// unambiguously above the inherited ceiling.
|
||
await writeFile(wrapper, '#!/bin/sh\nulimit -v 262144\nexec python3 "$@"\n', { mode: 0o755 })
|
||
const { runtime } = await setup({ pythonBin: wrapper })
|
||
const result = await runtime.run({
|
||
program: 'import resource\nreturn resource.getrlimit(resource.RLIMIT_AS)[1]',
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
// The applied hard limit is the inherited one, not the configured 512 MiB.
|
||
expect(result.value).toBe(256 * 1024 * 1024)
|
||
}, 15_000)
|
||
|
||
it('rejects at boot when an inherited RLIMIT_AS is too tight for the output budgets', async () => {
|
||
// The host gate validates the output budgets against the CONFIGURED
|
||
// addressSpaceMb, but a launch environment can inherit a STRICTER RLIMIT_AS
|
||
// (a `ulimit -v` wrapper below addressSpaceMb), which the bootstrap clamps the
|
||
// effective limit down to — leaving the budgets sized for a ceiling the child
|
||
// never gets, so a near-budget output would OOM mid-run as an opaque
|
||
// worker-exit. The bootstrap re-checks both budgets against the EFFECTIVE
|
||
// clamped limit and fails loud at boot instead. A 128 MiB inherited limit
|
||
// leaves 64 MiB budgetable (~5 MiB admissible under the 12x multiple), under
|
||
// which a 32 MiB maxLogBytes — admitted by the 512 MiB configured default — is
|
||
// rejected. The rejection surfaces as an 'exception' (bootstrap's
|
||
// setrlimit-phase failure class), not a mid-run OOM. The repro is Linux-only
|
||
// (macOS ignores `ulimit -v`); there the run proceeds.
|
||
const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-'))
|
||
const wrapper = join(dir, 'python3-tight')
|
||
await writeFile(wrapper, '#!/bin/sh\nulimit -v 131072\nexec python3 "$@"\n', { mode: 0o755 })
|
||
const { runtime } = await setup({ pythonBin: wrapper, maxLogBytes: 32 * 1024 * 1024, addressSpaceMb: 512 })
|
||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||
if (process.platform === 'darwin') {
|
||
expect(result.error).toBeUndefined()
|
||
} else {
|
||
// The re-check raises inside bootstrap's resource-limit block, which
|
||
// reports every setrlimit-phase failure as kind 'exception'; the message
|
||
// discriminates this config rejection from a generic setrlimit error.
|
||
expect(result.error?.kind).toBe('exception')
|
||
expect(result.error?.message).toContain('too large for the inherited RLIMIT_AS')
|
||
}
|
||
}, 15_000)
|
||
|
||
it('applies the configured limits when nothing tighter is inherited', async () => {
|
||
// The clamp must not weaken the normal path: with an infinite inherited hard
|
||
// limit there is nothing to clamp against, and RLIM_INFINITY compares as -1,
|
||
// so treating it as a numeric bound would collapse every limit to -1.
|
||
const { runtime } = await setup({ cpuSeconds: 42, addressSpaceMb: 400 })
|
||
const result = await runtime.run({
|
||
// `getrlimit` returns a tuple, which the lossless-JSON completion check
|
||
// rejects; the pair is listed explicitly rather than converted.
|
||
program: 'import resource\ncpu = resource.getrlimit(resource.RLIMIT_CPU)\nreturn [cpu[0], cpu[1], resource.getrlimit(resource.RLIMIT_AS)[1]]',
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
// Soft at cpuSeconds, hard at +1 (the SIGKILL backstop), address space at
|
||
// the configured megabytes — exactly what the unclamped path applied.
|
||
expect(result.value).toEqual([42, 43, 400 * 1024 * 1024])
|
||
}, 15_000)
|
||
|
||
it('preserves an inherited soft limit stricter than the configured cap', async () => {
|
||
// Clamping reads BOTH inherited bounds, not just the hard one. A deployment
|
||
// that inherited a soft rlimit below the configured cap must keep that
|
||
// stricter soft: returning the configured value would RAISE the effective
|
||
// soft limit, loosening containment. The wrapper lowers only the SOFT CPU
|
||
// limit (`ulimit -S -t`) and leaves the hard limit unlimited, so the
|
||
// requested soft (`cpuSeconds`) sits above the inherited soft — the case that
|
||
// exposed the bug. RLIMIT_CPU is used because macOS ignores `ulimit -v`
|
||
// (RLIMIT_AS), which is exactly why the backend skips address space there.
|
||
const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-soft-'))
|
||
const wrapper = join(dir, 'python3-soft-capped')
|
||
// Soft CPU 5 s, well below the configured 30 s, hard left unlimited.
|
||
await writeFile(wrapper, '#!/bin/sh\nulimit -S -t 5\nexec python3 "$@"\n', { mode: 0o755 })
|
||
const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30 })
|
||
const result = await runtime.run({
|
||
program: 'import resource\nreturn resource.getrlimit(resource.RLIMIT_CPU)[0]',
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
// The applied SOFT limit is the inherited 5 s, not the configured 30 s.
|
||
expect(result.value).toBe(5)
|
||
}, 15_000)
|
||
|
||
it('rechecks CPU at settlement against the effective inherited soft limit', async () => {
|
||
// The settlement-time CPU recheck must compare against the EFFECTIVE soft
|
||
// limit (`_clamped` may have lowered it to a stricter inherited value), not
|
||
// the configured `cpuSeconds`. A program that traps SIGXCPU, burns past the
|
||
// inherited soft, and returns inside the soft-to-hard gap would otherwise be
|
||
// compared to the configured value and falsely reported successful, bypassing
|
||
// the inherited limit. The wrapper sets a 1 s soft CPU limit; the program
|
||
// traps SIGXCPU and busy-loops past it, then returns — the recheck must
|
||
// re-deliver SIGXCPU so the host classifies the run as a timeout.
|
||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cpu-recheck-'))
|
||
const wrapper = join(dir, 'python3-cpu-capped')
|
||
await writeFile(wrapper, '#!/bin/sh\nulimit -S -t 1\nexec python3 "$@"\n', { mode: 0o755 })
|
||
const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 })
|
||
const result = await runtime.run({
|
||
program: [
|
||
'import signal, time',
|
||
// Trap SIGXCPU so the soft limit does not terminate the program; burn
|
||
// CPU well past the inherited 1 s soft, then return normally.
|
||
'signal.signal(signal.SIGXCPU, lambda *a: None)',
|
||
'end = time.process_time() + 2.5',
|
||
'while time.process_time() < end:',
|
||
' pass',
|
||
'return "returned"',
|
||
].join('\n'),
|
||
bindings: [],
|
||
})
|
||
// The recheck compares spent CPU against the effective 1 s soft, not 30 s, so
|
||
// the run is a timeout rather than a false success.
|
||
expect(result.error?.kind).toBe('timeout')
|
||
}, 20_000)
|
||
})
|
||
|
||
describe('PythonCodeRuntime — programs and bindings', () => {
|
||
it('runs a top-level script, captures print output, and returns `result`', async () => {
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({
|
||
program: [
|
||
'x = 40',
|
||
'y = 2',
|
||
'print("hello", x + y)',
|
||
'return {"answer": x + y}',
|
||
].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toEqual({ answer: 42 })
|
||
// `print` in Python emits: text, ' ', text, '\n'. Concat the captured
|
||
// fragments and assert the model-visible message survives.
|
||
expect(result.logs.join('')).toContain('hello 42')
|
||
// 15s: this is usually the suite's first real subprocess — a cold python3
|
||
// start (interpreter + asyncio import) on a loaded CI runner can exceed
|
||
// the 5s default alone; later tests reuse the warm page cache.
|
||
}, 15_000)
|
||
|
||
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
|
||
const { runtime } = await setup()
|
||
const calls: unknown[] = []
|
||
const result = await runtime.run({
|
||
program: [
|
||
'first = await tools.echo({"n": 1})',
|
||
'caught = ""',
|
||
'try:',
|
||
' await tools.fail({})',
|
||
'except RuntimeError as e:',
|
||
' caught = str(e)',
|
||
'return {"first": first, "caught": caught}',
|
||
].join('\n'),
|
||
bindings: tools({
|
||
echo: async (args) => { calls.push(args); return { echoed: args as CodeJsonValue } },
|
||
fail: async () => { throw new Error('nope') },
|
||
}),
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope' })
|
||
expect(calls).toEqual([{ n: 1 }])
|
||
})
|
||
|
||
it('still answers the call when the rejection value cannot be converted to a string', async () => {
|
||
// `messageOf` calls `String(error)`, which runs the value's own conversion,
|
||
// and this call site is a DETACHED async reply callback. A rejection whose
|
||
// `Symbol.toPrimitive` throws therefore escaped as an unhandled rejection:
|
||
// the reply frame was never written, the program stayed blocked on `await`,
|
||
// and the run degraded to a `maxWallMs` timeout (observed) — a host with no
|
||
// `unhandledRejection` listener would exit instead. The rejection must reach
|
||
// the program as an ordinary error carrying a fixed placeholder.
|
||
const { runtime } = await setup({ maxWallMs: 8_000 })
|
||
const result = await runtime.run({
|
||
program: [
|
||
'try:',
|
||
' await tools.hostile({})',
|
||
'except RuntimeError as e:',
|
||
' return "rejected: " + str(e)',
|
||
'return "no rejection"',
|
||
].join('\n'),
|
||
bindings: tools({
|
||
hostile: async () => {
|
||
throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive blew up') } }
|
||
},
|
||
}),
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toBe('rejected: <unrenderable rejection value>')
|
||
}, 15_000)
|
||
|
||
it('still answers the call when an Error carries a cyclic value in place of its message', async () => {
|
||
// `Error.message` is typed `string` but is a plain writable property, so a
|
||
// rejection can carry any value there. Returning it verbatim handed a
|
||
// non-string to `sendReply`, breaching `encodeJsonPlain`'s JSON-plain
|
||
// precondition: a cyclic object grew the encoder stack until the host threw
|
||
// RangeError from the detached reply callback, so no reply frame was written
|
||
// and the run degraded to a `maxWallMs` timeout (observed). The conversion
|
||
// must contain it — `String()` on a cycle throws inside the guard and lands
|
||
// on the placeholder, so the program sees an ordinary error.
|
||
const { runtime } = await setup({ maxWallMs: 8_000 })
|
||
const result = await runtime.run({
|
||
program: [
|
||
'try:',
|
||
' await tools.hostile({})',
|
||
'except RuntimeError as e:',
|
||
' return "rejected: " + str(e)',
|
||
'return "no rejection"',
|
||
].join('\n'),
|
||
bindings: tools({
|
||
hostile: async () => {
|
||
const cyclic: { self?: unknown; [Symbol.toPrimitive]: () => string } = {
|
||
// A cycle alone is inert for `String()`; the throwing conversion is
|
||
// what proves the guard runs rather than the encoder.
|
||
[Symbol.toPrimitive]: () => { throw new Error('cyclic message') },
|
||
}
|
||
cyclic.self = cyclic
|
||
const error = new Error('placeholder')
|
||
// Writable per spec, so no cast is needed to install a non-string.
|
||
;(error as unknown as { message: unknown }).message = cyclic
|
||
throw error
|
||
},
|
||
}),
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toBe('rejected: <unrenderable rejection value>')
|
||
}, 15_000)
|
||
|
||
it('renders an Error whose message is a value with no JSON form', async () => {
|
||
// The non-cyclic arm. A number would not discriminate: `scalarJson` renders
|
||
// it as digits and the child `str()`s the field back, so it survives the
|
||
// wire either way. `undefined` is the value that separates the two orders —
|
||
// `scalarJson` emits a bare `undefined` token, so the reply line is not JSON
|
||
// at all, the child's parse drops the frame, and the program stays blocked
|
||
// on `await` until the wall ceiling (observed). Converting first sends the
|
||
// string "undefined", which the program receives as an ordinary rejection.
|
||
const { runtime } = await setup({ maxWallMs: 8_000 })
|
||
const result = await runtime.run({
|
||
program: [
|
||
'try:',
|
||
' await tools.absent({})',
|
||
'except RuntimeError as e:',
|
||
' return "rejected: " + str(e)',
|
||
'return "no rejection"',
|
||
].join('\n'),
|
||
bindings: tools({
|
||
absent: async () => {
|
||
const error = new Error('placeholder')
|
||
;(error as unknown as { message: unknown }).message = undefined
|
||
throw error
|
||
},
|
||
}),
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toBe('rejected: undefined')
|
||
}, 15_000)
|
||
|
||
it('runs a program with no await', async () => {
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({
|
||
program: 'return 2 + 2',
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toBe(4)
|
||
})
|
||
|
||
it('returns JSON null whether the program returns None or falls off the end', async () => {
|
||
// Python has no `undefined`: an async body that returns None and one that
|
||
// never returns both yield None, so both complete as an exact JSON null.
|
||
// (The worker/TS backend can tell `return undefined` from `return null`;
|
||
// Python cannot, and reporting null for both is the honest rendering.)
|
||
const { runtime } = await setup()
|
||
const explicit = await runtime.run({ program: 'return None', bindings: [] })
|
||
expect(explicit.error).toBeUndefined()
|
||
expect(explicit.value).toBeNull()
|
||
const noReturn = await runtime.run({ program: 'x = 1', bindings: [] })
|
||
expect(noReturn.error).toBeUndefined()
|
||
expect(noReturn.value).toBeNull()
|
||
})
|
||
|
||
it('settles with no value on a forged valueless done frame', async () => {
|
||
// The child always sends a value now (return None → JSON null), so a done
|
||
// frame with no value key can only be forged; the host settles it as a
|
||
// value-less completion rather than crashing on the absent field.
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({
|
||
program: [
|
||
'import os',
|
||
'os.write(3, b\'{"type":"done"}\\n\')',
|
||
'import time',
|
||
'time.sleep(5)',
|
||
].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.value).toBeUndefined()
|
||
})
|
||
|
||
it('coalesces print arguments into one log line, not per-write fragments', async () => {
|
||
// print("a","b") calls write() per arg/sep/newline; the stream must emit
|
||
// one logical line "a b" so Code Mode's join(newline) does not insert
|
||
// spurious blank lines. Two prints → exactly two entries, no empties.
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({
|
||
program: ['print("a", "b")', 'print("c")', 'return None'].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.logs).toEqual(['a b', 'c'])
|
||
})
|
||
|
||
it('flushes a print with no trailing newline', async () => {
|
||
const { runtime } = await setup()
|
||
const result = await runtime.run({
|
||
program: ['print("partial", end="")', 'return None'].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
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('bounds a newline-free native flood by the ledger instead of buffering it whole', async () => {
|
||
// A newline-free write far larger than maxLogBytes must not accumulate in
|
||
// the host-side residual: when the pending residual would cross the budget
|
||
// it is admitted (and truncated) immediately, and once the ledger has
|
||
// truncated, later chunks stop buffering entirely. The run still completes
|
||
// and the captured output ends at the truncation marker rather than
|
||
// retaining the whole flood.
|
||
const { runtime } = await setup({ maxLogBytes: 4096 })
|
||
const result = await runtime.run({
|
||
program: ['import os', 'os.write(1, b"A" * 2_000_000)', 'return None'].join('\n'),
|
||
bindings: [],
|
||
})
|
||
expect(result.error).toBeUndefined()
|
||
expect(result.logs.at(-1)).toBe(logTruncationMarker(4096))
|
||
// The retained output is bounded by the budget, not the 2 MB flood.
|
||
expect(result.logs.join('').length).toBeLessThan(4096)
|
||
})
|
||
|
||
it('bounds a control-char-dense native residual by serialized cost, not raw length', async () => {
|
||
// A newline-free NUL flood passes the cheap `length + 3` lower bound at a
|
||
// raw length well under the budget, but each NUL serializes to ` |