Run commands
Your app starts Linux programs in the VM with two methods. vm.exec runs a command to the end and gives you all of its output and its exit status in one result. vm.spawn starts a process and hands you live streams, for installs, builds, dev servers and anything that runs for a while or prints a lot.
Both run real Linux programs in the VM, both start in /workspace unless you say otherwise, and both work the same way in Userspace and System.
vm.exec |
vm.spawn |
|
|---|---|---|
| You get | All output and the exit status, at the end | Streams, while the process runs |
| Output | Up to 1 MiB by default | As much as you read |
| Input | Fixed bytes, then closed | Write whenever you like |
| Good for | node --version, a short script |
Installs, builds, servers, watchers |
For interactive programs such as shells, use a terminal instead. See Terminals.
Name the program and its arguments
Section titled “Name the program and its arguments”A command is a program plus a list of arguments. The SDK passes each argument to the program unchanged. There is no shell in between, so nothing is split on spaces, quoted, expanded or globbed.
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });try { const result = await vm.exec({ command: "uname", args: ["-m"] }); console.log(new TextDecoder().decode(result.stdout).trim()); // x86_64} finally { await vm.dispose();}exec and spawn take the same command fields:
| Field | Meaning |
|---|---|
command |
The program. A name such as "node" is found on PATH, and a relative path such as "./build.sh" is found from cwd. Both forms go through the image’s /bin/sh, so the image must have one. An absolute path such as "/usr/bin/node" runs that file. |
args |
The arguments, as an array of strings (at most 4096). |
cwd |
The absolute path in the VM to start in. Defaults to /workspace. A relative cwd is rejected with INVALID_ARGUMENT. |
env |
Environment variables. They are added to the defaults, PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin and HOME=/workspace, and a value you pass for PATH or HOME replaces the default, even an empty string. Names use letters, digits and _, and don’t start with a digit. |
No shell strings
Section titled “No shell strings”{ command: "ls -la" } asks for a program whose name is literally ls -la, which doesn’t exist. Put the program in command and each argument in its own string: { command: "ls", args: ["-la"] }.
When you do want shell syntax, such as pipes, redirects, &&, * globs or $VARIABLES, start a shell yourself and pass the script as one argument:
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });try { const result = await vm.exec({ command: "/bin/sh", args: ["-c", "ls /etc | wc -l"] }); console.log(new TextDecoder().decode(result.stdout).trim());} finally { await vm.dispose();}Run a short command with exec
Section titled “Run a short command with exec”exec writes stdin if you pass it and then closes standard input, reads stdout and stderr at the same time, waits for the process to exit and resolves with { stdout, stderr, exit }. Both outputs are Uint8Arrays.
| Option | Meaning |
|---|---|
stdin |
Bytes to write to standard input before it closes. |
maxOutputBytes |
The limit for stdout and stderr together. Default 1 MiB, at most 64 MiB. |
signal |
An AbortSignal that cancels the command. |
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });try { const sorted = await vm.exec({ command: "sort", stdin: new TextEncoder().encode("banana\napple\ncherry\n"), }); console.log(new TextDecoder().decode(sorted.stdout)); // apple, banana and cherry, one per line
const greeting = await vm.exec({ command: "/bin/sh", args: ["-c", 'echo "Hello, $NAME, from $PWD"'], cwd: "/tmp", env: { NAME: "Ada" }, signal: AbortSignal.timeout(30_000), }); console.log(new TextDecoder().decode(greeting.stdout)); // Hello, Ada, from /tmp} finally { await vm.dispose();}When signal fires, the SDK stops the command’s whole process group, waits for it to end and rejects with a VMError whose code is ABORTED. When the output grows past maxOutputBytes, it does the same and rejects with QUOTA_EXCEEDED; detail.maxOutputBytes holds the limit. For more output than that, stream it with spawn.
Handle the exit status
Section titled “Handle the exit status”result.exit is a ProcessExit:
type ProcessExit = | { kind: "exited"; code: number } | { kind: "signaled"; signal: LinuxSignal | null; signalNumber: number };A program that fails is still a result. exec resolves normally, and exit.code is not 0. It rejects only when it can’t give you a complete result, for example after your signal fires or when output passes the limit. A signaled exit means the process was stopped by a signal; signal is its name, or null for an unnamed real-time signal, and signalNumber is always set.
import type { ExecResult } from "@oxidevm/sdk";
export function outputOrThrow(result: ExecResult): string { const { exit } = result; if (exit.kind === "signaled") { throw new Error(`Stopped by ${exit.signal ?? `signal ${exit.signalNumber}`}`); } if (exit.code !== 0) { throw new Error(`Exited with ${exit.code}: ${new TextDecoder().decode(result.stderr)}`); } return new TextDecoder().decode(result.stdout);}Stream a long-running process with spawn
Section titled “Stream a long-running process with spawn”spawn resolves as soon as the process has started. The ProcessHandle it returns has:
| Member | Meaning |
|---|---|
stdout, stderr |
ReadableStream<Uint8Array>s of the process’s output |
stdin |
A WritableStream<Uint8Array> for its input |
exit |
A Promise<ProcessExit> that settles when the process ends |
kill(signal?, options?) |
Sends a Linux signal; see Stop a process |
pid |
The process ID |
Three rules keep a spawned process healthy:
- Read both output streams until they end. Output is flow-controlled: when unread output fills a stream’s buffer, the process can’t send more on either stream. If you don’t need one of them, cancel it, for example
await child.stderr.cancel(), and the SDK discards that output. - Close
stdinwhen you have no more input. A program that reads its input, such ascat, waits until it closes. - Use
killonce the process runs. Thesignalin aspawnspec cancels only the start.
import { createVM, type ProcessExit, type VM } from "@oxidevm/sdk";
/** A sink that decodes UTF-8 correctly across chunk boundaries. */function textSink(render: (text: string) => void): WritableStream<Uint8Array> { const decoder = new TextDecoder(); return new WritableStream<Uint8Array>({ write(chunk) { render(decoder.decode(chunk, { stream: true })); }, close() { const tail = decoder.decode(); if (tail !== "") render(tail); }, });}
export async function runStreaming( vm: VM, command: string, args: readonly string[], render: (text: string) => void,): Promise<ProcessExit> { const child = await vm.spawn({ command, args }); // This program gets no input. Closing can fail once it has already exited, which is harmless. const noInput = child.stdin.close().catch(() => undefined); try { const [exit] = await Promise.all([ child.exit, child.stdout.pipeTo(textSink(render)), child.stderr.pipeTo(textSink(render)), noInput, ]); return exit; } catch (error: unknown) { await child.kill("SIGKILL", { scope: "process-group" }); // don't leave it running throw error; }}
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });try { await vm.workspace.writeText( "count.js", 'for (let i = 1; i <= 3; i++) console.log(`step ${i}`);\nconsole.error("done");\n', ); const exit = await runStreaming(vm, "node", ["count.js"], (text) => console.log(text)); console.log(exit); // { kind: "exited", code: 0 }} finally { await vm.dispose();}The same helper runs a project’s real commands, such as runStreaming(vm, "npm", ["run", "build"], log). A server started with spawn keeps running until you stop it; to open it in the browser, see Previews.
Stop a process
Section titled “Stop a process”kill(signal, { scope }) sends a Linux signal. The default is "SIGTERM" to the process alone.
| Signal | Use it to |
|---|---|
"SIGINT" |
Interrupt, as Ctrl+C does in a terminal |
"SIGTERM" |
Ask the program to stop (the default) |
"SIGHUP" |
Hang up, as when a terminal closes |
"SIGKILL" |
Stop it at once; the program can’t catch it |
Other Linux signal names, such as "SIGUSR1", work too. With { scope: "process-group" } the signal goes to the process’s whole process group, which includes the children it started there. Use it for programs that start other programs, such as npm run dev starting a Node.js server, so nothing keeps running.
kill sends the signal; exit tells you when the process has ended. A signal sent after the process exited does nothing, and it never reaches another process that later gets the same PID.
import type { ProcessExit, ProcessHandle } from "@oxidevm/sdk";
/** Asks the process group to stop, and forces it after five seconds. */export async function stop(child: ProcessHandle): Promise<ProcessExit> { await child.kill("SIGTERM", { scope: "process-group" }); const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)); const first = await Promise.race([child.exit, timeout]); if (first !== "timeout") return first; await child.kill("SIGKILL", { scope: "process-group" }); return child.exit;}Disposing the VM with await vm.dispose() ends every process it runs.
Common mistakes
Section titled “Common mistakes”| Symptom | Fix |
|---|---|
A command with spaces in command fails |
Split it into command and args, or run /bin/sh -c |
| A process stalls while printing | Read both stdout and stderr, or cancel the one you don’t need |
| A program waits for input forever | Close stdin |
exec rejects with QUOTA_EXCEEDED |
Raise maxOutputBytes, or stream the output with spawn |
Child processes keep running after kill |
Pass { scope: "process-group" } |
cwd: "src" is rejected |
Use an absolute path, such as "/workspace/src" |
Errors are VMErrors with a stable code; see Errors. Every method and option is listed in the VM reference.