Files
deepseek-harness/packages/experimental/webworker-runtime/tests/shell/shell-process.spec.ts
T
imccyu 4779ec9af9 feat(webworker): model-executed shell over nested worker processes
Buy the grammar, own the execution: @yarnpkg/parsers parses the command
line - aliased at bundle time to its shell entry so the root barrel's
syml/js-yaml closure stays out of the worker - and a VFS-backed evaluator
with a coreutils command table runs it inside the worker host. Each shell
process is a real child WebWorker spawned from the same bundle (the first
frame decides the role), so the TERM-then-KILL ladder is real - TERM
requests, KILL terminates the worker - and the file face stays
asynchronous end to end, since the deployment target serves no COOP/COEP
headers and SharedArrayBuffer never exists there. node:child_process
reports through the ChildProcess surface the subprocess service consumes;
execSync, execFileSync and fork refuse, and node-pty stays stubbed.
2026-08-21 20:35:32 +08:00

200 lines
7.0 KiB
TypeScript

/**
* The process model: a command runs in its own worker, reaches the VFS only by
* message, and dies when the host says so.
*
* The `Worker` here is a loopback that runs the REAL child half
* (`runShellProcess`) against the REAL host half, so the frames, the
* filesystem service, and the termination ladder are the shipped ones — only
* the thread boundary is simulated, because a Node test host has no DOM
* `Worker` to cross. That a browser worker really can start a nested worker
* and terminate it mid-burn is measured separately, in the preview probe.
*/
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
import { startProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/host.ts'
import { runShellProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/child.ts'
import { isShellStartFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
import type { FromProcessFrame, ToProcessFrame } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/protocol.ts'
const WORKSPACE = '/dsh/workspace'
const WORKER_URL = 'https://example.test/assets/worker.js'
let vfs: MemoryVfs
/** Every loopback worker the code under test constructed. */
let started: LoopbackWorker[]
/**
* A `Worker` that keeps the child half on this thread. Delivery is deferred so
* neither half can observe the other's synchronous progress, which is the one
* property of the real boundary that changes behaviour.
*/
class LoopbackWorker {
readonly url: string
terminated = false
private readonly hostListeners: ((event: MessageEvent) => void)[] = []
private childListener: ((event: MessageEvent) => void) | undefined
private closed = false
constructor(url: string | URL, options?: { type?: string }) {
this.url = String(url)
expect(options?.type).toBe('module')
started.push(this)
}
/** Host → child. The first frame starts the real child half. */
postMessage(frame: ToProcessFrame): void {
if (this.terminated) return
queueMicrotask(() => {
if (this.terminated) return
if (isShellStartFrame(frame)) {
runShellProcess(frame, {
postMessage: (reply: FromProcessFrame) => { this.toHost(reply) },
addEventListener: (_type: 'message', listener: (event: MessageEvent) => void) => { this.childListener = listener },
close: () => { this.closed = true },
})
return
}
this.childListener?.({ data: frame } as MessageEvent)
})
}
/** Child → host. */
private toHost(frame: FromProcessFrame): void {
if (this.terminated) return
queueMicrotask(() => {
if (this.terminated) return
for (const listener of this.hostListeners) listener({ data: frame } as MessageEvent)
})
}
addEventListener(type: 'message' | 'error', listener: (event: MessageEvent) => void): void {
if (type === 'message') this.hostListeners.push(listener)
}
terminate(): void {
this.terminated = true
}
/** Whether the child closed itself after reporting its status. */
get childClosed(): boolean {
return this.closed
}
}
beforeEach(() => {
vfs = new MemoryVfs()
setActiveVfs(vfs)
vfs.mkdirSync(WORKSPACE, { recursive: true })
started = []
// The selection in `startProcess` reads exactly these two globals.
vi.stubGlobal('Worker', LoopbackWorker)
vi.stubGlobal('self', { location: { href: WORKER_URL } })
})
afterEach(() => {
vi.unstubAllGlobals()
})
/** Run one command line through the process model and collect everything. */
async function run(script: string, stdin = ''): Promise<{ code: number; stdout: string; stderr: string }> {
let stdout = ''
let stderr = ''
const code = await new Promise<number>((settle) => {
startProcess({
script,
argv: ['bash', '-c', script],
cwd: WORKSPACE,
env: { HOME: '/dsh/home' },
stdin,
onOutput: (stream, text) => {
if (stream === 'stdout') stdout += text
else stderr += text
},
onExit: settle,
})
})
return { code, stdout, stderr }
}
it('starts the command as a worker from this bundle, not on this thread', async () => {
await run('echo hi')
expect(started).toHaveLength(1)
// The child is this very bundle in another role: no second asset to serve.
expect(started[0]?.url).toBe(WORKER_URL)
})
it('runs the command in the child and reports its output and status', async () => {
expect(await run('echo hi; echo oops >&2; exit 3')).toEqual({ code: 3, stdout: 'hi\n', stderr: 'oops\n' })
})
it('reaches the host filesystem by message', async () => {
const written = await run('mkdir -p nested && echo carried > nested/file.txt && cat nested/file.txt')
expect(written).toEqual({ code: 0, stdout: 'carried\n', stderr: '' })
// The child holds no VFS of its own: the bytes can only have arrived here
// through the filesystem frames.
expect(vfs.readFileSync(`${WORKSPACE}/nested/file.txt`, 'utf8')).toBe('carried\n')
})
it('carries a filesystem failure back with its code, not as a lost exception', async () => {
const missing = await run('cat nowhere.txt')
expect(missing.code).toBe(1)
expect(missing.stderr).toBe('cat: nowhere.txt: No such file or directory\n')
})
it('delivers standard input to the child', async () => {
expect((await run('grep -c ""', 'a\nb\nc\n')).stdout).toBe('3\n')
})
it('closes the child once the command settles', async () => {
await run('true')
expect(started[0]?.childClosed).toBe(true)
})
it('asks first and terminates second', async () => {
const events: number[] = []
const running = startProcess({
script: 'sleep 30',
argv: ['bash', '-c', 'sleep 30'],
cwd: WORKSPACE,
env: {},
stdin: '',
onOutput: () => {},
onExit: code => events.push(code),
})
// The first rung asks the command to stop; a `sleep` honours it.
running.interrupt()
await vi.waitFor(() => { expect(events).toHaveLength(1) })
expect(events[0]).toBe(130)
// The second rung does not ask: the worker is gone whatever it was doing.
const stubborn = startProcess({
script: 'sleep 30',
argv: ['bash', '-c', 'sleep 30'],
cwd: WORKSPACE,
env: {},
stdin: '',
onOutput: () => {},
onExit: code => events.push(code),
})
stubborn.destroy()
await vi.waitFor(() => { expect(events).toHaveLength(2) })
expect(started[1]?.terminated).toBe(true)
})
it('runs an explicit argv without a command line to parse', async () => {
vfs.writeFileSync(`${WORKSPACE}/spaced name.txt`, 'kept\n')
let stdout = ''
const code = await new Promise<number>((settle) => {
startProcess({
argv: ['cat', 'spaced name.txt'],
cwd: WORKSPACE,
env: {},
stdin: '',
onOutput: (_stream, text) => { stdout += text },
onExit: settle,
})
})
expect({ code, stdout }).toEqual({ code: 0, stdout: 'kept\n' })
})