#!/usr/bin/env bash # Boot computerd in docker (FUSE-disabled), drive a few exec scenarios # over the WebSocket from the host, assert outputs, tear down. # # Mirrors `script/shell` for the mount point — the linux computerd # binary is the only thing bind-mounted into the container. The # repo isn't mounted: everything else lives in the binary. # # Usage: # script/exec-tests # # Requires: # - artifacts/computerd/computerd-linux-x64 (run `npm run build:bin --workspace # @cloudflare/computerd` first) # - docker # - node 22+ on the host set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" binary="${repo_root}/artifacts/computerd/computerd-linux-x64" port="${PORT:-45678}" cid_file="$(mktemp -u)" trap 'cleanup; rm -f "${cid_file}"' EXIT cleanup() { if [[ -s "${cid_file}" ]]; then docker kill "$(cat "${cid_file}")" >/dev/null 2>&1 || true fi } if [[ ! -x "${binary}" ]]; then echo "missing or non-executable binary: ${binary}" >&2 echo "run \`npm run build:bin --workspace @cloudflare/computerd\` first" >&2 exit 2 fi echo "==> booting computerd in docker" docker run --rm -d \ --cidfile "${cid_file}" \ --platform linux/amd64 \ -e PORT="${port}" \ -e FUSE_MOUNT=none \ -e EXEC_LOG_MAX_BYTES=256 \ -p "${port}:${port}" \ -v "${binary}:/usr/local/bin/computerd:ro" \ debian:stable-slim \ /usr/local/bin/computerd >/dev/null # Wait for /health to come up. computerd binds quickly but pkg-bundled # binaries do a v8 snapshot warm-up on first launch. for _ in $(seq 1 30); do if curl -sf "http://localhost:${port}/health" >/dev/null 2>&1; then break fi sleep 0.5 done if ! curl -sf "http://localhost:${port}/health" >/dev/null 2>&1; then echo "computerd did not become healthy on port ${port}" >&2 docker logs "$(cat "${cid_file}")" 2>&1 | tail -20 >&2 exit 1 fi echo "==> computerd healthy on :${port}" # Drive the smoke from a node script. Inline so we don't add a # new file to mount; the script is self-contained. PORT="${port}" REPO_ROOT="${repo_root}" node --input-type=module -e ' const repoRoot = process.env.REPO_ROOT; const port = process.env.PORT; const { createWorkspaceClient } = await import( `${repoRoot}/node_modules/@cloudflare/computer-rpc/dist/client.js` ); const assert = (cond, msg) => { if (!cond) { console.error("FAIL:", msg); process.exit(1); } }; const client = createWorkspaceClient({ url: `ws://localhost:${port}/ws` }); try { // 1. echo + exit code. const h = await client.shell.exec({ command: "echo hello && exit 7" }); const dec = new TextDecoder(); const events = []; const reader = h.events.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; events.push(value); } const stdout = events.filter(e => e.name === "stdout") .map(e => dec.decode(e.value)).join(""); const exit = events.find(e => e.name === "exit"); assert(stdout === "hello\n", `unexpected stdout: ${JSON.stringify(stdout)}`); assert(exit && exit.value === 7, `unexpected exit: ${JSON.stringify(exit)}`); console.log(" PASS exec captures stdout + exit code"); // 1b. EEXEC_BUSY surfaces with the right code over the wire. // The runner refuses a second exec on a live id; the host // catches a plain Error with err.code intact (capnweb copies // own enumerable props on Error subclasses). const live = await client.shell.exec({ command: "sleep 30", id: "busy" }); let busyErr; try { await client.shell.exec({ command: "echo nope", id: "busy" }); } catch (err) { busyErr = err; } assert(busyErr && busyErr.code === "EEXEC_BUSY", `expected EEXEC_BUSY, got ${busyErr && busyErr.code}`); await client.shell.killExec({ id: "busy" }); // Drain so the kill resolves cleanly before the next test // touches the runner. const liveReader = live.events.getReader(); while (true) { const { done } = await liveReader.read(); if (done) break; } console.log(" PASS EEXEC_BUSY propagates with err.code"); // 2. kill a long-running process. const long = await client.shell.exec({ command: "sleep 30", id: "killme" }); await client.shell.killExec({ id: "killme", signal: "SIGTERM" }); const longReader = long.events.getReader(); let killExit; while (true) { const { value, done } = await longReader.read(); if (done) break; if (value.name === "exit") killExit = value.value; } // SIGTERM → 143 per Runner mapping. assert(killExit === 143, `kill exit code was ${killExit}`); console.log(" PASS killExec terminates a running command"); // 3. getExec replays a finished run by seq. const replay = await client.shell.getExec({ id: "killme", after: 0 }); let count = 0; const replayReader = replay.events.getReader(); while (true) { const { value, done } = await replayReader.read(); if (done) break; count++; } assert(count >= 1, `getExec returned no events`); console.log(" PASS getExec replays finished run"); // 4. disposeExec → subsequent getExec rejects. await client.shell.disposeExec({ id: "killme" }); let threw = false; try { const r = await client.shell.getExec({ id: "killme", after: 0 }); await r.events.getReader().read(); } catch (err) { threw = true; } assert(threw, `getExec after dispose did not throw`); console.log(" PASS disposeExec evicts the log"); // 5. size-cap eviction → getExec on a fresh exec that // overflowed the cap raises ELOG_TRUNCATED. EXEC_LOG_MAX_BYTES=256 // on the container makes the cap tiny so even an echo trips it. const big = await client.shell.exec({ command: "head -c 2048 /dev/urandom | base64", id: "big", }); // Drain the live stream first — eviction does not gate live. const bigReader = big.events.getReader(); while (true) { const { done } = await bigReader.read(); if (done) break; } let truncated = false; let evictStream; try { const r = await client.shell.getExec({ id: "big", after: 0 }); evictStream = r.events; const reader = r.events.getReader(); try { while (true) { const { done } = await reader.read(); if (done) break; } } finally { reader.releaseLock(); } } catch (err) { if (err && (err.code === "ELOG_TRUNCATED" || /ELOG_TRUNCATED/.test(err.message))) { truncated = true; } else { throw err; } } if (evictStream) { try { await evictStream.cancel(); } catch {} } assert(truncated, `expected ELOG_TRUNCATED on evicted log`); console.log(" PASS ELOG_TRUNCATED surfaces on evicted log"); // 6. backpressure smoke. A chatty command + a slow consumer: // the runner should pace the child via kernel pipe pressure // and the consumer should still see every byte. We do not try // to assert the pause edge directly (no observable signal // through capnweb); the test is that a 256 KiB stream lands // intact under a deliberately slow reader. // 6. backpressure smoke. A chatty command + a slow consumer: // the runner should pace the child via kernel pipe pressure // and the consumer should still see every byte. We do not try // to assert the pause edge directly (no observable signal // through capnweb); the test is that an 8 KiB stream lands // intact under a deliberately slow reader. Kept small because // capnweb 0.8 trips a WritableStream dispose error when the // flow-control window stalls for long — a real test of pipe // pause needs a capnweb upgrade or a different observable. const chatty = await client.shell.exec({ command: "head -c 8192 /dev/urandom | base64", id: "chatty", }); let totalBytes = 0; let sawExit = false; const cReader = chatty.events.getReader(); while (true) { const { value, done } = await cReader.read(); if (done) break; if (value.name === "stdout") totalBytes += value.value.byteLength; if (value.name === "exit") sawExit = true; await new Promise((r) => setTimeout(r, 10)); } assert(sawExit, `chatty stream never saw exit`); assert(totalBytes > 8000, `expected ~11 KiB of stdout, got ${totalBytes}`); console.log(` PASS slow reader: ${totalBytes} bytes delivered intact`); // 7. Workspace.runtime host wrapper (E3). Uses the composite // client through the high-level facade: encoding utf8 + // ExecHandle.result() in one call. // Import the specific files rather than the package barrel. // The barrel re-exports WorkspaceProxy, which extends // WorkerEntrypoint from cloudflare:workers — that scheme // only resolves under workerd, not plain Node. const { TestBackend } = await import( `${repoRoot}/packages/computer/dist/backends/test.js` ); const { Workspace } = await import( `${repoRoot}/packages/computer/dist/workspace.js` ); // Workspace needs DurableObjectStorageLike; supply the // node:sqlite-backed test storage so this runs under plain // Node. Workerd consumers pass ctx.storage instead. const { SQLiteTestStorage } = await import( `${repoRoot}/packages/dofs/dist/testing.js` ); const ws = new Workspace({ storage: new SQLiteTestStorage(), backends: [new TestBackend({ url: `http://localhost:${port}` })], }); try { await ws.ready(); const handle = await ws.runtime.exec("echo from-workspace && exit 0", { encoding: "utf8", }); const { exitCode, stdout } = await handle.result(); assert(stdout === "from-workspace\n", `Workspace.runtime stdout: ${JSON.stringify(stdout)}`); assert(exitCode === 0, `Workspace.runtime exitCode: ${exitCode}`); console.log(" PASS Workspace.runtime.exec(...).result() round-trip"); } finally { await ws.close(); } } finally { await client.close(); } ' echo "==> all checks passed"