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:
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/);
+});