diff --git a/README.md b/README.md index f04cb19..f4f4b11 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An in-browser **C++20 IDE** delivered as a Chrome / Chromium extension. |---------|--------| | Editor | Monaco Editor (the engine behind VS Code) | | Compiler | WASM-native Clang (runs entirely in the browser, offline) | -| Terminal | xterm.js with a bash-like shell (`g++`, `./a.out`, `ls`, `mkdir`, `touch`, `cat`, …) | +| Terminal | xterm.js with a bash-like shell and live line input on Chromium and Firefox 153+ | | File access | File System Access API on Chromium, fallback open/save/folder flows on Firefox | | File I/O | `fstream` / `ifstream` / `ofstream` – read and write workspace files at runtime | | Standards | C++14 · C++17 · **C++20** (selectable in the toolbar) | @@ -219,8 +219,9 @@ Firefox desktop is also a supported release target, but its support contract is different: - compile/run, Monaco, and extension-runtime flows are supported -- programs that read `std::cin` use pre-supplied buffered stdin (up to 256 KiB); - live prompt-by-prompt stdin remains available on Chromium-family builds +- Firefox 153+ uses WebAssembly JSPI for live, line-buffered `std::cin`, + `std::getline`, and `scanf` input; Firefox 140–152 (or a runtime without + JSPI) retains the pre-supplied buffered stdin fallback (up to 256 KiB) - file open/save and folder import use fallback browser flows rather than Chromium File System Access APIs - persistent folder write-back and directory-handle session restore may be @@ -235,6 +236,7 @@ Full parity requires: `showSaveFilePicker`) - Web Workers and WebAssembly - `SharedArrayBuffer` and `Atomics.waitAsync` for Chromium live interactive stdin +- `WebAssembly.Suspending` and `WebAssembly.promising` for Firefox 153+ live stdin - Managed browser policies that allow local file read/write prompts ### Release-blocking checks @@ -462,7 +464,8 @@ manual/GitHub-distributed channel. signed artifact under `release/firefox-unlisted/`. 5. Install the signed XPI in Firefox and complete the manual QA checklist in `docs/firefox-stdin-runtime-acceptance.md`, paying special attention to - buffered stdin and the documented workspace-persistence limitations. + JSPI live stdin, its buffered fallback, and the documented + workspace-persistence limitations. ### Manual release QA checklist @@ -526,9 +529,10 @@ Copy the resulting `clang.js` and `clang.wasm` into `dist/clang/`. script" dialog. The compiler runs in a dedicated Web Worker to avoid blocking the UI. - **Browser scope**: Full parity targets desktop Chrome, Edge, Brave, and - Chromium. Firefox is a compatible release target with buffered stdin and - workspace-persistence limitations; Safari is outside the current release - target. + Chromium. Firefox 153+ supports live canonical (line-buffered) stdin through + JSPI; older supported Firefox versions use buffered stdin. This is not a raw + POSIX PTY, and Firefox workspace-persistence limitations remain. Safari is + outside the current release target. - **Managed browsers**: Enterprise policies that block File System Access prompts prevent full local workspace read/write support. diff --git a/docs/firefox-stdin-runtime-acceptance.md b/docs/firefox-stdin-runtime-acceptance.md index 561787b..e6ba980 100644 --- a/docs/firefox-stdin-runtime-acceptance.md +++ b/docs/firefox-stdin-runtime-acceptance.md @@ -1,8 +1,13 @@ -# Firefox buffered stdin runtime acceptance +# Firefox stdin runtime acceptance -Use this procedure to prove GitHub issue #53 is fixed in a real Firefox -extension context. `npm run test:browser:firefox` validates packaging and -manifest compatibility, but it does not execute a compiled program in Firefox. +Use this procedure to validate live JSPI terminal input from GitHub issue #55 +in a real Firefox extension context. `npm run test:browser:firefox` validates +packaging and manifest compatibility, but does not execute a compiled program. + +Firefox 153+ should use live, canonical (line-buffered) input. Firefox 140–152, +or a runtime where JSPI is unavailable, should keep the pre-supplied buffered +fallback. Persistent folder write-back is outside this test and retains its +documented Firefox limitations. ## Setup @@ -17,13 +22,14 @@ manifest compatibility, but it does not execute a compiled program in Firefox. 3. Choose **Load Temporary Add-on** and select `dist-firefox/manifest.json`. For release validation, repeat with the signed XPI under `release/firefox-unlisted/`. -4. Open browser.cpp from the extension toolbar action. +4. Open browser.cpp from the extension toolbar action and open the Browser + Console for error inspection. -Record the Firefox version and the unpacked directory or signed XPI path used. +Record the exact Firefox version and unpacked directory or signed XPI path. -## Test program +## Live-input test for Firefox 153+ -Replace the editor contents with exactly: +Replace the editor contents with: ```cpp #include @@ -33,9 +39,9 @@ int main() { std::string name; int age = 0; - std::cout << "Name? "; - std::cin >> name; - std::cout << "Age? "; + std::cout << "Name? " << std::flush; + std::getline(std::cin, name); + std::cout << "Age? " << std::flush; std::cin >> age; if (!std::cin) { @@ -43,46 +49,64 @@ int main() { return 2; } - std::cout << "\nHello " << name << ", next year " << (age + 1) << "\n"; - return 0; + std::cout << "Hello " << name << ", next year " << (age + 1) << "\n"; } ``` -Choose **Compile and Run**. In the **Pre-supplied stdin** dialog, enter exactly: +1. Choose **Compile and Run**. +2. Confirm `Name? ` appears before entering anything and that the + **Pre-supplied stdin** dialog does not open. +3. Type `Ada`, press Enter, and confirm `Age? ` appears afterward. +4. Type `41` and press Enter. +5. Confirm the final line is `Hello Ada, next year 42` and the process exits + with code `0` without stderr. -```text -Ada -41 -``` +This validates two independent suspend/resume cycles and prompt ordering. -Keep the final newline after `41`, then choose **Run program**. +## EOF, interruption, and clean-session tests -## Expected result +1. Run the program again. At the empty `Name? ` prompt, press Ctrl+D. Confirm + stdin reaches EOF and the program exits with `input failed` and code `2`. +2. Run again. At `Name? `, press Ctrl+C. Confirm browser.cpp reports + `Process interrupted.` and returns to its shell prompt. +3. Run once more and complete both inputs successfully. Confirm no input from + either prior run appears in the new process. -The process exits with code `0`, stderr is empty, and stdout is exactly: +## Buffered fallback test -```text -Name? Age?␠ -Hello Ada, next year 42 -``` +Repeat in Firefox 140–152, or in a controlled environment where the worker does +not expose both `WebAssembly.Suspending` and `WebAssembly.promising`. + +1. Choose **Compile and Run**. +2. Confirm the **Pre-supplied stdin** dialog opens. +3. Enter `Ada`, a newline, `41`, and a final newline; then choose **Run + program**. +4. Confirm the same successful final output. + +The fallback must not attempt message-interactive stdin merely from the Firefox +version string or main-window capabilities. -The `␠` marker represents the single trailing ASCII space emitted after -`Age?`. +## Error exclusions -The terminal and browser console must not contain any of these strings: +The terminal and Browser Console must not contain unexpected instances of: ```text Interactive stdin requires SharedArrayBuffer Cross-Origin-Opener-Policy Cross-Origin-Embedder-Policy +JSPI is unavailable +Ignored stdin message for an inactive session +Unhandled promise rejection ``` ## PR evidence -Paste the following into the pull request: +Record the following in the pull request or follow-up release evidence: -- Firefox version -- tested artifact path -- observed stdout -- confirmation that stderr was empty -- confirmation that none of the old SharedArrayBuffer/COOP/COEP errors appeared +- Firefox version and tested artifact path +- exact observed prompt/output ordering +- confirmation that each input was entered only after its prompt appeared +- Ctrl+D, Ctrl+C, and clean-rerun results +- confirmation that the buffered dialog was absent on Firefox 153+ JSPI and + present in the no-JSPI fallback test +- confirmation that stderr and the Browser Console had no unexpected errors diff --git a/docs/release-playbook.md b/docs/release-playbook.md index 8b4f5be..e6a4838 100644 --- a/docs/release-playbook.md +++ b/docs/release-playbook.md @@ -87,8 +87,9 @@ real Firefox desktop build: 2. Confirm Monaco renders and the default sample appears without blocking console/runtime errors. 3. Compile and run the default sample program. -4. Complete `docs/firefox-stdin-runtime-acceptance.md` and confirm pre-supplied - buffered stdin works without the old SharedArrayBuffer/COOP/COEP error. +4. Complete `docs/firefox-stdin-runtime-acceptance.md`: confirm Firefox 153+ + JSPI live stdin and the older/no-JSPI buffered fallback both work without + SharedArrayBuffer/COOP/COEP errors. 5. Open a local source file with Firefox's fallback picker and save changes. 6. Import a folder, compile a multi-file project, and confirm diagnostics appear in the expected file. diff --git a/package.json b/package.json index 47d8f83..8cfaf64 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "build": "npm run build:webpack && npm run build:targets", "build:firefox": "npm run build", - "test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-buffered-stdin-ui.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", + "test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-buffered-stdin-ui.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", "test:preflight-clang": "node scripts/preflight-clang-artifacts.js", "test:browser:chrome": "npm run test:preflight-clang && node scripts/smoke-browser.mjs chrome", "test:browser:edge": "npm run test:preflight-clang && node scripts/smoke-browser.mjs edge", diff --git a/scripts/e2e-firefox-jspi-stdin.test.mjs b/scripts/e2e-firefox-jspi-stdin.test.mjs new file mode 100644 index 0000000..e93eefd --- /dev/null +++ b/scripts/e2e-firefox-jspi-stdin.test.mjs @@ -0,0 +1,243 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createStdinSessionRouter, + createWasiImports, + invokeWasiStart, + supportsJspi, +} from '../src/workers/jspi-stdin.mjs'; +import { + collectBrowserCapabilities, + createBrowserCompatibilityReport, + selectStdinTransport, +} from '../src/ui/browser-capabilities.mjs'; +import { createWasiRuntime } from '../src/workers/wasi-shim.mjs'; + +function jspiRoot() { + return { + WebAssembly: { + Suspending: function Suspending(fn) { + const wrapped = (...args) => fn(...args); + wrapped.suspending = true; + return wrapped; + }, + promising(fn) { + return async (...args) => fn(...args); + }, + }, + }; +} + +test('e2e: JSPI support requires both WebAssembly integration functions', () => { + assert.equal(supportsJspi(jspiRoot()), true); + assert.equal(supportsJspi({ WebAssembly: { Suspending() {} } }), false); + assert.equal(supportsJspi({ WebAssembly: { promising() {} } }), false); + assert.equal(supportsJspi({}), false); +}); + +test('e2e: JSPI wraps only the asynchronous WASI fd_read import', async () => { + const root = jspiRoot(); + const wasi = { + fd_read: async () => 0, + fd_write: () => 0, + }; + + const imports = createWasiImports(wasi, root); + + assert.notEqual(imports, wasi); + assert.equal(imports.fd_read.suspending, true); + assert.equal(await imports.fd_read(), 0); + assert.equal(imports.fd_write, wasi.fd_write); +}); + +test('e2e: JSPI invokes the WASI entry point through WebAssembly.promising', async () => { + const events = []; + const root = jspiRoot(); + const instance = { + exports: { + _start() { + events.push('start'); + return 42; + }, + }, + }; + + const result = await invokeWasiStart(instance, root); + + assert.equal(result, 42); + assert.deepEqual(events, ['start']); +}); + +test('e2e: JSPI helpers reject use when the runtime capability is absent', async () => { + const unsupportedRoot = { WebAssembly: {} }; + assert.throws( + () => createWasiImports({ fd_read() {} }, unsupportedRoot), + /JSPI is unavailable/ + ); + await assert.rejects( + () => invokeWasiStart({ exports: { _start() {} } }, unsupportedRoot), + /JSPI is unavailable/ + ); +}); + +test('e2e: Firefox 153 reports JSPI potential but waits for worker confirmation', () => { + const capabilities = collectBrowserCapabilities({ + navigator: { userAgent: 'Mozilla/5.0 Firefox/153.0' }, + browser: { runtime: { getURL() {} }, storage: { local: {} } }, + Worker() {}, + WebAssembly: { + instantiate() {}, + Suspending() {}, + promising() {}, + }, + Atomics: {}, + crossOriginIsolated: false, + }); + + assert.equal(capabilities.jspi, true); + assert.equal(capabilities.jspiPotentialInteractiveStdin, true); + assert.equal(capabilities.interactiveStdin, false); + assert.equal(capabilities.stdinMode, 'buffered'); + assert.equal(selectStdinTransport(capabilities, { jspi: false }), 'buffered'); + assert.equal(selectStdinTransport(capabilities, { jspi: true }), 'message-jspi'); + + const negotiatedReport = createBrowserCompatibilityReport({ + navigator: { userAgent: 'Mozilla/5.0 Firefox/153.0' }, + browser: { runtime: { getURL() {} }, storage: { local: {} } }, + Worker() {}, + WebAssembly: { instantiate() {}, Suspending() {}, promising() {} }, + Atomics: {}, + crossOriginIsolated: false, + }, { jspi: true }); + assert.equal(negotiatedReport.capabilities.interactiveStdin, true); + assert.equal(negotiatedReport.capabilities.stdinMode, 'interactive-message'); + assert.equal( + negotiatedReport.limitations.some((item) => item.key === 'limitedInteractiveStdin'), + false + ); + + const workerOnlyReport = createBrowserCompatibilityReport({ + navigator: { userAgent: 'Mozilla/5.0 Firefox/153.0' }, + browser: { runtime: { getURL() {} }, storage: { local: {} } }, + Worker() {}, + WebAssembly: { instantiate() {} }, + Atomics: {}, + crossOriginIsolated: false, + }, { jspi: true }); + assert.equal(workerOnlyReport.capabilities.jspi, false); + assert.equal(workerOnlyReport.capabilities.interactiveStdin, true); +}); + +test('e2e: stdin selection keeps Chromium SharedArrayBuffer first and gates older Firefox', () => { + const chromium = { + browserFamily: 'chromium', + firefoxMajor: null, + sharedBufferInteractiveStdin: true, + }; + assert.equal(selectStdinTransport(chromium, { jspi: true }), 'shared-buffer'); + + const firefox152 = { + browserFamily: 'firefox', + firefoxMajor: 152, + sharedBufferInteractiveStdin: false, + }; + assert.equal(selectStdinTransport(firefox152, { jspi: true }), 'buffered'); +}); + +test('e2e: stdin session routing ignores late messages from previous runs', () => { + const diagnostics = []; + const firstCalls = []; + const secondCalls = []; + const router = createStdinSessionRouter((message) => diagnostics.push(message)); + const fakeRuntime = (calls) => { + let ended = false; + return { + pushStdin(bytes) { + if (ended) return false; + calls.push(['data', ...bytes]); + return true; + }, + endStdin() { + if (ended) return false; + ended = true; + calls.push(['eof']); + return true; + }, + }; + }; + const firstRuntime = fakeRuntime(firstCalls); + const secondRuntime = fakeRuntime(secondCalls); + + router.activate('first', firstRuntime); + assert.equal(router.route({ + type: 'stdin-data', + sessionId: 'first', + bytes: new Uint8Array([1]), + }), true); + router.activate('second', secondRuntime); + assert.equal(router.route({ type: 'stdin-eof', sessionId: 'first' }), false); + assert.equal(router.route({ type: 'stdin-eof', sessionId: 'second' }), true); + assert.equal(router.route({ + type: 'stdin-data', + sessionId: 'second', + bytes: new Uint8Array([2]), + }), false); + router.clear(secondRuntime); + assert.equal(router.route({ + type: 'stdin-data', + sessionId: 'second', + bytes: new Uint8Array([2]), + }), false); + + assert.deepEqual(firstCalls, [['data', 1]]); + assert.deepEqual(secondCalls, [['eof']]); + assert.equal(diagnostics.length, 3); +}); + +test('e2e: JSPI flow prints prompts before each suspended line read and resumes on EOF', async () => { + const root = jspiRoot(); + const runtime = createWasiRuntime({ stdin: { mode: 'interactive-message' } }); + const memory = { buffer: new ArrayBuffer(512) }; + runtime.setMemory(memory); + const view = new DataView(memory.buffer); + const iovsPtr = 16; + const nreadPtr = 8; + const inputPtr = 128; + view.setUint32(iovsPtr, inputPtr, true); + view.setUint32(iovsPtr + 4, 32, true); + + const imports = createWasiImports(runtime.wasi, root); + const events = []; + let signalSecondRead; + const secondReadStarted = new Promise((resolve) => { signalSecondRead = resolve; }); + const instance = { + exports: { + async _start() { + events.push('prompt:name'); + await imports.fd_read(0, iovsPtr, 1, nreadPtr); + const firstLength = view.getUint32(nreadPtr, true); + events.push(new TextDecoder().decode( + new Uint8Array(memory.buffer, inputPtr, firstLength) + )); + + events.push('prompt:age'); + signalSecondRead(); + await imports.fd_read(0, iovsPtr, 1, nreadPtr); + events.push(`eof:${view.getUint32(nreadPtr, true)}`); + }, + }, + }; + + const execution = invokeWasiStart(instance, root); + await Promise.resolve(); + assert.deepEqual(events, ['prompt:name']); + + runtime.pushStdin(new TextEncoder().encode('Ada\n')); + await secondReadStarted; + assert.deepEqual(events, ['prompt:name', 'Ada\n', 'prompt:age']); + + runtime.endStdin(); + await execution; + assert.deepEqual(events, ['prompt:name', 'Ada\n', 'prompt:age', 'eof:0']); +}); diff --git a/scripts/e2e-run-request.test.mjs b/scripts/e2e-run-request.test.mjs index 208119e..13a3ba5 100644 --- a/scripts/e2e-run-request.test.mjs +++ b/scripts/e2e-run-request.test.mjs @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import { BUFFERED_STDIN_MAX_BYTES, + INTERACTIVE_STDIN_CHUNK_MAX_BYTES, validateRunRequest, + validateStdinMessage, } from '../src/workers/run-request.mjs'; function interactiveRequest(overrides = {}) { @@ -22,6 +24,17 @@ test('e2e: worker run contract accepts and normalizes all stdin modes', () => { assert.equal(interactive.ok, true); assert.equal(interactive.value.stdin.mode, 'interactive'); + const interactiveMessage = validateRunRequest(interactiveRequest({ + stdinMode: 'interactive-message', + sharedBuffer: undefined, + stdinSessionId: 'stdin-session-1', + })); + assert.equal(interactiveMessage.ok, true); + assert.deepEqual(interactiveMessage.value.stdin, { + mode: 'interactive-message', + sessionId: 'stdin-session-1', + }); + const buffered = validateRunRequest(interactiveRequest({ stdinMode: 'buffered', sharedBuffer: undefined, @@ -47,6 +60,12 @@ test('e2e: worker run contract rejects mismatched stdin variants', () => { interactiveRequest({ sharedBuffer: new ArrayBuffer(16) }), interactiveRequest({ stdinMode: 'buffered', sharedBuffer: undefined, stdinBuffer: 'Ada' }), interactiveRequest({ stdinMode: 'none', sharedBuffer: new SharedArrayBuffer(16) }), + interactiveRequest({ stdinMode: 'interactive-message', sharedBuffer: undefined }), + interactiveRequest({ + stdinMode: 'interactive-message', + sharedBuffer: undefined, + stdinSessionId: '', + }), interactiveRequest({ stdinMode: 'unknown' }), ]; @@ -57,6 +76,48 @@ test('e2e: worker run contract rejects mismatched stdin variants', () => { } }); +test('e2e: worker stdin message contract validates session-scoped data and EOF', () => { + const bytes = new TextEncoder().encode('Ada\n'); + const data = validateStdinMessage({ + type: 'stdin-data', + stdinSessionId: 'stdin-session-1', + bytes: bytes.buffer, + }); + assert.equal(data.ok, true); + assert.equal(data.value.type, 'stdin-data'); + assert.equal(data.value.sessionId, 'stdin-session-1'); + assert.deepEqual([...data.value.bytes], [...bytes]); + + const eof = validateStdinMessage({ + type: 'stdin-eof', + stdinSessionId: 'stdin-session-1', + }); + assert.deepEqual(eof, { + ok: true, + value: { type: 'stdin-eof', sessionId: 'stdin-session-1' }, + }); +}); + +test('e2e: worker stdin message contract rejects malformed and oversized input', () => { + const cases = [ + { type: 'stdin-data', stdinSessionId: '', bytes: new Uint8Array([1]) }, + { type: 'stdin-data', stdinSessionId: 'session', bytes: 'Ada' }, + { type: 'stdin-data', stdinSessionId: 'session', bytes: new Uint8Array() }, + { + type: 'stdin-data', + stdinSessionId: 'session', + bytes: new Uint8Array(INTERACTIVE_STDIN_CHUNK_MAX_BYTES + 1), + }, + { type: 'stdin-eof', stdinSessionId: 'session', bytes: new Uint8Array() }, + ]; + + for (const message of cases) { + const result = validateStdinMessage(message); + assert.equal(result.ok, false); + assert.match(result.error, /stdin/i); + } +}); + test('e2e: worker run contract rejects buffered stdin above the shared size limit', () => { const result = validateRunRequest(interactiveRequest({ stdinMode: 'buffered', diff --git a/scripts/e2e-terminal-stop.test.mjs b/scripts/e2e-terminal-stop.test.mjs index 1e7e932..8f0ba21 100644 --- a/scripts/e2e-terminal-stop.test.mjs +++ b/scripts/e2e-terminal-stop.test.mjs @@ -16,6 +16,7 @@ import { function setupTerminalHarness({ supportsInteractiveStdin = true, + supportsMessageInteractiveStdin = false, requestBufferedStdin = async () => '', onRun, } = {}) { @@ -24,6 +25,7 @@ function setupTerminalHarness({ const runPreparationChanges = []; const stopCalls = []; const runCalls = []; + const stdinMessages = []; const fakeTerm = { clear() {}, write(text) { writes.push(text); }, @@ -37,10 +39,21 @@ function setupTerminalHarness({ onRunStateChange: (running) => runStateChanges.push(running), onRunPreparationStateChange: (preparing) => runPreparationChanges.push(preparing), supportsInteractiveStdin: () => supportsInteractiveStdin, + supportsMessageInteractiveStdin: () => supportsMessageInteractiveStdin, requestBufferedStdin, + onStdinData: (message) => stdinMessages.push(message), + onStdinEOF: (message) => stdinMessages.push(message), + createStdinSessionId: () => 'stdin-session-test', }); - return { writes, runStateChanges, runPreparationChanges, stopCalls, runCalls }; + return { + writes, + runStateChanges, + runPreparationChanges, + stopCalls, + runCalls, + stdinMessages, + }; } function ctrlCEvent() { @@ -163,6 +176,50 @@ test('e2e: non-SAB run posts UTF-8 buffered stdin before entering running state' assert.equal(__getTerminalStateForTesting().preparingRun, false); }); +test('e2e: Firefox JSPI run forwards live terminal lines and EOF by session', async () => { + const ctx = setupTerminalHarness({ + supportsInteractiveStdin: false, + supportsMessageInteractiveStdin: true, + requestBufferedStdin: async () => { + throw new Error('buffered input should not be requested'); + }, + }); + + assert.equal(await startRun(), true); + assert.deepEqual(ctx.runCalls[0], { + stdinMode: 'interactive-message', + stdinSessionId: 'stdin-session-test', + }); + + onRunStart(ctx.runCalls[0]); + for (const character of 'Ada') { + __handleTerminalKeyForTesting(character, { + key: character, + ctrlKey: false, + altKey: false, + }); + } + __handleTerminalKeyForTesting('\r', { + key: 'Enter', + ctrlKey: false, + altKey: false, + }); + __handleTerminalKeyForTesting('', { + key: 'd', + ctrlKey: true, + altKey: false, + }); + + assert.equal(ctx.stdinMessages.length, 2); + assert.equal(ctx.stdinMessages[0].type, 'stdin-data'); + assert.equal(ctx.stdinMessages[0].stdinSessionId, 'stdin-session-test'); + assert.equal(new TextDecoder().decode(ctx.stdinMessages[0].bytes), 'Ada\n'); + assert.deepEqual(ctx.stdinMessages[1], { + type: 'stdin-eof', + stdinSessionId: 'stdin-session-test', + }); +}); + test('e2e: canceling buffered stdin restores idle state and posts no run request', async () => { const ctx = setupTerminalHarness({ supportsInteractiveStdin: false, diff --git a/scripts/e2e-wasi-shim.test.mjs b/scripts/e2e-wasi-shim.test.mjs index d80f8f0..730b713 100644 --- a/scripts/e2e-wasi-shim.test.mjs +++ b/scripts/e2e-wasi-shim.test.mjs @@ -32,6 +32,19 @@ function readStdin(runtime, spans) { return { errno, nread, bytes: new Uint8Array(memory.buffer) }; } +async function readStdinAsync(runtime, spans) { + const memory = runtime.getMemoryForTesting(); + const request = configureRead(memory, spans); + const errno = await 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); @@ -87,6 +100,45 @@ test('e2e: wasi shim returns immediate EOF for empty buffered and none stdin', ( } }); +test('e2e: wasi shim suspends message stdin until data arrives', async () => { + const { runtime } = makeRuntime({ mode: 'interactive-message' }); + const pendingRead = readStdinAsync(runtime, [ + { base: 128, len: 2 }, + { base: 160, len: 4 }, + ]); + + runtime.pushStdin(new TextEncoder().encode('Ada\n')); + const result = await pendingRead; + + assert.equal(result.errno, 0); + assert.equal(result.nread, 4); + assert.equal(new TextDecoder().decode(result.bytes.subarray(128, 130)), 'Ad'); + assert.equal(new TextDecoder().decode(result.bytes.subarray(160, 162)), 'a\n'); +}); + +test('e2e: wasi shim drains queued message stdin before reporting EOF', async () => { + const { runtime } = makeRuntime({ mode: 'interactive-message' }); + runtime.pushStdin(new TextEncoder().encode('42')); + runtime.endStdin(); + + const data = await readStdinAsync(runtime, [{ base: 128, len: 8 }]); + assert.equal(data.errno, 0); + assert.equal(data.nread, 2); + assert.equal(new TextDecoder().decode(data.bytes.subarray(128, 130)), '42'); + + const eof = await readStdinAsync(runtime, [{ base: 160, len: 8 }]); + assert.equal(eof.errno, 0); + assert.equal(eof.nread, 0); +}); + +test('e2e: wasi shim returns immediately for a zero-length message stdin read', async () => { + const { runtime } = makeRuntime({ mode: 'interactive-message' }); + const result = await readStdinAsync(runtime, [{ base: 128, len: 0 }]); + + 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; }); diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 888ad00..15bb97f 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -929,6 +929,12 @@ async function runSmoke(cdp, sessionId) { missing.length === 0, `Missing required browser capabilities on ${capabilities.href}: ${missing.join(', ')}` ); + assert( + capabilities.sharedArrayBuffer && + capabilities.atomicsWaitAsync && + capabilities.crossOriginIsolated, + 'Chromium smoke requires the existing SharedArrayBuffer stdin transport.' + ); let ready; try { diff --git a/scripts/smoke-firefox.mjs b/scripts/smoke-firefox.mjs index f4885c0..b5e5cbb 100644 --- a/scripts/smoke-firefox.mjs +++ b/scripts/smoke-firefox.mjs @@ -45,7 +45,7 @@ function main() { console.log(`Firefox smoke validation passed with ${builtArtifacts[0]}.`); console.log( - 'Runtime compile/run verification remains required via docs/firefox-stdin-runtime-acceptance.md.' + 'Firefox 153+ JSPI live-stdin runtime verification remains required via docs/firefox-stdin-runtime-acceptance.md.' ); } diff --git a/specs/issue-55-20260731-plan-agent-firefox-jspi-live-stdin.md b/specs/issue-55-20260731-plan-agent-firefox-jspi-live-stdin.md new file mode 100644 index 0000000..40e2d1b --- /dev/null +++ b/specs/issue-55-20260731-plan-agent-firefox-jspi-live-stdin.md @@ -0,0 +1,247 @@ +# Feature: Firefox 153+ live terminal stdin with WebAssembly JSPI + +## Feature Description + +Add live, prompt-by-prompt terminal input for C/C++ programs running in Firefox 153 and newer by using WebAssembly JavaScript Promise Integration (JSPI). A program blocked in WASI `fd_read` should suspend its WebAssembly stack, allow the compiler worker to receive terminal input messages, and resume when the user submits a line or EOF. + +The existing Chromium `SharedArrayBuffer`/Atomics path must remain the first-choice path and retain its current behavior. Firefox versions or environments without JSPI must keep the existing pre-supplied buffered-stdin fallback. Persistent folder write-back is explicitly out of scope. + +This issue follows merged PR #54 and supersedes only its Firefox live-stdin limitation; it does not remove the buffered fallback. + +## User Story + +As a Firefox 153+ user, +I want a running C++ program to display a prompt and wait for input in the terminal, +so that `std::cin`, `std::getline`, `scanf`, and similar line-oriented console workflows behave interactively without changing Chromium behavior. + +## Problem Statement + +The current WASI `fd_read` contract is synchronous. Chromium can block the worker with `Atomics.wait()` because extension pages opt into cross-origin isolation and can share a `SharedArrayBuffer`. Firefox extension pages cannot use that transport, so PR #54 collects all stdin before execution. + +Firefox 153 enables JSPI (`WebAssembly.Suspending` and `WebAssembly.promising`), which can suspend WebAssembly while an imported JavaScript function awaits a Promise. browser.cpp does not detect or use that capability, and its UI↔worker contract has no message-based live-stdin variant. + +## Solution Statement + +Add an explicit, additive `interactive-message` stdin variant for Firefox JSPI runs while preserving all existing variants: + +```js +// Existing Chromium path; semantics and fields remain unchanged. +{ type: 'run', stdinMode: 'interactive', sharedBuffer, ... } + +// New Firefox 153+ path. +{ type: 'run', stdinMode: 'interactive-message', stdinSessionId, ... } +{ type: 'stdin-data', stdinSessionId, bytes: Uint8Array } +{ type: 'stdin-eof', stdinSessionId } + +// Existing fallbacks remain unchanged. +{ type: 'run', stdinMode: 'buffered', stdinBuffer, ... } +{ type: 'run', stdinMode: 'none', ... } +``` + +The compiler worker will feature-detect JSPI in its own global scope and report that capability to the UI. For `interactive-message`, it will wrap the WASI `fd_read` import with `WebAssembly.Suspending`, wrap `_start` with `WebAssembly.promising`, and await terminal bytes through a per-run queue. Data arriving before a read is queued; an empty queue suspends; EOF resolves the current and all future reads with `nread = 0`. + +Transport selection order is fixed: + +1. Existing SAB/Atomics interactive stdin whenever the current Chromium capability gate succeeds. +2. JSPI message stdin only for Firefox when both worker-side JSPI functions are available. +3. Existing pre-supplied buffered stdin otherwise. + +Use feature detection as the execution authority rather than trusting the Firefox version string alone. Firefox 153 is the documented support floor for JSPI live input, while the extension's existing Firefox 140+ buffered support remains available. + +## Relevant Files + +- `src/workers/run-request.mjs` — extend and validate the run/input discriminated union without changing existing variants. +- `src/workers/wasi-shim.mjs` — add the queued asynchronous stdin source and expose narrowly scoped data/EOF methods while preserving synchronous SAB, buffered, file-descriptor, and VFS behavior. +- `src/workers/compiler.worker.js` — report worker-side JSPI support, select the wrapped import/export only for `interactive-message`, route session-scoped input messages, and reject stale or malformed input. +- `src/ui/terminal.js` — choose the Firefox JSPI mode, route Enter/Ctrl+D through callbacks, preserve canonical line editing and Ctrl+C termination, and retain the SAB implementation unchanged. +- `src/ui/app.js` — forward session-scoped stdin data/EOF messages to the current worker and clear transport state when the worker is replaced. +- `src/ui/toolbar.js` — consume additive compiler capability/run-start metadata and preserve compile/run/stop state transitions. +- `src/ui/browser-capabilities.mjs` — distinguish SAB interactivity, JSPI availability, effective live stdin, transport, and buffered fallback in compatibility output. +- `scripts/e2e-run-request.test.mjs` — contract validation for the new run and input variants, including invalid and stale session data. +- `scripts/e2e-wasi-shim.test.mjs` — queued async reads, repeated reads, multiple iovecs, EOF, UTF-8, and unchanged synchronous modes. +- `scripts/e2e-terminal-stop.test.mjs` — terminal mode selection, line delivery, EOF, cancellation, and worker restart behavior. +- `scripts/e2e-browser-compatibility.test.mjs` — Chromium non-regression assertions. +- `scripts/e2e-firefox-compatibility.test.mjs` — Firefox 153+ JSPI and pre-153/no-JSPI fallback assertions. +- `scripts/smoke-browser.mjs` — keep Chromium's expected SAB transport explicit in browser smoke checks. +- `scripts/smoke-firefox.mjs` — retain package validation and report the JSPI runtime acceptance requirement. +- `README.md`, `docs/firefox-stdin-runtime-acceptance.md`, and `docs/release-playbook.md` — document the support matrix and exact real-Firefox acceptance evidence. +- `package.json` — include the new focused E2E file in `npm run test:e2e`. + +### New Files + +- `scripts/e2e-firefox-jspi-stdin.test.mjs` — focused integration test for capability negotiation, message routing, asynchronous WASI suspension/resumption, prompt ordering, line input, EOF, and buffered fallback. + +## Implementation Plan + +### Phase 1: Foundation + +Define the additive worker protocol and capability handshake first. Add failing contract and runtime tests before modifying production code. Keep session identity explicit so late input from a completed run cannot feed a later process. + +### Phase 2: Core Implementation + +Implement an asynchronous queued stdin source in the WASI layer and use JSPI wrappers only for the new mode. Add worker routing and terminal message delivery while leaving the existing SAB code path structurally intact. + +### Phase 3: Integration + +Connect worker capability reporting to Firefox-only transport selection, update user-facing compatibility text, prove fallback behavior, run real Firefox acceptance, and execute explicit Chromium regressions. + +## Step by Step Tasks + +### 1. Define the additive stdin protocol with failing tests + +- Add `interactive-message` to the validated run-request union without modifying accepted `interactive`, `buffered`, or `none` payloads. +- Require a non-empty, bounded `stdinSessionId` for the new mode and prohibit `sharedBuffer`/`stdinBuffer` on that variant. +- Define `stdin-data` and `stdin-eof` message validation: matching session ID, `Uint8Array`/`ArrayBuffer` bytes, bounded chunk size, and no bytes on EOF. +- Specify consistent invalid-message behavior: never crash the worker, never mutate another run, and emit a diagnostic suitable for tests without exposing internal state. +- Keep the run request as the only message that can create an input session. + +### 2. Add the focused Firefox JSPI E2E test file + +- Create `scripts/e2e-firefox-jspi-stdin.test.mjs` before implementation. +- Use controlled JSPI-compatible test doubles or a minimal Wasm fixture to demonstrate: prompt output precedes input, execution suspends at `fd_read`, a submitted line resumes execution, a second read can suspend again, and Ctrl+D produces deterministic EOF. +- Assert that absent JSPI selects buffered input and never opens a message session. +- Assert that a Chromium-like environment continues selecting the SAB request and never sends message-stdin traffic. +- Add the file to `npm run test:e2e`. + +### 3. Implement the asynchronous WASI stdin source + +- Add an internal byte queue with cursor-based reads; do not use repeated `Array.shift()` for byte consumption. +- Expose methods such as `pushStdin(bytes)`, `endStdin()`, and `cancelStdin()` only for `interactive-message` runtimes. +- Make async `fd_read` fill all requested iovecs in order, wait only when no data is available, and return promptly once at least one queued chunk can satisfy the read. +- Resolve EOF as `WASI_ERRNO_SUCCESS` with `nread = 0`, including repeated reads after EOF. +- Preserve synchronous SAB, buffered, `none`, and regular-file reads byte-for-byte. + +### 4. Add worker-side JSPI capability negotiation and execution + +- Feature-detect both `WebAssembly.Suspending` and `WebAssembly.promising` in the compiler worker. +- Add worker capability metadata to `compiler-ready` without changing existing fields or timing. +- For `interactive-message` only, wrap the `fd_read` import in `WebAssembly.Suspending` and invoke `_start` through `WebAssembly.promising`; keep direct `_start()` for every existing mode. +- Maintain one active stdin session, queue early data, route matching input, ignore/reject stale sessions, and clear state in `finally` after success, exit, or failure. +- Verify that the existing thrown `proc_exit` sentinel and runtime errors are normalized correctly through the promising export. + +### 5. Route Firefox terminal input through worker messages + +- Store worker-reported JSPI capability in the UI and update it whenever `setWorker()` installs a replacement worker. +- Generate a fresh session ID for each message-interactive run. +- Reuse the current terminal's canonical line editing: printable characters echo locally, Enter sends UTF-8 bytes plus `\n`, Ctrl+D on an empty line sends EOF, and Ctrl+C terminates/replaces the worker. +- Refactor delivery behind a transport-neutral helper so SAB flushing remains unchanged and message mode calls the new app callbacks. +- Ensure input before `run-start`, after completion, or for an old worker is not delivered. + +### 6. Select the transport without changing Chromium behavior + +- Preserve the existing SAB capability predicate and evaluate it first. +- Select message-interactive stdin only when the browser family is Firefox and the execution worker reports JSPI. +- Keep buffered collection for Firefox 140–152, Firefox 153+ with JSPI disabled/unavailable, and any other non-SAB unsupported context. +- Do not add JSPI manifest permissions, change Chromium COOP/COEP settings, or change the Firefox `strict_min_version` while buffered support remains available. + +### 7. Update compatibility reporting and documentation + +- Report separate fields for SAB support, JSPI support, effective live-input support, and selected transport. +- Remove the “live interactive terminal input is unavailable” limitation only when Firefox can actually negotiate worker-side JSPI. +- Document Firefox 153+ live line-buffered input, Firefox 140–152 buffered input, Chromium SAB input, and the fact that this is not a full POSIX PTY/raw terminal. +- Update the runtime acceptance guide to type input only after each prompt and record Firefox version, output ordering, Ctrl+D behavior, and console errors. +- Explicitly state that persistent folder write-back remains out of scope and retains its existing limitation. + +### 8. Add regression and failure-path coverage + +- Test UTF-8, empty lines, input split across chunks, multiple iovecs, multiple sequential reads, input queued before `fd_read`, EOF before/while waiting, and EOF after partial data. +- Test malformed bytes, missing/wrong session IDs, duplicate EOF, data after EOF, run completion, runtime exception, and worker replacement during a suspended read. +- Assert no buffered-input dialog appears on a JSPI-capable Firefox run. +- Assert the dialog still appears when JSPI is absent. +- Assert existing Chromium request shape, SAB synchronization, prompt behavior, Ctrl+C, compile/run flow, stdout/stderr ordering, and VFS write-back tests remain unchanged. + +### 9. Perform real browser acceptance + +- In Firefox 153+, load `dist-firefox`, compile a two-prompt `std::cin`/`std::getline` program, and enter each response only after its prompt appears. +- Confirm the buffered-input dialog does not appear, output ordering is correct, Ctrl+D reaches EOF, Ctrl+C stops a blocked program, and rerunning starts a clean session. +- Repeat with JSPI unavailable (an older supported Firefox or controlled capability override) and confirm the pre-supplied buffered fallback still works. +- Run the existing Chrome smoke and manually confirm its run request uses the SAB path and interactive behavior is unchanged. + +### 10. Run all validation commands and prepare the PR + +- Implement on `feature/firefox-jspi-stdin`, based on current `main` after merged PR #54. +- Run every command below and record results plus Firefox runtime evidence in the PR. +- Open a PR containing `Closes #55` and leave approval/merge to a human. + +## Testing Strategy + +### Unit and Contract Tests + +- Validate every run/input message variant at the worker boundary. +- Test queued asynchronous WASI reads independently from browser UI. +- Test terminal transport selection and keystroke routing with deterministic harnesses. +- Test capability negotiation independently in window-like and worker-like contexts. + +### End-to-End Tests + +- Use the new focused E2E file to exercise UI-to-worker message flow and Wasm suspend/resume behavior. +- Keep the exact manual Firefox extension test because Node mocks and `web-ext lint/build` do not prove JSPI availability in a signed extension context. +- Run Chromium browser smoke as a mandatory non-regression gate. + +### Edge Cases + +- Firefox reports version 153+ but JSPI functions are unavailable. +- JSPI functions exist on the window but not in the execution worker. +- Input arrives before the program first calls `fd_read`. +- A read spans several iovecs or receives a line larger than one message chunk. +- EOF arrives with queued bytes, while suspended, more than once, or after completion. +- Ctrl+C terminates while `_start` is suspended. +- A stale message arrives after worker replacement or a new run starts. +- `proc_exit`, traps, rejected promises, and malformed input clean up the active session. +- Existing Chromium cross-origin-isolated execution still uses only SAB/Atomics. + +## Acceptance Criteria + +- Firefox 153+ with worker-side JSPI support displays program prompts before accepting terminal input and resumes correctly after each submitted line. +- `std::cin`, `std::getline`, and `scanf` line-oriented programs work through repeated reads; Ctrl+D yields EOF and Ctrl+C stops the process. +- Firefox never requires `SharedArrayBuffer`, `crossOriginIsolated`, or COOP/COEP for the JSPI path. +- Firefox without JSPI retains the current accessible pre-supplied buffered-input flow. +- Chromium-family browsers retain the existing `stdinMode: 'interactive'` plus `SharedArrayBuffer` request shape and observable behavior. +- Stale, malformed, or post-EOF input cannot cross run/session boundaries or crash the worker. +- Existing VFS, runtime file output, compiler lifecycle, stop/restart, packaging, and release behavior are unchanged. +- Compatibility text and documentation accurately describe Firefox 153+, older Firefox fallback, and the remaining filesystem limitation. +- All automated commands pass and real Firefox/Chrome evidence is attached to the PR. + +## Validation Commands + +Execute in order: + +```bash +npm ci +npm run fetch-clang +node --experimental-detect-module --test scripts/e2e-run-request.test.mjs +node --experimental-detect-module --test scripts/e2e-wasi-shim.test.mjs +node --experimental-detect-module --test scripts/e2e-terminal-stop.test.mjs +node --experimental-detect-module --test scripts/e2e-firefox-jspi-stdin.test.mjs +node --experimental-detect-module --test scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs +npm run lint +npm run build +npm run test:e2e +npm run test:preflight-clang +npm run test:browser:firefox +npm run test:browser:chrome +npm run version:check +npm run release:check-version +``` + +Then complete the updated `docs/firefox-stdin-runtime-acceptance.md` procedure in Firefox 153+ and record: + +- Firefox version and tested artifact path +- exact prompt/output ordering +- input entered after each prompt +- Ctrl+D and Ctrl+C results +- absence of the buffered-input dialog +- absence of `SharedArrayBuffer`, JSPI, unhandled rejection, and worker-session errors + +## Notes + +- Human request from @kbuffardi: “ignore the file persistence feature for now and finish the plan to only handle live cin in terminal” +- Planned by Codex plan-agent (GPT-5) using the repository's required GitHub workflow. +- JSPI is the suspension mechanism; `postMessage` is the data transport. A plain message-only implementation without JSPI cannot unblock synchronous Wasm `fd_read`. +- “Live terminal input” means canonical, line-buffered interaction. Raw-mode PTY behavior, terminal ioctls, job control, signals beyond the existing stop behavior, and character-at-a-time applications are not included. +- No new runtime dependency is expected. +- Persistent Firefox folder write-back, OPFS, downloads-based export, native messaging, and all Chromium filesystem behavior are out of scope. +- Official references: + - Firefox 153 JSPI release notes: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/153 + - `WebAssembly.Suspending`: https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/Suspending + - Firefox extension-page SharedArrayBuffer tracking: https://bugzilla.mozilla.org/show_bug.cgi?id=1673477 diff --git a/src/ui/app.js b/src/ui/app.js index 6e25944..fb67e01 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -30,10 +30,6 @@ import { applyWorkspaceSnapshot, } from './toolbar.js'; import { createSessionPersistence, createPersistenceGate } from './session-persistence.mjs'; -import { - createBrowserCompatibilityReport, - formatBrowserCompatibilityMessage, -} from './browser-capabilities.mjs'; // ── Boot ────────────────────────────────────────────────────────────────────── @@ -68,6 +64,8 @@ window.addEventListener('DOMContentLoaded', async () => { const binaryBytes = toolbarController?.getLastRunBinaryBytes?.() || null; worker.postMessage({ type: 'run', ...runRequest, vfsFiles, binaryBytes }); }, + onStdinData: (message) => worker.postMessage(message), + onStdinEOF: (message) => worker.postMessage(message), onStopRun: () => { worker.terminate(); worker = createCompilerWorker(); @@ -99,13 +97,6 @@ window.addEventListener('DOMContentLoaded', async () => { readWorkspaceFile: (path) => fsAPI.readWorkspaceFile(path), }); - const compatibilityReport = createBrowserCompatibilityReport(window); - const compatibilityMessage = formatBrowserCompatibilityMessage(compatibilityReport); - if (compatibilityMessage) { - console.warn('[browser.cpp]', compatibilityMessage); - terminalAPI.printInfo(compatibilityMessage); - } - // 4. Toolbar (wires buttons + worker messages + keyboard shortcuts) const { restoreSession, persistSession } = createSessionPersistence({ fsAPI, diff --git a/src/ui/browser-capabilities.mjs b/src/ui/browser-capabilities.mjs index 839449b..e874bdd 100644 --- a/src/ui/browser-capabilities.mjs +++ b/src/ui/browser-capabilities.mjs @@ -4,6 +4,7 @@ import { getExtensionAPI } from '../extension-api.mjs'; const MINIMUM_CHROMIUM_MAJOR = 105; const MINIMUM_FIREFOX_MAJOR = 140; +export const FIREFOX_JSPI_STDIN_MINIMUM_MAJOR = 153; const BASE_REQUIRED_CAPABILITIES = [ { key: 'extensionRuntime', label: 'Extension runtime API' }, @@ -72,7 +73,7 @@ function getBrowserProfile(userAgent = '') { }; } -export function collectBrowserCapabilities(root = globalThis) { +export function collectBrowserCapabilities(root = globalThis, workerCapabilities = {}) { const nav = root.navigator ?? {}; const api = getExtensionAPI(root); const atomics = root.Atomics ?? {}; @@ -83,8 +84,18 @@ export function collectBrowserCapabilities(root = globalThis) { const sharedArrayBuffer = hasFunction(root.SharedArrayBuffer); const atomicsWaitAsync = hasFunction(atomics.waitAsync); const crossOriginIsolated = root.crossOriginIsolated === true; - const interactiveStdin = + const sharedBufferInteractiveStdin = sharedArrayBuffer && atomicsWaitAsync && crossOriginIsolated; + const jspi = hasFunction(root.WebAssembly?.Suspending) && + hasFunction(root.WebAssembly?.promising); + const jspiPotentialInteractiveStdin = profile.family === 'firefox' && + firefoxMajor >= FIREFOX_JSPI_STDIN_MINIMUM_MAJOR && + jspi; + const workerJspi = workerCapabilities.jspi === true; + const jspiInteractiveStdin = profile.family === 'firefox' && + firefoxMajor >= FIREFOX_JSPI_STDIN_MINIMUM_MAJOR && + workerJspi; + const interactiveStdin = sharedBufferInteractiveStdin || jspiInteractiveStdin; return { userAgent: nav.userAgent ?? '', @@ -105,13 +116,39 @@ export function collectBrowserCapabilities(root = globalThis) { sharedArrayBuffer, atomicsWaitAsync, crossOriginIsolated, + jspi, + workerJspi, + sharedBufferInteractiveStdin, + jspiPotentialInteractiveStdin, + jspiInteractiveStdin, interactiveStdin, - stdinMode: interactiveStdin ? 'interactive' : 'buffered', + stdinMode: sharedBufferInteractiveStdin + ? 'interactive' + : jspiInteractiveStdin + ? 'interactive-message' + : 'buffered', }; } -export function createBrowserCompatibilityReport(root = globalThis) { - const capabilities = collectBrowserCapabilities(root); +/** + * Choose the run-time stdin transport after the compiler worker reports its + * own JSPI capability. The established Chromium SharedArrayBuffer path always + * wins and Firefox message stdin is never selected from the user agent alone. + */ +export function selectStdinTransport(capabilities, workerCapabilities = {}) { + if (capabilities.sharedBufferInteractiveStdin) return 'shared-buffer'; + if ( + capabilities.browserFamily === 'firefox' && + capabilities.firefoxMajor >= FIREFOX_JSPI_STDIN_MINIMUM_MAJOR && + workerCapabilities.jspi === true + ) { + return 'message-jspi'; + } + return 'buffered'; +} + +export function createBrowserCompatibilityReport(root = globalThis, workerCapabilities = {}) { + const capabilities = collectBrowserCapabilities(root, workerCapabilities); const requiredCapabilities = capabilities.browserFamily === 'chromium' ? CHROMIUM_FULL_PARITY_CAPABILITIES : capabilities.browserFamily === 'firefox' diff --git a/src/ui/terminal.js b/src/ui/terminal.js index f01a9be..a97b337 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -31,8 +31,15 @@ import { normalizeOverlayPath, } from './build-request.mjs'; import { validateNewDirectoryPath, validateNewFilePath } from './workspace-fs.mjs'; -import { BUFFERED_STDIN_MAX_BYTES } from '../workers/run-request.mjs'; +import { + BUFFERED_STDIN_MAX_BYTES, + INTERACTIVE_STDIN_CHUNK_MAX_BYTES, +} from '../workers/run-request.mjs'; import { requestBufferedStdin } from './buffered-stdin-dialog.mjs'; +import { + collectBrowserCapabilities, + selectStdinTransport, +} from './browser-capabilities.mjs'; function moduleExports(pkg) { return Object.prototype.hasOwnProperty.call(pkg, 'default') ? pkg['default'] : pkg; @@ -93,6 +100,7 @@ let running = false; let preparingRun = false; /** Stdin behavior for the active run. */ let activeStdinMode = 'none'; +let activeStdinSessionId = null; /** Resolve function set when waiting for run output to complete */ let runDone = null; @@ -144,6 +152,16 @@ function _clearSAB() { */ function _sendStdinLine(line) { const bytes = new TextEncoder().encode(line + '\n'); + if (activeStdinMode === 'interactive-message') { + for (let offset = 0; offset < bytes.length; offset += INTERACTIVE_STDIN_CHUNK_MAX_BYTES) { + _onStdinData?.({ + type: 'stdin-data', + stdinSessionId: activeStdinSessionId, + bytes: bytes.slice(offset, offset + INTERACTIVE_STDIN_CHUNK_MAX_BYTES), + }); + } + return; + } for (let offset = 0; offset < bytes.length; offset += SAB_DATA_BYTES) { _pendingChunks.push(bytes.subarray(offset, offset + SAB_DATA_BYTES)); } @@ -152,6 +170,13 @@ function _sendStdinLine(line) { /** Signal EOF on stdin (Ctrl+D on an empty line, or Ctrl+C). */ function _sendStdinEOF() { + if (activeStdinMode === 'interactive-message') { + _onStdinEOF?.({ + type: 'stdin-eof', + stdinSessionId: activeStdinSessionId, + }); + return; + } _pendingChunks.push(null); // null sentinel = EOF _flushStdin(); } @@ -197,10 +222,15 @@ async function _doFlush() { let _onCompile = null; let _onRun = null; let _onStopRun = null; +let _onStdinData = null; +let _onStdinEOF = null; let _onRunStateChange = null; let _onRunPreparationStateChange = null; let _getSource = null; // () => string – returns current editor source let _supportsInteractiveStdin = supportsInteractiveStdin; +let _workerCapabilities = { jspi: false }; +let _getStdinTransport = getStdinTransport; +let _createStdinSessionId = createStdinSessionId; let _requestBufferedStdin = requestBufferedStdin; function supportsInteractiveStdin() { @@ -211,6 +241,21 @@ function supportsInteractiveStdin() { ); } +function getStdinTransport() { + const capabilities = collectBrowserCapabilities(globalThis); + if (_supportsInteractiveStdin()) { + capabilities.sharedBufferInteractiveStdin = true; + } + return selectStdinTransport(capabilities, _workerCapabilities); +} + +function createStdinSessionId() { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `stdin-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + // ── Public API ──────────────────────────────────────────────────────────────── /** @@ -221,6 +266,8 @@ function supportsInteractiveStdin() { * onCompile: (request:{sourcePaths:(string[]|null), flags:string[], std:string, outputName:(string|null), cwd:string}) => void, * onRun: (request:{stdinMode:string,sharedBuffer?:SharedArrayBuffer,stdinBuffer?:Uint8Array}) => Promise|void, * onStopRun?: () => void, + * onStdinData?: (message:{type:'stdin-data',stdinSessionId:string,bytes:Uint8Array}) => void, + * onStdinEOF?: (message:{type:'stdin-eof',stdinSessionId:string}) => void, * onRunStateChange?: (running:boolean) => void, * onRunPreparationStateChange?: (preparing:boolean) => void, * getSource: () => string, @@ -233,6 +280,8 @@ export function createTerminal(container, { onCompile, onRun, onStopRun, + onStdinData, + onStdinEOF, onRunStateChange, onRunPreparationStateChange, getSource, @@ -243,6 +292,8 @@ export function createTerminal(container, { _onCompile = onCompile; _onRun = onRun; _onStopRun = onStopRun || null; + _onStdinData = onStdinData || null; + _onStdinEOF = onStdinEOF || null; _onRunStateChange = onRunStateChange || null; _onRunPreparationStateChange = onRunPreparationStateChange || null; _getSource = getSource; @@ -250,6 +301,8 @@ export function createTerminal(container, { _onMkdir = onMkdir || null; _onTouch = onTouch || null; _supportsInteractiveStdin = supportsInteractiveStdin; + _getStdinTransport = getStdinTransport; + _createStdinSessionId = createStdinSessionId; _requestBufferedStdin = requestBufferedStdin; initialPromptShown = false; busy = true; @@ -323,11 +376,17 @@ export function showInitialPrompt() { writePrompt(); } +/** Update capabilities reported by the currently active compiler worker. */ +export function setWorkerCapabilities(capabilities = {}) { + _workerCapabilities = { jspi: capabilities.jspi === true }; +} + /** * Start executing the last compiled binary. * * Uses live SharedArrayBuffer stdin when cross-origin isolation is available, - * otherwise collects pre-supplied buffered stdin before dispatch. + * Firefox JSPI message stdin when its worker confirms support, and otherwise + * collects pre-supplied buffered stdin before dispatch. * Can be called from the terminal command line (`./a.out`) or directly from * the toolbar Run button. * @@ -348,10 +407,17 @@ export async function startRun() { try { let request; - if (_supportsInteractiveStdin()) { + const stdinTransport = _getStdinTransport(); + if (stdinTransport === 'shared-buffer') { const sharedBuffer = new SharedArrayBuffer(SAB_HEADER_BYTES + SAB_DATA_BYTES); _initSAB(sharedBuffer); request = { stdinMode: 'interactive', sharedBuffer }; + } else if (stdinTransport === 'message-jspi') { + activeStdinSessionId = _createStdinSessionId(); + request = { + stdinMode: 'interactive-message', + stdinSessionId: activeStdinSessionId, + }; } else { const text = await _requestBufferedStdin(); if (text === null) { @@ -375,6 +441,7 @@ export async function startRun() { } catch (error) { setRunPreparationState(false); activeStdinMode = 'none'; + activeStdinSessionId = null; busy = false; _clearSAB(); term.write(`${C.red}Could not start program: ${error?.message || String(error)}${C.reset}${CRLF}`); @@ -384,9 +451,10 @@ export async function startRun() { } /** Mark a validated worker request as actively running. */ -export function onRunStart({ stdinMode = 'none' } = {}) { +export function onRunStart({ stdinMode = 'none', stdinSessionId = null } = {}) { setRunPreparationState(false); activeStdinMode = stdinMode; + activeStdinSessionId = stdinMode === 'interactive-message' ? stdinSessionId : null; setRunState(true); busy = false; } @@ -411,6 +479,7 @@ export function stopRun({ echoCtrlC = false } = {}) { _clearSAB(); setRunPreparationState(false); activeStdinMode = 'none'; + activeStdinSessionId = null; setRunState(false); busy = false; runDone?.(); @@ -466,6 +535,7 @@ export function onRunResult({ exitCode }) { } setRunPreparationState(false); activeStdinMode = 'none'; + activeStdinSessionId = null; setRunState(false); busy = false; _clearSAB(); @@ -534,7 +604,7 @@ function handleKey({ key, domEvent }) { // While a program is executing, route keystrokes to stdin instead of the shell if (running) { - if (activeStdinMode === 'interactive') { + if (activeStdinMode === 'interactive' || activeStdinMode === 'interactive-message') { handleStdinKey(key, domEvent); } else if (domEvent.ctrlKey && domEvent.key === 'c') { stopRun({ echoCtrlC: true }); @@ -1020,6 +1090,8 @@ export function __setTerminalTestHarness({ onCompile = null, onRun = null, onStopRun = null, + onStdinData = null, + onStdinEOF = null, onRunStateChange = null, onRunPreparationStateChange = null, getSource = () => '', @@ -1027,6 +1099,8 @@ export function __setTerminalTestHarness({ onMkdir = null, onTouch = null, supportsInteractiveStdin: supportsInteractiveStdinForTest = () => true, + supportsMessageInteractiveStdin: supportsMessageInteractiveStdinForTest = () => false, + createStdinSessionId: createStdinSessionIdForTest = () => 'stdin-session-test', requestBufferedStdin: requestBufferedStdinForTest = async () => '', } = {}) { term = terminalInstance || null; @@ -1034,6 +1108,8 @@ export function __setTerminalTestHarness({ _onCompile = onCompile; _onRun = onRun; _onStopRun = onStopRun; + _onStdinData = onStdinData; + _onStdinEOF = onStdinEOF; _onRunStateChange = onRunStateChange; _onRunPreparationStateChange = onRunPreparationStateChange; _getSource = getSource; @@ -1041,6 +1117,12 @@ export function __setTerminalTestHarness({ _onMkdir = onMkdir; _onTouch = onTouch; _supportsInteractiveStdin = supportsInteractiveStdinForTest; + _getStdinTransport = () => { + if (supportsInteractiveStdinForTest()) return 'shared-buffer'; + if (supportsMessageInteractiveStdinForTest()) return 'message-jspi'; + return 'buffered'; + }; + _createStdinSessionId = createStdinSessionIdForTest; _requestBufferedStdin = requestBufferedStdinForTest; lastBuiltArtifactPath = artifactPath; inputBuffer = ''; @@ -1050,6 +1132,7 @@ export function __setTerminalTestHarness({ running = false; preparingRun = false; activeStdinMode = 'none'; + activeStdinSessionId = null; runDone = null; initialPromptShown = false; _clearSAB(); diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index a8f106d..c03765c 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -17,6 +17,10 @@ import { } from './build-request.mjs'; import { parseDiagnostics, diagnosticsForPath } from './diagnostics.mjs'; import { directoriesForPath } from './workspace-fs.mjs'; +import { + createBrowserCompatibilityReport, + formatBrowserCompatibilityMessage, +} from './browser-capabilities.mjs'; // ── State ───────────────────────────────────────────────────────────────────── let _worker = null; @@ -34,6 +38,7 @@ let _workspaceSyncTimer = null; let _workspaceSyncRunning = false; let _workspaceSyncQueued = false; let _workspaceSyncEventsBound = false; +let _lastCompatibilityMessage = null; // ── Multi-tab state ─────────────────────────────────────────────────────────── // Map @@ -100,6 +105,7 @@ export function setWorker(worker) { _worker = worker; _runAfterSuccessfulCompile = false; _runPreparationActive = false; + _terminalAPI?.setWorkerCapabilities?.({ jspi: false }); handleWorkerMessages(); updateStatusBar('compiler', 'loading', 'Compiler loading…'); setButtonsEnabled(false); @@ -196,6 +202,16 @@ async function handleWorkerMessage(data) { break; case 'compiler-ready': + _terminalAPI.setWorkerCapabilities?.(data.capabilities); + { + const report = createBrowserCompatibilityReport(globalThis, data.capabilities); + const message = formatBrowserCompatibilityMessage(report); + if (message && message !== _lastCompatibilityMessage) { + _lastCompatibilityMessage = message; + console.warn('[browser.cpp]', message); + _terminalAPI.printInfo(message); + } + } updateStatusBar('compiler', 'ready', 'Compiler ready'); setButtonsEnabled(true); _terminalAPI.printInfo('Clang WASM compiler loaded. Ready to compile C++20.'); diff --git a/src/workers/compiler.worker.js b/src/workers/compiler.worker.js index 50f9b07..2ac8df4 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -10,6 +10,10 @@ * std: string, flags: string[], primarySourcePath, outputName } * { type: 'run', stdinMode: 'interactive', sharedBuffer: SharedArrayBuffer, * vfsFiles, binaryBytes?: Uint8Array } + * { type: 'run', stdinMode: 'interactive-message', stdinSessionId: string, + * vfsFiles, binaryBytes?: Uint8Array } + * { type: 'stdin-data', stdinSessionId: string, bytes: Uint8Array } + * { type: 'stdin-eof', stdinSessionId: string } * { type: 'run', stdinMode: 'buffered', stdinBuffer: Uint8Array|ArrayBuffer, * vfsFiles, binaryBytes?: Uint8Array } * { type: 'run', stdinMode: 'none', vfsFiles, binaryBytes?: Uint8Array } @@ -47,7 +51,13 @@ import { parseCompilePlan } from './compile-plan.mjs'; import { parseDiagnostics } from '../ui/diagnostics.mjs'; import { createWasiRuntime } from './wasi-shim.mjs'; -import { validateRunRequest } from './run-request.mjs'; +import { validateRunRequest, validateStdinMessage } from './run-request.mjs'; +import { + createStdinSessionRouter, + createWasiImports, + invokeWasiStart, + supportsJspi, +} from './jspi-stdin.mjs'; // ── State ──────────────────────────────────────────────────────────────────── @@ -78,6 +88,10 @@ let sysrootBuffer = null; */ let compiledBinary = null; +const stdinSessions = createStdinSessionRouter((diagnostic) => { + send({ type: 'stderr', data: `${diagnostic}\n` }); +}); + // ── Helpers ────────────────────────────────────────────────────────────────── function send(msg) { @@ -183,7 +197,7 @@ async function loadCompiler() { compilerState = 'ready'; send({ type: 'compiler-loading', progress: 100 }); - send({ type: 'compiler-ready' }); + send({ type: 'compiler-ready', capabilities: { jspi: supportsJspi() } }); return true; } @@ -478,6 +492,7 @@ function groupDiagnostics(text) { * * @param {{ * stdin: ({mode:'interactive',sharedBuffer:SharedArrayBuffer}| + * {mode:'interactive-message',sessionId:string}| * {mode:'buffered',bytes:Uint8Array}| * {mode:'none'}), * vfsFiles:Array<{path:string,bytes:Uint8Array}>, @@ -503,18 +518,26 @@ async function run({ stdin, vfsFiles = [], binaryBytes = null }) { onStderr: (text) => send({ type: 'stderr', data: text }), }); wasiRuntime.initRunVfs(vfsFiles); + const useJspi = stdin.mode === 'interactive-message'; + if (useJspi) { + stdinSessions.activate(stdin.sessionId, wasiRuntime); + } let exitCode = 0; try { + const wasiImports = useJspi + ? createWasiImports(wasiRuntime.wasi) + : wasiRuntime.wasi; const { instance } = await WebAssembly.instantiate(compiledBinary, { - wasi_snapshot_preview1: wasiRuntime.wasi, + wasi_snapshot_preview1: wasiImports, }); // Give the WASI shim access to the module's memory wasiRuntime.setMemory(instance.exports.memory); // WASI entry point - instance.exports._start(); + if (useJspi) await invokeWasiStart(instance); + else instance.exports._start(); } catch (e) { if (e && e.__wasi_exit__) { exitCode = e.code; @@ -525,6 +548,8 @@ async function run({ stdin, vfsFiles = [], binaryBytes = null }) { send({ type: 'stderr', data: `Unexpected error: ${String(e)}\n` }); exitCode = 1; } + } finally { + stdinSessions.clear(wasiRuntime); } // Flush any still-open writable file descriptors so their content is saved @@ -576,11 +601,34 @@ self.onmessage = async ({ data }) => { send({ type: 'run-result', exitCode: 1, vfsChanges: [], vfsDeletes: [] }); break; } - send({ type: 'run-start', stdinMode: validation.value.stdin.mode }); + if ( + validation.value.stdin.mode === 'interactive-message' && + !supportsJspi() + ) { + send({ type: 'stderr', data: 'Live Firefox stdin requires JSPI support.\n' }); + send({ type: 'run-result', exitCode: 1, vfsChanges: [], vfsDeletes: [] }); + break; + } + send({ + type: 'run-start', + stdinMode: validation.value.stdin.mode, + stdinSessionId: validation.value.stdin.sessionId, + }); await run(validation.value); break; } + case 'stdin-data': + case 'stdin-eof': { + const validation = validateStdinMessage(data); + if (!validation.ok) { + send({ type: 'stderr', data: `${validation.error}\n` }); + break; + } + stdinSessions.route(validation.value); + break; + } + case 'status': send({ type: 'status-reply', state: compilerState }); break; diff --git a/src/workers/jspi-stdin.mjs b/src/workers/jspi-stdin.mjs new file mode 100644 index 0000000..2c52f06 --- /dev/null +++ b/src/workers/jspi-stdin.mjs @@ -0,0 +1,55 @@ +'use strict'; + +export function supportsJspi(root = globalThis) { + return typeof root.WebAssembly?.Suspending === 'function' && + typeof root.WebAssembly?.promising === 'function'; +} + +function requireJspi(root) { + if (!supportsJspi(root)) { + throw new Error('JSPI is unavailable in this worker runtime.'); + } +} + +export function createWasiImports(wasi, root = globalThis) { + requireJspi(root); + return { + ...wasi, + fd_read: new root.WebAssembly.Suspending(wasi.fd_read), + }; +} + +export async function invokeWasiStart(instance, root = globalThis) { + requireJspi(root); + const promisingStart = root.WebAssembly.promising(instance.exports._start); + return promisingStart(); +} + +export function createStdinSessionRouter(onInactiveMessage = () => {}) { + let active = null; + + return { + activate(sessionId, runtime) { + active = { sessionId, runtime }; + }, + + clear(runtime) { + if (!runtime || active?.runtime === runtime) active = null; + }, + + route(message) { + if (message.sessionId !== active?.sessionId) { + onInactiveMessage('Ignored stdin message for an inactive session.'); + return false; + } + const accepted = message.type === 'stdin-data' + ? active.runtime.pushStdin(message.bytes) + : active.runtime.endStdin(); + if (!accepted) { + onInactiveMessage('Ignored stdin message after EOF.'); + return false; + } + return true; + }, + }; +} diff --git a/src/workers/run-request.mjs b/src/workers/run-request.mjs index 70f12ff..ddd3bf2 100644 --- a/src/workers/run-request.mjs +++ b/src/workers/run-request.mjs @@ -1,6 +1,8 @@ 'use strict'; export const BUFFERED_STDIN_MAX_BYTES = 256 * 1024; +export const INTERACTIVE_STDIN_CHUNK_MAX_BYTES = 64 * 1024; +const STDIN_SESSION_ID_MAX_LENGTH = 128; function isByteSource(value) { return value instanceof Uint8Array || value instanceof ArrayBuffer; @@ -17,6 +19,50 @@ function invalid(error) { return { ok: false, error }; } +function isValidStdinSessionId(value) { + return typeof value === 'string' && + value.length > 0 && + value.length <= STDIN_SESSION_ID_MAX_LENGTH; +} + +export function validateStdinMessage(message) { + if (!message || !isValidStdinSessionId(message.stdinSessionId)) { + return invalid('Invalid stdin message: stdinSessionId is required.'); + } + + if (message.type === 'stdin-data') { + if (!isByteSource(message.bytes)) { + return invalid('Invalid stdin-data message: bytes must be a Uint8Array or ArrayBuffer.'); + } + if (message.bytes.byteLength === 0) { + return invalid('Invalid stdin-data message: bytes must not be empty.'); + } + if (message.bytes.byteLength > INTERACTIVE_STDIN_CHUNK_MAX_BYTES) { + return invalid('Invalid stdin-data message: input chunk exceeds the 64 KiB limit.'); + } + return { + ok: true, + value: { + type: 'stdin-data', + sessionId: message.stdinSessionId, + bytes: asUint8Array(message.bytes), + }, + }; + } + + if (message.type === 'stdin-eof') { + if (message.bytes !== undefined) { + return invalid('Invalid stdin-eof message: bytes are not allowed.'); + } + return { + ok: true, + value: { type: 'stdin-eof', sessionId: message.stdinSessionId }, + }; + } + + return invalid('Invalid stdin message type. Expected stdin-data or stdin-eof.'); +} + /** * Validate and normalize the main-thread → compiler-worker run contract. * @@ -41,6 +87,22 @@ export function validateRunRequest(request) { stdin = { mode: 'interactive', sharedBuffer: request.sharedBuffer }; break; + case 'interactive-message': + if ( + !isValidStdinSessionId(request.stdinSessionId) || + request.sharedBuffer !== undefined || + request.stdinBuffer !== undefined + ) { + return invalid( + 'Invalid interactive-message stdin: stdinSessionId is required and no buffer may be supplied.' + ); + } + stdin = { + mode: 'interactive-message', + sessionId: request.stdinSessionId, + }; + break; + case 'buffered': if (!isByteSource(request.stdinBuffer) || request.sharedBuffer !== undefined) { return invalid('Invalid buffered stdin: stdinBuffer must be a Uint8Array or ArrayBuffer.'); @@ -59,7 +121,9 @@ export function validateRunRequest(request) { break; default: - return invalid('Invalid stdinMode. Expected interactive, buffered, or none.'); + return invalid( + 'Invalid stdinMode. Expected interactive, interactive-message, buffered, or none.' + ); } let binaryBytes = null; diff --git a/src/workers/wasi-shim.mjs b/src/workers/wasi-shim.mjs index 8bd3599..f0464b7 100644 --- a/src/workers/wasi-shim.mjs +++ b/src/workers/wasi-shim.mjs @@ -52,8 +52,13 @@ export function createWasiRuntime({ let sharedBuffer = null; let sabControl = null; const stdinQueue = []; + const messageStdinChunks = []; + let messageStdinChunkIndex = 0; + let messageStdinChunkOffset = 0; let stdinBytes = new Uint8Array(); let stdinCursor = 0; + let stdinEnded = false; + const stdinWaiters = []; if (stdinMode === 'interactive') { if ( @@ -69,10 +74,77 @@ export function createWasiRuntime({ throw new TypeError('Buffered stdin requires Uint8Array or ArrayBuffer bytes.'); } stdinBytes = new Uint8Array(stdin.bytes); - } else if (stdinMode !== 'none') { + } else if (stdinMode !== 'none' && stdinMode !== 'interactive-message') { throw new TypeError(`Unsupported stdin mode: ${String(stdinMode)}`); } + function wakeStdinWaiters() { + for (const resolve of stdinWaiters.splice(0)) resolve(); + } + + function pushStdin(bytes) { + if (stdinMode !== 'interactive-message') return false; + if (stdinEnded) return false; + if (!(bytes instanceof Uint8Array) && !(bytes instanceof ArrayBuffer)) { + throw new TypeError('Message stdin requires Uint8Array or ArrayBuffer bytes.'); + } + const chunk = new Uint8Array(bytes).slice(); + if (chunk.byteLength === 0) return false; + messageStdinChunks.push(chunk); + wakeStdinWaiters(); + return true; + } + + function endStdin() { + if (stdinMode !== 'interactive-message') return false; + if (stdinEnded) return false; + stdinEnded = true; + wakeStdinWaiters(); + return true; + } + + function cancelStdin() { + if (stdinMode !== 'interactive-message') return false; + stdinEnded = true; + messageStdinChunks.length = 0; + messageStdinChunkIndex = 0; + messageStdinChunkOffset = 0; + wakeStdinWaiters(); + return true; + } + + function waitForStdin() { + if (messageStdinChunkIndex < messageStdinChunks.length || stdinEnded) { + return Promise.resolve(); + } + return new Promise((resolve) => stdinWaiters.push(resolve)); + } + + function drainMessageStdin(base, len) { + let copied = 0; + while (copied < len && messageStdinChunkIndex < messageStdinChunks.length) { + const chunk = messageStdinChunks[messageStdinChunkIndex]; + const available = chunk.length - messageStdinChunkOffset; + const toCopy = Math.min(len - copied, available); + u8().set( + chunk.subarray(messageStdinChunkOffset, messageStdinChunkOffset + toCopy), + base + copied + ); + copied += toCopy; + messageStdinChunkOffset += toCopy; + if (messageStdinChunkOffset === chunk.length) { + messageStdinChunkIndex += 1; + messageStdinChunkOffset = 0; + } + } + + if (messageStdinChunkIndex === messageStdinChunks.length) { + messageStdinChunks.length = 0; + messageStdinChunkIndex = 0; + } + return copied; + } + const setMemory = (m) => { memory = m; }; @@ -450,6 +522,31 @@ export function createWasiRuntime({ }, }; + if (stdinMode === 'interactive-message') { + const fdReadSync = wasi.fd_read.bind(wasi); + wasi.fd_read = async (fd, iovsPtr, iovsLen, nreadPtr) => { + if (fd !== 0) return fdReadSync(fd, iovsPtr, iovsLen, nreadPtr); + + const spans = iovSpans(iovsPtr, iovsLen); + if (spans.every(({ len }) => len === 0)) { + view().setUint32(nreadPtr, 0, true); + return WASI_ERRNO_SUCCESS; + } + + await waitForStdin(); + let total = 0; + + for (const { base, len } of spans) { + const copied = drainMessageStdin(base, len); + total += copied; + if (copied < len) break; + } + + view().setUint32(nreadPtr, total, true); + return WASI_ERRNO_SUCCESS; + }; + } + return { wasi, setMemory, @@ -458,5 +555,8 @@ export function createWasiRuntime({ flushRunFds, getDirtyVfsFiles, getDeletedVfsFiles, + pushStdin, + endStdin, + cancelStdin, }; }