tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit 316961cf1b3e5af5f54b0b6bd23a6caca8c27aa9
parent e383016e0b0e185d6a76e6d771ee6b7b147a6222
Author: James Eversole <james@eversole.co>
Date:   Sat, 17 Oct 2026 16:42:24 -0500

Retain compiled drafts independently of disposable executions

Checkpoint the collected lazy graph without normalization or sharing expansion. Keep the trusted checkpoint in page memory and restore it into a fresh Worker for each application. Worker errors, result-codec failures, limits, cancellation, reset, pane changes and source/preset edits retain the last successful compilation. Failed compilation and package replacement invalidate it. Run remains available during an attempt and restarts it; unapplied edits are explicitly labelled.

Preserve per-run work allowances and current limits; terminate completed Workers and isolate failed applications from retained code. No recompilation or compiler downloads on retry; no persistent browser storage. Document graph-copy overhead and that repeated IO does not undo prior effects.

Validation: 89 bounded Node tests, lazy/sharing/poison checkpoint controls, independent compiler/contract conformance, complete Chromium suite including codec/fuel/deadline/Stop/Reset/IO/pane/preset retries and failed-compilation/package-load controls, generated JS parity and nix flake check.

Diffstat:
Mext/demo-site/README.md | 49+++++++++++++++++++++++++++++--------------------
Mext/demo-site/dist/app.js | 218+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mext/demo-site/dist/compiler-worker.js | 13+++++++++++--
Mext/demo-site/dist/graph.js | 10++++++++++
Mext/demo-site/src/app.ts | 128+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mext/demo-site/src/compiler-protocol.ts | 6++++--
Mext/demo-site/src/compiler-worker.ts | 10++++++++--
Mext/demo-site/src/graph.ts | 10++++++++++
Mext/demo-site/test/browser.mjs | 54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Mext/demo-site/test/graph.test.mjs | 17+++++++++++++++++
10 files changed, 329 insertions(+), 186 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -219,14 +219,19 @@ included. Reference source without a supported compiler environment is labelled **Source (read-only) / Read-only source**, with editing and local compilation explicitly unavailable. Published source is read-only until **Edit source** is clicked. Opening the editor alone keeps **Run published**: the authenticated -bundle is executed, not recompiled. Changing text switches to **Local draft**, -invalidates any compiled heap/result, and disables **Run draft** until an explicit -**Compile draft** in the source footer succeeds. Compilation does not run the result. -The footer also provides Stop and a link that opens Try and jumps to its Run controls. Subsequent draft -runs reuse the compiled definitions; edits, errors and discarded sessions require -compilation again. A failed draft never falls back to an earlier draft or the -published executable. **Restore published source** reinstates the original bundle -path and read-only source. There is no combined compile-and-run button. +bundle is executed, not recompiled. Changing text switches to **Local draft**. +The first run requires an explicit **Compile draft**; compilation does not run +its result. After success, **Run draft** always runs the last successful compiled +version, even after further source edits. A persistent note identifies unapplied +edits. A failed compilation invalidates that version: it never falls back to an +earlier draft or the published executable. Loading another program also clears +it. **Restore published source** reinstates the original bundle and read-only +source. There is no combined compile-and-run button. + +Run errors (including result interpretation errors), Stop, Reset, limits changes, +and pane changes do not require recompilation. Run remains enabled during an +application; clicking it cancels that attempt and starts a fresh one. The footer +also provides Stop and a link to the Run controls. **Compile draft** starts directly when clicked; there is no confirmation popup. The inline notice identifies the JavaScript Tree Calculus reducer, notes the time @@ -404,18 +409,22 @@ is permitted. With optional limits off (the default), compilation has no application ceiling, but still has engine/OS limits. Enabling optional limits can stop a large live graph at its configured memory budget. -One compilation supports repeated -applications in the same Worker; each application resets its work allowance to -the applied limit. Compilation has its own allowance; allocation/work/GC counters -remain cumulative. Live-node/frame limits are enforced only when guards are enabled. Stop, profile changes, reset, source editing, -recompilation, or replacing the session with an ordinary package discards that -heap. A compiled idle heap persists until discarded; Stop is available while it -is idle too. Invalid literal JSON can be corrected without recompiling. Successful -runs retain every compiled definition and collect run temporaries; change arguments, -output mode or definition selection and run again. Owned IO collection retains the -definition roots too, so replaying an IO definition requests fresh input/actions. -Errors discard the session; the active-time deadline resets for each run. Selection links never fetch the compiler or automatically execute source. Ordinary examples still need -no compiler download. +One compilation supports repeated applications through a page-local checkpoint +of the collected lazy graph and definition handles. Checkpointing neither forces +code nor expands shared trees. Every run uses a new Worker and its own graph copy, +so an error, partial reduction, IO pause, work limit or hard Worker termination +cannot damage the saved definitions. Each run receives the current execution +settings and a fresh work allowance. Compilation and successful-run work are +reported cumulatively; failed/terminated work is not included in that total. + +The checkpoint remains in memory only, adding a graph-sized retained copy plus +per-run structured-clone overhead. There is no browser storage or source +recompilation on retry. Completed Workers are terminated. IO retries execute +fresh actions and request fresh input; already performed external effects are +not rolled back. Failed compilation or a different loaded program invalidates +the checkpoint, and page reload discards it. Selection links never fetch the +compiler or automatically execute source. Ordinary examples still need no +compiler download. ## Sessions and execution limits diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -21,6 +21,8 @@ const restoreButton = el('restore-source'), downloadButton = el('download-bundle let editingSource = false; let sourceAvailable = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource; +let compiledDraft, compiledSource; +let totalWork = 0; let compilationStats = '', preferredName = 'id'; let compilerTimer; function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } @@ -67,15 +69,18 @@ function controls(working) { hashInput.disabled = busy; el('load-release').disabled = busy || !programPackage(programPicker.value); el('load').disabled = busy; - runButton.disabled = busy || (draft ? !compiled : !loaded); + runButton.disabled = draft ? !compiled : busy || !loaded; runButton.textContent = draft ? 'Run draft' : 'Run published'; - el('draft-run-note').hidden = !draft || compiled; + el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); + el('draft-run-note').textContent = compiled + ? 'Run draft uses the last successful compilation. Compile draft to apply source edits.' + : 'Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.'; argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; runButton.classList.toggle('draft', draft); runButton.classList.toggle('primary', !draft || compiled); compileButton.classList.toggle('primary', !compiled); - compileButton.disabled = busy || !draft || compiled; + compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource); compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy; @@ -102,7 +107,7 @@ function controls(working) { el('package-settings').hidden = draft; for (const button of document.querySelectorAll('[data-compiler-example]')) button.disabled = busy; - stopButton.disabled = !busy && !(draft && worker); + stopButton.disabled = !busy && !(draft && (worker || compiled)); el('stop-compile').disabled = stopButton.disabled; el('stop-compile').hidden = !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); @@ -114,13 +119,17 @@ function controls(working) { shareButton.disabled = busy || draft || !loaded; treeExport.controls(busy, loaded && !draft); } -function dispose() { +function dispose(preserveDraft = false) { stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; - compiled = false; + if (!preserveDraft) { + compiledDraft = undefined; + compiledSource = undefined; + } + compiled = compiledDraft !== undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; @@ -153,7 +162,7 @@ function finish(message, error = false, reason = 'failure') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); } - dispose(); + dispose(draft && (operation !== 'compiling' || reason === 'cancelled') && operation !== 'loading'); el('status').textContent = error ? 'Stopped' : 'Finished'; el('result').textContent = message; el('result').className = error ? 'error' : ''; @@ -461,10 +470,10 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish('Stopped. Session discarded.', true, 'cancelled'); +stopButton.onclick = () => finish(draft && compiled ? 'Stopped. Last compiled draft retained.' : 'Stopped. Session discarded.', true, 'cancelled'); el('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el('try-program').open = true; }; -el('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el('reset').onclick = () => { dispose(true); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; @@ -480,7 +489,7 @@ shareButton.onclick = async () => { el('result').textContent = url.href; } }; -window.addEventListener('pagehide', dispose); +window.addEventListener('pagehide', () => dispose()); // Associate an example with its published name only through the catalogue's exact pin. function examplePackage(entry) { return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; @@ -655,22 +664,24 @@ sourceEditor.oninput = () => { editingSource = true; el('source-section').open = true; preferredName = exportsInput.value || preferredName; - dispose(); + dispose(true); clearOutput(); clearTreeExport(); - exportsInput.replaceChildren(); - draft = scratch || sourceEditor.value !== originalSource; + if (!compiled) + exportsInput.replaceChildren(); + draft = compiled || scratch || sourceEditor.value !== originalSource; controls(false); if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); } else - el('status').textContent = 'Ready to compile'; + el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; }; restoreButton.onclick = () => { if (originalSource === undefined) return; + dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); editingSource = false; @@ -680,17 +691,14 @@ compileButton.onclick = () => compileDraft(); function compileDraft() { if (!draft) return; - preferredName = exportsInput.value || preferredName; - dispose(); + dispose(true); operation = 'compiling'; clearOutput(); clearTreeExport(); - exportsInput.replaceChildren(); try { sourceText(sourceEditor.value); settings = readSettings('compiler'); - const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); - worker = current; + const current = draftWorker(); controls(true); deadline.reset(activeMilliseconds(settings)); clock(); @@ -700,79 +708,6 @@ function compileDraft() { const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.onmessage = ({ data }) => { - if (worker !== current) - return; - switch (data.type) { - case 'compiled': - stopCompilerActivity(); - pauseClock(); - operation = undefined; - compiled = true; - for (const name of data.names) { - const option = document.createElement('option'); - option.value = name; - option.textContent = name; - exportsInput.append(option); - } - if (data.names.includes(preferredName)) - exportsInput.value = preferredName; - controls(false); - el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; - el('compiler-stats').textContent = compilationStats; - break; - case 'state': - if (data.state === 'running') { - clock(); - pending = undefined; - el('status').textContent = compiled ? 'Running' : 'Compiling'; - } - else { - pauseClock(); - pending = { id: data.id, kind: data.state }; - inputForm.hidden = data.state !== 'input'; - nextButton.disabled = data.state !== 'step'; - el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; - } - break; - case 'trace': { - const row = document.createElement('li'); - row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; - el('trace').append(row); - el('trace-empty').hidden = true; - break; - } - case 'output': - el('terminal').textContent += data.text; - break; - case 'done': - pauseClock(); - operation = undefined; - pending = undefined; - inputForm.hidden = true; - nextButton.disabled = true; - controls(false); - el('status').textContent = 'Finished'; - if (data.result.type === 'tree' || data.result.type === 'list') { - el('result-inspection').hidden = false; - el('graph-result').hidden = false; - el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); - el('result').textContent = `Returned ${data.result.type}: tree graph`; - } - else - el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; - el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; - break; - case 'error': - finish(data.message, true); - break; - } - }; - current.onerror = event => { if (worker === current) { - event.preventDefault(); - finish(event.message || 'Compiler Worker failed.', true); - } }; current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } @@ -780,22 +715,104 @@ function compileDraft() { finish(error instanceof Error ? error.message : String(error), true); } } +function draftWorker() { + const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); + worker = current; + current.onmessage = ({ data }) => { + if (worker !== current) + return; + switch (data.type) { + case 'compiled': + stopCompilerActivity(); + pauseClock(); + operation = undefined; + compiledDraft = data.draft; + compiledSource = sourceEditor.value; + compiled = true; + totalWork = data.stats.work; + exportsInput.replaceChildren(); + for (const name of data.names) { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + exportsInput.append(option); + } + if (data.names.includes(preferredName)) + exportsInput.value = preferredName; + dispose(true); + el('status').textContent = 'Compiled'; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; + el('compiler-stats').textContent = compilationStats; + break; + case 'state': + if (data.state === 'running') { + clock(); + pending = undefined; + el('status').textContent = operation === 'compiling' ? 'Compiling' : 'Running'; + } + else { + pauseClock(); + pending = { id: data.id, kind: data.state }; + inputForm.hidden = data.state !== 'input'; + nextButton.disabled = data.state !== 'step'; + el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; + } + break; + case 'trace': { + const row = document.createElement('li'); + row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; + el('trace').append(row); + el('trace-empty').hidden = true; + break; + } + case 'output': + el('terminal').textContent += data.text; + break; + case 'done': + totalWork += data.stats.work; + dispose(true); + el('status').textContent = 'Finished'; + if (data.result.type === 'tree' || data.result.type === 'list') { + el('result-inspection').hidden = false; + el('graph-result').hidden = false; + el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); + el('result').textContent = `Returned ${data.result.type}: tree graph`; + } + else + el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; + el('compiler-stats').textContent = `${compilationStats} · ${totalWork.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; + break; + case 'error': + finish(data.message, true); + break; + } + }; + current.onerror = event => { if (worker === current) { + event.preventDefault(); + finish(event.message || 'Compiler Worker failed.', true); + } }; + return current; +} function runDraft() { - if (!compiled || !worker) + if (!compiledDraft) return; + dispose(true); operation = 'running'; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); + settings = readSettings('compiler'); clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); + const current = draftWorker(); + current.postMessage({ type: 'restore', draft: compiledDraft, ...settings }); + current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); } catch (error) { el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; - operation = undefined; + dispose(true); } } const examples = { @@ -813,13 +830,14 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) chooseProgram(''); editingSource = true; el('source-section').open = true; - dispose(); + dispose(true); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; - exportsInput.replaceChildren(); + if (!compiled) + exportsInput.replaceChildren(); preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; @@ -830,7 +848,7 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) mode.value = example.mode; controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; - el('status').textContent = 'Ready to compile'; + el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; }; function loadFragment() { const fragment = new URLSearchParams(location.hash.slice(1)); @@ -948,9 +966,9 @@ window.addEventListener('limits-change', event => { const runner = event.detail; if (runner === 'compiler') { if (draft) { - dispose(); + dispose(true); clearOutput(); - el('status').textContent = 'Ready to compile'; + el('status').textContent = compiled ? 'Compiled' : 'Ready to compile'; } return; } diff --git a/ext/demo-site/dist/compiler-worker.js b/ext/demo-site/dist/compiler-worker.js @@ -58,7 +58,7 @@ async function compile(source, settings, profileName) { exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. phase = 'ready'; - send({ type: 'compiled', names: [...exports.keys()], stats: graph.stats, milliseconds: performance.now() - start }); + send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); } async function run(message) { if (!graph || !exports) @@ -102,7 +102,16 @@ scope.onmessage = ({ data }) => { } let operation; try { - if (message['type'] === 'compile' && phase === 'empty') { + if (message['type'] === 'restore' && phase === 'empty') { + // Only the owning page supplies this trusted in-memory checkpoint. + const restored = data; + const budget = executionBudget(executionSettings(restored.limits, 'compiler', restored.uncapped ?? false)); + graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); + exports = new Map(restored.draft.exports); + phase = 'ready'; + return; + } + else if (message['type'] === 'compile' && phase === 'empty') { if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment']); diff --git a/ext/demo-site/dist/graph.js b/ext/demo-site/dist/graph.js @@ -28,6 +28,16 @@ export class Graph { this.threshold = collection.threshold; } } + checkpoint() { return { nodes: [...this.heap], nextId: this.nextId }; } + static restore(checkpoint, budget = {}, collection) { + const graph = new Graph(budget, collection); + if (checkpoint.nodes.length > graph.budget.nodes) + throw new Error('Graph allocation limit exceeded'); + graph.heap = new Map(checkpoint.nodes); + graph.nextId = checkpoint.nextId; + graph.peak = graph.heap.size; + return graph; + } get stats() { return { nodes: this.heap.size, work: this.spent, fuel: this.remaining, allocations: this.nextId, peakNodes: this.peak, collections: this.collections, reclaimed: this.reclaimed, gcWork: this.gcWork }; diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -7,7 +7,7 @@ import { ActiveClock } from './clock.js'; import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout, type Operation } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex, packageReleases, type Release } from './packages.js'; -import type { CompilerMessage } from './compiler-protocol.js'; +import type { CompiledDraft, CompilerMessage } from './compiler-protocol.js'; import { bundleHash, fetchBytes, type BundleHash } from './bundle.js'; import { Graph } from './graph.js'; import { line, literals, outputType, type OutputType } from './codecs.js'; @@ -20,6 +20,8 @@ const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = let editingSource = false; let sourceAvailable = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; +let compiledDraft: CompiledDraft | undefined, compiledSource: string | undefined; +let totalWork = 0; let compilationStats = '', preferredName = 'id'; let compilerTimer: ReturnType<typeof setInterval> | undefined; function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } @@ -61,15 +63,18 @@ function controls(working: boolean): void { versionPicker.disabled = busy; hashInput.disabled = busy; el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value); el<HTMLButtonElement>('load').disabled = busy; - runButton.disabled = busy || (draft ? !compiled : !loaded); + runButton.disabled = draft ? !compiled : busy || !loaded; runButton.textContent = draft ? 'Run draft' : 'Run published'; - el('draft-run-note').hidden = !draft || compiled; + el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); + el('draft-run-note').textContent = compiled + ? 'Run draft uses the last successful compilation. Compile draft to apply source edits.' + : 'Draft not compiled. Compile draft, then run it; published programs are not used as a fallback.'; argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; runButton.classList.toggle('draft', draft); runButton.classList.toggle('primary', !draft || compiled); compileButton.classList.toggle('primary', !compiled); - compileButton.disabled = busy || !draft || compiled; compileButton.hidden = !draft; restoreButton.hidden = !draft; + compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource); compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy; sourceEditor.readOnly = !editingSource && !draft; el<HTMLButtonElement>('edit-source').disabled = busy; el('edit-source').hidden = originalSource === undefined || editingSource || draft; @@ -92,7 +97,7 @@ function controls(working: boolean): void { el('export-draft-note').hidden = !draft; el('package-settings').hidden = draft; for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy; - stopButton.disabled = !busy && !(draft && worker); + stopButton.disabled = !busy && !(draft && (worker || compiled)); el<HTMLButtonElement>('stop-compile').disabled = stopButton.disabled; el('stop-compile').hidden = !draft; exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); @@ -100,8 +105,10 @@ function controls(working: boolean): void { shareButton.disabled = busy || draft || !loaded; treeExport.controls(busy, loaded && !draft); } -function dispose(): void { - stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; compiled = false; pending = undefined; +function dispose(preserveDraft = false): void { + stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; + if (!preserveDraft) { compiledDraft = undefined; compiledSource = undefined; } + compiled = compiledDraft !== undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); } function clearOutput(): void { @@ -117,7 +124,7 @@ function finish(message: string, error = false, reason: 'failure' | 'timeout' | if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); if (operation === 'downloading') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); } - dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; + dispose(draft && (operation !== 'compiling' || reason === 'cancelled') && operation !== 'loading'); el('status').textContent = error ? 'Stopped' : 'Finished'; el('result').textContent = message; el('result').className = error ? 'error' : ''; for (const row of traceRows.values()) row.classList.remove('pending'); } @@ -275,10 +282,10 @@ inputForm.onsubmit = event => { catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish('Stopped. Session discarded.', true, 'cancelled'); +stopButton.onclick = () => finish(draft && compiled ? 'Stopped. Last compiled draft retained.' : 'Stopped. Session discarded.', true, 'cancelled'); el<HTMLButtonElement>('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el<HTMLDetailsElement>('try-program').open = true; }; -el<HTMLButtonElement>('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el<HTMLButtonElement>('reset').onclick = () => { dispose(true); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value }); @@ -287,7 +294,7 @@ shareButton.onclick = async () => { try { await navigator.clipboard.writeText(url.href); el('result').textContent = 'Copied a link to this package and export.'; } catch { el('result').textContent = url.href; } }; -window.addEventListener('pagehide', dispose); +window.addEventListener('pagehide', () => dispose()); // Associate an example with its published name only through the catalogue's exact pin. function examplePackage(entry: CatalogueEntry): string | undefined { return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; @@ -392,71 +399,80 @@ mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('term sourceEditor.oninput = () => { editingSource = true; el<HTMLDetailsElement>('source-section').open = true; preferredName = exportsInput.value || preferredName; - dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); - draft = scratch || sourceEditor.value !== originalSource; + dispose(true); clearOutput(); clearTreeExport(); + if (!compiled) exportsInput.replaceChildren(); + draft = compiled || scratch || sourceEditor.value !== originalSource; controls(false); if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); } - else el('status').textContent = 'Ready to compile'; + else el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; }; restoreButton.onclick = () => { if (originalSource === undefined) return; - sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); editingSource = false; controls(busy); + dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); editingSource = false; controls(busy); }; compileButton.onclick = () => compileDraft(); function compileDraft(): void { if (!draft) return; - preferredName = exportsInput.value || preferredName; - dispose(); operation = 'compiling'; clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); + dispose(true); operation = 'compiling'; clearOutput(); clearTreeExport(); try { sourceText(sourceEditor.value); settings = readSettings('compiler'); - const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); worker = current; + const current = draftWorker(); controls(true); deadline.reset(activeMilliseconds(settings)); clock(); el('status').textContent = 'Compiling'; el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…'; const started = performance.now(); const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { - if (worker !== current) return; - switch (data.type) { - case 'compiled': - stopCompilerActivity(); pauseClock(); operation = undefined; compiled = true; - for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } - if (data.names.includes(preferredName)) exportsInput.value = preferredName; - controls(false); el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; - el('compiler-stats').textContent = compilationStats; - break; - case 'state': - if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = compiled ? 'Running' : 'Compiling'; } - else { pauseClock(); pending = { id: data.id, kind: data.state }; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; } - break; - case 'trace': { - const row = document.createElement('li'); row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; el('trace').append(row); el('trace-empty').hidden = true; break; - } - case 'output': el('terminal').textContent += data.text; break; - case 'done': - pauseClock(); operation = undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); el('status').textContent = 'Finished'; - if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } - else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; - el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; - break; - case 'error': finish(data.message, true); break; - } - }; - current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } }; current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } } +function draftWorker(): Worker { + const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); worker = current; + current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { + if (worker !== current) return; + switch (data.type) { + case 'compiled': + stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; compiledSource = sourceEditor.value; compiled = true; totalWork = data.stats.work; + exportsInput.replaceChildren(); + for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } + if (data.names.includes(preferredName)) exportsInput.value = preferredName; + dispose(true); el('status').textContent = 'Compiled'; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; + el('compiler-stats').textContent = compilationStats; + break; + case 'state': + if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = operation === 'compiling' ? 'Compiling' : 'Running'; } + else { pauseClock(); pending = { id: data.id, kind: data.state }; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; } + break; + case 'trace': { + const row = document.createElement('li'); row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; el('trace').append(row); el('trace-empty').hidden = true; break; + } + case 'output': el('terminal').textContent += data.text; break; + case 'done': + totalWork += data.stats.work; + dispose(true); el('status').textContent = 'Finished'; + if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } + else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; + el('compiler-stats').textContent = `${compilationStats} · ${totalWork.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; + break; + case 'error': finish(data.message, true); break; + } + }; + current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } }; + return current; +} function runDraft(): void { - if (!compiled || !worker) return; - operation = 'running'; + if (!compiledDraft) return; + dispose(true); operation = 'running'; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); + settings = readSettings('compiler'); clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); - } catch (error) { el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; operation = undefined; } + const current = draftWorker(); + current.postMessage({ type: 'restore', draft: compiledDraft, ...settings }); + current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); + } catch (error) { el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; dispose(true); } } const examples = { identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, @@ -469,11 +485,13 @@ for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compile const example = examples[button.dataset['compilerExample'] as keyof typeof examples]; if (!example) return; chooseProgram(''); editingSource = true; el<HTMLDetailsElement>('source-section').open = true; - dispose(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; - exportsInput.replaceChildren(); preferredName = example.name; + dispose(true); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; + if (!compiled) exportsInput.replaceChildren(); + preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = example.output; mode.value = example.mode; - controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; el('status').textContent = 'Ready to compile'; + controls(false); el('source-label').textContent = 'Scratch program · contract helpers'; + el('status').textContent = compiled ? 'Source edited · Run draft uses the last successful compilation' : 'Ready to compile'; }; function loadFragment(): boolean { const fragment = new URLSearchParams(location.hash.slice(1)); @@ -537,7 +555,7 @@ window.addEventListener('pane-change', () => { }); window.addEventListener('limits-change', event => { const runner = (event as CustomEvent<string>).detail; - if (runner === 'compiler') { if (draft) { dispose(); clearOutput(); el('status').textContent = 'Ready to compile'; } return; } + if (runner === 'compiler') { if (draft) { dispose(true); clearOutput(); el('status').textContent = compiled ? 'Compiled' : 'Ready to compile'; } return; } if (runner !== 'package' || draft) return; preferredExport = exportsInput.value || preferredExport; dispose(); loaded = false; clearOutput(); diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -1,9 +1,11 @@ import type { ExecutionLimits } from './execution.js'; -import type { GraphStats } from './graph.js'; +import type { GraphCheckpoint, NodeId, GraphStats } from './graph.js'; import type { OutputType } from './codecs.js'; import type { RequestId, WorkerMessage } from './protocol.js'; +export interface CompiledDraft { graph: GraphCheckpoint; exports: Array<[string, NodeId]>; } export type CompilerClient = { type: 'compile'; source: unknown; environment?: string; uncapped?: boolean; limits?: ExecutionLimits } + | { type: 'restore'; draft: CompiledDraft; uncapped?: boolean; limits?: ExecutionLimits } | { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'resume'; id: RequestId; value?: string }; export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }> - | { type: 'compiled'; names: string[]; stats: GraphStats; milliseconds: number }; + | { type: 'compiled'; names: string[]; draft: CompiledDraft; stats: GraphStats; milliseconds: number }; diff --git a/ext/demo-site/src/compiler-worker.ts b/ext/demo-site/src/compiler-worker.ts @@ -52,7 +52,7 @@ async function compile(source: unknown, settings: ExecutionSettings, profileName } exports = compiledDefinitions(graph, graph.apply(compiler, [environment, input])); graph.collect(exports.values()); // Safe phase boundary; compiler/input temporaries are no longer caller roots. - phase = 'ready'; send({ type: 'compiled', names: [...exports.keys()], stats: graph.stats, milliseconds: performance.now() - start }); + phase = 'ready'; send({ type: 'compiled', names: [...exports.keys()], draft: { graph: graph.checkpoint(), exports: [...exports] }, stats: graph.stats, milliseconds: performance.now() - start }); } async function run(message: Extract<CompilerClient, { type: 'run' }>): Promise<void> { if (!graph || !exports) throw new Error('Compile a unit first'); @@ -84,7 +84,13 @@ scope.onmessage = ({ data }) => { } let operation: Promise<void>; try { - if (message['type'] === 'compile' && phase === 'empty') { + if (message['type'] === 'restore' && phase === 'empty') { + // Only the owning page supplies this trusted in-memory checkpoint. + const restored = data as Extract<CompilerClient, { type: 'restore' }>; + const budget = executionBudget(executionSettings(restored.limits, 'compiler', restored.uncapped ?? false)); + graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); + exports = new Map(restored.draft.exports); phase = 'ready'; return; + } else if (message['type'] === 'compile' && phase === 'empty') { if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment'] as string | undefined); } else if (message['type'] === 'run' && phase === 'ready') { diff --git a/ext/demo-site/src/graph.ts b/ext/demo-site/src/graph.ts @@ -16,6 +16,8 @@ export interface GraphStats { nodes: number; work: number; fuel: number; allocations: number; peakNodes: number; collections: number; reclaimed: number; gcWork: number; } +// Trusted, structured-cloneable checkpoint; never forces lazy nodes or expands sharing. +export interface GraphCheckpoint { nodes: Array<[NodeId, Node]>; nextId: number; } export class Graph { readonly leaf = 0 as NodeId; // Monotonic handles are never reused: collection cannot turn a stale handle @@ -42,6 +44,14 @@ export class Graph { this.threshold = collection.threshold; } } + checkpoint(): GraphCheckpoint { return { nodes: [...this.heap], nextId: this.nextId }; } + static restore(checkpoint: GraphCheckpoint, budget: Partial<Budget> = {}, collection?: CollectionPolicy): Graph { + const graph = new Graph(budget, collection); + if (checkpoint.nodes.length > graph.budget.nodes) throw new Error('Graph allocation limit exceeded'); + graph.heap = new Map(checkpoint.nodes); graph.nextId = checkpoint.nextId; + graph.peak = graph.heap.size; + return graph; + } get stats(): GraphStats { return { nodes: this.heap.size, work: this.spent, fuel: this.remaining, allocations: this.nextId, peakNodes: this.peak, collections: this.collections, reclaimed: this.reclaimed, gcWork: this.gcWork }; diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -704,13 +704,16 @@ try { await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); console.log('PASS curated intro edit with explicit empty environment',await evaluate("document.getElementById('compiler-stats').textContent")); - // An edit invalidates the compiled heap immediately. A failed draft never - // executes the previously compiled 43 or the published 42. + // Edits keep the last successful definitions runnable until a compilation + // fails. Failed compilation must never fall back to the earlier 43 or published 42. await evaluate("document.getElementById('source-editor').value='answer = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); - assert.equal(await evaluate("document.getElementById('export').disabled"),true); - assert.equal(await evaluate("document.getElementById('run').disabled"),true); + assert.equal(await evaluate("document.getElementById('export').disabled"),false); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await click('run');await wait(status('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); await click('compile');await wait(status('Stopped')); assert.match(await evaluate("document.getElementById('result').textContent"),/Compiler diagnostic/); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); await click('restore-source');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-editor').value"),publishedSource); assert.match(await evaluate("document.getElementById('authorship').textContent"),/Published/); @@ -733,7 +736,7 @@ try { assert.equal(await evaluate("document.getElementById('download-bundle').disabled"),false); console.log('PASS named releases / exact bundle download / local draft invalidation / failure without fallback / restore / arbitrary read-only packages'); await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - // Compiler UI: compile outputs stay in the Worker and never become flat source. + // Compiler UI: checkpoint transport preserves lazy DAGs, never flat source. const compilerStatus=text=>`document.getElementById('status').textContent===${JSON.stringify(text)}`; await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); await click('compile');await wait(compilerStatus('Compiled')); @@ -746,6 +749,34 @@ try { assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); assert.equal(await evaluate("document.getElementById('run').disabled"),false); const compiledRequests=requests.filter(path=>path.endsWith('/compiler.json')).length; + // Output interpretation errors discard only the application, not compilation. + await evaluate("document.getElementById('output-type').value='string'"); + await click('run');await wait(compilerStatus('Stopped')); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await evaluate("document.getElementById('output-type').value='number'"); + for(const action of ['retry','reset','stop']){ + if(action==='reset')await click('reset'); + if(action==='stop')await click('stop'); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await click('run');await wait(compilerStatus('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + } + // A failed bounded run and subsequent limit change also retain the checkpoint. + await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-fuel').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); + await click('run');await wait(compilerStatus('Stopped')); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); + await click('run');await wait(compilerStatus('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + + // Even termination by the main-page deadline must leave runnable definitions. + await evaluate(`document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-fuel').value='0';document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit(); + globalThis.savedDraftPost=Worker.prototype.postMessage; + Worker.prototype.postMessage=function(message,...args){if(message.type!=='run')return globalThis.savedDraftPost.call(this,message,...args);}`); + await click('run');await wait(compilerStatus('Stopped'),5); + assert.match(await evaluate("document.getElementById('result').textContent"),/^Program execution stopped: active time limit exceeded\.$/); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await evaluate("Worker.prototype.postMessage=globalThis.savedDraftPost;delete globalThis.savedDraftPost;document.getElementById('compiler-limit-seconds').value='0';document.querySelector('#compiler-settings .limits-editor').requestSubmit();document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); for(const value of ['7','99']){ await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`); await click('run');await wait(compilerStatus('Finished')); @@ -756,6 +787,7 @@ try { await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'"); assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/optional limits off/); await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); + await evaluate("document.getElementById('compiler-limit-fuel').value='0';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); console.log('PASS uncapped compiler UI / corrected arguments / repeated values without recompile / profile disposal'); assert.equal(await evaluate("document.getElementById('source-editor').maxLength"),32768); await evaluate("document.getElementById('source-editor').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); @@ -767,6 +799,7 @@ try { for(const example of ['pair','definitions']){ await evaluate(`document.querySelector('[data-compiler-example=${example}]').click()`); + assert.equal(await evaluate("document.getElementById('run').disabled"),false,'scratch source presets retain the last compilation'); await click('compile');await wait(compilerStatus('Compiled')); assert.equal(await evaluate("document.getElementById('export').value"),example==='pair'?'pair':'answer'); if(example==='definitions')assert.equal(await evaluate("document.getElementById('export').options.length"),2); @@ -828,6 +861,17 @@ try { await wait(compilerStatus('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned string: "${value}"`); } + const beforeRestart=requests.filter(path=>path.endsWith('/compiler.json')).length; + await click('run');await wait(compilerStatus('Waiting for input')); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await click('run');await wait(compilerStatus('Waiting for input')); + await click('tab-how');await click('tab-playground'); + assert.equal(await evaluate("document.getElementById('run').disabled"),false); + await click('run');await wait(compilerStatus('Waiting for input')); + await evaluate("document.getElementById('line-input').value='after cancel';document.getElementById('input-form').requestSubmit()"); + await wait(compilerStatus('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "after cancel"'); + assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,beforeRestart); console.log('PASS repeated compiled IO / fresh input / stepping / retained definition roots'); await evaluate("document.getElementById('source-editor').value='id = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(compilerStatus('Stopped')); diff --git a/ext/demo-site/test/graph.test.mjs b/ext/demo-site/test/graph.test.mjs @@ -143,3 +143,20 @@ test('IO action/output budgets and abort do not force arbitrary payloads',async( await assert.rejects(runIO(g,io,host()),/Execution aborted/); assert.ok(g.stats.work<500); }); + +test('draft checkpoints preserve sharing and lazy poison across failed disposable runs',()=>{ + const g=new Graph({fuel:10000}); + const q=g.fromTernary('212110021100'), loop=g.app(q,q); + const partial=g.stem(loop), shared=g.fork(partial,partial); + g.collect([shared]);const stats={...g.stats}; + const checkpoint=structuredClone(g.checkpoint()); + assert.deepEqual(g.stats,stats,'capturing does not evaluate or spend fuel'); + const attempt=()=>Graph.restore(checkpoint,{fuel:500,nodes:10000,stack:100}); + const first=attempt();assert.throws(()=>first.force(loop),/limit/); + const next=attempt(); + assert.deepEqual(next.observe(shared).node,{kind:'fork',left:partial,right:partial}); + assert.deepEqual(next.observe(partial).node,{kind:'stem',child:loop}); + assert.deepEqual(next.checkpoint(),checkpoint,'failed run never mutates checkpoint'); + assert.throws(()=>Graph.restore(checkpoint,{nodes:1}),/allocation/); + assert.throws(()=>next.force(loop),/limit/); +});