emergent_testing: keep what the shared-runner attempt taught, revert the code - #1737
Conversation
The browser runner and `fjs t` implemented the same proof semantics twice — leaf discovery, tree walking, the structural `throw` expectation, promise resolution, path formatting, counting — in `emergent_testing/browser.mjs` and `emergent_testing/module.f.mjs`, and had begun to drift. They are now one runner. `runModuleMap` is the single source of truth; a host supplies a `Reporter` and an effect interpreter and nothing else. - `fjs/effects/common/` holds the operations no host owns — `all`, `await`, `fetch`, `import`, `now`, `sandbox` and the `IoError` helpers — moved out of `fjs/effects/node/`, which re-exports every one of them unchanged. - `fjs/effects/browser/module.mjs` interprets exactly that set against a browser realm, and takes the composed runner so a page can add operations of its own. - `emergent_testing`'s new `report`/`reported` operations and `recordingReporter` normalize each leaf into a `TestResult` carrying no terminal text and no DOM. - `emergent_testing/browser/module.f.mjs` is the pure browser application — link, run, report — provable from Node with a stand-in interpreter; `emergent_testing/browser/module.mjs` is left with the DOM, the published promise and the completion event. Verified end to end in Chromium: 3435 proofs, all passing, rendered and published from the generated page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | b9b273c | Commit Preview URL Branch Preview URL |
Aug 27 2026, 10:55 AM |
|
You have reached your Codex usage limits for security reviews. Please try again later. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01f29ad7fe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…without a report Two defects found by review, both reproduced first and both regressions against the deleted runner. `all` started every child in the same turn, so the macrotask the `report` handler awaited paused only that result's own continuation while its siblings ran on: no task boundary, and a page frozen on its first frame until the last proof body finished. The slicing belongs to the browser interpreter, where the scheduling is, so `all` now runs its children 25 at a time and yields between slices; the adapter's counter is gone. Reading the tree a proof returns runs user code, and the shared traversal has no `try`/`catch` to give it, so a throwing getter panicked through the run: the promise rejected, the page stayed in `running`, and no report or completion event ever arrived — the one outcome an automated controller cannot act on. The adapter now turns that panic into an `infrastructure-error` report. Attributing such a failure to the leaf that caused it, rather than to the run, stays in `todo/hostile-proof-values.md`, which is updated to say what is now handled and what is not. Verified in Chromium: loading progress and result rows advance throughout the run (349 → 3435) instead of appearing only at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Measured at b4575877a (head has since moved to 0aac2255). Gates green: npm test 3481/3481 exit 0 vs main's 3470/3470, tsc --noEmit exit 0 on both.
The unification is real, not a re-skin: fjs/emergent_testing/module.f.mjs holds the semantics, and both entry points import it — node via fjs/module.f.mjs:11 → testAll(defaultReporter) → runModuleMap, browser via browser/module.mjs:35 → browser/module.f.mjs:28, which imports recordingReporter, reported and runModuleMap from the same module. share-browser-console-runner.md is deleted because it shipped, with its unshipped half correctly moved to website/todo/website-preparation-program.md and zero dangling references. Changelog: is literal and matches the entry file. fjs/effects/browser/module.mjs is new but is exercised by npm test.
Four findings:
-
A throwing getter in a proof tree now kills the whole run — undocumented, and a regression. Probe at head: the producing leaf prints
ok, thenError: getter … at Function.entries … at parseTestSet (module.f.mjs:83) … at collectTests (module.f.mjs:100)escapes outside any sandbox and noNumber of testssummary is printed at all. Main proved the opposite inexportedTreeThrows/returnedTreeThrows(one failed test, run still reaches a terminal state).todo/hostile-proof-values.mdcoverserrorDetailsand cross-realm promises but never mentionsparseTestSet/Object.entriesover a hostile tree, so this one is not among the trade-offs the PR declares. -
Six leaves are lost outright, and two of them cover live behaviour that is now unproven:
sourcesLoadingSummaryIsSynchronousandsourcesProgress— theLoading n/mtext still exists inbrowser/module.mjsand still carries the comment defending the synchronous write, butgrep Loading browser/proof.mjsreturns nothing, so the bug fixed two PRs ago is now unpinned. Also gone:runControlNewRunAfterCompletion(re-running on the same root),reportingThrows,exportedTreeThrows,returnedTreeThrows. The other 37 map cleanly — 14 movedeffects/node→effects/commonverbatim, 10 browser renames, 4 folded intoproof.helpers.errorDetails.*, 9 dropped deliberately and documented. -
sandboxandioare duplicated byte-identically between the two interpreters —effects/node/module.mjs:254-272vseffects/browser/module.mjs:76-94(diffempty), likewisenode:99-102vsbrowser:57-60, andawaitPromiseatnode:275vsbrowser:109. The browser file's own comment says "exactly as the Node runner does". That is the drift this PR set out to remove, surviving in the one place there is no impureeffects/common/module.mjsto hold it. -
The browser suite is still unreachable from CI — third PR in a row. Measured, not inferred: added a failing
.f.mjsproof,npm run website→ exit 0, and the failing module is present in the generated_browser-suite.mjs.npm testexits 1 only becausefjs truns the same file, which was already true on main. Nothing in.github/workflows/ci.ymlstarts a browser and nothing consumesfjsBrowserTestReportorfjs-browser-test-complete. Unifying the runners was the natural moment to close this; it is still open.
Two notes, not blocking. The cross-realm rejection reported as a pass reproduces exactly as the changelog declares (proof.crossRealmRejection(): ok, then the unhandled rejection ends the process with no summary) — deliberate, but worth stating plainly that main asserted tests: 2, failed: 1 there. And a pre-existing gap this PR does not touch: making collectTests skip every leaf whose key starts with e survives npm test at exit 0 with 3131 of 3481 proofs silently gone — nothing anywhere asserts a proof count. Killing that would need one assertion, and it would have caught finding 2 as well.
…iew found missing `sandbox`, `io` and the `await` body were byte-identical in the Node and browser interpreters, with a comment in one saying it matched the other — the drift this PR exists to remove, surviving where there was no impure module to hold it. `effects/common/module.mjs` holds them now, with its own proofs: nothing in them touches a host, and `sandbox` in particular is the operation that executes a proof body, so a runner spelling it its own way would make one suite mean different things in two hosts. Four proof leaves that the move dropped are back, and two of them pin live code that had gone unpinned: `loadingSummaryIsSynchronous` and `loadingProgress` defend the synchronous `Loading n/m` write, and `newRunAfterCompletion` covers re-running on the same root. `renderingThrows` came back with the guard it proves — showing a result is the page's own code, and a renderer that throws must not cost the report every consumer is waiting for; the result is recorded before it is rendered. `browser-testing.md` records what the review measured: the "demonstrably execute inside browsers" gate for a CI job is met, the controller is what still blocks one, and no runner asserts a floor on the number of proofs it discovers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
Thank you — this is the review that found the two things I had wrong. Head is now 1. Throwing getter — half already fixed, half not this PR'sYou measured On Identical. What is genuinely still worse than main is attribution: main lost one test, this loses the run. I looked hard for a small fix and did not find one. Sandboxing the traversal is the obvious move and does not work — 2. Lost leaves — four back, two of them the ones that matteredYou were right that The remaining two are 3. Duplicated
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc10dcf160
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…sing every infrastructure error as a load failure The guard added for a panicking proof tree was not itself total: describing the panic reads the value that caused it, so a proxy whose traps throw *itself* made `errorDetails` panic in turn and the page stuck in `running` again — the guard becoming the thing it was meant to prevent. It is the last handler there is, so it now says what it cannot describe rather than being thrown by it. `infrastructure-error` covers a run that panicked and a runner missing an operation as well as a module that would not link, so the summary no longer claims they all "failed to load" — a false diagnosis sends a reader to debug their imports. Each result still carries its own module and message. The browser clock reads `performance.timeOrigin + performance.now()` rather than `Date.now()`. The operation means the same thing — milliseconds since the epoch, as the Node runner answers — but a suite runs for minutes, and a report's duration is the difference between two reads: with wall-clock time an NTP correction inside a run makes that negative or inflated, which is what the deleted runner avoided by measuring in `performance.now()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Measured at dc10dcf16. Gates green: npm test 3496/3496 exit 0 vs main's 3470/3470, tsc --noEmit exit 0 on both.
Two of the four are closed.
F3 fixed. effects/node/module.mjs:33 and effects/browser/module.mjs:22 both now import { awaitPromise, io, sandbox } from '../common/module.mjs', and a grep for local redefinitions in either file returns nothing. effects/common/proof.mjs is discovered and run — 9 leaves (io.value/.thrown, sandbox.value/.thrown/.thenable/.promise/.rejected, awaitPromise.plainValue/.promise).
F2 mostly fixed, and the decisive one bites. The names came back restructured rather than verbatim: loadingSummaryIsSynchronous, loadingProgress, newRunAfterCompletion, renderingThrows, hostileProofTree. Deleting the synchronous summary.textContent = \Loading 0/${sources.length}`write atbrowser/module.mjs:115is **KILLED** — 3495 pass / 1 fail,proof.loadingSummaryIsSynchronous(): error— so that is a real restoration, not a name. Still with no counterpart:exportedTreeThrowsandreturnedTreeThrows`, which is finding 1 below in proof form.
F1 still stands, and the mitigation landed on the wrong side. The new hostileProofTree proof exercises the browser's startBrowserTestSources; the finding was about the fjs t path, and collectTests/walk in emergent_testing/module.f.mjs are still unguarded. Re-measured by inserting get hostileGetter() { throw new Error('trap-f1') } into the exported proof object of fjs/proof.f.mjs and running the full npm test — uncaught crash, no Number of tests summary, Node's default crash exit, same stack as before:
Error: trap-f1
at get hostileGetter (fjs/proof.f.mjs:25:33)
at Function.entries (<anonymous>)
at parseTestSet (fjs/emergent_testing/module.f.mjs:83:31)
at collectTests (fjs/emergent_testing/module.f.mjs:100:17)
at walk (fjs/emergent_testing/module.f.mjs:205:25)
Main proved the opposite through exportedTreeThrows/returnedTreeThrows. Since the two runners are now one, guarding parseTestSet would cover both paths at once and restore those two leaves.
F4 unchanged — fourth PR in a row. Added a failing leaf to fjs/proof.f.mjs: npm run website exits 0 while the failing module is in the generated _browser-suite.mjs ("browser proof modules: 139 of 139"), and grep -rl 'fjsBrowserTestReport\|fjs-browser-test-complete' .github/ still finds nothing. Nothing in CI gates on the browser suite's actual result.
…raversal Review pressed on the one gap left, and correctly: since the two runners are now one, guarding the proof-tree traversal once covers `fjs t` and the browser together. The todo carried a vague "read user values behind `sandbox`" and one wrong reason for why that does not work. Timing is not the obstacle — the sub-tree walk runs after the leaf's promise has resolved, so a synchronous thunk over a settled value would be fine. The obstacle is the virtual runner: its `sandbox` is a deliberate pass-through, because a `.f.mjs` runner has no `try`/`catch` to implement a real one with, and routing the traversal through it would break every fixture. So the design is a second, honest operation beside it — `catch`, "run this pure thunk; a throw is the `error` branch" — with the file-by-file work and the proofs it restores written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
Thanks — and thank you for mutation-testing the restored proof rather than taking the name for the thing. Head is F1 — you are right that unification makes this one fix, and I had the reason wrongI told you earlier that The actual obstacle is the virtual runner. Its sandbox: f => state => [state, ok(/** @type {SandboxResult<unknown>} */ (f()))],— with the fixture returning the So the fix is a second, honest operation beside it, and I have written the design into export type Catch = readonly['catch', <T>(f: () => T) => OpResult<Result<T, unknown>>]"Run this pure thunk; a throw is the I am not pushing it here. It adds an operation to the shared vocabulary, touches all three runners and both mock maps, and changes what What I did settle in this PR is the half that has no design question: the browser always terminates with a report, and the guard that does it is now itself total (57e295b, after the self-throwing-proxy finding). F4 — no argument, and the todo now says so in your termsEverything you measured is accurate and I have recorded it in Your proof-count note is also on the list there, in your framing: nothing in any runner asserts a floor, so a Where the PR standsAll 19 checks green on the current head. Generated by Claude Code |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approved. Verified at 68fc98372: npm test 3498/3498 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470).
Both commits are sound as scoped. The panic guard is real and load-bearing, not decoration: reverting browser/module.mjs:186-189 to a bare errorDetails(error) call is KILLED — the new unreadableFailure leaf reports error and, more tellingly, a later unhandled rejection then crashes the process with no Number of tests summary, which is exactly the failure signature the guard exists to prevent. The classification change is asserted too, by infrastructureSummaryNamesNoCause, not merely stated.
On the traversal: 68fc98372 designs the fix rather than making it, and says so. I re-ran my probe and the crash is unchanged — but todo/hostile-proof-values.md now scopes it correctly ("the whole run is lost … fjs t still ends with a stack trace and no summary"), names exportedTreeThrows/returnedTreeThrows as the leaves to restore, and is Status: open with all four tasks unchecked. That is the right way to defer it, and it clears the concern I raised about the mitigation landing on the browser side only.
The two open items stay open, neither introduced here: the unguarded collectTests traversal above, and the browser suite still being unreachable from CI — re-measured, npm run website exits 0 with a failing leaf baked into the bundle and .github/ consumes neither fjsBrowserTestReport nor fjs-browser-test-complete.
|
This is good PR but still far away from what it should look like. The first obvious observation is the formatting of messages. Everywhere (in test runners, in registers), we use the format A common runner that sends some generic effects (currently, sandbox, log, import, etc.). If the browser can't work with some effects like log, we lift an abstraction. Replace the log with an artificial effect (e.g. I don't propose changing it now, but we definitely need another TODO to share more code and logic across different platforms. Two more things,
|
… review asked for
The page rendered `./a.proof.f.mjs .x` where the terminal rendered
`import("./a.proof.f.mjs").proof.x()` — one identifier in two spellings, months
after the semantics were shared, because rendering was still per host. The
format now lives once, in `fmtCall`, which takes a module and an
already-rendered key chain so a reporter holding a `TestResult` names a test
exactly as `fjs t` does; `fmtImport` is that function over an unrendered `Path`.
`passing` asserts the rendered line, so the two cannot drift again.
The browser's `all` yields every ten effects rather than every twenty-five. A
count measures the wrong thing — proofs differ in cost by orders of magnitude,
so ten fast ones waste a boundary and one slow one stalls the page anyway — so
this is a mitigation and is labelled as one, with the elapsed-time design in
`todo/report-scheduling.md`.
Three TODOs, none of them changes here:
- `share-the-whole-runner.md` — the semantics are shared but the runner around
them is written per host: discovery, reporting and the outcome. Compares an
artificial effect per capability against injecting the host's verbs, with the
formatting drift above as the symptom to keep in mind.
- `report-scheduling.md` — yield on a time budget instead of a count.
- `imports-promises-realms.md` — a study, not a design: a module namespace
adopts a `then`, a proof tree refuses to, and `instanceof Promise` does not
survive a realm. Three mechanisms whose interaction nobody has written down,
which is why it keeps being rediscovered.
`hostile-proof-values.md` hands the cross-realm brand check to the last of
those rather than sketching it twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
All four done in 505766d. The formatting — you are right, and it is the sharpest possible criticism of this PRThe page rendered The format now lives once: export const fmtCall = (file, path) => `import(${JSON.stringify(file)}).proof${path}()`
export const fmtImport = (file, path) => fmtCall(file, fmtPath(path))
Batching → 10, with the real design written downDone. And your diagnosis is the right one: a count measures the wrong thing. Ten fast proofs waste a task boundary; one slow proof stalls the page for as long as it runs and no count would have helped. So the constant is labelled a mitigation in the code, and It also records the part I could not resolve by argument: a proof body is synchronous from the runner's point of view, so a budget bounds how many more start after a slow one, not the stall the slow one causes. Reporting a proof's start as well as its result may matter more, and is the cheaper experiment.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 505766d8a6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
One more thing. It looks like batching is exactly the premature optimization that is the root of all evil, and it shouldn't have happened. Nobody complained that it is slow. I would prefer to see it in another experiment (not in this PR), to remove batching completely and see how it behaves and only after that (if needed) solve the problem. Let's adjust the todo about scheduling so that we try no bactching/scheduling at all. The same way our CLI runner works. |
…han designing it The batching is premature optimization. It was not added because anyone found the suite slow — `fjs t` schedules nothing at all and nobody has complained — but because a page that renders nothing until the run finishes looked wrong in review. Observing a behaviour is not the same as someone having a problem with it, and the count moved from 25 to 10 with no measurement on either side. So the scheduling todo now asks for the opposite of a design: remove the batching entirely, run the real suite in a browser, and look at what actually happens — the page may paint anyway, since module loading is network-bound and dominates the first seconds. Only if that produces a stall someone objects to is there a problem to solve, and the elapsed-time argument is kept for that case rather than presented as the plan. The code says the same thing where the constant is defined, so a reader meets the probation before the rationale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
You are right, and I should say plainly what happened rather than defend it. I added the batching in a review round because a bot pointed out the page renders nothing until the run finishes, and I confirmed that it does. But confirming a behaviour is not finding a problem: nobody reported a stall, and
Only if that produces a stall someone objects to is there a problem, and the elapsed-time argument is kept for that case rather than presented as the plan. It also notes that The comment where On this PR: I read "(not in this PR)" as scoping the removal experiment out, so the code still batches at 10 and only the todo changed. If you would rather the batching come out now — it is a small, clean deletion, and leaving in something that shouldn't have happened is its own cost — say so and it is one commit. Generated by Claude Code |
|
BTW, 10 looks much, much better. Even if it runs longer, because it updates progress much more often, it looks much better. It confirms that batching is a wrong, premature optimization nobody asked for. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 569636be1c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ing about it Ran the real suite in headless Chromium four ways, 3435 proofs each, sampling how often the page visibly moves. Two findings, and the first contradicts what "remove the batching" expected. Removing the yield removes the progress: with plain `Promise.all` the first row appears at 39.8 s of a 39.7 s run — nothing at all until the end. A browser cannot paint between microtasks and every operation resolves through one, so the whole suite is a single task. It is not faster for it. The grouping was never the point; the clamp was. Yielding after every result — what a reader actually wants, and what `fjs t` does — costs 2% over no yielding at all when the yield is a `MessageChannel`. It cost 45% only through `setTimeout`, which clamps to 4 ms once nested: 3435 results times 4 ms is the whole difference. So batching was a workaround for a bad yield primitive, and the workaround is what made grouping look necessary. The todo carries the table and the change it indicates: delete `batchSize` and `runBatched`, yield after each result over an unclamped primitive, and re-point the proof at that. Not made here, per the request to keep it out of this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
That observation was worth more than either of our arguments, so I ran the experiment rather than reasoning further. Headless Chromium, the generated page, 3435 proofs each time, sampling every 150 ms. "Progress steps" is how many distinct result-counts a reader ever sees — how often the page visibly moves.
Your instinct was right, and your conclusion needs one correction. Right: grouping is the wrong mechanism, and it should go. What a reader wants is what The correction: removing the yield does not give you that. It gives you nothing at all until the end — first row at 39.8 s of a 39.7 s run. A browser cannot paint between microtasks, and every operation resolves through one, so the whole suite is a single task with no frame in it. The run is not even faster for it (39.7 s against 40.2 s). "10 looks much better than 25" extrapolates toward more yielding, not none — and the numbers agree: per-result is better again. What made grouping look necessary was my choice of yield, not the yielding.
I have not made the change here — you scoped the experiment out of this PR and I have kept to that. But it is now a deletion plus a three-line Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4923ddbf2b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * @type {(module: string, message: string) => TestResult} | ||
| */ | ||
| const infrastructureResult = (module, message) => | ||
| ({ module, path: '', status: 'failed', duration: 0, message, stack: '' }) |
There was a problem hiding this comment.
Preserve loader stacks in infrastructure results
In the generated website flow, when a native module import rejects with an Error, the browser import handler reduces it to an IoError and this helper always emits stack: ''. The deleted runner passed the rejection through errorDetails and retained error.stack, while fjs/emergent_testing/todo/browser-testing.md still requires failures to retain their stack; consequently load failures now lose the source location needed to diagnose the broken dependency graph.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Verified, and it is a regression rather than a declared trade-off — recorded in 0773613.
The measurement, against the browser import handler with a rejecting loader:
effect channel : ["error",["ioError",{"message":"Unexpected token in ./broken.f.mjs"}]]
what main kept : "SyntaxError: Unexpected token in ./broken.f.mjs" (+ frames)
The cause is one level below infrastructureResult's stack: ''. Linking is an Import effect now, so its failure is an IoError — { code?, message } — and toIoError drops the stack before the pure application ever sees it. There is nothing infrastructureResult could put there.
You are also right that it matters rather than being cosmetic: for a broken dependency graph the loader's frames are what name the importing module and line, which the message alone does not.
The fix is one additive optional field, stack?: string on IoErrorInfo, filled by toIoError and read by infrastructureResult. Worth saying why that is not a violation of the type's own rationale: IoError drops the thrown object because "a stack, a cause, and arbitrary own properties do not survive a wire hop" — which is right about the last two and wrong about a stack, since a stack is a string and serializes fine. The one subtlety is that reading .stack is a user-observable operation on a hostile value, the same exposure toIoError already has reading .message.
I have not made the change: it is a field on a type shared by every runner and every consumer of the effect layer, and the repository owner has twice asked me not to widen this PR. It is written up as a task on todo/browser-testing.md, beside the requirement it breaks, with the patch shape above. Land it here in one commit or take it as a follow-up — either is fine, and it is his call rather than mine.
Generated by Claude Code
The slice loop awaited each slice before starting the next, which broke `all`'s concurrency promise rather than delaying it: a child waiting on something a later sibling produces waited for a sibling that was never started, and the run hung with no report and no completion event — on a graph the Node runner completes. Reproduced with a proof that waits on a gate its eleventh sibling opens; it hangs before the fix and passes after. The loop now starts every effect, yielding between one slice's launch and the next, and awaits them all at the end. That keeps the paint boundary — what a slice does when it starts is exactly the work worth bounding, since a proof body runs synchronously inside `sandbox` before that handler's first `await` — while restoring the concurrency. `allStartsEveryChildBeforeAwaiting` pins it. It also corrects the scheduling todo, whose measurements were taken against the serializing loop. "Yield after every result" was the wrong way to describe the target: per-result yielding *is* sequential execution, a bigger break than the batching it was meant to remove. Per-*launch* yielding is the right shape, costs about 3% over no yielding at all, and reaches the first row sooner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approval still stands. Verified at 4923ddbf2: npm test 3498/3498 exit 0, tsc --noEmit exit 0 — unchanged from the approval point, as expected for three commits that add todo documents and one refactor.
The module.f.mjs change is behaviour-preserving where it matters and fixes a real drift where it does not: fmtCall(file, path) is extracted from fmtImport, which now calls fmtCall(file, fmtPath(path)) with the same template. Sorted proof leaf names across full runs at 68fc98372 and 4923ddbf2 differ in zero of 3498 lines. The actual behaviour change is that renderResult in browser/module.mjs now uses fmtCall too, so the page stops rendering ./a.proof.f.mjs .x where the terminal renders import(...).proof.x() — and that is pinned by a new assertion rather than left to inspection.
The three new todos all describe remaining work, all Status: open, and their claims about the current code check out on spot-check: batchSize = 10 in effects/browser/module.mjs, operations.allYieldsBetweenBatches present and passing, both main entry points where share-the-whole-runner.md says they are, and the referenced spec files exist.
The two known findings reproduce identically and remain open, neither touched by this delta: the unguarded collectTests traversal (same crash, same stack, no summary) and the browser suite still unreachable from CI (npm run website exit 0 with a failing leaf in the bundle, no consumer in .github/).
|
I've also checked |
Linking is an `Import` effect now and its failure is an `IoError`, which is
`{ code?, message }` — so a module-loading failure reports `stack: ''` where the
deleted runner reported the loader's own frames. Those frames are what name the
importing module and line for a broken dependency graph, and this section
already requires failures to retain a stack, so it is a regression rather than a
declared trade-off.
The task carries the fix: one additive optional field on `IoErrorInfo`. The
rationale that made `IoError` drop it — a stack does not survive a wire hop — is
right about `cause` and own properties and wrong about a stack, which is a
string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
Another issue (need to create a TODO). We now print |
You checked `batchSize = 1` and found it much more responsive, at almost twice the run time. It does not have to cost that: the 2x was `setTimeout`'s 4 ms clamp, not the yielding. Over a `MessageChannel` the same per-launch yield costs about 3%. So there is no batch size any more. `all` starts every effect, hands the event loop back between one launch and the next, and awaits them all at the end. Measured on the real suite in Chromium: 40.7 s against 39.7 s with no yielding at all, first row at 2.7 s (against 4.0 s at slices of ten), and roughly three times as many visible progress steps. No constant left to tune, which was the complaint. `todo/report-scheduling.md` is deleted — its experiment is done and its answer is the code above. The one part that outlives it, checking the yield primitive across browsers and whether `scheduler.yield()` is worth preferring, moves to the cross-browser task in `browser-testing.md`. `allStartsEveryChildBeforeAwaiting` is rewritten. It raced a 1 s wall clock, which in a suite of 3499 concurrent proofs measures how loaded the machine is — it failed once under coverage. It now counts turns of the event loop and records which opener reached the gate first, so it fails rather than hangs, and cannot flake. Checked in both directions against a serializing `all`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
|
Since you found So there is no batch size any more (401f191).
Your responsiveness, without the slowdown, and no constant left to tune.
One thing worth flagging:
I'm going to stop chasing further automated-review rounds here and leave the PR to you. Generated by Claude Code |
Every runner reports a test only once it has finished, so a running test is invisible: a slow proof looks like a hung runner, the browser page counts completions rather than naming where it is, and — the case that matters — when a proof takes the process down, the last line printed is the last test that *succeeded* and the one that broke is never named. `Reporter` has no event for it: `result` takes a `SandboxResult`, so it cannot be called before there is one. The todo adds a start event and notes that the easy part is the event; the real question is terminal output under concurrency, which is the same question in both hosts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
works very well for me and others. It could be a problem with your setup. |
|
It leads with the reason you gave, because it is the strongest one: when a proof takes the process down, the last line printed is the last test that succeeded, so the one that actually broke is never named — precisely the case where a name is worth more than a result, and the case the current design has none for. The other two are that a slow proof is indistinguishable from a hung runner, and that the page counts completions where a reader wants to know which test it is on.
Note I have unsubscribed this session from the PR's events, so I will not see further comments here. Ping me through the session if you want anything else on it. Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a74d4d654
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * | ||
| * @type {(root: Element, sources: readonly string[], importer?: BrowserImporter) => Promise<BrowserTestReport>} | ||
| */ | ||
| export const startBrowserTestSources = (root, sources, importer = source => import(source)) => { |
There was a problem hiding this comment.
Resolve default imports against the application root
When a caller relies on the newly optional importer and passes the relative source paths this API normally consumes (for example, ./proof.mjs), the import() expression resolves them relative to fjs/emergent_testing/browser/module.mjs, not relative to the page or application entry that called startBrowserTestSources. The generated website avoids this only by supplying its own lexical source => import(source) importer; other consumers using the advertised default will load the wrong URL and receive an infrastructure-error report. Either require the importer again or resolve default sources against the root document/application base URL.
Useful? React with 👍 / 👎.
`allYieldsBetweenLaunches` queued a `setTimeout(0)` before calling `all` and asserted the timer had fired by the time `all` resolved. That reads as engine-neutral and is not: bun delivers every pending `MessageChannel` message before it runs a due timer, so the 59 yields `all` performs keep a `setTimeout(0)` queued behind them indefinitely. Confirmed directly — 59 yields, then 100000 more, and the timer never fires under bun 1.3.11, while node fires it on the first. The proof was calling a yielding `all` a non-yielding one, and bun CI failed on it. Queue a `MessageChannel` message instead, which is the same queue `all` posts to, so the assertion states the property every engine agrees on: a launch ends the task, so anything already queued runs before `all` resolves. Still load-bearing — deleting the yield from `runYielding` fails it under bun. effects/browser/module.mjs is unchanged; the browser behaviour measured in Chromium is what it was. Also files a todo for browser timer precision: `performance.now()` is coarsened to 100 us in Chromium and rounded and jittered to 1 ms in Firefox, which is at or above what a typical proof takes, so the page's per-proof durations are largely the clamp rather than a measurement. Changelog: no user-visible change.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approval still stands. Verified at 5a74d4d65: npm test 3499/3499 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470).
The concurrency change is genuinely pinned, which is the part I was worried about — "start every child before awaiting any" is an ordering claim, and those are easy to assert without covering. Both mutants die: making all await each child as it goes fails allStartsEveryChildBeforeAwaiting (the fallback opens the gate instead of a later sibling), and removing the per-launch yield fails allYieldsBetweenLaunches (setTimeout(0) never fires before all resolves) — 3498/1 in each case.
Deleting report-scheduling.md is legitimate: tasks 1-3 shipped in cbc445420/401f1917c, and task 4 (the cross-browser scheduler.yield() check) is relocated to browser-testing.md:162-163 rather than dropped. report-before-running.md is unrelated new work, Status: open, and both claims I spot-checked hold — reports print only after completion (module.f.mjs:476, the format string needs duration) and Reporter has no start event, only result/summary/test (types.ts:65-69).
Both known findings reproduce unchanged and remain open: the unguarded collectTests traversal, and the browser suite still unreachable from CI.
|
In general, we would avoid all of these problems if we follow the simplest workflow that already exist |
"Reuse code" is satisfiable while still getting the important half wrong: share a module, then give the new context its own rules, and the result looks unified but is two behaviours behind one name. Records that the existing implementation is the specification for a port, that a difference has to be justified rather than merely noticed, and that a problem the new context reveals is fixed for the shared code or recorded as an issue -- never worked around in one host.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f5dd5977a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let started = [] | ||
| for (const effect of effects) { | ||
| if (started.length !== 0) { await yieldToLoop() } | ||
| started = [...started, run(effect)] |
There was a problem hiding this comment.
Attach rejection handlers before yielding between launches
When an early child of an all panics before the remaining children are launched, its promise remains without a rejection handler across one or more MessageChannel task boundaries because Promise.all(started) is attached only after the loop. I reproduced this with a throwing first Pure and later siblings: the outer call eventually catches the panic, but an unhandledrejection is emitted first (followed by rejectionhandled), so a browser controller can classify the page as crashed even though the adapter later publishes an infrastructure report. Attach a rejection handler to each promise as soon as it is started while preserving the eventual aggregate rejection.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
Restores every code file to main. The unified runner worked -- one `runModuleMap`, an `effects/common` layer, a browser interpreter, 100% coverage, green CI, 3435 proofs executed in Chromium -- but the way it got there is not a way worth repeating, and the record of why is worth more than the code was. What is left is the knowledge: - `share-browser-console-runner.md` is restored and now carries a "How to do this" section: `fjs t` is the specification including the things it does not do; sharing modules while keeping per-host rules looks like success and is not; the browser is sequential for a reason; a problem the port reveals is everyone's problem and belongs in an issue, not in a workaround inside the port. It also traces the six review rounds that a single unrequested batch-size constant produced, all the way to the bun failure, and ends where copying the example would have started. - `DESIGN.md` section 4 gains "Follow the example", the general form of the same rule. - Four issues the attempt surfaced stay, rewritten to describe the code as it is on main rather than as the branch left it: `hostile-proof-values.md`, `imports-promises-realms.md`, `report-before-running.md`, `timer-precision.md`. No code changes: the diff against main is documentation only.
fjs t and the browserThere was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 565f4cee73
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| the new context — `fjs t` runs proofs one after another, and its report is | ||
| readable, attributable and reproducible because of it. |
There was a problem hiding this comment.
Preserve fjs t's concurrent execution
fjs t does not run proofs one after another: runModuleMap and walk compose siblings with allOk, and the Node interpreter handles all with Promise.all(effects.map(runNodeEffect)) (fjs/effects/node/module.mjs:361). Treating sequential execution as the repository-wide specification—and repeating that premise in the sharing todo—would direct the future port to change existing behavior and can deadlock a proof that waits for a later sibling to start. Correct this factual premise before making it a design rule.
AGENTS.md reference: AGENTS.md:L47-L51
Useful? React with 👍 / 👎.
| `walk` then reads a sub-tree through `catch` and, on the `error` branch, reports | ||
| one failed result at that path instead of panicking — which is what restores | ||
| `exportedTreeThrows` / `returnedTreeThrows`, and gives `fjs t` a behaviour it | ||
| never had. `errorDetails` gets the same treatment at its one call site. |
There was a problem hiding this comment.
Make the proposed normalization fallback total
When errorDetails inspects a self-throwing proxy, wrapping that call in catch only produces another error containing the same hostile value; attempting to normalize that error repeats the trap, so the promised failed result still cannot be constructed. The fresh evidence in this documentation commit is that the proposed design records only “the same treatment” and never captures the trap-free terminal fallback learned from the earlier self-throwing-proxy fix. Specify a constant fallback that performs no operations on the caught value, and add that hostile case to the tasks, before this design is implemented.
AGENTS.md reference: AGENTS.md:L47-L51
Useful? React with 👍 / 👎.
…oth" The first draft of "Follow the example" read as a demand for identical behaviour, which is the wrong target: a browser has no stdout and a terminal has no DOM, so different APIs and wrappers around a shared core are the normal shape. It also understated the obligation that actually keeps two contexts together. Restates the principle as the cycle: share the code; adjust where the host requires it; document every difference that remains; open an issue for each problem the port revealed; solve each of those issues for every context at once. Differences are allowed -- undocumented ones are not, and a fix that lands in one context only is how the two drift apart again while hiding the finding from the older one. `share-browser-console-runner.md` follows the same wording, and its constraints now say that host APIs may differ freely, behaviour only for a written reason, and a fix for either runner lands in both.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Measured at 6f5dd5977. npm test 3499/3499 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470). The yield assertion is sound — removing the per-launch yield is KILLED with "all resolved without yielding to the event loop".
One thing to fix before merge: DESIGN.md:134 states a falsehood about the code it is justifying. It says fjs t "runs proofs one after another", but the node interpreter's all is async (...effects) => ok(await Promise.all(effects.map(runNodeEffect))) at effects/node/module.mjs:317, and effects/common/module.f.mjs:150-151 says so outright: "Every effect still runs… all performs them concurrently." A normative document that mis-describes the behaviour it reasons from is worse than one that omits it — the rest of the "Follow the example" section is normative rather than factual and is unaffected, so dropping or correcting that one sentence is enough.
For the record, on the queue question I raised: swapping the assertion's MessageChannel for setTimeout survives here, but that is expected and the commit says so — the setTimeout spelling only breaks under bun, and this machine has node only. Not a defect, just a limit on what I could verify.
timer-precision.md is correctly Status: open with everything unchecked, and both claims I spot-checked hold (sandbox's before/after performance.now() reads at effects/common/module.mjs:62-78, monotonic now at effects/browser/module.mjs:137).
Both long-standing findings reproduce unchanged: the unguarded collectTests traversal, and npm run website exiting 0 with a failing leaf — browser-prepare.mjs only scans text and never executes a proof, and .github/workflows/ references neither website nor browser-prepare.
"Share the code" left open where the sharing stops, which is the question that actually decides whether two runners stay one. States it: what is shared is the skeleton -- the control flow, the order of operations, the decisions and their names. Every context runs that same skeleton, and differs only by supplying a different part at a place the skeleton names. That leaves exactly two ways to accommodate a context, both additive: change that context's part, or improve the skeleton so every context benefits. There is no third. A branch inside the skeleton asking which host it is running on is a fork wearing a shared name, and a need no existing part can express is a missing extension point rather than a special case. `share-browser-console-runner.md` names the runner's skeleton and its parts in those terms -- execute a leaf, report a result, link a module -- and gains a task to check that nothing host-specific is left outside one of them.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b9b273c723
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| The rule that follows: **land the shared skeleton with behaviour unchanged, then | ||
| take each new problem as its own change — in the skeleton where it belongs | ||
| there, so both runners get it, or in every part at once.** An improvement the | ||
| browser could have is an issue, not something to introduce inside a port. A |
There was a problem hiding this comment.
Preserve browser defenses while sharing the runner
Fresh evidence in this documentation commit is the explicit ordering that lands the shared skeleton before addressing newly exposed problems. When the skeleton copies fjs t, this removes behavior the current browser runner already provides: hostile proof-tree reads become attributed failures there, and cross-realm promises are subscribed through the intrinsic then, while fjs t can crash or silently pass them. Following this sequence therefore introduces a browser regression between changes; require existing behavior from either runner to be preserved during the sharing change rather than deferred.
AGENTS.md reference: AGENTS.md:L103-L106
Useful? React with 👍 / 👎.
| **A value that resists being read is not attributed to the test that produced | ||
| it.** Two functions in the shared core read user-supplied values without a | ||
| guard: the `collectTests` traversal enumerates a returned proof tree, and | ||
| `errorDetails` reads `message`/`stack` and calls `String` on a thrown value. A |
There was a problem hiding this comment.
Point the guard at the CLI normalization sites
For hostile values thrown under fjs t, this problem statement identifies errorDetails as an unguarded shared-core reader, but at this commit that helper is browser-only and already guards both property access and String conversion (browser.mjs:24-57). The actual CLI coercions are the unguarded String(v) and template interpolation in defaultReporter.result (module.f.mjs:402-412), so following the proposed tasks leaves the CLI panic path untouched. Rewrite the design around those reporter normalization sites.
AGENTS.md reference: AGENTS.md:L47-L51
Useful? React with 👍 / 👎.
| ## Investigate imports, promises and realms | ||
|
|
||
| **Priority:** P3 | ||
| **Status:** open — investigation, not yet actionable |
There was a problem hiding this comment.
The repository's todo format restricts Status to exact values such as open, but this value appends an explanation and therefore no longer matches any allowed status. Keep the field as **Status:** open and move “investigation, not yet actionable” into the issue body so status searches and tooling can classify it consistently.
AGENTS.md reference: AGENTS.md:L55-L56
Useful? React with 👍 / 👎.
This PR no longer changes any code. The diff against
mainis documentationonly; every
.mjs,.tsand.jsonfile is byte-identical tomain.An earlier revision of this branch implemented
emergent_testing/todo/share-browser-console-runner.md: one sharedrunModuleMap, aReporterper host, aneffects/commonlayer, a browserinterpreter, 100% coverage, 19 green checks, and 3435 proofs executed in
Chromium. It worked. It is reverted anyway, because how it got there is a cost
worth not paying twice, and the record of why is worth more than the code was.
What is kept
emergent_testing/todo/share-browser-console-runner.md— restored, and witha new "How to do this — read before designing" section. The issue's design was
never the problem; the order of work was. It now says so:
fjs tis the specification, including the things it does not do. Theattempt shared the modules and then let the browser keep its own test-name
format, its own scheduling policy and its own clock. That looks like success
and is not: one name over two behaviours is worse than two names over two
implementations, because nothing signals the difference.
fjs tis sequential, and that is a decision to copy. The attemptinvented a batch size nobody asked for and no measurement motivated. The
section traces what followed, in order — a batching bug, a deadlock on a graph
fjs tcompletes, a flaky proof, a proof that passed for the wrong reason, aMessageChannelyield needed only becausesetTimeoutclamps, and finally abun failure caused by that
MessageChannel— six review rounds, all downstreamof one constant that was ultimately deleted. The end state, no batching at all,
is where copying the example would have started.
hostile values are not browser bugs to work around locally;
sandboxisshared, so each is one decision for both hosts, taken as its own change.
scheduling argument could not be separated from the sharing argument.
It also gains constraints the attempt had to learn the hard way: both runners
must produce the same test name for the same leaf; no host-specific behaviour
fjs tdoes not already have; and a proof must assert the property rather thanan engine's incidental scheduling, because node, deno and bun do not agree on
the ordering of timers against other task sources.
DESIGN.md§4, "Follow the example" — the general rule. "Reuse code" issatisfiable while still getting the important half wrong. When a capability
already exists and is being brought to a second context, the existing one is the
specification: reproduce it first, including its simplifications; a difference
has to be justified in an issue rather than merely noticed; and a problem the
new context reveals is fixed for the shared code or recorded — never worked
around in one host.
Four issues the attempt surfaced, each rewritten to describe the code as it
is on
mainrather than as the branch left it:hostile-proof-values.md— the traversal anderrorDetailsread user valueswith no guard, and a
catchoperation besidesandboxis the design. Thebrowser has partial cover here and
fjs thas none, so unifying forces oneanswer.
imports-promises-realms.md— a namespace adopts athen, a proof treerefuses to, and
instanceofdoes not survive a realm. The browser'sSymbol.speciesmachinery is the only place the third is covered today.report-before-running.md— every runner names a test only after it finishes,which is exactly wrong when a run crashes.
timer-precision.md—performance.now()is coarsened to 100 µs in Chromiumand rounded and jittered to 1 ms in Firefox, at or above what a typical proof
takes, so per-proof durations there are largely the clamp.
Verification
git diff origin/main -- '*.mjs' '*.ts' '*.json'is empty. The reverted tree ismain's tree, sofjs t,bun testand coverage aremain's results.Changelog: no user-visible change — documentation only.
🤖 Generated with Claude Code
https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg