mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-07 04:10:42 +00:00
The host stray-capture cost function charged illegal UTF-8 bytes (0x80-0xC1,
0xF5-0xFF, and orphaned multibyte leads) the raw 1, but toString('utf8')
renders each as U+FFFD (3 serialized bytes). A b"\xff" flood was undercounted
threefold, so the residual grew to a full budget's worth of raw bytes before
flushing and, near a large maxLogBytes, expanded toward a ~1 GiB peak in the
flush's concat plus toString. Replace serializedBufferCost with accrueStrayCost,
a cross-chunk UTF-8 walker that charges each byte its decoded serialized width;
carry its sequence state on each StrayBuffer.
The child _LogStream had the same-family bug: its early-flush trigger compared
_pending_chars (character count) against remaining (a serialized-byte budget),
so a 30M-NUL newline-free flood stayed under a 50 MB char trigger yet encoded to
~180 MB at settlement, breaching RLIMIT_AS as worker-exit. Track _pending_cost
via the _JSON_BYTE_COST table and trigger on it; keep _pending_chars for the
char-based slice bounds.
Correct the note's surrogate claim (only the string-walking jsonStringCostUpTo
charges a lone surrogate six bytes; the byte walker never sees one). Shrink the
post-truncation fixture below PIPE_BUF for a deterministic single callback. List
the shared stdout/stderr budget as a third honest fail-before exception
(cross-pipe arrival timing is nondeterministic). Add illegal-UTF-8,
broken-multibyte, and child-log-flood regression tests; sync the zh pair.
3968 lines
189 KiB
TypeScript
3968 lines
189 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 'cordis'
|
|
import { PythonCodeRuntime } 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.
|
|
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible })
|
|
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 — 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('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 ` |