From fd36de3aa34a9909e8460f8649e9972dd79f6a93 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Thu, 30 Jul 2026 18:43:40 -0700 Subject: [PATCH 1/5] feat(runtime): add buffered stdin contract Validate discriminated stdin modes at the worker boundary and let the WASI shim consume pre-supplied bytes with deterministic EOF. Preserve the existing SharedArrayBuffer path for interactive input.\n\nRefs #53 --- scripts/e2e-run-request.test.mjs | 67 ++++++++++++++++++++++++ scripts/e2e-wasi-shim.test.mjs | 87 ++++++++++++++++++++++++++++-- src/workers/compiler.worker.js | 38 +++++++++----- src/workers/run-request.mjs | 90 ++++++++++++++++++++++++++++++++ src/workers/wasi-shim.mjs | 50 +++++++++++++++++- 5 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 scripts/e2e-run-request.test.mjs create mode 100644 src/workers/run-request.mjs diff --git a/scripts/e2e-run-request.test.mjs b/scripts/e2e-run-request.test.mjs new file mode 100644 index 0000000..49d5ceb --- /dev/null +++ b/scripts/e2e-run-request.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateRunRequest } from '../src/workers/run-request.mjs'; + +function interactiveRequest(overrides = {}) { + return { + type: 'run', + stdinMode: 'interactive', + sharedBuffer: new SharedArrayBuffer(16), + vfsFiles: [{ path: 'input.txt', bytes: new Uint8Array([1, 2]) }], + binaryBytes: new Uint8Array([0, 97, 115, 109]), + ...overrides, + }; +} + +test('e2e: worker run contract accepts and normalizes all stdin modes', () => { + const interactive = validateRunRequest(interactiveRequest()); + assert.equal(interactive.ok, true); + assert.equal(interactive.value.stdin.mode, 'interactive'); + + const buffered = validateRunRequest(interactiveRequest({ + stdinMode: 'buffered', + sharedBuffer: undefined, + stdinBuffer: new TextEncoder().encode('Ada\n41\n').buffer, + })); + assert.equal(buffered.ok, true); + assert.equal(buffered.value.stdin.mode, 'buffered'); + assert.deepEqual( + [...buffered.value.stdin.bytes], + [...new TextEncoder().encode('Ada\n41\n')] + ); + + const none = validateRunRequest(interactiveRequest({ + stdinMode: 'none', + sharedBuffer: undefined, + })); + assert.equal(none.ok, true); + assert.deepEqual(none.value.stdin, { mode: 'none' }); +}); + +test('e2e: worker run contract rejects mismatched stdin variants', () => { + const cases = [ + interactiveRequest({ sharedBuffer: new ArrayBuffer(16) }), + interactiveRequest({ stdinMode: 'buffered', sharedBuffer: undefined, stdinBuffer: 'Ada' }), + interactiveRequest({ stdinMode: 'none', sharedBuffer: new SharedArrayBuffer(16) }), + interactiveRequest({ stdinMode: 'unknown' }), + ]; + + for (const request of cases) { + const result = validateRunRequest(request); + assert.equal(result.ok, false); + assert.match(result.error, /stdin/i); + } +}); + +test('e2e: worker run contract rejects invalid binary and VFS byte fields', () => { + const invalidBinary = validateRunRequest(interactiveRequest({ binaryBytes: 'wasm' })); + assert.equal(invalidBinary.ok, false); + assert.match(invalidBinary.error, /binaryBytes/); + + const invalidVfs = validateRunRequest(interactiveRequest({ + vfsFiles: [{ path: 'input.txt', bytes: [1, 2] }], + })); + assert.equal(invalidVfs.ok, false); + assert.match(invalidVfs.error, /vfsFiles/); +}); diff --git a/scripts/e2e-wasi-shim.test.mjs b/scripts/e2e-wasi-shim.test.mjs index 64b6049..d80f8f0 100644 --- a/scripts/e2e-wasi-shim.test.mjs +++ b/scripts/e2e-wasi-shim.test.mjs @@ -3,11 +3,10 @@ import assert from 'node:assert/strict'; import { createWasiRuntime } from '../src/workers/wasi-shim.mjs'; -function makeRuntime() { - const sharedBuffer = new SharedArrayBuffer(8 + 32); +function makeRuntime(stdin = { mode: 'interactive', sharedBuffer: new SharedArrayBuffer(8 + 32) }) { const writes = []; const runtime = createWasiRuntime({ - sharedBuffer, + stdin, onStdout: (text) => writes.push(['stdout', text]), onStderr: (text) => writes.push(['stderr', text]), }); @@ -16,12 +15,94 @@ function makeRuntime() { return { runtime, writes }; } +function configureRead(memory, spans, iovsPtr = 16, nreadPtr = 8) { + const dv = new DataView(memory.buffer); + spans.forEach(({ base, len }, index) => { + dv.setUint32(iovsPtr + (index * 8), base, true); + dv.setUint32(iovsPtr + (index * 8) + 4, len, true); + }); + return { iovsPtr, nreadPtr, iovsLen: spans.length }; +} + +function readStdin(runtime, spans) { + const memory = runtime.getMemoryForTesting(); + const request = configureRead(memory, spans); + const errno = runtime.wasi.fd_read(0, request.iovsPtr, request.iovsLen, request.nreadPtr); + const nread = new DataView(memory.buffer).getUint32(request.nreadPtr, true); + return { errno, nread, bytes: new Uint8Array(memory.buffer) }; +} + function writeString(memory, ptr, text) { const bytes = new TextEncoder().encode(text); new Uint8Array(memory.buffer).set(bytes, ptr); return bytes.length; } +test('e2e: wasi shim drains buffered stdin across iovecs and repeated reads', () => { + const input = new TextEncoder().encode('Ada\n41'); + const { runtime } = makeRuntime({ mode: 'buffered', bytes: input }); + + const first = readStdin(runtime, [ + { base: 128, len: 2 }, + { base: 160, len: 3 }, + ]); + assert.equal(first.errno, 0); + assert.equal(first.nread, 5); + assert.equal(new TextDecoder().decode(first.bytes.subarray(128, 130)), 'Ad'); + assert.equal(new TextDecoder().decode(first.bytes.subarray(160, 163)), 'a\n4'); + + const second = readStdin(runtime, [{ base: 192, len: 8 }]); + assert.equal(second.errno, 0); + assert.equal(second.nread, 1); + assert.equal(new TextDecoder().decode(second.bytes.subarray(192, 193)), '1'); + + const eof = readStdin(runtime, [{ base: 224, len: 8 }]); + assert.equal(eof.errno, 0); + assert.equal(eof.nread, 0); +}); + +test('e2e: wasi shim preserves UTF-8 bytes and input without a trailing newline', () => { + const input = new TextEncoder().encode('Grüße'); + const { runtime } = makeRuntime({ mode: 'buffered', bytes: input }); + + const result = readStdin(runtime, [{ base: 128, len: 32 }]); + + assert.equal(result.errno, 0); + assert.equal(result.nread, input.length); + assert.deepEqual( + [...result.bytes.subarray(128, 128 + result.nread)], + [...input] + ); +}); + +test('e2e: wasi shim returns immediate EOF for empty buffered and none stdin', () => { + for (const stdin of [ + { mode: 'buffered', bytes: new Uint8Array() }, + { mode: 'none' }, + ]) { + const { runtime } = makeRuntime(stdin); + const result = readStdin(runtime, [{ base: 128, len: 8 }]); + assert.equal(result.errno, 0); + assert.equal(result.nread, 0); + } +}); + +test('e2e: wasi shim drains long buffered input without truncation', () => { + const input = new Uint8Array(32 * 1024); + input.forEach((_, index) => { input[index] = index % 251; }); + const { runtime } = makeRuntime({ mode: 'buffered', bytes: input }); + const memory = { buffer: new ArrayBuffer(64 * 1024) }; + runtime.setMemory(memory); + + const result = readStdin(runtime, [{ base: 1024, len: input.length }]); + + assert.equal(result.nread, input.length); + assert.deepEqual( + result.bytes.subarray(1024, 1024 + input.length), + input + ); +}); + test('e2e: wasi shim exposes callable fd_fdstat_set_flags', () => { const { runtime } = makeRuntime(); assert.equal(typeof runtime.wasi.fd_fdstat_set_flags, 'function'); diff --git a/src/workers/compiler.worker.js b/src/workers/compiler.worker.js index d27f679..50f9b07 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -8,8 +8,11 @@ * Inbound messages (from main thread): * { type: 'compile', sourcePaths: string[], files: Array<{path,content}>, * std: string, flags: string[], primarySourcePath, outputName } - * { type: 'run', sharedBuffer: SharedArrayBuffer, vfsFiles, - * binaryBytes?: Uint8Array } + * { type: 'run', stdinMode: 'interactive', sharedBuffer: SharedArrayBuffer, + * vfsFiles, binaryBytes?: Uint8Array } + * { type: 'run', stdinMode: 'buffered', stdinBuffer: Uint8Array|ArrayBuffer, + * vfsFiles, binaryBytes?: Uint8Array } + * { type: 'run', stdinMode: 'none', vfsFiles, binaryBytes?: Uint8Array } * { type: 'status' } * * Outbound messages (to main thread): @@ -44,6 +47,7 @@ import { parseCompilePlan } from './compile-plan.mjs'; import { parseDiagnostics } from '../ui/diagnostics.mjs'; import { createWasiRuntime } from './wasi-shim.mjs'; +import { validateRunRequest } from './run-request.mjs'; // ── State ──────────────────────────────────────────────────────────────────── @@ -472,14 +476,15 @@ function groupDiagnostics(text) { /** * Instantiate and execute the compiled WASM binary with a minimal WASI shim. * - * @param {SharedArrayBuffer} sharedBuffer – SAB created by the terminal that - * provides interactive stdin via Atomics. The SAB must be pre-zeroed (state - * = 0) so that fd_read blocks immediately when no input is ready. - * @param {Array<{path:string, bytes:Uint8Array}>} vfsFiles – workspace files - * to expose to the program via fstream. Written/created files are collected - * and returned in the `run-result` message as `vfsChanges`. + * @param {{ + * stdin: ({mode:'interactive',sharedBuffer:SharedArrayBuffer}| + * {mode:'buffered',bytes:Uint8Array}| + * {mode:'none'}), + * vfsFiles:Array<{path:string,bytes:Uint8Array}>, + * binaryBytes:Uint8Array|null + * }} request validated run request */ -async function run(sharedBuffer, vfsFiles = [], binaryBytes = null) { +async function run({ stdin, vfsFiles = [], binaryBytes = null }) { if (binaryBytes) { compiledBinary = binaryBytes instanceof Uint8Array ? new Uint8Array(binaryBytes) @@ -493,7 +498,7 @@ async function run(sharedBuffer, vfsFiles = [], binaryBytes = null) { } const wasiRuntime = createWasiRuntime({ - sharedBuffer, + stdin, onStdout: (text) => send({ type: 'stdout', data: text }), onStderr: (text) => send({ type: 'stderr', data: text }), }); @@ -564,10 +569,17 @@ self.onmessage = async ({ data }) => { } break; - case 'run': - send({ type: 'run-start' }); - await run(data.sharedBuffer, data.vfsFiles || [], data.binaryBytes || null); + case 'run': { + const validation = validateRunRequest(data); + if (!validation.ok) { + send({ type: 'stderr', data: `${validation.error}\n` }); + send({ type: 'run-result', exitCode: 1, vfsChanges: [], vfsDeletes: [] }); + break; + } + send({ type: 'run-start', stdinMode: validation.value.stdin.mode }); + await run(validation.value); break; + } case 'status': send({ type: 'status-reply', state: compilerState }); diff --git a/src/workers/run-request.mjs b/src/workers/run-request.mjs new file mode 100644 index 0000000..b15fea6 --- /dev/null +++ b/src/workers/run-request.mjs @@ -0,0 +1,90 @@ +'use strict'; + +function isByteSource(value) { + return value instanceof Uint8Array || value instanceof ArrayBuffer; +} + +function asUint8Array(value) { + if (value instanceof Uint8Array) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + return new Uint8Array(value); +} + +function invalid(error) { + return { ok: false, error }; +} + +/** + * Validate and normalize the main-thread → compiler-worker run contract. + * + * This is the trust boundary for UI-provided binary, VFS, and stdin data. The + * returned value contains only byte views and a discriminated stdin source. + */ +export function validateRunRequest(request) { + if (!request || request.type !== 'run') { + return invalid('Invalid run request.'); + } + + let stdin; + switch (request.stdinMode) { + case 'interactive': + if ( + typeof SharedArrayBuffer === 'undefined' || + !(request.sharedBuffer instanceof SharedArrayBuffer) || + request.stdinBuffer !== undefined + ) { + return invalid('Invalid interactive stdin: sharedBuffer must be a SharedArrayBuffer.'); + } + stdin = { mode: 'interactive', sharedBuffer: request.sharedBuffer }; + break; + + case 'buffered': + if (!isByteSource(request.stdinBuffer) || request.sharedBuffer !== undefined) { + return invalid('Invalid buffered stdin: stdinBuffer must be a Uint8Array or ArrayBuffer.'); + } + stdin = { mode: 'buffered', bytes: asUint8Array(request.stdinBuffer) }; + break; + + case 'none': + if (request.sharedBuffer !== undefined || request.stdinBuffer !== undefined) { + return invalid('Invalid none stdin: no stdin buffer may be supplied.'); + } + stdin = { mode: 'none' }; + break; + + default: + return invalid('Invalid stdinMode. Expected interactive, buffered, or none.'); + } + + let binaryBytes = null; + if (request.binaryBytes !== undefined && request.binaryBytes !== null) { + if (!isByteSource(request.binaryBytes)) { + return invalid('Invalid binaryBytes: expected a Uint8Array or ArrayBuffer.'); + } + binaryBytes = asUint8Array(request.binaryBytes); + } + + const rawVfsFiles = request.vfsFiles ?? []; + if (!Array.isArray(rawVfsFiles)) { + return invalid('Invalid vfsFiles: expected an array.'); + } + + const vfsFiles = []; + for (const file of rawVfsFiles) { + if ( + !file || + typeof file.path !== 'string' || + file.path.length === 0 || + !isByteSource(file.bytes) + ) { + return invalid('Invalid vfsFiles entry: expected a non-empty path and Uint8Array or ArrayBuffer bytes.'); + } + vfsFiles.push({ path: file.path, bytes: asUint8Array(file.bytes) }); + } + + return { + ok: true, + value: { stdin, vfsFiles, binaryBytes }, + }; +} diff --git a/src/workers/wasi-shim.mjs b/src/workers/wasi-shim.mjs index ba614c7..8bd3599 100644 --- a/src/workers/wasi-shim.mjs +++ b/src/workers/wasi-shim.mjs @@ -37,15 +37,41 @@ function createWritableFile(path, data, flags = 0) { }; } -export function createWasiRuntime({ sharedBuffer, onStdout, onStderr }) { +export function createWasiRuntime({ + stdin = { mode: 'none' }, + onStdout = () => {}, + onStderr = () => {}, +}) { let memory = null; let runVfs = new Map(); let runVfsDirty = new Set(); let runVfsDeletes = new Set(); let runFds = new Map(); let runNextFd = 4; - const sabControl = new Int32Array(sharedBuffer); + const stdinMode = stdin?.mode || 'none'; + let sharedBuffer = null; + let sabControl = null; const stdinQueue = []; + let stdinBytes = new Uint8Array(); + let stdinCursor = 0; + + if (stdinMode === 'interactive') { + if ( + typeof SharedArrayBuffer === 'undefined' || + !(stdin.sharedBuffer instanceof SharedArrayBuffer) + ) { + throw new TypeError('Interactive stdin requires a SharedArrayBuffer.'); + } + sharedBuffer = stdin.sharedBuffer; + sabControl = new Int32Array(sharedBuffer); + } else if (stdinMode === 'buffered') { + if (!(stdin.bytes instanceof Uint8Array) && !(stdin.bytes instanceof ArrayBuffer)) { + throw new TypeError('Buffered stdin requires Uint8Array or ArrayBuffer bytes.'); + } + stdinBytes = new Uint8Array(stdin.bytes); + } else if (stdinMode !== 'none') { + throw new TypeError(`Unsupported stdin mode: ${String(stdinMode)}`); + } const setMemory = (m) => { memory = m; @@ -137,6 +163,7 @@ export function createWasiRuntime({ sharedBuffer, onStdout, onStderr }) { fd_write(fd, iovsPtr, iovsLen, nwrittenPtr) { const spans = iovSpans(iovsPtr, iovsLen); let total = 0; + for (const { base, len } of spans) { if (fd === 1 || fd === 2) { const text = new TextDecoder().decode(u8().subarray(base, base + len)); @@ -184,6 +211,25 @@ export function createWasiRuntime({ sharedBuffer, onStdout, onStderr }) { const spans = iovSpans(iovsPtr, iovsLen); let total = 0; + + if (stdinMode === 'none') { + view().setUint32(nreadPtr, 0, true); + return WASI_ERRNO_SUCCESS; + } + + if (stdinMode === 'buffered') { + for (const { base, len } of spans) { + const available = stdinBytes.length - stdinCursor; + if (available <= 0) break; + const toRead = Math.min(len, available); + u8().set(stdinBytes.subarray(stdinCursor, stdinCursor + toRead), base); + stdinCursor += toRead; + total += toRead; + } + view().setUint32(nreadPtr, total, true); + return WASI_ERRNO_SUCCESS; + } + for (const { base, len } of spans) { if (stdinQueue.length === 0) { if (Atomics.load(sabControl, 0) === 0) { From bf9673bfecc35f46c50ebba63974e110fa5a7c0e Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Thu, 30 Jul 2026 23:35:44 -0700 Subject: [PATCH 2/5] feat(ui): collect buffered stdin before Firefox runs Make run preparation asynchronous and keep toolbar and terminal state consistent across submit, cancellation, duplicate actions, and failures. Add an accessible size-limited dialog while preserving Chromium live stdin.\n\nRefs #53 --- scripts/e2e-buffered-stdin-ui.test.mjs | 24 +++ scripts/e2e-terminal-stop.test.mjs | 103 ++++++++++-- src/ui/app.js | 7 +- src/ui/index.html | 23 +++ src/ui/styles.css | 73 +++++++++ src/ui/terminal.js | 213 ++++++++++++++++++++++--- src/ui/toolbar.js | 23 ++- 7 files changed, 424 insertions(+), 42 deletions(-) create mode 100644 scripts/e2e-buffered-stdin-ui.test.mjs diff --git a/scripts/e2e-buffered-stdin-ui.test.mjs b/scripts/e2e-buffered-stdin-ui.test.mjs new file mode 100644 index 0000000..3186e18 --- /dev/null +++ b/scripts/e2e-buffered-stdin-ui.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +test('e2e: buffered stdin dialog has accessible controls and status feedback', async () => { + const html = await readFile('src/ui/index.html', 'utf8'); + const dialog = html.match(//)?.[0]; + + assert.ok(dialog, 'buffered stdin dialog markup should exist'); + assert.match(dialog, /aria-labelledby="buffered-stdin-title"/); + assert.match(dialog, /