File size: 7,003 Bytes
488d96b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | /**
* Starting and supervising shell processes from the host worker.
*
* A process is a Web Worker started from this same bundle, told by its first
* frame to be a shell process rather than a host. That is what buys real
* process semantics in a browser: the command runs off the host's thread, and
* `terminate()` stops it even mid-loop — the one thing a cooperative in-thread
* interpreter can never do.
*
* Where no `Worker` constructor exists (a Node test host), the same command
* runs inline on this thread. Everything except preemption behaves the same,
* and the difference is named rather than hidden: {@link RunningProcess.destroy}
* can only ask an inline command to stop.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/host
*/
import { runShellCommand, runShellProgram } from '../interpret.ts'
import { hostFileSystem } from '../fs-access.ts'
import type { ShellFileSystem } from '../types.ts'
import type { FilesystemOperation, FromProcessFrame, ShellStartFrame } from './protocol.ts'
import { runShellProcess } from './child.ts'
import type { ProcessScope } from './child.ts'
/** What the caller must supply to start one process. */
export interface ProcessStartOptions {
/** Command source for `bash -c`, or undefined when `argv` names a program. */
script?: string | undefined
/** The program and its arguments. */
argv: readonly string[]
/** Working directory the command starts in. */
cwd: string
/** Environment the command starts with. */
env: Record<string, string>
/** Everything on standard input. */
stdin: string
/** Receives output as it is produced. */
onOutput: (stream: 'stdout' | 'stderr', text: string) => void
/** Receives the settled status exactly once. */
onExit: (code: number) => void
/** The filesystem the command acts on; defaults to the mounted VFS. */
fs?: ShellFileSystem | undefined
}
/** A started command, from the host's side. */
export interface RunningProcess {
/** Ask the command to stop at its next command boundary (the `SIGTERM` rung). */
interrupt(): void
/**
* Stop the command now (the `SIGKILL` rung). A worker-backed process dies
* whatever it was doing; an inline one can only be asked, because nothing
* can preempt a synchronous loop on its own thread.
*/
destroy(): void
}
/** Whether this thread can start a real process worker. */
function canSpawnWorker(): boolean {
return typeof Worker === 'function' && typeof self !== 'undefined' && typeof self.location.href === 'string'
}
/**
* Start one command.
* @param options - the command, its environment, and the sinks for its output and status.
* @returns the handle the process table signals through.
*/
export function startProcess(options: ProcessStartOptions): RunningProcess {
return canSpawnWorker() ? startWorkerProcess(options) : startInlineProcess(options)
}
/** Serve one filesystem call for a process worker. */
async function serveFilesystemCall(fs: ShellFileSystem, op: FilesystemOperation, args: readonly unknown[]): Promise<unknown> {
switch (op) {
case 'stat': return await fs.stat(args[0] as string)
case 'list': return await fs.list(args[0] as string)
case 'readText': return await fs.readText(args[0] as string)
case 'writeText':
await fs.writeText(args[0] as string, args[1] as string, args[2] as boolean)
return undefined
case 'mkdir':
await fs.mkdir(args[0] as string, args[1] as boolean)
return undefined
case 'remove':
await fs.remove(args[0] as string, args[1] as { recursive: boolean; force: boolean })
return undefined
case 'rename':
await fs.rename(args[0] as string, args[1] as string)
return undefined
default:
// The op crossed a worker postMessage: a name the union does not carry
// must fail the call rather than answer `{ value: undefined }`.
throw new Error(`webworker shell: unknown filesystem op ${String(op)}`)
}
}
/** The worker-backed process: a second copy of this bundle, running one command. */
function startWorkerProcess(options: ProcessStartOptions): RunningProcess {
const fs = options.fs ?? hostFileSystem()
// Same bundle, different role: the first frame decides. Starting from this
// worker's own URL keeps the deployment free of a second static asset and of
// the build-order trap a sibling artifact would bring.
const worker = new Worker(self.location.href, { type: 'module' })
let settled = false
const settle = (code: number): void => {
if (settled) return
settled = true
worker.terminate()
options.onExit(code)
}
worker.addEventListener('message', (event: MessageEvent) => {
const frame = event.data as FromProcessFrame
if (frame.t === 'shell-out') {
options.onOutput(frame.stream, frame.text)
return
}
if (frame.t === 'shell-exit') {
settle(frame.code)
return
}
void serveFilesystemCall(fs, frame.op, frame.args).then(
(value) => { worker.postMessage({ t: 'fs-reply', id: frame.id, value }) },
(error: unknown) => {
const failure = {
code: (error as { code?: string }).code,
message: error instanceof Error ? error.message : String(error),
}
worker.postMessage({ t: 'fs-reply', id: frame.id, failure })
},
)
})
worker.addEventListener('error', (event: ErrorEvent) => {
options.onOutput('stderr', `bash: process worker failed: ${event.message}\n`)
settle(1)
})
const start: ShellStartFrame = {
t: 'shell-start',
script: options.script,
argv: options.argv,
cwd: options.cwd,
env: options.env,
stdin: options.stdin,
}
worker.postMessage(start)
return {
interrupt: () => { if (!settled) worker.postMessage({ t: 'shell-signal' }) },
// The reason this whole module exists: a worker dies on command, even
// mid-loop, so a timeout is enforceable rather than advisory.
destroy: () => { settle(130) },
}
}
/** The inline process: the same command on this thread, stoppable only by asking. */
function startInlineProcess(options: ProcessStartOptions): RunningProcess {
const stopping = new AbortController()
const runOptions = {
cwd: options.cwd,
env: options.env,
stdin: options.stdin,
signal: stopping.signal,
fs: options.fs ?? hostFileSystem(),
onOutput: options.onOutput,
}
const run = options.script === undefined
? runShellProgram(options.argv, runOptions)
: runShellCommand(options.script, runOptions)
void run.then(
(outcome) => { options.onExit(outcome.exitCode) },
(error: unknown) => {
options.onOutput('stderr', `bash: ${String(error)}\n`)
options.onExit(1)
},
)
const stop = (): void => { stopping.abort(new Error('killed by signal')) }
return { interrupt: stop, destroy: stop }
}
/** Re-exported for the worker entry, which decides its role from the first frame. */
export { runShellProcess }
export type { ProcessScope }
|