From 08a4de9f7d84710cc7d22f7998accfad403bd626 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 15:07:22 +0800 Subject: [PATCH 001/150] docs(compat): design runnable corpus sprint - #80 Define the audited runnable-corpus denominator, five-hour quantitative gates, source-driven three-stage workflow, bounded parallel ownership, and PR evidence contract. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../20260802-corpus-coverage-w43-design.md | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 .agents/docs/20260802-corpus-coverage-w43-design.md diff --git a/.agents/docs/20260802-corpus-coverage-w43-design.md b/.agents/docs/20260802-corpus-coverage-w43-design.md new file mode 100644 index 00000000..bd816b25 --- /dev/null +++ b/.agents/docs/20260802-corpus-coverage-w43-design.md @@ -0,0 +1,422 @@ +# Node/Bun runnable corpus 100% campaign design (W43) + +Date: 2026-08-02 + +Tracking issue: #80 + +Target branch: `rewrite_bun_in_mcpp` + +Campaign branch: `agent/corpus-coverage-w43` +Approved direction: evidence-first five-hour sprint toward an unchanged final +100% runnable-corpus target + +## 1. Goal and acceptance boundaries + +This campaign has two explicit acceptance layers. They must never be collapsed +into one claim. + +### 1.1 Final campaign goal + +The final goal is 100% of the runnable, locally provisionable native Node and +Bun corpora on one frozen mbun binary: + +| Corpus | Raw denominator | Currently excluded classification | Current runnable floor | +| --- | ---: | --- | ---: | +| Node `test/parallel` | 4,433 | 535 upstream self-skips | 3,898 / 3,898 | +| Bun `test/**` | 1,902 | 73 all-skipped, 6 no-tests, 19 blocked-external | 1,804 / 1,804 | +| Combined | 6,335 | 633 classified exclusions | 5,702 / 5,702 | + +The raw `x/4433`, `x/1902`, and `x/6335` figures remain public beside the +runnable figures. Exclusions are not passes. The 3,898 and 1,804 denominators +are floors derived from the current classifications, not permission for future +runs to skip more files. A self-skip or all-skipped result that exists because +mbun lacks a capability remains actionable; only a pinned-upstream platform, +environment, or service exclusion survives the final audit. When implementing +a capability makes a file runnable, the runnable denominator grows and that +file must pass. A Bun `ahead-of-reference` result is not silently counted as +green: it is inspected and either converted to the current upstream contract +or retained as a named, evidence-backed category. + +The last complete Linux measurement was made on the PR #36 merge tree rather +than current target commit `163cb6d`: + +- Node: 3,136 pass, 662 fail, 535 skipped, 98 timeout, 2 oom-kill. +- Bun: 1,042 green, 701 test-failure, 73 all-skipped, 45 timeout, + 19 blocked-external, 4 crash, 4 load-error, 4 oom-kill, 6 no-tests, + 4 ahead-of-reference. + +Those numbers imply a provisional runnable gap of 1,524 files. They are only a +planning input. The first W43 checkpoint replaces them with a full run on the +current target and one frozen binary. + +### 1.2 First five-hour sprint goal + +The five-hour sprint is an implementation checkpoint, not a redefinition of +final success. Its acceptance target is: + +- fresh full Node and Bun baselines on current target commit `163cb6d`; +- net +30 to +45 real pass/fully-green files; +- Node contribution +22 to +33 and Bun contribution +8 to +12; +- every claimed new green file repeated serially on the candidate binary; +- zero previously green file regressions; +- no increase in timeout or oom-kill counts; +- refreshed full summaries, README metrics, committed data record, changelog, + and batched PR milestone comments; +- Linux GCC 16.1.0 and LLVM 22.1.8 CI terminal states reported without + inferring pending lanes. + +Failure to reach +30 is reported as a missed sprint target with the measured +cause. It is not rewritten as success. Exceeding +45 does not remove the final +5,702/5,702 gate. + +## 2. Why five hours cannot mean immediate 100% + +The provisional runnable gap is 1,524 files. Historical long-tail measurements +show that a mechanism normally converts one to three files, with about ten +minutes of source reading, implementation, build, and focused measurement per +mechanism. Measured lane averages are approximately 3.4 Node files/hour and +1.7 Bun files/hour before coordinator integration overhead. + +With this session's maximum of three simultaneous worker agents, a realistic +five-hour integrated result is +30 to +45 files. At a sustained net rate of +6–9 files/hour, the current provisional gap implies roughly 170–255 coordinator +wall-hours, or 34–51 five-hour sprints. The rate is recalculated after every +fresh full checkpoint from the trailing three waves; it is not presented as a +fixed delivery promise because the remaining tail can become harder or expose +a high-density shared cause. + +Raw 6,335/6,335 is not an alternative completion claim: it would require +counting self-skips, no-tests, or unavailable external services as green, which +would weaken the repository's measurement contract. + +## 3. Architecture and ownership boundaries + +### 3.1 Coordinator + +The coordinator owns all shared and authoritative operations: + +- freezes the base commit and binary identity; +- runs release builds through `tools/integration/build_lock.sh` or + `build_or_die.sh`; +- runs full Node/Bun corpus measurements exactly once per full checkpoint; +- generates disjoint worklists from the current result set; +- rejects source-touch overlap before dispatch; +- reviews and integrates lane commits; +- serially re-runs every claimed green file; +- runs impact and cross-corpus regression gates; +- updates README, `compat/data/mbun-corpus-runs.json`, `changelog.md`, and the + W43 progress record from the same summaries; +- pushes milestone commits and publishes batched PR comments. + +No worker may publish a whole-corpus number or update the shared metrics. + +### 3.2 Worker lane + +One logical lane has exactly one owner, one disjoint worklist, one predicted +source touch-set, and one quantitative target. A lane may contain multiple +test files only when source inspection supports a shared cause. + +Each worker receives: + +- exact base commit and isolated worktree path; +- exact corpus file list and before result rows; +- upstream Node/Bun source locations or a requirement to locate and cite them; +- allowed source area and forbidden overlapping hotspots; +- minimum/target green delta; +- focused reproduce command and timeout; +- required return payload: upstream mechanism, root cause, changed paths, + before/after counts, serial evidence, and remaining failures. + +Workers do not run full corpus suites, modify `compat/`, edit shared metrics, +or publish PR comments. + +### 3.3 Integration artifacts + +| Artifact | Owner | Responsibility | +| --- | --- | --- | +| `target/integration/w43-*-baseline/` | coordinator | immutable full baseline outputs | +| `target/integration/w43-*-candidate/` | coordinator | composed candidate outputs | +| lane-local `target/integration/w43-a1-before/` and corresponding lane directories | worker | focused red/green evidence | +| `.agents/docs/20260802-corpus-coverage-w43.md` | coordinator | durable progress and handoff ledger | +| `compat/data/mbun-corpus-runs.json` | coordinator | committed full-run provenance and counts | +| `README.md` | coordinator | concise current full-corpus metrics only | +| `changelog.md` | coordinator | substantive source progress and evidence | +| GitHub issue #80 | coordinator | campaign-level decisions and blockers | +| W43 Draft PR | coordinator | source changes, review, CI, milestone comments | + +Target outputs are local evidence and are not staged. Committed metrics contain +repo-relative commands, commit identity, binary identity, run scope, resource +profile, counts, and timestamps; they contain no machine-specific path. + +## 4. Source-driven three-stage development + +Every lane uses the same three-stage contract. A lane that skips a stage is not +eligible for integration. + +### Stage 1: Red / upstream comparison + +1. Run the exact worklist through the bounded corpus runner and save the before + result. +2. Re-run the deciding file serially to separate load noise from a stable + failure. +3. Read the pinned upstream implementation under `compat/node/lib/`, + `compat/node/src/`, `compat/bun/src/`, or the relevant upstream test helper. +4. Record the required mechanism and map it to the mbun implementation site. +5. Demonstrate that the failure is a missing or divergent mbun behavior rather + than missing dependency provisioning, an upstream skip, or a stale binary. + +Log-text clustering is only a worklist hint. It is never accepted as root-cause +evidence by itself. + +### Stage 2: Green / semantic translation + +1. Implement the smallest complete mechanism that matches the upstream + contract. +2. Keep the common runtime kernel generic and Node/Bun dialect layers thin. +3. If the corpora intentionally require different behavior, use an explicit + compatibility dispatch point; do not record the conflict as unreachable. +4. Add or extend a focused C++/JSC regression test when the upstream corpus + alone cannot isolate the mechanism. +5. Rebuild through the serialized build lock and run the exact worklist with + the same parameters as Stage 1. + +No code change may weaken, edit, or replace an assertion under `compat/`. + +### Stage 3: Refactor / proof + +1. Remove duplication and keep runtime slices within existing structural + limits while the focused tests remain green. +2. Run each newly green file with `--jobs 1` on the frozen candidate binary. +3. Run the diff-derived impact gate and any shared Node/Bun counterpart files. +4. Compare before/after with `tools/integration/corpus_diff.py`; bucket totals + alone are insufficient because assertion counts can move inside a bucket. +5. Commit one independent defect with explicit paths, issue reference, + builder `Signed-off-by`, and Codex co-author trailer. + +## 5. Parallel execution model + +The user requested 5–10 agents per round. This runtime exposes four total +concurrency slots, including the coordinator, so at most three worker agents +can run simultaneously. W43 therefore defines five logical lanes per wave and +executes them in two rolling batches: + +```text +coordinator: baseline / review / build / integration / full verification +batch 1: lane 1 + lane 2 + lane 3 +batch 2: lane 4 + lane 5 + next free validation slot +``` + +This is reported publicly as five lanes with three-worker physical concurrency, +never as five simultaneous agents. Every worker uses a separate worktree. +`build_lock.sh` keeps one active build, and worker corpus runners use at most +`--jobs 3`. Resource pressure may reduce worker jobs but cannot increase them +without a coordinator check of memory, swap, tasks, and disk. + +### 5.1 Conflict exclusions + +The following pairs cannot be separate simultaneous owners: + +- crypto and webcrypto: shared key and OpenSSL bridges; +- Node HTTP and Bun HTTP: shared parser/transport/response machinery; +- async_hooks, test-runner, and worker scheduling: shared bootstrap/event pump; +- two N-API lanes: shared `runtime/napi*.inc` slices; +- two changes to the same JS builtin payload or runtime include slice. + +Before dispatch, the coordinator compares predicted touch-sets. If two lanes +overlap, the lower-value lane is replaced with the next disjoint lane rather +than relying on a later conflict resolution. + +## 6. Five-hour execution design + +### 6.1 Time and gates + +| Window | Work | Quantitative gate | PR synchronization | +| --- | --- | --- | --- | +| 0:00–0:45 | build current target; full Node+Bun baseline on one binary | exact current pass/green, failure buckets, runnable gap | open Draft PR with issue/design/baseline; comment checkpoint 0 | +| 0:45–2:05 | Wave A: five lanes in two batches | +12–20 net green | no routine probe comments | +| 2:05–2:30 | integrate Wave A; serial and impact gates | zero regression; timeout/OOM non-increase | push checkpoint 1 and one batched comment | +| 2:30–3:50 | Wave B: five lanes in two batches | +12–22 net green | no routine probe comments | +| 3:50–4:20 | integrate Wave B; serial and impact gates | cumulative +30–45 target | push checkpoint 2 and one batched comment | +| 4:20–5:00 | full Node+Bun candidate measurement and metric sync | full summaries, final delta, honest misses | final sprint comment with CI state and next route | + +The two full corpora took approximately 38 minutes sequentially in the latest +measurement. The design reserves 40 minutes for the final pair. If a run is +slower, final full evidence takes priority over starting another lane. + +### 6.2 Wave A candidate lanes + +The baseline planner must confirm these files remain red before dispatch. + +| Lane | Scope | Target delta | Predicted touch area | +| --- | --- | ---: | --- | +| A1 | Node zlib/Buffer validation leaves | +2 to +5 | zlib/buffer validation paths | +| A2 | Node assert near-green leaves | +2 to +4 | Node assert builtin only | +| A3 | Node permission or V8 validation leaves | +2 to +4 | permission gate or V8 builtin, one selected after touch-set check | +| A4 | Bun N-API near-green files | +3 to +5 | N-API runtime slices | +| A5 | Bun test-runner near-green files | +3 to +5 | test-runner payload and isolated runner bridges | + +Wave A's integration target is +12 to +20. If a candidate is already green or +shares a source hotspot, it is replaced by a current near-green file from Bun +`regression/issue` or `third_party`, with its own fresh red proof and target. + +### 6.3 Wave B candidate lanes + +Wave B worklists are generated after checkpoint 1 and may change based on the +new failure frontier. The initial disjoint candidates are: + +| Lane | Scope | Target delta | Predicted touch area | +| --- | --- | ---: | --- | +| B1 | Node REPL leaves excluding struck inspector work | +2 to +5 | REPL builtin | +| B2 | combined Node crypto/webcrypto owner | +2 to +5 | one shared crypto owner | +| B3 | Bun third-party leaf failures | +2 to +4 | package-specific public API gap | +| B4 | Bun CLI/run leaf failures | +2 to +4 | CLI/run dispatch | +| B5 | Node fs one-off mechanisms | +2 to +4 | fs builtin/runtime only | + +Wave B's integration target is +12 to +22. No B lane starts from a stale W42 +result: every target must be present in the W43 baseline or checkpoint-1 +candidate output. + +## 7. Measurement and test interfaces + +### 7.1 Full baseline and final runs + +Node: + +```bash +W43_BIN_REL=$(find target -name mbun -type f -printf '%T@ %p\n' \ + | sort -rn | head -1 | cut -d' ' -f2) +W43_BIN="$PWD/$W43_BIN_REL" +sha256sum "$W43_BIN_REL" + +python3 tools/integration/node_corpus_runner.py \ + --bin "$W43_BIN" \ + --out target/integration/w43-node-baseline \ + --jobs 4 \ + --timeout 15 +``` + +Bun: + +```bash +python3 tools/integration/bun_corpus_runner.py \ + --bin "$W43_BIN" \ + --cwd compat/bun \ + --discover compat/bun/test \ + --sample-per-group 100000 \ + --out target/integration/w43-bun-baseline \ + --jobs 4 \ + --timeout 30 +``` + +The coordinator resolves `W43_BIN` once from the newest mtime after its build +and records `W43_BIN_REL` plus the checksum. The same path is passed to both +runners. A run made against a +different binary cannot be compared as the same checkpoint. + +### 7.2 Focused lane runs + +Node lanes use `--files` or a narrowly justified `--filter` against the exact +worklist. Bun lanes use the exact file-list interface supported by the current +runner; the plan records the generated list path and command after checking the +runner's live `--help`. Worker jobs are at most three; deciding re-runs use one. + +Every spawn, socket, server, worker, install, and hang-prone test remains behind +the runners or `tools/integration/safe-test.sh`. Bare execution is forbidden. + +### 7.3 Regression decision + +A change is integrable only when all of the following are true: + +- its stable red moved to green for the intended upstream reason; +- every new green repeats serially; +- no file in its focused before set moves pass/green to a worse category; +- assertion counts do not regress in shared Bun files; +- the diff-derived impact set is green or unchanged; +- a relevant member/JSC test passes; +- the coordinator understands the source diff and issue mapping. + +## 8. Error handling and replanning + +| Condition | Required action | +| --- | --- | +| stale binary or mismatched commit | discard the run; rebuild and repeat | +| missing Bun dependencies | stop before measurement; provision with frozen lockfiles | +| timeout or spawn storm | sandbox kills it; preserve log; reduce jobs, never run bare | +| OOM or swap pressure | stop new dispatch; lower jobs/concurrency; publish a resource-strategy milestone if schedule changes | +| parallel-only regression | reproduce serially before attributing it to source | +| worker misses target | report actual delta and root cause; do not widen scope without coordinator review | +| overlapping source edits | serialize or replace the lower-value lane before implementation | +| upstream Node/Bun conflict | create explicit dialect-dispatch issue/design; do not mark unreachable | +| external service/toolchain requirement | classify `blocked-external` with evidence; do not count green | +| CI pending/cancelled/superseded | state that exact status; only final-head terminal jobs support a CI claim | +| five-hour clock threatens final verification | stop dispatch and spend remaining time on composed full evidence | + +## 9. Git, issue, and PR lifecycle + +1. Campaign tracker #80 owns the overall acceptance definition and sprint + decisions. +2. Each independently fixed defect links an existing issue or gets a new issue + with environment, reproduction, actual error, upstream comparison, and + suspected source owner. +3. The coordinator branch starts at `origin/rewrite_bun_in_mcpp@163cb6d` and + preserves history through ordinary commits and pushes; no amend, rebase, or + force-push. +4. The Draft PR targets `rewrite_bun_in_mcpp`, not `main`, and links #80. +5. Commits use conventional subjects, a builder primary author and + `Signed-off-by`, plus `Co-authored-by: Codex (GPT-5) <>`. +6. Explicit paths are staged. `git add -A`, `git commit -a`, and corpus gitlink + changes are forbidden. +7. W43 does not modify protected hagent, skill, CI, license, or governance + surfaces. If implementation discovers that such a change is required, it is + split out and awaits maintainer approval. + +## 10. PR milestone comment contract + +Only meaningful milestones produce comments: + +1. **Checkpoint 0 — fresh baseline:** base/head, frozen binary identity, full + Node/Bun counts, runnable gap, worklists, physical concurrency disclosure. +2. **Checkpoint 1 — Wave A integrated:** source commits/issues, lane targets vs + actuals, serial proof, zero-regression result, blockers, Wave B changes. +3. **Checkpoint 2 — Wave B integrated:** cumulative target vs actual, build and + impact gates, any resource or strategy change. +4. **Final sprint checkpoint:** fresh full before→after, raw and runnable rates, + timeout/crash/OOM movement, CI terminal/pending states, missed requirements, + recalculated remaining gap and ETA. + +Each comment states whether its data is full or focused. Comments contain only +repo-relative paths and sanitized error excerpts; no user name, host name, +local absolute path, token, environment value, private URL, or machine ID. + +## 11. Compatibility and risk + +- `compat/` is a read-only oracle. Portability adapters live outside it and may + not weaken assertion semantics. +- Node semantics govern `node:*` APIs unless the pinned Bun corpus specifies a + genuine, intentional dialect difference. Such differences use an explicit + compatibility dispatch point. +- Shared runtime changes carry a dual-corpus assertion-count gate, not merely a + green-file gate. +- Linux x86_64 is the corpus measurement platform. macOS CI is reported + separately and never used to inflate Linux coverage. +- Current target CI run status is not inherited by the campaign; the Draft PR's + final head must obtain its own terminal results. +- The biggest schedule risks are long-tail heterogeneity, stale worklists, + shared JSC hotspots, full-run duration, resource contention, and external + dependencies. The ownership and stop rules above bound each risk without + redefining success. + +## 12. Review checklist + +- Final target is at least Node 3,898/3,898 and Bun 1,804/1,804; capability + enablement may raise the audited runnable denominators but never lower them. +- Raw denominators and excluded categories stay visible. +- Five-hour success is +30 to +45, not an immediate 100% claim. +- Latest-target full baseline precedes lane implementation. +- Five logical lanes run with at most three physical workers. +- Every lane uses Red/upstream comparison, Green/translation, Refactor/proof. +- Coordinator alone owns builds, full runs, integration, metrics, and comments. +- Every source fix is issue-first and commit-scoped. +- Final verification takes precedence over extra dispatch. +- No placeholder, unassigned decision, or hidden external dependency remains in + this design. From 0cfcd5915947c0f709e482165e96aef9531bc337 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 15:22:43 +0800 Subject: [PATCH 002/150] docs(compat): add W43 execution plan - #80 Define exact baseline gates, Wave A worklists, rolling concurrency, integration checks, full verification, and PR milestones. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../docs/20260802-corpus-coverage-w43-plan.md | 772 ++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 .agents/docs/20260802-corpus-coverage-w43-plan.md diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md new file mode 100644 index 00000000..88598ece --- /dev/null +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -0,0 +1,772 @@ +# W43 Node/Bun Runnable Corpus Sprint Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce a fresh same-binary Node/Bun full baseline, land two bounded five-lane source-driven waves, and prove a net +30 to +45 pass/fully-green files without hiding regressions or changing the final 100% runnable-corpus goal. + +**Architecture:** The coordinator owns the frozen binary, full measurements, worklist disjointness, integration, metrics, and GitHub updates. Five logical lanes per wave run in isolated worktrees with at most three workers active; every lane follows Red/upstream comparison, Green/semantic translation, and Refactor/proof before its commit can be integrated. + +**Tech Stack:** C++26 modules, JavaScriptCore C API, embedded JavaScript builtins, Python corpus runners, `mcpp 2026.7.31.1`, GCC 16.1.0, LLVM 22.1.8, Git worktrees, GitHub issue/PR workflow. + +## Global Constraints + +- Target `rewrite_bun_in_mcpp`; never target `main`. +- Freeze W43 at `163cb6d3fedb9d22cf069cb5f8e20fcb2bc76049`; target movement after the sprint starts is handled by a later ordinary merge, never rebase or force-push. +- Final acceptance is at least Node 3,898/3,898 and Bun 1,804/1,804 runnable files; capability enablement may increase but never decrease those denominators. +- The first five-hour checkpoint targets net +30 to +45: Node +22 to +33 and Bun +8 to +12. +- `compat/bun/**` and `compat/node/**` are read-only upstream inputs; assertion semantics are immutable. +- All corpus execution uses `node_corpus_runner.py`, `bun_corpus_runner.py`, `safe-test.sh`, or `bounded_run.py`; no bare spawn/hang-prone test. +- Full corpus measurement and release builds belong only to the coordinator. +- Worker runs use at most `--jobs 3`; deciding re-runs use `--jobs 1`; builds serialize through `build_lock.sh`. +- One coordinator plus three workers is the physical ceiling. A wave has five logical lanes in two rolling batches; public data states both numbers. +- Every independent source fix is issue-first, one substantive conventional commit, explicit staging, builder `Signed-off-by`, and `Co-authored-by: Codex (GPT-5) <>`. +- Never use `git add -A`, `git commit -a`, amend, rebase, force-push, or stage corpus gitlink changes. +- Full/focused scope labels, exact before→after counts, final-head CI status, and unresolved blockers are mandatory in PR comments. +- Do not publish user names, host names, absolute local paths, tokens, environment values, machine identifiers, or private URLs. + +--- + +## File and responsibility map + +- `.agents/docs/20260802-corpus-coverage-w43-design.md`: approved acceptance and architecture; do not rewrite during implementation except to correct a proven factual error. +- `.agents/docs/20260802-corpus-coverage-w43-plan.md`: this executable plan and checkbox record. +- `.agents/docs/20260802-corpus-coverage-w43.md`: create in Task 2; durable baseline, lane, integration, and final evidence ledger. +- `tools/integration/manifests/w43-a1-node-zlib-buffer.txt`: exact Wave A1 corpus inputs. +- `tools/integration/manifests/w43-a2-node-assert.txt`: exact Wave A2 corpus inputs. +- `tools/integration/manifests/w43-a3-node-permission.txt`: exact Wave A3 corpus inputs. +- `tools/integration/manifests/w43-a4-bun-napi.txt`: exact Wave A4 corpus inputs. +- `tools/integration/manifests/w43-a5-bun-test-runner.txt`: exact Wave A5 corpus inputs. +- `target/integration/w43-node-baseline/` and `w43-bun-baseline/`: authoritative local baseline; never stage. +- `target/integration/w43-*-before/` and `w43-*-after/`: worker-local focused evidence; never stage. +- `compat/data/mbun-corpus-runs.json`: update only from completed full baseline/final summaries on the recorded binary. +- `README.md`: publish concise full-corpus metrics only. +- `changelog.md`: record substantive source movement and exact evidence. +- `modules/jsc/src/builtins/*.cppm`, `modules/jsc/src/runtime/*.inc`, `modules/jsc/src/runtime/napi/*`: source areas selected only after Stage 1 proves the upstream mechanism. +- `modules/jsc/tests/*.cpp`: focused member regression tests for mechanisms that need an isolated native/JSC seam. + +--- + +### Task 1: Freeze and verify the coordinator base + +**Files:** +- Verify: `.agents/docs/20260802-corpus-coverage-w43-design.md` +- Verify: `.agents/docs/20260802-corpus-coverage-w43-plan.md` +- Verify: `compat/bun`, `compat/node` + +**Interfaces:** +- Consumes: commit `08a4de9f7d84710cc7d22f7998accfad403bd626` on branch `agent/corpus-coverage-w43`. +- Produces: a clean coordinator worktree whose only diff from `163cb6d` is the design and plan commits. + +- [ ] **Step 1: Confirm branch, ancestry, and clean scope** + +```bash +git status --short --branch +git merge-base --is-ancestor 163cb6d HEAD +git diff --name-status 163cb6d...HEAD +``` + +Expected: branch `agent/corpus-coverage-w43`; ancestry command exits 0; diff contains only the two `.agents/docs` files before baseline recording. + +- [ ] **Step 2: Prove corpus gitlinks and paths** + +```bash +tools/integration/check_submodule_gitlinks.sh +test -e compat/node/test/parallel/test-assert-async.js +test -e compat/bun/test/js/bun/test/done-async.test.ts +git ls-files -s compat/bun compat/node +``` + +Expected: checker prints `clean`; both paths exist; both index entries remain mode `160000`. + +- [ ] **Step 3: Confirm Bun dependency provisioning** + +```bash +test -d compat/bun/node_modules +test -d compat/bun/test/node_modules +``` + +Expected: both commands exit 0. If either fails, run the repository-documented frozen installs from `compat/README.md` before any Bun measurement and record that provisioning time separately from runtime implementation time. + +### Task 2: Build one binary and take the fresh full baseline + +**Files:** +- Create: `.agents/docs/20260802-corpus-coverage-w43.md` +- Modify: `compat/data/mbun-corpus-runs.json` +- Evidence only: `target/integration/w43-node-baseline/` +- Evidence only: `target/integration/w43-bun-baseline/` + +**Interfaces:** +- Consumes: clean Task 1 tree at `08a4de9`. +- Produces: `W43_BIN`, its SHA-256, full `summary.json`/`results.tsv` for both corpora, exact current runnable gap, and a committed baseline record. + +- [ ] **Step 1: Build through the global lock and capture the binary** + +```bash +W43_BIN=$(bash tools/integration/build_or_die.sh) +test -x "$W43_BIN" +W43_BIN_SHA=$(sha256sum "$W43_BIN" | awk '{print $1}') +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_BASE_BIN="$W43_COMMON_DIR/w43/baseline/mbun" +install -Dm755 "$W43_BIN" "$W43_BASE_BIN" +test "$(sha256sum "$W43_BASE_BIN" | awk '{print $1}')" = "$W43_BIN_SHA" +tools/integration/safe-test.sh 10 "$W43_BIN" --version +``` + +Expected: build exits 0; version reports mbun 2026.07.18.0, Bun 1.3.14 compatibility, and Node v26.3.0 compatibility. The shared frozen copy under the Git common directory has the same SHA-256 and is readable from every lane worktree. Record the coordinator-relative binary path and `W43_BIN_SHA` in the W43 ledger. + +- [ ] **Step 2: Run the full Node baseline** + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_BASE_BIN="$W43_COMMON_DIR/w43/baseline/mbun" +python3 tools/integration/node_corpus_runner.py \ + --bin "$W43_BASE_BIN" \ + --root "$PWD" \ + --out target/integration/w43-node-baseline \ + --jobs 4 \ + --timeout 15 +``` + +Expected: `summary.json` exists and category sum equals 4,433. + +- [ ] **Step 3: Validate the Node denominator** + +```bash +jq -e '.files == 4433 and ([.categories[]] | add) == 4433' \ + target/integration/w43-node-baseline/summary.json +``` + +Expected: `true`, exit 0. + +- [ ] **Step 4: Run the full Bun baseline on the same binary** + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_BASE_BIN="$W43_COMMON_DIR/w43/baseline/mbun" +python3 tools/integration/bun_corpus_runner.py \ + --bin "$W43_BASE_BIN" \ + --root "$PWD" \ + --cwd compat/bun \ + --discover compat/bun/test \ + --sample-per-group 100000 \ + --out target/integration/w43-bun-baseline \ + --jobs 4 \ + --timeout 30 +``` + +Expected: `summary.json` exists and category sum equals 1,902. + +- [ ] **Step 5: Validate the Bun denominator and resource profile** + +```bash +jq -e '.files == 1902 and ([.categories[]] | add) == 1902 and .resource_profile.memory_max == "4G" and .resource_profile.tasks_max == 512' \ + target/integration/w43-bun-baseline/summary.json +``` + +Expected: `true`, exit 0. + +- [ ] **Step 6: Write the baseline ledger and committed data record** + +Create `.agents/docs/20260802-corpus-coverage-w43.md` with: base/head, binary relative path and SHA-256, exact commands, Node/Bun categories, raw rates, current excluded categories, audited runnable floor, elapsed time, and the statement that no source implementation has started. Update `compat/data/mbun-corpus-runs.json` so the new same-binary Node and Bun rows are contemporaneous and the stale note no longer describes the latest rows. + +- [ ] **Step 7: Verify and commit the baseline** + +```bash +jq empty compat/data/mbun-corpus-runs.json +git diff --check +tools/integration/check_submodule_gitlinks.sh +git add .agents/docs/20260802-corpus-coverage-w43.md compat/data/mbun-corpus-runs.json +git commit --author='Sunrisepeak ' \ + -m 'research(compat): freeze W43 corpus baseline' \ + -m '- #80' \ + -m 'Record same-binary Node and Bun full-corpus categories, provenance, and the audited runnable gap before source implementation.' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +### Task 3: Publish the plan and open the Draft PR + +**Files:** +- Modify: `.agents/docs/20260802-corpus-coverage-w43-plan.md` only to check completed Task 1–2 boxes and record measured elapsed time. +- GitHub: Draft PR from `agent/corpus-coverage-w43` to `rewrite_bun_in_mcpp`. + +**Interfaces:** +- Consumes: committed design, plan, and Task 2 baseline. +- Produces: Draft PR linked to #80 and checkpoint-0 comment containing only full baseline data. + +- [ ] **Step 1: Verify the already-published plan checkpoint** + +```bash +git log --format=full -1 -- .agents/docs/20260802-corpus-coverage-w43-plan.md +git diff --check origin/agent/corpus-coverage-w43...HEAD +``` + +Expected: the plan commit contains #80, builder sign-off, and the Codex co-author trailer; diff check exits 0. + +- [ ] **Step 2: Push normally and create a Draft PR** + +```bash +git push -u origin agent/corpus-coverage-w43 +``` + +Create a Draft PR titled `compat: W43 five-hour Node/Bun corpus sprint`, base `rewrite_bun_in_mcpp`, head `agent/corpus-coverage-w43`. Its body links #80, names the raw/runnable denominators, distinguishes five logical lanes from three physical workers, includes the full baseline commands and counts, states +30 to +45 as the sprint target, and states that final 100% remains open. + +- [ ] **Step 3: Publish checkpoint 0** + +Post one PR comment containing: base `163cb6d`, current head, binary SHA-256, full Node/Bun category tables, exact runnable gap, baseline elapsed time, Wave A targets, and current CI state as pending/not-started. Do not post worker probes as separate comments. + +### Task 4: Create and gate the five exact Wave A worklists + +**Files:** +- Create: `tools/integration/manifests/w43-a1-node-zlib-buffer.txt` +- Create: `tools/integration/manifests/w43-a2-node-assert.txt` +- Create: `tools/integration/manifests/w43-a3-node-permission.txt` +- Create: `tools/integration/manifests/w43-a4-bun-napi.txt` +- Create: `tools/integration/manifests/w43-a5-bun-test-runner.txt` + +**Interfaces:** +- Consumes: Task 2 `results.tsv` files. +- Produces: five disjoint, baseline-confirmed non-green lists and five lane briefs with fixed targets. + +- [ ] **Step 1: Write A1 with the known zlib/Buffer validation frontier** + +```text +compat/node/test/parallel/test-buffer-constants.js +compat/node/test/parallel/test-buffer-constructor-deprecation-error.js +compat/node/test/parallel/test-zlib-brotli-kmaxlength-rangeerror.js +compat/node/test/parallel/test-zlib-kmaxlength-rangeerror.js +compat/node/test/parallel/test-zlib-zstd-kmaxlength-rangeerror.js +``` + +Target: +3 to +5. Source boundary: `node_buffer_extra.cppm`, `node_zlib_iter.cppm`, `zlib_stream.cppm`, `runtime/zlib_stream.inc`. + +- [ ] **Step 2: Write A2 with the exact assert frontier** + +```text +compat/node/test/parallel/test-assert-async.js +compat/node/test/parallel/test-assert-class-destructuring.js +compat/node/test/parallel/test-assert-class.js +compat/node/test/parallel/test-assert-deep-with-error.js +compat/node/test/parallel/test-assert-deep.js +compat/node/test/parallel/test-assert-first-line.js +compat/node/test/parallel/test-assert-partial-deep-equal.js +compat/node/test/parallel/test-assert-typedarray-deepequal.js +``` + +Target: +2 to +4. Source boundary: `node_assert_deepequal.cppm` plus the existing assert registration seam only. + +- [ ] **Step 3: Write A3 with the exact permission frontier** + +```text +compat/node/test/parallel/test-permission-child-process-cli.js +compat/node/test/parallel/test-permission-config-file.mjs +compat/node/test/parallel/test-permission-fs-internal-module-stat.js +compat/node/test/parallel/test-permission-fs-read.js +compat/node/test/parallel/test-permission-fs-traversal-path.js +compat/node/test/parallel/test-permission-fs-write.js +compat/node/test/parallel/test-permission-net-fetch.js +compat/node/test/parallel/test-permission-net-udp.js +compat/node/test/parallel/test-permission-processbinding.js +compat/node/test/parallel/test-permission-sqlite-load-extension.js +``` + +Target: +2 to +4. Source boundary: `node_permission.cppm` and existing native permission gates. If Stage 1 proves more than one root cause, implement only the largest source-coherent group and leave the rest named in the lane result. + +- [ ] **Step 4: Write A4 with the N-API one-failure frontier** + +```text +compat/bun/test/napi/napi-finalizer-delete-ref.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_bigint/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_dataview/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_function/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_instance_data/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_new_target/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_number/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_typedarray/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_async/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_fatal_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_general/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_make_callback/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_null_init/do.test.ts +compat/bun/test/napi/uv.test.ts +compat/bun/test/napi/uv_stub.test.ts +``` + +Target: +3 to +5. Source boundary: `runtime/napi_core.inc`, `runtime/napi_objects.inc`, and `runtime/napi/*.h`; only one N-API worker exists in the wave. + +- [ ] **Step 5: Write A5 with the Bun test-runner one-failure frontier** + +```text +compat/bun/test/cli/test/test-filter-lifecycle-snapshot.test.ts +compat/bun/test/js/bun/test/done-async.test.ts +compat/bun/test/js/bun/test/expect-extend-preload.test.ts +compat/bun/test/js/bun/test/fake-timers/sinonjs/fake-timers.test.ts +compat/bun/test/js/bun/test/mock/6874/A.test.ts +compat/bun/test/js/bun/test/mock/6874/B.test.ts +compat/bun/test/js/bun/test/only-failures.test.ts +compat/bun/test/js/bun/test/test-error-code-done-callback.test.ts +``` + +Target: +3 to +5. Source boundary: `src/test_runner.cppm`, `builtins/node_test_run.cppm`, `builtins/node_test_runner.cppm`, and `tests/test_test_runner.cpp`. + +- [ ] **Step 6: Intersect every manifest with the fresh baseline** + +For Node manifests, every retained row must have classification `fail`, `timeout`, or `oom-kill` in `w43-node-baseline/results.tsv`. For Bun manifests, every retained row must be `test-failure`, `timeout`, `crash`, `load-error`, or `oom-kill` in `w43-bun-baseline/results.tsv`. Remove already-green or legitimately excluded paths before dispatch and record each removal in the W43 ledger. + +- [ ] **Step 7: Check retired approaches and overlap** + +```bash +python3 tools/integration/check_struck.py --area node zlib buffer +python3 tools/integration/check_struck.py --area node assert +python3 tools/integration/check_struck.py --area node permission +python3 tools/integration/check_struck.py --area bun napi +python3 tools/integration/check_struck.py --area bun test runner +``` + +Expected: each output is copied into its lane brief. A struck result forbids repeating the named approach but does not hide the still-red file. + +- [ ] **Step 8: Commit the frozen Wave A worklists** + +```bash +git diff --check +git add \ + tools/integration/manifests/w43-a1-node-zlib-buffer.txt \ + tools/integration/manifests/w43-a2-node-assert.txt \ + tools/integration/manifests/w43-a3-node-permission.txt \ + tools/integration/manifests/w43-a4-bun-napi.txt \ + tools/integration/manifests/w43-a5-bun-test-runner.txt \ + .agents/docs/20260802-corpus-coverage-w43.md +git commit --author='Sunrisepeak ' \ + -m 'test(compat): freeze W43 Wave A worklists' \ + -m '- #80' \ + -m 'Record five disjoint baseline-confirmed lists, fixed targets, struck checks, and source ownership before worker dispatch.' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +### Task 5: Execute Wave A batch 1 — A1, A2, A3 + +**Files:** +- Worker A1: only its proven zlib/Buffer source/test paths. +- Worker A2: only assert builtin/test paths. +- Worker A3: only permission builtin/native-gate/test paths. + +**Interfaces:** +- Consumes: frozen Task 2 binary and exact Task 4 manifests. +- Produces: three issue-linked worker branches, each with Red evidence, upstream source mapping, Green evidence, Refactor proof, and one reviewable commit. + +- [ ] **Step 1: Create three isolated worktrees from the same coordinator checkpoint** + +Use `tools/integration/worktree_setup.sh` only on three new paths/branches: `w43/a1-zlib-buffer`, `w43/a2-assert`, and `w43/a3-permission`. Copy the exact manifest into each prompt; do not share a build target. + +- [ ] **Step 2: Dispatch three workers simultaneously** + +Each worker first derives the same frozen binary path: + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_BASE_BIN="$W43_COMMON_DIR/w43/baseline/mbun" +test -x "$W43_BASE_BIN" +``` + +Worker A1 runs: + +```bash +python3 tools/integration/node_corpus_runner.py \ + --bin "$W43_BASE_BIN" \ + --root "$PWD" \ + --files tools/integration/manifests/w43-a1-node-zlib-buffer.txt \ + --out target/integration/w43-a1-before \ + --jobs 1 \ + --timeout 30 +``` + +Worker A2 runs the same command with manifest `w43-a2-node-assert.txt` and output `target/integration/w43-a2-before`. Worker A3 uses `w43-a3-node-permission.txt` and `target/integration/w43-a3-before`. Each worker reads the relevant pinned `compat/node/lib/` or `compat/node/src/` implementation before editing, creates or links the exact defect issue, implements one source-coherent cause, builds through `build_or_die.sh`, repeats the same manifest against the new binary, serially re-runs new greens, runs the relevant JSC member test, and commits with builder/co-author trailers. + +- [ ] **Step 3: Reject black-box or unproven results** + +Reject a worker branch if its report lacks the upstream source location, a stable before failure, exact changed paths, after categories, serial new-green proof, or remaining red files. Do not accept a branch solely because its build passed. + +### Task 6: Integrate and prove Wave A batch 1 + +**Files:** +- Modify only worker-proven source/tests and `.agents/docs/20260802-corpus-coverage-w43.md`. + +**Interfaces:** +- Consumes: three reviewed worker commits from Task 5. +- Produces: one composed coordinator tree with zero focused regressions and measured batch-1 delta. + +- [ ] **Step 1: Review each branch before integration** + +```bash +git diff --stat HEAD...w43/a1-zlib-buffer +git diff --stat HEAD...w43/a2-assert +git diff --stat HEAD...w43/a3-permission +git log --format=full -1 w43/a1-zlib-buffer +git log --format=full -1 w43/a2-assert +git log --format=full -1 w43/a3-permission +``` + +Confirm no worker modified `compat/`, shared metrics, protected surfaces, or another lane's source boundary. + +- [ ] **Step 2: Merge accepted branches normally with attributed merge commits** + +```bash +GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ +git merge --no-ff w43/a1-zlib-buffer \ + -m 'merge(compat): integrate W43 A1 zlib buffer lane' \ + -m '- #80' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' + +GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ +git merge --no-ff w43/a2-assert \ + -m 'merge(compat): integrate W43 A2 assert lane' \ + -m '- #80' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' + +GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ +git merge --no-ff w43/a3-permission \ + -m 'merge(compat): integrate W43 A3 permission lane' \ + -m '- #80' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +Do not squash, cherry-pick, amend, or rebase; a rejected lane is omitted and reported. + +- [ ] **Step 3: Build the composed tree and derive impact lists** + +```bash +W43_A_BIN=$(bash tools/integration/build_or_die.sh) +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_WAVE_A_BIN="$W43_COMMON_DIR/w43/wave-a/mbun" +install -Dm755 "$W43_A_BIN" "$W43_WAVE_A_BIN" +python3 tools/integration/impact_gate.py \ + --rev-range 08a4de9..HEAD \ + --node-run target/integration/w43-node-baseline \ + --bun-run target/integration/w43-bun-baseline \ + --out target/integration/w43-wave-a-node-impact.txt \ + --bun-out target/integration/w43-wave-a-bun-impact.txt \ + --explain +``` + +- [ ] **Step 4: Run composed batch-1 focused and member gates** + +Run A1, A2, and A3 separately against `"$W43_WAVE_A_BIN"` with the Node runner, their committed manifest, `--jobs 3`, `--timeout 30`, and distinct `target/integration/w43-a*-composed` output directories. Then repeat every newly green file with `--jobs 1`. Run: + +```bash +tools/integration/build_lock.sh mcpp test -p jsc +python3 tools/integration/corpus_diff.py target/integration/w43-a1-before target/integration/w43-a1-composed --json +python3 tools/integration/corpus_diff.py target/integration/w43-a2-before target/integration/w43-a2-composed --json +python3 tools/integration/corpus_diff.py target/integration/w43-a3-before target/integration/w43-a3-composed --json +``` + +Any stable green→non-green result blocks batch 2. + +### Task 7: Execute Wave A batch 2 — A4 and A5 + +**Files:** +- Worker A4: N-API runtime/test boundary only. +- Worker A5: test-runner builtin/member-test boundary only. + +**Interfaces:** +- Consumes: Task 6 composed checkpoint and A4/A5 manifests. +- Produces: two issue-linked, source-driven branches with combined target +6 to +10. + +- [ ] **Step 1: Create two new worktrees on the Task 6 checkpoint** + +Branches: `w43/a4-bun-napi` and `w43/a5-bun-test-runner`. + +- [ ] **Step 2: Dispatch both workers simultaneously** + +Each worker derives the shared pre-edit binary and A4 runs: + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_WAVE_A_BIN="$W43_COMMON_DIR/w43/wave-a/mbun" +python3 tools/integration/bun_corpus_runner.py \ + --bin "$W43_WAVE_A_BIN" \ + --root "$PWD" \ + --cwd compat/bun \ + --list tools/integration/manifests/w43-a4-bun-napi.txt \ + --out target/integration/w43-a4-before \ + --jobs 1 \ + --timeout 30 +``` + +A5 runs the same command with list `w43-a5-bun-test-runner.txt` and output `target/integration/w43-a5-before`. Each worker reads the relevant Bun/Node N-API or Bun test-runner upstream source before editing, creates/links the defect issue, implements one coherent cause, builds, repeats its list, serially proves new greens, runs the relevant JSC member test, and commits. + +- [ ] **Step 3: Apply the same evidence rejection gate as Task 5** + +No upstream mechanism, no stable red, no serial proof, or any `compat/` edit means rejection. + +### Task 8: Integrate Wave A and publish checkpoint 1 + +**Files:** +- Modify: `.agents/docs/20260802-corpus-coverage-w43.md` +- Modify: `changelog.md` in the same substantive integration commit when useful. + +**Interfaces:** +- Consumes: accepted A4/A5 commits plus Task 6 tree. +- Produces: full Wave A focused evidence, actual +N versus target +12 to +20, zero-regression verdict, and one PR comment. + +- [ ] **Step 1: Review and merge A4 then A5 normally** + +Run branch diff/trailer checks and ensure source boundaries are disjoint, then: + +```bash +GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ +git merge --no-ff w43/a4-bun-napi \ + -m 'merge(compat): integrate W43 A4 Bun N-API lane' \ + -m '- #80' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' + +GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ +git merge --no-ff w43/a5-bun-test-runner \ + -m 'merge(compat): integrate W43 A5 Bun test runner lane' \ + -m '- #80' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +- [ ] **Step 2: Build and run the five-manifest union** + +```bash +W43_WAVE_A_FINAL_BIN=$(bash tools/integration/build_or_die.sh) +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_WAVE_B_BASE_BIN="$W43_COMMON_DIR/w43/wave-b-base/mbun" +install -Dm755 "$W43_WAVE_A_FINAL_BIN" "$W43_WAVE_B_BASE_BIN" +sha256sum "$W43_WAVE_B_BASE_BIN" +``` + +Run A1–A3 with the Node runner and A4–A5 with the Bun runner, at `--jobs 3`; serially repeat every claimed new green. Run `tools/integration/build_lock.sh mcpp test -p jsc` and the diff-derived impact lists. + +- [ ] **Step 3: Record target versus actual** + +Append lane target, actual green delta, remaining classifications, issue, commit, member-test result, serial proof count, and elapsed time to the W43 ledger. State `Wave A target missed` if net delta is below 12; do not lower the target after observing results. + +- [ ] **Step 4: Push and publish one checkpoint comment** + +The comment contains source issues/commits, five lane targets→actuals, cumulative new greens, zero-regression result, timeout/OOM movement in the focused sets, physical concurrency, blockers, and the exact Wave B selection command. It explicitly says no post-Wave-A full corpus was run. + +### Task 9: Materialize Wave B from the measured frontier + +**Files:** +- Create: five `tools/integration/manifests/w43-b*.txt` files from the Task 2 baseline after excluding Wave A paths. +- Modify: `.agents/docs/20260802-corpus-coverage-w43.md` with the literal assignments and targets. + +**Interfaces:** +- Consumes: Task 2 full baseline plus Task 8 integrated outcomes. +- Produces: five literal, disjoint manifests assigned to REPL, combined crypto/webcrypto, Bun third-party, Bun CLI/run, and Node fs; combined target +12 to +22. + +- [ ] **Step 1: Run the ranked planner with Wave A areas excluded** + +```bash +python3 tools/integration/wave_planner.py \ + --node-run target/integration/w43-node-baseline \ + --bun-run target/integration/w43-bun-baseline \ + --plan 5 \ + --lane-hours 1.3 \ + --min-actionable 2 \ + --min-per-corpus 1 \ + --exclude zlib \ + --exclude buffer \ + --exclude assert \ + --exclude permission \ + --exclude napi \ + --exclude test-runner +``` + +- [ ] **Step 2: Select only the spec-approved disjoint owners** + +Use this priority order: Node REPL, one combined crypto+webcrypto owner, Bun `js/third_party`, Bun `cli/run`, Node fs. If the planner reports fewer than two actionable files for one owner, take the next ranked owner that does not overlap another source touch-set. Record the literal replacement and planner evidence in the ledger before dispatch. + +- [ ] **Step 3: Write literal manifests from baseline rows** + +Sort each owner's non-green rows by failed-assertion ratio and stable duration, cap each manifest at 12 files, exclude Wave A paths and struck approaches, then write the exact paths to `w43-b1` through `w43-b5`. Run every manifest against the integrated pre-Wave-B binary with `--jobs 1`; remove any now-green path and record it as an inherited Wave A gain rather than Wave B credit. + +- [ ] **Step 4: Lock targets before workers start** + +Assign +2 to +5 per Node lane and +2 to +4 per Bun lane, totaling +12 to +22. Targets are written to the ledger and PR checkpoint comment before implementation; they are not changed after results arrive. + +- [ ] **Step 5: Commit the literal Wave B manifests before dispatch** + +```bash +git diff --check +git add tools/integration/manifests/w43-b1-repl.txt \ + tools/integration/manifests/w43-b2-crypto-webcrypto.txt \ + tools/integration/manifests/w43-b3-bun-third-party.txt \ + tools/integration/manifests/w43-b4-bun-cli-run.txt \ + tools/integration/manifests/w43-b5-node-fs.txt \ + .agents/docs/20260802-corpus-coverage-w43.md +git commit --author='Sunrisepeak ' \ + -m 'test(compat): freeze W43 Wave B worklists' \ + -m '- #80' \ + -m 'Record the measured post-Wave-A assignments, literal paths, fixed targets, and disjoint source ownership.' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +### Task 10: Execute and integrate Wave B in two rolling batches + +**Files:** +- Worker source/test boundaries determined by the five literal Task 9 briefs. +- Modify: `.agents/docs/20260802-corpus-coverage-w43.md` +- Modify: `changelog.md` + +**Interfaces:** +- Consumes: five Task 9 manifests/briefs and integrated Wave A binary. +- Produces: accepted B1–B5 source commits, composed build, focused +12 to +22 target evidence, and checkpoint-2 PR comment. + +- [ ] **Step 1: Run B1–B3 with three workers** + +Create isolated worktrees from the manifest commit on branches `w43/b1-repl`, `w43/b2-crypto-webcrypto`, and `w43/b3-bun-third-party`. Each worker must: + +1. derive and verify `"$W43_WAVE_B_BASE_BIN"` under the Git common directory; +2. run its literal manifest at `--jobs 1` into a lane-specific `*-before` directory and preserve the stable-red classifications; +3. read the corresponding pinned Node/Bun upstream implementation and record the exact mechanism/source locations before editing; +4. create or link the narrow defect issue, then change only its assigned source/test boundary; +5. build through `build_or_die.sh`, rerun the literal manifest into `*-after`, and serially repeat every claimed new green; +6. run the relevant JSC member test through `build_lock.sh`, record all remaining red files, and commit with #80, builder sign-off, and Codex co-author trailers. + +Reject any lane that widens beyond its manifest/source boundary, edits `compat/`, or lacks stable Red, upstream mapping, Green delta, serial proof, and remaining-red evidence. + +- [ ] **Step 2: Review, merge, build, and focused-gate B1–B3** + +Review each branch diff and full commit trailers, then merge each with `--no-ff`; the subjects are respectively `merge(compat): integrate W43 B1 lane`, `merge(compat): integrate W43 B2 lane`, and `merge(compat): integrate W43 B3 lane`. Every body references #80 and includes builder sign-off plus Codex co-author trailer. Then run a single coordinator build, run all three literal manifests at `--jobs 3` into distinct composed-result directories, serially repeat every new green at `--jobs 1`, run the relevant member tests through `build_lock.sh`, derive impact lists with `impact_gate.py`, and compare each before/composed pair with `corpus_diff.py`. + +- [ ] **Step 3: Run B4–B5 with two workers from the accepted B1–B3 checkpoint** + +Use branches `w43/b4-bun-cli-run` and `w43/b5-node-fs`. For each lane, verify the shared pre-edit binary; capture a single-job stable Red run; map the failure to pinned upstream source; create/link the defect issue; implement only that source-coherent mechanism; build; capture the after run; serially prove every new green; run the relevant member test through `build_lock.sh`; record remaining reds; and commit with #80, builder sign-off, and Codex co-author trailers. Reject any `compat/` edit, unproven source mapping, boundary widening, or missing before→after/serial evidence. + +- [ ] **Step 4: Integrate and prove all five B lanes** + +Merge B4 and B5 with attributed `--no-ff` merge commits whose bodies reference #80 and include builder sign-off plus Codex co-author trailer. Build once, run every B1–B5 manifest into its own composed-result directory at `--jobs 3`, serially repeat all claimed B greens at `--jobs 1`, run `tools/integration/build_lock.sh mcpp test -p jsc`, and compare every before/composed pair against the frozen pre-Wave-B results with `corpus_diff.py`. + +- [ ] **Step 5: Publish checkpoint 2** + +Push normally. Post one comment with B1–B5 targets→actuals, cumulative Wave A+B delta, focused zero-regression result, elapsed time, resource changes, failed/rejected lanes, and the explicit statement that final full corpus verification is still pending. + +### Task 11: Run final same-binary full verification + +**Files:** +- Evidence only: `target/integration/w43-node-candidate/` +- Evidence only: `target/integration/w43-bun-candidate/` +- Modify: `compat/data/mbun-corpus-runs.json` +- Modify: `README.md` +- Modify: `changelog.md` +- Modify: `.agents/docs/20260802-corpus-coverage-w43.md` + +**Interfaces:** +- Consumes: composed Wave A+B source tree. +- Produces: authoritative before→after proof, final sprint verdict, remaining runnable gap, and recalibrated ETA. + +- [ ] **Step 1: Stop new dispatch and build one final binary** + +```bash +W43_FINAL_BIN=$(bash tools/integration/build_or_die.sh) +W43_FINAL_SHA=$(sha256sum "$W43_FINAL_BIN" | awk '{print $1}') +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_FINAL_FROZEN_BIN="$W43_COMMON_DIR/w43/final/mbun" +install -Dm755 "$W43_FINAL_BIN" "$W43_FINAL_FROZEN_BIN" +test "$(sha256sum "$W43_FINAL_FROZEN_BIN" | awk '{print $1}')" = "$W43_FINAL_SHA" +tools/integration/safe-test.sh 10 "$W43_FINAL_BIN" --version +``` + +- [ ] **Step 2: Run Node then Bun full candidate suites** + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_FINAL_FROZEN_BIN="$W43_COMMON_DIR/w43/final/mbun" +test -x "$W43_FINAL_FROZEN_BIN" + +python3 tools/integration/node_corpus_runner.py \ + --bin "$W43_FINAL_FROZEN_BIN" \ + --root "$PWD" \ + --out target/integration/w43-node-candidate \ + --jobs 4 \ + --timeout 15 + +python3 tools/integration/bun_corpus_runner.py \ + --bin "$W43_FINAL_FROZEN_BIN" \ + --root "$PWD" \ + --cwd compat/bun \ + --discover compat/bun/test \ + --sample-per-group 100000 \ + --out target/integration/w43-bun-candidate \ + --jobs 4 \ + --timeout 30 +``` + +Do not resume from baseline directories. Both suites must consume the same frozen SHA-256 captured in Step 1. + +- [ ] **Step 3: Gate denominators and regressions** + +```bash +jq -e '.files == 4433 and ([.categories[]] | add) == 4433' target/integration/w43-node-candidate/summary.json +jq -e '.files == 1902 and ([.categories[]] | add) == 1902' target/integration/w43-bun-candidate/summary.json +python3 tools/integration/corpus_diff.py target/integration/w43-node-baseline target/integration/w43-node-candidate --json +python3 tools/integration/corpus_diff.py target/integration/w43-bun-baseline target/integration/w43-bun-candidate --json +``` + +Expected: both denominator checks exit 0 and both diffs report zero stable green→non-green regressions. Any regression blocks a positive sprint verdict. + +- [ ] **Step 4: Compute the sprint verdict without relabeling scope** + +Calculate Node pass delta, Bun green delta, combined delta, skip/exclusion movement, timeout/crash/OOM movement, and audited runnable denominator. Verdicts are exactly: `target met` for +30 to +45 with all gates; `target exceeded` above +45 with all gates; `target missed` below +30; `regressed` for any stable green loss or timeout/OOM increase. + +- [ ] **Step 5: Synchronize committed evidence** + +Update README only from final full summaries. Replace stale latest rows in `mbun-corpus-runs.json` with same-binary W43 baseline and final entries including commands, hashes, resource profile, and notes. Update changelog and W43 ledger with full before→after and the fact that 100% remains incomplete unless the audited runnable denominator is fully green. + +- [ ] **Step 6: Verify and commit the final evidence** + +```bash +jq empty compat/data/mbun-corpus-runs.json +git diff --check +tools/integration/check_submodule_gitlinks.sh +git add README.md compat/data/mbun-corpus-runs.json changelog.md .agents/docs/20260802-corpus-coverage-w43.md +git commit --author='Sunrisepeak ' \ + -m 'docs(compat): publish W43 measured corpus result' \ + -m '- #80' \ + -m 'Synchronize the same-binary full Node/Bun before-after evidence, sprint verdict, remaining runnable gap, and recalibrated ETA.' \ + -m 'Signed-off-by: Sunrisepeak ' \ + -m 'Co-authored-by: Codex (GPT-5) <>' +``` + +### Task 12: Final PR synchronization and CI audit + +**Files:** +- GitHub Draft PR conversation and metadata. + +**Interfaces:** +- Consumes: final evidence commit and full summary files. +- Produces: one final sprint checkpoint comment and an evidence-backed CI state; PR remains Draft unless all required scope gates are complete. + +- [ ] **Step 1: Push final head normally** + +```bash +git push origin agent/corpus-coverage-w43 +``` + +- [ ] **Step 2: Publish the final sprint checkpoint** + +Post one comment with full Node/Bun category tables, raw and runnable before→after rates, exact +N, timeout/crash/OOM movement, zero-regression result, elapsed wall time, accepted/rejected lane table, final head SHA, remaining gap, and recalculated 100% ETA. State every unmet original requirement explicitly. + +- [ ] **Step 3: Observe final-head CI only** + +Use `gh pr checks` and Actions run/job output for the final head. Do not treat running, queued, cancelled, superseded, or earlier-head jobs as success. Record GCC 16.1.0, LLVM 22.1.8, and macOS probe separately. + +- [ ] **Step 4: Completion audit** + +Check issue #80, design §1/§6/§10, and every task above requirement-by-requirement. The sprint may be reported as a measured checkpoint while the thread goal remains active. Mark the overall 100% goal complete only if current full summaries prove the entire audited runnable denominator green and no required work remains. + +## Long-range continuation after the five-hour checkpoint + +After Task 12, repeat the same two-wave cycle from the new full summary. Recompute the trailing-three-wave net rate and remaining audited gap after every final full run. At the provisional 6–9 net files/hour, the initial 1,524-file runnable gap implies 170–255 coordinator wall-hours or 34–51 five-hour sprints; this estimate must move with evidence and is never used to claim completion early. From 7d137ccc3815d7bb4c55b58ade3c9c7510cedc78 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:10:39 +0800 Subject: [PATCH 003/150] research(compat): freeze W43 corpus baseline - #80 Record same-binary Node and Bun full-corpus categories, provenance, and the audited runnable gap before source implementation. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .agents/docs/20260802-corpus-coverage-w43.md | 130 +++++++++++++++++++ compat/data/mbun-corpus-runs.json | 126 +++++++++++------- 2 files changed, 207 insertions(+), 49 deletions(-) create mode 100644 .agents/docs/20260802-corpus-coverage-w43.md diff --git a/.agents/docs/20260802-corpus-coverage-w43.md b/.agents/docs/20260802-corpus-coverage-w43.md new file mode 100644 index 00000000..675b3fec --- /dev/null +++ b/.agents/docs/20260802-corpus-coverage-w43.md @@ -0,0 +1,130 @@ +# W43 Node/Bun corpus sprint ledger + +Date: 2026-08-02 + +Tracking issue: #80 + +Target branch: `rewrite_bun_in_mcpp` + +Campaign branch: `agent/corpus-coverage-w43` + +## Current status + +Checkpoint 0 is the pre-implementation baseline. No W43 product source change +has started. The first five-hour sprint target remains net +30 to +45 fully +green files; the final campaign target remains the complete audited runnable +denominator. + +Five logical lanes will be executed in rolling batches because this session has +three physical worker slots. The coordinator alone owns builds, full corpus +runs, integration, metrics, and GitHub milestone comments. + +## Frozen provenance + +| Field | Value | +| --- | --- | +| Target base | `163cb6d3fedb9d22cf069cb5f8e20fcb2bc76049` | +| Baseline source head | `0cfcd5915947c0f709e482165e96aef9531bc337` | +| Binary location | Git common directory `w43/baseline/mbun` | +| Binary SHA-256 | `5e17080bb82a6c5ac46c56c8c66070409371e46e368bdf285304f42695f8d395` | +| Runtime versions | mbun `2026.07.18.0`; Bun `1.3.14` compatible; Node `v26.3.0` compatible | +| Node window | 2026-08-02 15:34:56–15:50:14 +08:00; 917.4 s | +| Bun window | 2026-08-02 15:50:29–16:08:34 +08:00; 1085.4 s | + +The first build attempt was correctly rejected at link time. Root-cause evidence +showed that the worktree-local installed JSC package contained the GCC 15.1 +`libstdc++.a` byte-for-byte while the pinned compiler was GCC 16.1. The local, +generated dependency copy was refreshed from the recipe-required GCC 16.1 +archive; no repository source or corpus input changed. `build_or_die.sh` then +linked successfully and the frozen binary above was created. This provisioning +recovery is not counted as compatibility progress. + +## Reproduction commands + +```bash +W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) +W43_BASE_BIN="$W43_COMMON_DIR/w43/baseline/mbun" + +python3 tools/integration/node_corpus_runner.py \ + --bin "$W43_BASE_BIN" \ + --root "$PWD" \ + --out target/integration/w43-node-baseline \ + --jobs 4 \ + --timeout 15 + +python3 tools/integration/bun_corpus_runner.py \ + --bin "$W43_BASE_BIN" \ + --root "$PWD" \ + --cwd compat/bun \ + --discover compat/bun/test \ + --sample-per-group 100000 \ + --out target/integration/w43-bun-baseline \ + --jobs 4 \ + --timeout 30 +``` + +Both category sums equal their raw denominators. The Bun run used `memory_max = +4G` and `tasks_max = 512`. + +## Checkpoint 0 full baseline + +### Node `test/parallel` + +| Classification | Files | +| --- | ---: | +| pass | 3,138 | +| fail | 660 | +| skipped | 535 | +| timeout | 97 | +| oom-kill | 3 | +| total | 4,433 | + +- Raw pass rate: `3,138 / 4,433 = 70.79%`. +- Audited runnable floor: `3,138 / 3,898 = 80.50%`. +- Runnable gap: 760 files. + +### Bun `test/**` + +| Classification | Files | +| --- | ---: | +| green | 1,041 | +| test-failure | 704 | +| all-skipped | 73 | +| timeout | 44 | +| blocked-external | 19 | +| no-tests | 6 | +| ahead-of-reference | 4 | +| load-error | 4 | +| oom-kill | 4 | +| crash | 3 | +| total | 1,902 | + +Test-level counters: 33,861 passed, 13,452 failed, 50,163 ran, and 467,237 +expects. + +- Raw green rate: `1,041 / 1,902 = 54.73%`. +- Audited runnable floor: `1,041 / 1,804 = 57.71%`. +- Runnable gap: 763 files. + +### Combined + +- Raw: `4,179 / 6,335 = 65.97%`. +- Audited runnable floor: `4,179 / 5,702 = 73.29%`. +- Runnable gap: 1,523 files. +- Exclusions are not passes. The 3,898 and 1,804 denominators are floors and + may increase when capabilities are enabled; they may not shrink through new + skips or exclusions. + +## Lane ledger + +| Lane | Corpus/owner | Fixed target | Actual | Issue | Commit | State | +| --- | --- | ---: | ---: | --- | --- | --- | +| A1 | Node zlib/Buffer | +3 to +5 | — | — | — | not started | +| A2 | Node assert | +2 to +4 | — | — | — | not started | +| A3 | Node permission | +2 to +4 | — | — | — | not started | +| A4 | Bun N-API | +3 to +5 | — | — | — | not started | +| A5 | Bun test runner | +3 to +5 | — | — | — | not started | + +Worker probes, focused results, integration deltas, serial confirmations, and +remaining reds are appended here only after coordinator review. Full-corpus +numbers are updated only at the next same-binary full checkpoint. diff --git a/compat/data/mbun-corpus-runs.json b/compat/data/mbun-corpus-runs.json index efbbacf1..4b0851ee 100644 --- a/compat/data/mbun-corpus-runs.json +++ b/compat/data/mbun-corpus-runs.json @@ -1,68 +1,96 @@ { - "generated_at": "2026-07-25", - "binary": "mbun 2026.07.18.0 (source snapshot, branch fix/corpus-round7)", + "generated_at": "2026-08-02T16:08:34+08:00", + "binary": "mbun 2026.07.18.0, Bun 1.3.14 compatible, Node v26.3.0 compatible", + "source_commit": "0cfcd5915947c0f709e482165e96aef9531bc337", + "target_base": "163cb6d3fedb9d22cf069cb5f8e20fcb2bc76049", + "binary_sha256": "5e17080bb82a6c5ac46c56c8c66070409371e46e368bdf285304f42695f8d395", "runs": [ + { + "corpus": "compat/node/test/parallel", + "runner": "python3 tools/integration/node_corpus_runner.py --bin /w43/baseline/mbun --root $PWD --out target/integration/w43-node-baseline --jobs 4 --timeout 15", + "files": 4433, + "categories": { + "fail": 660, + "oom-kill": 3, + "pass": 3138, + "skipped": 535, + "timeout": 97 + }, + "rates": { + "strict_pass": 0.707873, + "audited_runnable": 0.805028 + }, + "audited_runnable": { + "denominator_floor": 3898, + "green": 3138, + "gap": 760, + "excluded": { + "skipped": 535 + } + }, + "started_at": "2026-08-02T15:34:56+08:00", + "finished_at": "2026-08-02T15:50:14+08:00", + "elapsed_seconds": 917.4, + "notes": "A Node self-skip is not a pass. The runnable denominator is a floor and may increase when missing capabilities are implemented; it may not shrink by introducing more skips." + }, { "corpus": "compat/bun/test", - "runner": "tools/integration/bun_corpus_runner.py --cwd compat/bun --discover compat/bun/test --sample-per-group 100000 --jobs 5 --timeout 30 --resume", - "binary": "rewrite_bun_in_mcpp + r9/integration @ e4639ae (round 14 checkpoint, base merged)", + "runner": "python3 tools/integration/bun_corpus_runner.py --bin /w43/baseline/mbun --root $PWD --cwd compat/bun --discover compat/bun/test --sample-per-group 100000 --out target/integration/w43-bun-baseline --jobs 4 --timeout 30", "files": 1902, "categories": { - "ahead-of-reference": 3, + "ahead-of-reference": 4, "all-skipped": 73, - "blocked-external": 13, - "fixture-build-error": 5, - "green": 868, + "blocked-external": 19, + "crash": 3, + "green": 1041, "load-error": 4, "no-tests": 6, - "oom-kill": 5, - "test-failure": 884, - "timeout": 41 + "oom-kill": 4, + "test-failure": 704, + "timeout": 44 }, "test_level": { - "ran": 51656, - "passed": 31479, - "failed": 17391, - "expects": 466865 - }, - "notes": "file-level green = every executed test in the file passed and nothing errored outside a test. ahead-of-reference = every failure in the file is a stale bun `test.failing` marker that now passes, i.e. mbun is MORE correct than bun there; it is NOT counted as green because the file still does not pass bun's suite as written.", - "checkpoint_note": "Same binary and session as the node row. 869 green against 885 in the last published round-7/8 snapshot, but those were never contemporaneous — that snapshot predated ~20 tasks over shared code. Running both corpora together is what caught an earlier 868 -> 612 excursion caused by one missing field in Bun.spawn." - }, - { - "corpus": "compat/node/test/parallel", - "runner": "tools/integration/node_corpus_runner.py --jobs 6 --timeout 15", - "binary": "rewrite_bun_in_mcpp + r9/integration @ e4639ae (round 14 checkpoint, base merged)", - "files": 4433, - "categories": { - "fail": 1127, - "oom-kill": 4, - "pass": 2654, - "skipped": 552, - "timeout": 96 + "ran": 50163, + "passed": 33861, + "failed": 13452, + "expects": 467237 }, "rates": { - "strict_pass": 0.599, - "excluding_self_skips": 0.684 + "strict_green": 0.547319, + "audited_runnable": 0.577051 }, - "baseline_for_comparison": { - "binary": "round-8 HEAD (23217dd), measured with the SAME corrected runner", - "categories": { - "fail": 1862, - "oom-kill": 14, - "pass": 1695, - "skipped": 279, - "timeout": 583 - }, - "rates": { - "strict_pass": 0.382, - "excluding_self_skips": 0.408 + "audited_runnable": { + "denominator_floor": 1804, + "green": 1041, + "gap": 763, + "excluded": { + "all-skipped": 73, + "blocked-external": 19, + "no-tests": 6 } }, - "notes": "A file that skipped itself (common.skip() -> '1..0 # Skipped:', exit 0) is classified `skipped`, never `pass`. common.mustCall is enforced from this binary onward (node registers its verifier in process.on('exit'), which previously never fired). Both corrections lower the number relative to previously published figures; the 44.5% published against round 8 was a product of both defects and was never real. Full estimate-vs-actual and methodology: compat/data/round-estimates.json and compat/README.md. A third verification defect was fixed after the previous entry: assert.throws ignored its error argument, so any throw satisfied any matcher. That removed 126 passes (25 of 25 sampled confirmed vacuous), which is why this figure is LOWER than the previously recorded 47.9%/54.7%.", - "jobs_note": "Measured at --jobs 6, not 8/10. Higher parallelism does not change the total (a jobs-6 full run moved exactly the 11 files the wave's agents claimed and no others) but it makes per-file attribution unreliable: individual pass->fail results at --jobs 10 have repeatedly failed to reproduce standalone. See compat/README.md 'A parallel guard invents regressions'.", - "measurement_caveat": "Measured on the build output directly. A binary COPIED to a different basename (mbun-) reports 10 phantom regressions across child_process/process/signal/module -- tests that re-spawn the runtime fail with 'spawn mbun ENOENT'. Snapshot to /bin/mbun instead; verified both ways.", - "checkpoint_note": "Measured on the SAME binary as the bun row below, in the same session — the first time the two corpora are directly comparable. Round 14 merged six agent worklists: net-dgram +9, http +8, http2 +7, tls +7, crypto +7, worker +7 against a target of +6 each." + "resource_profile": { + "memory_max": "4G", + "tasks_max": 512 + }, + "started_at": "2026-08-02T15:50:29+08:00", + "finished_at": "2026-08-02T16:08:34+08:00", + "elapsed_seconds": 1085.4, + "notes": "File-level green means every executed test in the file passed and nothing errored outside a test. ahead-of-reference remains non-green until audited. The runnable denominator is a floor and may not shrink by adding exclusions." } ], - "note": "generated_at applies to the node row only. The bun row is a stale round-7/8 snapshot; see its staleness_note. Do not read the two rows as contemporaneous." + "combined": { + "raw": { + "green": 4179, + "denominator": 6335, + "rate": 0.659669 + }, + "audited_runnable": { + "green": 4179, + "denominator_floor": 5702, + "rate": 0.732901, + "gap": 1523 + } + }, + "note": "Both rows are a contemporaneous full baseline measured in one session on the same frozen binary before any W43 source implementation. Raw and audited-runnable rates are intentionally reported together; exclusions are not passes." } From 45c1fe42209bf8bdbdfa95987b59a9dd2f4c2dda Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:14:32 +0800 Subject: [PATCH 004/150] test(compat): freeze W43 Wave A worklists - #80 Record five disjoint baseline-confirmed lists, fixed targets, struck checks, and source ownership before worker dispatch. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../docs/20260802-corpus-coverage-w43-plan.md | 42 +++++++++---------- .agents/docs/20260802-corpus-coverage-w43.md | 20 +++++++++ .../manifests/w43-a1-node-zlib-buffer.txt | 5 +++ .../manifests/w43-a2-node-assert.txt | 8 ++++ .../manifests/w43-a3-node-permission.txt | 10 +++++ .../integration/manifests/w43-a4-bun-napi.txt | 18 ++++++++ .../manifests/w43-a5-bun-test-runner.txt | 8 ++++ 7 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 tools/integration/manifests/w43-a1-node-zlib-buffer.txt create mode 100644 tools/integration/manifests/w43-a2-node-assert.txt create mode 100644 tools/integration/manifests/w43-a3-node-permission.txt create mode 100644 tools/integration/manifests/w43-a4-bun-napi.txt create mode 100644 tools/integration/manifests/w43-a5-bun-test-runner.txt diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md index 88598ece..d1a33534 100644 --- a/.agents/docs/20260802-corpus-coverage-w43-plan.md +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -57,7 +57,7 @@ - Consumes: commit `08a4de9f7d84710cc7d22f7998accfad403bd626` on branch `agent/corpus-coverage-w43`. - Produces: a clean coordinator worktree whose only diff from `163cb6d` is the design and plan commits. -- [ ] **Step 1: Confirm branch, ancestry, and clean scope** +- [x] **Step 1: Confirm branch, ancestry, and clean scope** ```bash git status --short --branch @@ -67,7 +67,7 @@ git diff --name-status 163cb6d...HEAD Expected: branch `agent/corpus-coverage-w43`; ancestry command exits 0; diff contains only the two `.agents/docs` files before baseline recording. -- [ ] **Step 2: Prove corpus gitlinks and paths** +- [x] **Step 2: Prove corpus gitlinks and paths** ```bash tools/integration/check_submodule_gitlinks.sh @@ -78,7 +78,7 @@ git ls-files -s compat/bun compat/node Expected: checker prints `clean`; both paths exist; both index entries remain mode `160000`. -- [ ] **Step 3: Confirm Bun dependency provisioning** +- [x] **Step 3: Confirm Bun dependency provisioning** ```bash test -d compat/bun/node_modules @@ -99,7 +99,7 @@ Expected: both commands exit 0. If either fails, run the repository-documented f - Consumes: clean Task 1 tree at `08a4de9`. - Produces: `W43_BIN`, its SHA-256, full `summary.json`/`results.tsv` for both corpora, exact current runnable gap, and a committed baseline record. -- [ ] **Step 1: Build through the global lock and capture the binary** +- [x] **Step 1: Build through the global lock and capture the binary** ```bash W43_BIN=$(bash tools/integration/build_or_die.sh) @@ -114,7 +114,7 @@ tools/integration/safe-test.sh 10 "$W43_BIN" --version Expected: build exits 0; version reports mbun 2026.07.18.0, Bun 1.3.14 compatibility, and Node v26.3.0 compatibility. The shared frozen copy under the Git common directory has the same SHA-256 and is readable from every lane worktree. Record the coordinator-relative binary path and `W43_BIN_SHA` in the W43 ledger. -- [ ] **Step 2: Run the full Node baseline** +- [x] **Step 2: Run the full Node baseline** ```bash W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) @@ -129,7 +129,7 @@ python3 tools/integration/node_corpus_runner.py \ Expected: `summary.json` exists and category sum equals 4,433. -- [ ] **Step 3: Validate the Node denominator** +- [x] **Step 3: Validate the Node denominator** ```bash jq -e '.files == 4433 and ([.categories[]] | add) == 4433' \ @@ -138,7 +138,7 @@ jq -e '.files == 4433 and ([.categories[]] | add) == 4433' \ Expected: `true`, exit 0. -- [ ] **Step 4: Run the full Bun baseline on the same binary** +- [x] **Step 4: Run the full Bun baseline on the same binary** ```bash W43_COMMON_DIR=$(cd "$(git rev-parse --git-common-dir)" && pwd) @@ -156,7 +156,7 @@ python3 tools/integration/bun_corpus_runner.py \ Expected: `summary.json` exists and category sum equals 1,902. -- [ ] **Step 5: Validate the Bun denominator and resource profile** +- [x] **Step 5: Validate the Bun denominator and resource profile** ```bash jq -e '.files == 1902 and ([.categories[]] | add) == 1902 and .resource_profile.memory_max == "4G" and .resource_profile.tasks_max == 512' \ @@ -165,11 +165,11 @@ jq -e '.files == 1902 and ([.categories[]] | add) == 1902 and .resource_profile. Expected: `true`, exit 0. -- [ ] **Step 6: Write the baseline ledger and committed data record** +- [x] **Step 6: Write the baseline ledger and committed data record** Create `.agents/docs/20260802-corpus-coverage-w43.md` with: base/head, binary relative path and SHA-256, exact commands, Node/Bun categories, raw rates, current excluded categories, audited runnable floor, elapsed time, and the statement that no source implementation has started. Update `compat/data/mbun-corpus-runs.json` so the new same-binary Node and Bun rows are contemporaneous and the stale note no longer describes the latest rows. -- [ ] **Step 7: Verify and commit the baseline** +- [x] **Step 7: Verify and commit the baseline** ```bash jq empty compat/data/mbun-corpus-runs.json @@ -194,7 +194,7 @@ git commit --author='Sunrisepeak ' \ - Consumes: committed design, plan, and Task 2 baseline. - Produces: Draft PR linked to #80 and checkpoint-0 comment containing only full baseline data. -- [ ] **Step 1: Verify the already-published plan checkpoint** +- [x] **Step 1: Verify the already-published plan checkpoint** ```bash git log --format=full -1 -- .agents/docs/20260802-corpus-coverage-w43-plan.md @@ -203,7 +203,7 @@ git diff --check origin/agent/corpus-coverage-w43...HEAD Expected: the plan commit contains #80, builder sign-off, and the Codex co-author trailer; diff check exits 0. -- [ ] **Step 2: Push normally and create a Draft PR** +- [x] **Step 2: Push normally and create a Draft PR** ```bash git push -u origin agent/corpus-coverage-w43 @@ -211,7 +211,7 @@ git push -u origin agent/corpus-coverage-w43 Create a Draft PR titled `compat: W43 five-hour Node/Bun corpus sprint`, base `rewrite_bun_in_mcpp`, head `agent/corpus-coverage-w43`. Its body links #80, names the raw/runnable denominators, distinguishes five logical lanes from three physical workers, includes the full baseline commands and counts, states +30 to +45 as the sprint target, and states that final 100% remains open. -- [ ] **Step 3: Publish checkpoint 0** +- [x] **Step 3: Publish checkpoint 0** Post one PR comment containing: base `163cb6d`, current head, binary SHA-256, full Node/Bun category tables, exact runnable gap, baseline elapsed time, Wave A targets, and current CI state as pending/not-started. Do not post worker probes as separate comments. @@ -228,7 +228,7 @@ Post one PR comment containing: base `163cb6d`, current head, binary SHA-256, fu - Consumes: Task 2 `results.tsv` files. - Produces: five disjoint, baseline-confirmed non-green lists and five lane briefs with fixed targets. -- [ ] **Step 1: Write A1 with the known zlib/Buffer validation frontier** +- [x] **Step 1: Write A1 with the known zlib/Buffer validation frontier** ```text compat/node/test/parallel/test-buffer-constants.js @@ -240,7 +240,7 @@ compat/node/test/parallel/test-zlib-zstd-kmaxlength-rangeerror.js Target: +3 to +5. Source boundary: `node_buffer_extra.cppm`, `node_zlib_iter.cppm`, `zlib_stream.cppm`, `runtime/zlib_stream.inc`. -- [ ] **Step 2: Write A2 with the exact assert frontier** +- [x] **Step 2: Write A2 with the exact assert frontier** ```text compat/node/test/parallel/test-assert-async.js @@ -255,7 +255,7 @@ compat/node/test/parallel/test-assert-typedarray-deepequal.js Target: +2 to +4. Source boundary: `node_assert_deepequal.cppm` plus the existing assert registration seam only. -- [ ] **Step 3: Write A3 with the exact permission frontier** +- [x] **Step 3: Write A3 with the exact permission frontier** ```text compat/node/test/parallel/test-permission-child-process-cli.js @@ -272,7 +272,7 @@ compat/node/test/parallel/test-permission-sqlite-load-extension.js Target: +2 to +4. Source boundary: `node_permission.cppm` and existing native permission gates. If Stage 1 proves more than one root cause, implement only the largest source-coherent group and leave the rest named in the lane result. -- [ ] **Step 4: Write A4 with the N-API one-failure frontier** +- [x] **Step 4: Write A4 with the N-API one-failure frontier** ```text compat/bun/test/napi/napi-finalizer-delete-ref.test.ts @@ -297,7 +297,7 @@ compat/bun/test/napi/uv_stub.test.ts Target: +3 to +5. Source boundary: `runtime/napi_core.inc`, `runtime/napi_objects.inc`, and `runtime/napi/*.h`; only one N-API worker exists in the wave. -- [ ] **Step 5: Write A5 with the Bun test-runner one-failure frontier** +- [x] **Step 5: Write A5 with the Bun test-runner one-failure frontier** ```text compat/bun/test/cli/test/test-filter-lifecycle-snapshot.test.ts @@ -312,11 +312,11 @@ compat/bun/test/js/bun/test/test-error-code-done-callback.test.ts Target: +3 to +5. Source boundary: `src/test_runner.cppm`, `builtins/node_test_run.cppm`, `builtins/node_test_runner.cppm`, and `tests/test_test_runner.cpp`. -- [ ] **Step 6: Intersect every manifest with the fresh baseline** +- [x] **Step 6: Intersect every manifest with the fresh baseline** For Node manifests, every retained row must have classification `fail`, `timeout`, or `oom-kill` in `w43-node-baseline/results.tsv`. For Bun manifests, every retained row must be `test-failure`, `timeout`, `crash`, `load-error`, or `oom-kill` in `w43-bun-baseline/results.tsv`. Remove already-green or legitimately excluded paths before dispatch and record each removal in the W43 ledger. -- [ ] **Step 7: Check retired approaches and overlap** +- [x] **Step 7: Check retired approaches and overlap** ```bash python3 tools/integration/check_struck.py --area node zlib buffer @@ -328,7 +328,7 @@ python3 tools/integration/check_struck.py --area bun test runner Expected: each output is copied into its lane brief. A struck result forbids repeating the named approach but does not hide the still-red file. -- [ ] **Step 8: Commit the frozen Wave A worklists** +- [x] **Step 8: Commit the frozen Wave A worklists** ```bash git diff --check diff --git a/.agents/docs/20260802-corpus-coverage-w43.md b/.agents/docs/20260802-corpus-coverage-w43.md index 675b3fec..a86ebbfe 100644 --- a/.agents/docs/20260802-corpus-coverage-w43.md +++ b/.agents/docs/20260802-corpus-coverage-w43.md @@ -125,6 +125,26 @@ expects. | A4 | Bun N-API | +3 to +5 | — | — | — | not started | | A5 | Bun test runner | +3 to +5 | — | — | — | not started | +All 49 literal paths were found in the fresh baseline and were non-green. A1, +A2, and A3 contain respectively 5, 8, and 10 Node `fail` rows. A4 contains 18 +Bun `test-failure` rows and A5 contains 8; every Bun row has exactly one failed +test at baseline. The five manifests are path-disjoint. + +Retired-approach gate: + +- A1 zlib/Buffer and A3 permission have no matching struck record. +- A2's broad `assert` query returns existing records in unrelated process, + crypto, HTTP/2, and TLS areas; no listed A2 path or proposed assert-deep-equal + mechanism is named as retired. +- A4 must not repeat the old claim that all N-API addons are blocked, nor repeat + the already-landed dynamic-symbol/global fix. That verdict is overturned; + the remaining files require real per-entry-point N-API diagnosis, while five + shared-libstdc++ addon cases remain outside a runtime-only quick fix. +- A5 must not blindly re-land the previously reverted global bunfig preload + behavior: it can delete the runner's private TMPDIR and has whole-corpus blast + radius. Any preload-related result must preserve sandbox environment state and + remains blocked on the final full Bun gate. + Worker probes, focused results, integration deltas, serial confirmations, and remaining reds are appended here only after coordinator review. Full-corpus numbers are updated only at the next same-binary full checkpoint. diff --git a/tools/integration/manifests/w43-a1-node-zlib-buffer.txt b/tools/integration/manifests/w43-a1-node-zlib-buffer.txt new file mode 100644 index 00000000..86809d58 --- /dev/null +++ b/tools/integration/manifests/w43-a1-node-zlib-buffer.txt @@ -0,0 +1,5 @@ +compat/node/test/parallel/test-buffer-constants.js +compat/node/test/parallel/test-buffer-constructor-deprecation-error.js +compat/node/test/parallel/test-zlib-brotli-kmaxlength-rangeerror.js +compat/node/test/parallel/test-zlib-kmaxlength-rangeerror.js +compat/node/test/parallel/test-zlib-zstd-kmaxlength-rangeerror.js diff --git a/tools/integration/manifests/w43-a2-node-assert.txt b/tools/integration/manifests/w43-a2-node-assert.txt new file mode 100644 index 00000000..c8a86d64 --- /dev/null +++ b/tools/integration/manifests/w43-a2-node-assert.txt @@ -0,0 +1,8 @@ +compat/node/test/parallel/test-assert-async.js +compat/node/test/parallel/test-assert-class-destructuring.js +compat/node/test/parallel/test-assert-class.js +compat/node/test/parallel/test-assert-deep-with-error.js +compat/node/test/parallel/test-assert-deep.js +compat/node/test/parallel/test-assert-first-line.js +compat/node/test/parallel/test-assert-partial-deep-equal.js +compat/node/test/parallel/test-assert-typedarray-deepequal.js diff --git a/tools/integration/manifests/w43-a3-node-permission.txt b/tools/integration/manifests/w43-a3-node-permission.txt new file mode 100644 index 00000000..fe5cbb1e --- /dev/null +++ b/tools/integration/manifests/w43-a3-node-permission.txt @@ -0,0 +1,10 @@ +compat/node/test/parallel/test-permission-child-process-cli.js +compat/node/test/parallel/test-permission-config-file.mjs +compat/node/test/parallel/test-permission-fs-internal-module-stat.js +compat/node/test/parallel/test-permission-fs-read.js +compat/node/test/parallel/test-permission-fs-traversal-path.js +compat/node/test/parallel/test-permission-fs-write.js +compat/node/test/parallel/test-permission-net-fetch.js +compat/node/test/parallel/test-permission-net-udp.js +compat/node/test/parallel/test-permission-processbinding.js +compat/node/test/parallel/test-permission-sqlite-load-extension.js diff --git a/tools/integration/manifests/w43-a4-bun-napi.txt b/tools/integration/manifests/w43-a4-bun-napi.txt new file mode 100644 index 00000000..b090a092 --- /dev/null +++ b/tools/integration/manifests/w43-a4-bun-napi.txt @@ -0,0 +1,18 @@ +compat/bun/test/napi/napi-finalizer-delete-ref.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_bigint/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_dataview/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_function/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_instance_data/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_new_target/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_number/do.test.ts +compat/bun/test/napi/node-napi-tests/test/js-native-api/test_typedarray/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_async/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_callback_scope/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_fatal_exception/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_general/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_make_callback/do.test.ts +compat/bun/test/napi/node-napi-tests/test/node-api/test_null_init/do.test.ts +compat/bun/test/napi/uv.test.ts +compat/bun/test/napi/uv_stub.test.ts diff --git a/tools/integration/manifests/w43-a5-bun-test-runner.txt b/tools/integration/manifests/w43-a5-bun-test-runner.txt new file mode 100644 index 00000000..f9587e6b --- /dev/null +++ b/tools/integration/manifests/w43-a5-bun-test-runner.txt @@ -0,0 +1,8 @@ +compat/bun/test/cli/test/test-filter-lifecycle-snapshot.test.ts +compat/bun/test/js/bun/test/done-async.test.ts +compat/bun/test/js/bun/test/expect-extend-preload.test.ts +compat/bun/test/js/bun/test/fake-timers/sinonjs/fake-timers.test.ts +compat/bun/test/js/bun/test/mock/6874/A.test.ts +compat/bun/test/js/bun/test/mock/6874/B.test.ts +compat/bun/test/js/bun/test/only-failures.test.ts +compat/bun/test/js/bun/test/test-error-code-done-callback.test.ts From 71b2c2b8aa7cd8550b07f72c1b2f7d68f32eb5b1 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:41:46 +0800 Subject: [PATCH 005/150] fix(node): honor buffer and zlib output limits - #83 - #80 Enforce Node's published string length before JSC allocation and capture the buffer output limit at the observable zlib require edge. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../jsc/src/builtins/node_buffer_extra.cppm | 27 +++++++++++++ modules/jsc/src/builtins/zlib_stream.cppm | 38 ++++++++++++++++++- .../jsc/tests/test_node_compat_bridges.cpp | 11 ++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/modules/jsc/src/builtins/node_buffer_extra.cppm b/modules/jsc/src/builtins/node_buffer_extra.cppm index f8427ba0..491cc711 100644 --- a/modules/jsc/src/builtins/node_buffer_extra.cppm +++ b/modules/jsc/src/builtins/node_buffer_extra.cppm @@ -110,6 +110,33 @@ inline constexpr std::string_view kNodeBufferExtraJS = R"JS( // V8-compatible string cap and the existing compatibility buffer value. const bunDialect = G.__mbunDialect !== "node"; const MAX_STRING_LENGTH = bunDialect ? 0x7fffffff : 536870888; + // JavaScriptCore accepts strings beyond Node's published V8 cap. Keep the + // node:buffer value and the observable String.prototype.repeat() boundary + // coherent without changing Bun's native dialect. + if (!bunDialect && typeof String.prototype.repeat === "function") { + const originalStringRepeat = String.prototype.repeat; + Object.defineProperty(String.prototype, "repeat", { + value: function repeat(count) { + // Keep the engine's conversion/error behavior for non-numeric counts + // (including Symbols and coercion side effects). The numeric fast path + // is sufficient to enforce Node's fixed V8 string limit before JSC + // attempts the allocation. + if (typeof count !== "number" || !Number.isFinite(count)) + return originalStringRepeat.call(this, count); + if (this == null) return originalStringRepeat.call(this, count); + const text = String(this); + const repetitions = count < 0 ? Math.ceil(count) : Math.floor(count); + if (repetitions > 0 && text.length > 0 && + repetitions > MAX_STRING_LENGTH / text.length) { + throw new RangeError("Invalid string length"); + } + return originalStringRepeat.call(text, count); + }, + writable: true, + enumerable: false, + configurable: true, + }); + } const errStringTooLong = () => { const e = new Error("Cannot create a string longer than " + MAX_STRING_LENGTH + " characters"); e.code = "ERR_STRING_TOO_LONG"; diff --git a/modules/jsc/src/builtins/zlib_stream.cppm b/modules/jsc/src/builtins/zlib_stream.cppm index 83e626c8..6ee4e662 100644 --- a/modules/jsc/src/builtins/zlib_stream.cppm +++ b/modules/jsc/src/builtins/zlib_stream.cppm @@ -32,6 +32,27 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( const zmod = M["zlib"] || M["node:zlib"]; const streamMod = M["stream"] || M["node:stream"]; if (!zmod || !streamMod || !streamMod.Transform) return; + // Node captures buffer.kMaxLength when require('zlib') initializes its module. + // mbun pre-registers native modules at bootstrap, so retain the same visible + // load edge with an accessor that arms the cap whenever zlib is required. + let zlibMaxOutputLength = 0x7fffffff; + const armZlibMaxOutputLength = () => { + const bufferModule = M["buffer"] || M["node:buffer"]; + const value = bufferModule && bufferModule.kMaxLength; + if (typeof value === "number" && Number.isFinite(value) && value > 0) + zlibMaxOutputLength = value; + return zmod; + }; + for (const name of ["zlib", "node:zlib"]) { + try { + Object.defineProperty(M, name, { + get: armZlibMaxOutputLength, + enumerable: true, + configurable: true, + }); + } catch (_) {} + } + G.__mbunZlibArmKMax = armZlibMaxOutputLength; const Transform = streamMod.Transform; const finished = streamMod.finished; const Buffer = G.Buffer; @@ -715,6 +736,21 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( zstdDecompressSync: { kind: K_ZDEC, Engine: ZstdDecompress }, }; const asyncOf = { inflateSync: "inflate", inflateRawSync: "inflateRaw", gunzipSync: "gunzip", unzipSync: "unzip", brotliDecompressSync: "brotliDecompress", zstdDecompressSync: "zstdDecompress" }; + const enforceDefaultOutputLimit = (result, opts) => { + // An explicit maxOutputLength is already passed to the native one-shot + // helper. This branch supplies Node's captured default for bootstrap-backed + // modules, whose native facade otherwise has no require-time cap. + if (opts && typeof opts === "object" && opts.maxOutputLength !== undefined) + return result; + if (result && typeof result.byteLength === "number" && + result.byteLength > zlibMaxOutputLength) { + const error = new RangeError("Cannot create a Buffer larger than " + + zlibMaxOutputLength + " bytes"); + error.code = "ERR_BUFFER_TOO_LARGE"; + throw error; + } + return result; + }; for (const name of Object.keys(decoderOneShots)) { const cfg = decoderOneShots[name]; const orig = zmod[name]; @@ -726,7 +762,7 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( const buf = decodeThroughHandle(cfg, data, opts, F_SYNC); return opts.info ? { buffer: buf, engine: Object.create(cfg.Engine.prototype) } : buf; } - try { return orig(data, opts); } + try { return enforceDefaultOutputLimit(orig(data, opts), opts); } catch (e) { // The whole-buffer natives collapse every decode failure into one generic // message; node distinguishes truncated input ("unexpected end of file") diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 18f41773..18c92eb4 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -28,6 +28,17 @@ int main() { "(()=>{try{const b=process.binding('inspector');" "return b&&typeof b==='object'?1:0}catch{return -1}})()", 1, "the inspector binding allowlist has a namespace"); + expect_number( + "(()=>{try{const b=require('node:buffer');" + "' '.repeat(b.constants.MAX_STRING_LENGTH+1);return 0}" + "catch(e){return e instanceof RangeError&&e.message==='Invalid string length'?1:0}})()", + 1, "node buffer string limit is enforced before JSC allocation"); + expect_number( + "(()=>{try{const b=require('node:buffer'),old=b.kMaxLength;" + "b.kMaxLength=64;const z=require('node:zlib');b.kMaxLength=old;" + "z.gunzipSync(Buffer.from('H4sIAAAAAAAAA0tMHFgAAIw2K/GAAAAA','base64'));return 0}" + "catch(e){return e instanceof RangeError&&e.code==='ERR_BUFFER_TOO_LARGE'?1:0}})()", + 1, "node zlib captures the buffer limit at its require edge"); if (failures != 0) { std::println("test_node_compat_bridges: {} failed", failures); From e7b8d99c77de7b9a2229675c584938bebc2e1373 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:45:37 +0800 Subject: [PATCH 006/150] fix(assert): expose Assert instances - #82 - #80 Install the Node assert Assert constructor with strict aliases, instance diff propagation, and partial deep assertion dispatch. Add a JSC runtime probe. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../src/builtins/node_assert_deepequal.cppm | 109 ++++++++++++++++++ .../jsc/tests/test_node_compat_bridges.cpp | 6 + 2 files changed, 115 insertions(+) diff --git a/modules/jsc/src/builtins/node_assert_deepequal.cppm b/modules/jsc/src/builtins/node_assert_deepequal.cppm index 6aa6a359..e39342e6 100644 --- a/modules/jsc/src/builtins/node_assert_deepequal.cppm +++ b/modules/jsc/src/builtins/node_assert_deepequal.cppm @@ -580,6 +580,114 @@ inline constexpr std::string_view kNodeAssertDeepEqualJS = R"JS( if (Object.is(actual, expected)) throw assertionError(message, actual, expected, "notStrictEqual", notStrictEqualMessage(actual)); }; + + // Node exposes `Assert` alongside the singleton module. Its methods are + // deliberately unbound: destructuring one must fall back to the default + // options, while an instance call retains that instance's diff/strict mode. + const kAssertOptions = Symbol("node.assert.options"); + const strictAliases = { + equal: "strictEqual", + deepEqual: "deepStrictEqual", + notEqual: "notStrictEqual", + notDeepEqual: "notDeepStrictEqual", + }; + const instanceMethodNames = [ + "fail", "ok", "equal", "notEqual", "deepEqual", "notDeepEqual", + "strictEqual", "notStrictEqual", "deepStrictEqual", "notDeepStrictEqual", + "partialDeepStrictEqual", "throws", "rejects", "doesNotThrow", + "doesNotReject", "ifError", "match", "doesNotMatch", + ]; + const partialDeepStrictEqual = function partialDeepStrictEqual(actual, expected, message) { + const compare = (received, wanted, memo) => { + if (Object.is(received, wanted)) return true; + if (received === null || wanted === null || + typeof received !== "object" || typeof wanted !== "object") return false; + if (isWeakMap(received) || isWeakMap(wanted) || isWeakSet(received) || isWeakSet(wanted)) return false; + const remembered = memo.get(received); + if (remembered === wanted) return true; + memo.set(received, wanted); + try { + if (Array.isArray(received) || Array.isArray(wanted)) { + if (!Array.isArray(received) || !Array.isArray(wanted) || received.length < wanted.length) return false; + for (let i = 0; i < wanted.length; ++i) + if (!(i in received) || !(i in wanted) || !compare(received[i], wanted[i], memo)) return false; + } else if (isMap(received) || isMap(wanted)) { + if (!isMap(received) || !isMap(wanted) || mapSize.call(received) < mapSize.call(wanted)) return false; + for (const [key, value] of wanted) { + if (!received.has(key) || !compare(received.get(key), value, memo)) return false; + } + } else if (isSet(received) || isSet(wanted)) { + if (!isSet(received) || !isSet(wanted) || setSize.call(received) < setSize.call(wanted)) return false; + for (const wantedValue of wanted) { + let found = false; + for (const receivedValue of received) { + if (compare(receivedValue, wantedValue, memo)) { found = true; break; } + } + if (!found) return false; + } + } else if ((taTag(received) !== undefined) || (taTag(wanted) !== undefined)) { + if (taTag(received) !== taTag(wanted) || received.byteLength < wanted.byteLength) return false; + const actualBytes = bytesOf(received), expectedBytes = bytesOf(wanted); + let offset = 0; + for (const byte of expectedBytes) { + while (offset < actualBytes.length && actualBytes[offset] !== byte) ++offset; + if (offset === actualBytes.length) return false; + ++offset; + } + } else if (isError(received) || isError(wanted)) { + if (!isError(received) || !isError(wanted) || received.name !== wanted.name) return false; + if (wanted.message !== "" && !compare(received.message, wanted.message, memo)) return false; + } + for (const key of ownKeys(wanted)) { + if (!Object.prototype.hasOwnProperty.call(received, key) || + !compare(received[key], wanted[key], memo)) return false; + } + return true; + } finally { + memo.delete(received); + } + }; + if (!compare(actual, expected, new Map())) + throw assertionError(message, actual, expected, "partialDeepStrictEqual", + eqMsg(actual, expected, "partially and strictly deep-equal")); + }; + assertMod.partialDeepStrictEqual = partialDeepStrictEqual; + + function Assert(options) { + if (!new.target) { + const error = new TypeError("Class constructor Assert cannot be invoked without 'new'"); + error.code = "ERR_CONSTRUCT_CALL_REQUIRED"; + throw error; + } + options = Object.assign({ strict: true, skipPrototype: false }, options || {}); + if (options.diff !== undefined && options.diff !== "simple" && options.diff !== "full") { + const error = new TypeError("The property 'options.diff' must be one of: 'simple', 'full'. Received '" + options.diff + "'"); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; + } + Object.defineProperty(this, kAssertOptions, { value: options }); + this.AssertionError = AssertionError; + if (options.strict) { + this.equal = this.strictEqual; + this.deepEqual = this.deepStrictEqual; + this.notEqual = this.notStrictEqual; + this.notDeepEqual = this.notDeepStrictEqual; + } + } + for (const name of instanceMethodNames) { + Assert.prototype[name] = function (...args) { + const options = this && this[kAssertOptions]; + const targetName = options && options.strict && strictAliases[name] ? strictAliases[name] : name; + const target = targetName === "partialDeepStrictEqual" ? partialDeepStrictEqual : assertMod[targetName]; + try { + return target.apply(assertMod, args); + } catch (error) { + if (error && typeof error === "object") error.diff = options && options.diff !== undefined ? options.diff : "simple"; + throw error; + } + }; + } + assertMod.Assert = Assert; for (const target of [assertMod.strict, M["assert/strict"], M["node:assert/strict"]]) { if (!target || target === assertMod) continue; if (typeof target !== "object" && typeof target !== "function") continue; @@ -587,6 +695,7 @@ inline constexpr std::string_view kNodeAssertDeepEqualJS = R"JS( target.notEqual = assertMod.notStrictEqual; target.strictEqual = assertMod.strictEqual; target.notStrictEqual = assertMod.notStrictEqual; + target.Assert = Assert; } const util = M["util"] || M["node:util"]; diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 18f41773..552ae834 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -28,6 +28,12 @@ int main() { "(()=>{try{const b=process.binding('inspector');" "return b&&typeof b==='object'?1:0}catch{return -1}})()", 1, "the inspector binding allowlist has a namespace"); + expect_number( + "(()=>{try{const a=require('node:assert');const x=new a.Assert({strict:false});" + "x.equal(2,'2');let constructed=false;try{a.Assert()}catch(e){constructed=e.code==='ERR_CONSTRUCT_CALL_REQUIRED'}" + "let partial=false;try{x.partialDeepStrictEqual({a:1},{a:2})}catch(e){partial=e.code==='ERR_ASSERTION'}" + "return constructed&&partial&&x.deepEqual!==x.deepStrictEqual?1:0}catch{return -1}})()", + 1, "Assert instances retain Node option and partial comparison semantics"); if (failures != 0) { std::println("test_node_compat_bridges: {} failed", failures); From c48839fe96e0b27bbf399eec7224e95a862dbad0 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:51:14 +0800 Subject: [PATCH 007/150] fix(permission): preserve native fs denials - #84 - #80 Keep Node filesystem adapters from masking or deferring permission errors, and check mkdtemp permissions against the six-character template resource at the native boundary. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/node_permission.cppm | 91 +++++++++++++++++++ modules/jsc/src/runtime/io_bindings.inc | 5 +- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/modules/jsc/src/builtins/node_permission.cppm b/modules/jsc/src/builtins/node_permission.cppm index 074f15ee..30178b5e 100644 --- a/modules/jsc/src/builtins/node_permission.cppm +++ b/modules/jsc/src/builtins/node_permission.cppm @@ -251,6 +251,29 @@ inline constexpr std::string_view kNodePermissionJS = R"JS( // while the model is on rather than trying to map an fd back to a path. The // list and the message are node's (lib/fs.js). const fsMod = (G.__mbunNativeModules || {})["fs"] || (G.__mbunNativeModules || {})["node:fs"]; + // node's fs wrappers convert a valid path-like value before it reaches + // node_file.cc's permission check. Keep this adapter deliberately narrow: + // invalid values still go to the original function and its full validator. + const permissionPath = (value) => { + if (typeof value === "string") return value; + if (isBuffer(value) || ArrayBuffer.isView(value)) { + try { return G.Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("utf8"); } + catch (e) { return undefined; } + } + if (value && typeof value === "object" && value.protocol === "file:") { + try { + const url = G.require && G.require("url"); + return url && typeof url.fileURLToPath === "function" ? url.fileURLToPath(value) : undefined; + } catch (e) { return undefined; } + } + return undefined; + }; + const deniedFsError = (scope, value, suffix = "") => { + const path = permissionPath(value); + if (path === undefined) return undefined; + const resource = path + suffix; + return PN.has(scope, resource) ? undefined : PN.denyError(scope, resource); + }; // EXACTLY node's list (lib/fs.js + internal/fs/promises.js): fsync, fdatasync, // fchmod, fchown, futimes. Notably NOT read/write/close — those take an fd that // could only have come from a gated open(), so gating them again would break @@ -258,6 +281,74 @@ inline constexpr std::string_view kNodePermissionJS = R"JS( // without adding any protection. const disabledUnderModel = ["fsync", "fdatasync", "fchmod", "fchown", "futimes"]; if (fsMod) { + // node node_file.cc keeps these checks outside the filesystem operation's + // async branch. mbun's load-bearing C++ gates already deny access, but the + // JS adapters used to mask access's denial as ENOENT and defer utimes / + // lutimes until a microtask. These thin wrappers restore node's public + // timing/error contract; direct calls to __mbunFsNative remain gated too. + const origAccessSync = fsMod.accessSync; + if (typeof origAccessSync === "function") { + fsMod.accessSync = function (path, ...rest) { + const err = deniedFsError("fs.read", path); + if (err) throw err; + return origAccessSync.call(this, path, ...rest); + }; + } + const origAccess = fsMod.access; + if (typeof origAccess === "function") { + fsMod.access = function (path, mode, callback) { + const cb = typeof mode === "function" ? mode : callback; + const err = typeof cb === "function" ? deniedFsError("fs.read", path) : undefined; + if (err) { G.queueMicrotask(() => cb(err)); return; } + return origAccess.apply(this, arguments); + }; + } + for (const name of ["utimes", "lutimes", "mkdir", "chmod"]) { + const original = fsMod[name]; + if (typeof original === "function") { + fsMod[name] = function (path, ...rest) { + const err = deniedFsError("fs.write", path); + if (err) throw err; + return original.call(this, path, ...rest); + }; + } + } + // chown/lchown are no-op compatibility stubs on this runtime, so there is + // no syscall capability to protect. They still report node's sync/callback + // refusal contracts while the model is enabled. + for (const name of ["chownSync", "lchownSync"]) { + const original = fsMod[name]; + if (typeof original === "function") { + fsMod[name] = function (path, ...rest) { + const err = deniedFsError("fs.write", path); + if (err) throw err; + return original.call(this, path, ...rest); + }; + } + } + for (const name of ["chown", "lchown"]) { + const original = fsMod[name]; + if (typeof original === "function") { + fsMod[name] = function (path, uid, gid, callback) { + const err = typeof callback === "function" ? deniedFsError("fs.write", path) : undefined; + if (err) { G.queueMicrotask(() => callback(err)); return; } + return original.apply(this, arguments); + }; + } + } + const FileHandle = fsMod.promises && fsMod.promises.FileHandle; + if (FileHandle && FileHandle.prototype && typeof FileHandle.prototype.chown === "function") { + const original = FileHandle.prototype.chown; + FileHandle.prototype.chown = function (...args) { + // Run the original first for its fd/uid/gid validation. It has no + // fchown syscall behind it, so success is replaced with node's model-on + // refusal without exposing an operation between validation and denial. + return Promise.resolve(original.apply(this, args)).then(() => { + throw PN.denyError("", "", "fchown API is disabled when Permission Model is enabled."); + }); + }; + } + for (const base of disabledUnderModel) { for (const name of [base, base + "Sync"]) { const original = fsMod[name]; diff --git a/modules/jsc/src/runtime/io_bindings.inc b/modules/jsc/src/runtime/io_bindings.inc index 6e38c351..2a32220a 100644 --- a/modules/jsc/src/runtime/io_bindings.inc +++ b/modules/jsc/src/runtime/io_bindings.inc @@ -1481,7 +1481,10 @@ JSValueRef fsn_readdirRecursive_cb(JSContextRef ctx, JSObjectRef, JSObjectRef, s JSValueRef fsn_mkdtemp_cb(JSContextRef ctx, JSObjectRef, JSObjectRef, std::size_t argc, const JSValueRef args[], JSValueRef* exc) { std::string prefix{fs_arg(ctx, argc, args, 0)}; - if (permission_deny_fs(ctx, exc, prefix, true)) return JSValueMakeUndefined(ctx); + // node appends its six-character template before checking permission, so a + // denial reports the exact resource it considered rather than the prefix. + // Keep the check at this native boundary: the JS adapter is not a sandbox. + if (permission_deny_fs(ctx, exc, prefix + "XXXXXX", true)) return JSValueMakeUndefined(ctx); // create with a clock-seeded counter — a plain 0-based // counter collides with directories left behind by previous runs. static std::atomic counter{static_cast( From 09fd2b76ed9d5e31ac6bca37245b025f1837dd28 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 16:54:34 +0800 Subject: [PATCH 008/150] fix(node): enforce limits before materialization - #83 - #80 Pass the captured zlib output cap into native decoders, preserve one-shot repeat coercion, and surface registry hook failures. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../jsc/src/builtins/node_buffer_extra.cppm | 15 ++++--- modules/jsc/src/builtins/zlib_stream.cppm | 42 ++++++++++++------- .../jsc/tests/test_node_compat_bridges.cpp | 19 +++++++++ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/modules/jsc/src/builtins/node_buffer_extra.cppm b/modules/jsc/src/builtins/node_buffer_extra.cppm index 491cc711..22250243 100644 --- a/modules/jsc/src/builtins/node_buffer_extra.cppm +++ b/modules/jsc/src/builtins/node_buffer_extra.cppm @@ -117,20 +117,19 @@ inline constexpr std::string_view kNodeBufferExtraJS = R"JS( const originalStringRepeat = String.prototype.repeat; Object.defineProperty(String.prototype, "repeat", { value: function repeat(count) { - // Keep the engine's conversion/error behavior for non-numeric counts - // (including Symbols and coercion side effects). The numeric fast path - // is sufficient to enforce Node's fixed V8 string limit before JSC - // attempts the allocation. - if (typeof count !== "number" || !Number.isFinite(count)) - return originalStringRepeat.call(this, count); if (this == null) return originalStringRepeat.call(this, count); const text = String(this); - const repetitions = count < 0 ? Math.ceil(count) : Math.floor(count); + // Unary + performs the spec's ToNumber exactly once: objects retain + // their coercion side effects, while Symbol and BigInt still throw. + // Pass the resulting primitive to JSC so it cannot coerce count again. + const number = +count; + const repetitions = Number.isNaN(number) ? 0 + : (number < 0 ? Math.ceil(number) : Math.floor(number)); if (repetitions > 0 && text.length > 0 && repetitions > MAX_STRING_LENGTH / text.length) { throw new RangeError("Invalid string length"); } - return originalStringRepeat.call(text, count); + return originalStringRepeat.call(text, repetitions); }, writable: true, enumerable: false, diff --git a/modules/jsc/src/builtins/zlib_stream.cppm b/modules/jsc/src/builtins/zlib_stream.cppm index 6ee4e662..703bfe43 100644 --- a/modules/jsc/src/builtins/zlib_stream.cppm +++ b/modules/jsc/src/builtins/zlib_stream.cppm @@ -43,6 +43,7 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( zlibMaxOutputLength = value; return zmod; }; + const zlibArmFailures = []; for (const name of ["zlib", "node:zlib"]) { try { Object.defineProperty(M, name, { @@ -50,8 +51,17 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( enumerable: true, configurable: true, }); - } catch (_) {} + if (Object.getOwnPropertyDescriptor(M, name).get !== armZlibMaxOutputLength) + throw new Error("accessor was not installed"); + } catch (error) { + zlibArmFailures.push(name + ": " + ((error && error.message) || String(error))); + } } + // Bootstrap must remain usable if an embedding freezes its registry, but the + // missed require edge must never be silent: embedders/tests can inspect this + // stable diagnostic and fail deterministically. + if (zlibArmFailures.length) + G.__mbunZlibArmError = zlibArmFailures.join("; "); G.__mbunZlibArmKMax = armZlibMaxOutputLength; const Transform = streamMod.Transform; const finished = streamMod.finished; @@ -736,20 +746,22 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( zstdDecompressSync: { kind: K_ZDEC, Engine: ZstdDecompress }, }; const asyncOf = { inflateSync: "inflate", inflateRawSync: "inflateRaw", gunzipSync: "gunzip", unzipSync: "unzip", brotliDecompressSync: "brotliDecompress", zstdDecompressSync: "zstdDecompress" }; - const enforceDefaultOutputLimit = (result, opts) => { - // An explicit maxOutputLength is already passed to the native one-shot - // helper. This branch supplies Node's captured default for bootstrap-backed - // modules, whose native facade otherwise has no require-time cap. - if (opts && typeof opts === "object" && opts.maxOutputLength !== undefined) - return result; - if (result && typeof result.byteLength === "number" && - result.byteLength > zlibMaxOutputLength) { - const error = new RangeError("Cannot create a Buffer larger than " + - zlibMaxOutputLength + " bytes"); - error.code = "ERR_BUFFER_TOO_LARGE"; - throw error; + const nativeDecodeOpts = (opts) => { + if (opts === undefined || opts === null || typeof opts === "object") { + const nativeOpts = opts && typeof opts === "object" ? Object.create(opts) : {}; + // Resolve an explicit getter once, then shadow it on the derived object; + // the native helper cannot trigger a second coercion or mutate the caller. + const explicitMax = opts && typeof opts === "object" ? opts.maxOutputLength : undefined; + Object.defineProperty(nativeOpts, "maxOutputLength", { + value: explicitMax === undefined ? zlibMaxOutputLength : explicitMax, + writable: true, + enumerable: true, + configurable: true, + }); + return nativeOpts; } - return result; + // Preserve the native type error for invalid primitive options. + return opts; }; for (const name of Object.keys(decoderOneShots)) { const cfg = decoderOneShots[name]; @@ -762,7 +774,7 @@ inline constexpr std::string_view kZlibStreamJS = R"JS( const buf = decodeThroughHandle(cfg, data, opts, F_SYNC); return opts.info ? { buffer: buf, engine: Object.create(cfg.Engine.prototype) } : buf; } - try { return enforceDefaultOutputLimit(orig(data, opts), opts); } + try { return orig(data, nativeDecodeOpts(opts)); } catch (e) { // The whole-buffer natives collapse every decode failure into one generic // message; node distinguishes truncated input ("unexpected end of file") diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 18c92eb4..9cf47a9e 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -33,12 +33,31 @@ int main() { "' '.repeat(b.constants.MAX_STRING_LENGTH+1);return 0}" "catch(e){return e instanceof RangeError&&e.message==='Invalid string length'?1:0}})()", 1, "node buffer string limit is enforced before JSC allocation"); + expect_number( + "(()=>{const b=require('node:buffer');let calls=0;" + "const count={[Symbol.toPrimitive](){++calls;return String(b.constants.MAX_STRING_LENGTH+1)}};" + "try{' '.repeat(count);return 0}catch(e){return e instanceof RangeError&&calls===1?1:0}})()", + 1, "node buffer string limit coerces repeat count exactly once"); + expect_number( + "(()=>{let bigint=false,symbol=false;" + "try{'x'.repeat(1n)}catch(e){bigint=e instanceof TypeError}" + "try{'x'.repeat(Symbol())}catch(e){symbol=e instanceof TypeError}" + "return bigint&&symbol?1:0})()", + 1, "node buffer repeat preserves BigInt and Symbol type errors"); expect_number( "(()=>{try{const b=require('node:buffer'),old=b.kMaxLength;" "b.kMaxLength=64;const z=require('node:zlib');b.kMaxLength=old;" "z.gunzipSync(Buffer.from('H4sIAAAAAAAAA0tMHFgAAIw2K/GAAAAA','base64'));return 0}" "catch(e){return e instanceof RangeError&&e.code==='ERR_BUFFER_TOO_LARGE'?1:0}})()", 1, "node zlib captures the buffer limit at its require edge"); + expect_number( + "(()=>{const z=require('node:zlib');let reads=0;const opts={get maxOutputLength(){++reads;return 64}};" + "try{z.gunzipSync(Buffer.from('H4sIAAAAAAAAA0tMHFgAAIw2K/GAAAAA','base64'),opts);return 0}" + "catch(e){return e.code==='ERR_BUFFER_TOO_LARGE'&&reads===1?1:0}})()", + 1, "node zlib preserves explicit output limits without mutating options"); + expect_number( + "typeof globalThis.__mbunZlibArmError==='undefined'?1:0", + 1, "node zlib require-edge accessor installs without diagnostics"); if (failures != 0) { std::println("test_node_compat_bridges: {} failed", failures); From 0a102f625e1c8f8950349bde1e9ddb936945720e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 16:57:10 +0800 Subject: [PATCH 009/150] fix(assert): narrow Assert compatibility surface - #82 - #80 Remove the incomplete partial-deep implementation and discard unsupported skipPrototype state. Preserve instance receivers, and only attach diff metadata to AssertionError instances. Expand the JSC runtime probes for retained behavior. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../src/builtins/node_assert_deepequal.cppm | 75 +++---------------- .../jsc/tests/test_node_compat_bridges.cpp | 20 ++++- 2 files changed, 29 insertions(+), 66 deletions(-) diff --git a/modules/jsc/src/builtins/node_assert_deepequal.cppm b/modules/jsc/src/builtins/node_assert_deepequal.cppm index e39342e6..cac0749f 100644 --- a/modules/jsc/src/builtins/node_assert_deepequal.cppm +++ b/modules/jsc/src/builtins/node_assert_deepequal.cppm @@ -594,64 +594,9 @@ inline constexpr std::string_view kNodeAssertDeepEqualJS = R"JS( const instanceMethodNames = [ "fail", "ok", "equal", "notEqual", "deepEqual", "notDeepEqual", "strictEqual", "notStrictEqual", "deepStrictEqual", "notDeepStrictEqual", - "partialDeepStrictEqual", "throws", "rejects", "doesNotThrow", - "doesNotReject", "ifError", "match", "doesNotMatch", + "throws", "rejects", "doesNotThrow", "doesNotReject", "ifError", + "match", "doesNotMatch", ]; - const partialDeepStrictEqual = function partialDeepStrictEqual(actual, expected, message) { - const compare = (received, wanted, memo) => { - if (Object.is(received, wanted)) return true; - if (received === null || wanted === null || - typeof received !== "object" || typeof wanted !== "object") return false; - if (isWeakMap(received) || isWeakMap(wanted) || isWeakSet(received) || isWeakSet(wanted)) return false; - const remembered = memo.get(received); - if (remembered === wanted) return true; - memo.set(received, wanted); - try { - if (Array.isArray(received) || Array.isArray(wanted)) { - if (!Array.isArray(received) || !Array.isArray(wanted) || received.length < wanted.length) return false; - for (let i = 0; i < wanted.length; ++i) - if (!(i in received) || !(i in wanted) || !compare(received[i], wanted[i], memo)) return false; - } else if (isMap(received) || isMap(wanted)) { - if (!isMap(received) || !isMap(wanted) || mapSize.call(received) < mapSize.call(wanted)) return false; - for (const [key, value] of wanted) { - if (!received.has(key) || !compare(received.get(key), value, memo)) return false; - } - } else if (isSet(received) || isSet(wanted)) { - if (!isSet(received) || !isSet(wanted) || setSize.call(received) < setSize.call(wanted)) return false; - for (const wantedValue of wanted) { - let found = false; - for (const receivedValue of received) { - if (compare(receivedValue, wantedValue, memo)) { found = true; break; } - } - if (!found) return false; - } - } else if ((taTag(received) !== undefined) || (taTag(wanted) !== undefined)) { - if (taTag(received) !== taTag(wanted) || received.byteLength < wanted.byteLength) return false; - const actualBytes = bytesOf(received), expectedBytes = bytesOf(wanted); - let offset = 0; - for (const byte of expectedBytes) { - while (offset < actualBytes.length && actualBytes[offset] !== byte) ++offset; - if (offset === actualBytes.length) return false; - ++offset; - } - } else if (isError(received) || isError(wanted)) { - if (!isError(received) || !isError(wanted) || received.name !== wanted.name) return false; - if (wanted.message !== "" && !compare(received.message, wanted.message, memo)) return false; - } - for (const key of ownKeys(wanted)) { - if (!Object.prototype.hasOwnProperty.call(received, key) || - !compare(received[key], wanted[key], memo)) return false; - } - return true; - } finally { - memo.delete(received); - } - }; - if (!compare(actual, expected, new Map())) - throw assertionError(message, actual, expected, "partialDeepStrictEqual", - eqMsg(actual, expected, "partially and strictly deep-equal")); - }; - assertMod.partialDeepStrictEqual = partialDeepStrictEqual; function Assert(options) { if (!new.target) { @@ -659,15 +604,18 @@ inline constexpr std::string_view kNodeAssertDeepEqualJS = R"JS( error.code = "ERR_CONSTRUCT_CALL_REQUIRED"; throw error; } - options = Object.assign({ strict: true, skipPrototype: false }, options || {}); + options = options || {}; if (options.diff !== undefined && options.diff !== "simple" && options.diff !== "full") { const error = new TypeError("The property 'options.diff' must be one of: 'simple', 'full'. Received '" + options.diff + "'"); error.code = "ERR_INVALID_ARG_VALUE"; throw error; } - Object.defineProperty(this, kAssertOptions, { value: options }); + // Only retain options this bridge implements. In particular, do not + // advertise skipPrototype until the comparator can honor it end to end. + const assertOptions = { strict: options.strict !== false, diff: options.diff }; + Object.defineProperty(this, kAssertOptions, { value: assertOptions }); this.AssertionError = AssertionError; - if (options.strict) { + if (assertOptions.strict) { this.equal = this.strictEqual; this.deepEqual = this.deepStrictEqual; this.notEqual = this.notStrictEqual; @@ -678,11 +626,12 @@ inline constexpr std::string_view kNodeAssertDeepEqualJS = R"JS( Assert.prototype[name] = function (...args) { const options = this && this[kAssertOptions]; const targetName = options && options.strict && strictAliases[name] ? strictAliases[name] : name; - const target = targetName === "partialDeepStrictEqual" ? partialDeepStrictEqual : assertMod[targetName]; + const target = assertMod[targetName]; try { - return target.apply(assertMod, args); + return target.apply(this, args); } catch (error) { - if (error && typeof error === "object") error.diff = options && options.diff !== undefined ? options.diff : "simple"; + if (error instanceof AssertionError) + error.diff = options && options.diff !== undefined ? options.diff : "simple"; throw error; } }; diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 552ae834..ccc93fc4 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -31,9 +31,23 @@ int main() { expect_number( "(()=>{try{const a=require('node:assert');const x=new a.Assert({strict:false});" "x.equal(2,'2');let constructed=false;try{a.Assert()}catch(e){constructed=e.code==='ERR_CONSTRUCT_CALL_REQUIRED'}" - "let partial=false;try{x.partialDeepStrictEqual({a:1},{a:2})}catch(e){partial=e.code==='ERR_ASSERTION'}" - "return constructed&&partial&&x.deepEqual!==x.deepStrictEqual?1:0}catch{return -1}})()", - 1, "Assert instances retain Node option and partial comparison semantics"); + "return constructed&&x.deepEqual!==x.deepStrictEqual&&typeof x.partialDeepStrictEqual==='undefined'?1:0}catch{return -1}})()", + 1, "Assert instances retain the proven constructor and strict option surface"); + expect_number( + "(()=>{try{const a=require('node:assert');const x=new a.Assert({diff:'full'});" + "try{x.strictEqual(1,2)}catch(e){return e instanceof a.AssertionError&&e.diff==='full'?1:0}" + "return 0}catch{return -1}})()", + 1, "Assert instance assertions retain the configured diff mode"); + expect_number( + "(()=>{try{const a=require('node:assert');const {strictEqual}=new a.Assert({diff:'full'});" + "try{strictEqual(1,2)}catch(e){return e instanceof a.AssertionError&&e.diff==='simple'?1:0}" + "return 0}catch{return -1}})()", + 1, "destructured Assert methods use default options"); + expect_number( + "(()=>{try{const a=require('node:assert');const x=new a.Assert({diff:'full'});const original=new Error('original');" + "try{x.fail(original)}catch(e){return e===original&&!Object.prototype.hasOwnProperty.call(e,'diff')?1:0}" + "return 0}catch{return -1}})()", + 1, "Assert methods preserve non-AssertionError objects"); if (failures != 0) { std::println("test_node_compat_bridges: {} failed", failures); From bacd24687e390c8dc6b81c91ddc854da68c30fcd Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:15:11 +0800 Subject: [PATCH 010/150] test(node): select dialect for compatibility probes - #83 - #80 Initialize the standalone JSC member test in Node dialect before its first runtime evaluation, matching the compat/node CLI path. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/tests/test_node_compat_bridges.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 9cf47a9e..e9d8e2f9 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -19,6 +19,13 @@ void expect_number(std::string_view source, double expected, std::string_view na } // namespace int main() { + // Member tests bypass the CLI's resolve_dialect() dispatch. Select Node + // before the first eval initializes the singleton runtime so these probes + // exercise the same builtin branches as the compat/node corpus. + mbun::jsc::runtime::set_dialect(mbun::jsc::runtime::Dialect::Node); + expect_number( + "globalThis.__mbunDialect === 'node' ? 1 : 0", + 1, "node compatibility probes run in the Node dialect"); expect_number( "(()=>{try{const fs=require('node:fs');" "const p=require('node:util').promisify(fs.exists);" From f0d7533fab309e2f8888c109cbf9cf334b477e69 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:17:50 +0800 Subject: [PATCH 011/150] fix(permission): validate fs calls before denial Move permission translation behind the public fs validators and preserve pinned Node's immediate callback rejection order for access and ownership calls. Add a real JSC runtime regression covering invalid arguments, path-like getters, denial timing, and the synchronous-denial APIs.\n\n- #84\n- #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/bootstrap.cppm | 60 +++++++++---- modules/jsc/src/builtins/node_permission.cppm | 78 ---------------- .../test_runtime_permission_fs_validation.cpp | 89 +++++++++++++++++++ 3 files changed, 134 insertions(+), 93 deletions(-) create mode 100644 modules/jsc/tests/test_runtime_permission_fs_validation.cpp diff --git a/modules/jsc/src/builtins/bootstrap.cppm b/modules/jsc/src/builtins/bootstrap.cppm index ba797451..9721e8e6 100644 --- a/modules/jsc/src/builtins/bootstrap.cppm +++ b/modules/jsc/src/builtins/bootstrap.cppm @@ -6234,6 +6234,22 @@ inline constexpr char kBootstrapJS_[] = R"JS( if (x instanceof ArrayBuffer) return Buffer.from(x).toString("utf8"); return x && x.toString ? x.toString() : String(x); }; + // Permission enforcement remains in the native fs rows. This helper only + // translates a denial at the public validation-to-dispatch seam, where node + // makes the same decision. Callers must finish every public argument + // validator before asking it, so ERR_INVALID_ARG_* keeps precedence. + const fsPermissionError = (scope, path, suffix = "") => { + const permission = G.__mbunPermissionNative; + if (!permission || !permission.enabled) return undefined; + const resource = toStr(path) + suffix; + return permission.has(scope, resource) + ? undefined + : permission.denyError(scope, resource); + }; + const fsThrowPermission = (scope, path, suffix = "") => { + const error = fsPermissionError(scope, path, suffix); + if (error) throw error; + }; const recur = (o) => !!(o && (o === true || o.recursive)); // fd → path registry. mbun's descriptors are virtual (no procfs mapping), so // fchmod/fchown/futimes — which take an fd but must touch the underlying inode @@ -6331,10 +6347,10 @@ inline constexpr char kBootstrapJS_[] = R"JS( // (recursive, mode) tuple the native mkdir consumes; mode defaults to 0o777. const mkdirOpts = (o) => { let recursive = false, mode = 0o777; - if (typeof o === "number") mode = o; + if (typeof o === "number") mode = fsParseFileMode(o, "mode"); // node accepts an octal-string mode as the positional arg (kernel masks the // high bits, e.g. "10644" → 0o644). ref test-fs-mkdir-mode-mask. - else if (typeof o === "string") mode = parseInt(o, 8); + else if (typeof o === "string") mode = fsParseFileMode(o, "mode"); else if (o === true) recursive = true; else if (o && typeof o === "object") { if ("recursive" in o && o.recursive !== undefined) { @@ -6342,7 +6358,7 @@ inline constexpr char kBootstrapJS_[] = R"JS( throw nodeArgTypeError("options.recursive", "boolean", o.recursive); recursive = o.recursive; } - if (o.mode != null) mode = typeof o.mode === "string" ? parseInt(o.mode, 8) : (Number(o.mode) & 0o7777); + if (o.mode !== undefined) mode = fsParseFileMode(o.mode, "options.mode"); } return [recursive, mode]; }; @@ -6865,7 +6881,7 @@ inline constexpr char kBootstrapJS_[] = R"JS( } try { return F.exists(toStr(p)); } catch (e) { return false; } }, - mkdirSync: (p, o) => { validatePath(p); const [rec, mode] = mkdirOpts(o); try { return F.mkdir(toStr(p), rec, mode); } catch (e) { e.path = toStr(p); throw e; } }, + mkdirSync: (p, o) => { validatePath(p); const [rec, mode] = mkdirOpts(o); fsThrowPermission("fs.write", p); try { return F.mkdir(toStr(p), rec, mode); } catch (e) { e.path = toStr(p); throw e; } }, rmSync: (p, o) => F.rm(toStr(p), recur(o), !!(o && o.force)), rmdirSync: (p, o) => F.rm(toStr(p), recur(o), true), readdirSync: (p, o) => { @@ -6949,7 +6965,7 @@ inline constexpr char kBootstrapJS_[] = R"JS( fsyncSync: () => {}, fdatasyncSync: () => {}, // permission/owner/time metadata: no-ops (our fs has no perm model); access // checks existence; readlink resolves via realpath (we have no real symlinks). - chmodSync: (p, m) => { validatePath(p); return F.chmod(toStr(p), typeof m === "string" ? parseInt(m, 8) : (Number(m) & 0o7777)); }, fchmodSync: () => {}, lchmodSync: () => {}, + chmodSync: (p, m) => { validatePath(p); const mode = fsParseFileMode(m, "mode"); fsThrowPermission("fs.write", p); return F.chmod(toStr(p), mode); }, fchmodSync: () => {}, lchmodSync: () => {}, chownSync: () => {}, fchownSync: () => {}, lchownSync: () => {}, utimesSync: (p, a, m) => { const s = (v) => v instanceof Date ? v.getTime() / 1000 : Number(v); F.utimes(toStr(p), s(a), s(m)); }, futimesSync: () => {}, lutimesSync: () => {}, truncateSync: () => {}, ftruncateSync: () => {}, @@ -6960,7 +6976,7 @@ inline constexpr char kBootstrapJS_[] = R"JS( // a microtask and write accumulates then flushes on end/close. createReadStream: (p, opts) => { const rs = new Readable(); rs.path = toStr(p); rs.bytesRead = 0; const enc = typeof opts === "string" ? opts : (opts && opts.encoding); const start = (opts && typeof opts === "object") ? opts.start : undefined; const end = (opts && typeof opts === "object") ? opts.end : undefined; if (start !== undefined && typeof start !== "number") throw fsArgTypeErr("start", "of type number", start); if (end !== undefined && typeof end !== "number") throw fsArgTypeErr("end", "of type number", end); G.queueMicrotask(() => { try { const data = fsMod.readFileSync(toStr(p)); rs.emit("open", 3); rs.emit("ready"); let buf = Buffer.from(data); if (typeof start === "number" || typeof end === "number") { const s = typeof start === "number" ? start : 0; const e2 = typeof end === "number" ? end + 1 : buf.length; buf = buf.subarray(s, e2); } rs.bytesRead = buf.length; rs.push(enc ? buf.toString(enc) : buf); rs.push(null); rs.emit("close"); } catch (e) { e.code = e.code || "ENOENT"; rs.emit("error", e); } }); rs.close = (cb) => { if (cb) cb(); return rs; }; return rs; }, createWriteStream: (p, opts) => { const wstart = (opts && typeof opts === "object") ? opts.start : undefined; if (wstart !== undefined && typeof wstart !== "number") throw fsArgTypeErr("start", "of type number", wstart); const ws = new Writable(); ws.path = toStr(p); ws.bytesWritten = 0; const parts = []; const enc = (opts && opts.encoding) || "utf8"; ws._write = (chunk, e, cb) => { const b = typeof chunk === "string" ? Buffer.from(chunk, (typeof e === "string" && e && e !== "buffer") ? e : enc) : Buffer.from(chunk); parts.push(b); ws.bytesWritten += b.length; if (typeof (cb || e) === "function") (cb || e)(); }; const flush = () => { try { fsMod.writeFileSync(toStr(p), parts.length === 1 ? parts[0] : Buffer.concat(parts)); } catch (er) { ws.emit("error", er); } }; const superEnd = ws.end.bind(ws); ws.end = (chunk, e, cb) => { if (chunk != null && typeof chunk !== "function") ws._write(chunk, enc, null); flush(); G.queueMicrotask(() => { ws.emit("finish"); ws.emit("close"); }); const f = cb || (typeof e === "function" ? e : typeof chunk === "function" ? chunk : null); if (f) f(); return ws; }; ws.close = (cb) => { if (cb) cb(); return ws; }; G.queueMicrotask(() => { ws.emit("open", 3); ws.emit("ready"); }); return ws; }, - chmod: (p, m, cb) => { validatePath(p); const fn = typeof m === "function" ? m : cb; try { if (typeof m !== "function") F.chmod(toStr(p), typeof m === "string" ? parseInt(m, 8) : (Number(m) & 0o7777)); if (typeof fn === "function") fn(null); } catch (e) { if (typeof fn === "function") fn(e); } }, + chmod: (p, m, cb) => { validatePath(p); const mode = fsParseFileMode(m, "mode"); const fn = fsMakeCallback(cb); fsThrowPermission("fs.write", p); try { F.chmod(toStr(p), mode); fn(null); } catch (e) { fn(e); } }, chown: (p, u, g, cb) => { const fn = cb || g; if (typeof fn === "function") fn(null); }, utimes: (p, a, m, cb) => { const fn = cb || m; try { const s = (v) => v instanceof Date ? v.getTime() / 1000 : Number(v); F.utimes(toStr(p), s(a), s(m)); if (typeof fn === "function") fn(null); } catch (e) { if (typeof fn === "function") fn(e); } }, access: (p, m, cb) => { const fn = cb || m; try { if (!F.exists(toStr(p))) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); fn(null); } catch (e) { fn(e); } }, @@ -7084,7 +7100,7 @@ inline constexpr char kBootstrapJS_[] = R"JS( if (err !== null) cb(err); else if (!owns) cb(null); }); }, - mkdir: (p, a, b) => { validatePath(p); const cb = b || a; const [rec, mode] = mkdirOpts(typeof a === "object" || typeof a === "number" || typeof a === "string" ? a : null); try { const __r = F.mkdir(toStr(p), rec, mode); cb(null, __r); } catch (e) { e.path = toStr(p); cb(e); } }, + mkdir: (p, a, b) => { const cb = typeof a === "function" ? a : b; const [rec, mode] = mkdirOpts(typeof a === "object" || typeof a === "number" || typeof a === "string" ? a : null); fsMakeCallback(cb); validatePath(p); fsThrowPermission("fs.write", p); try { const __r = F.mkdir(toStr(p), rec, mode); cb(null, __r); } catch (e) { e.path = toStr(p); cb(e); } }, // callback-style async (node passes (err, result); mirror the *Sync impls). stat: (p, a, b) => { const cb = typeof a === "function" ? a : b; try { cb(null, fsMod.statSync(toStr(p))); } catch (e) { cb(e); } }, lstat: (p, a, b) => { const cb = typeof a === "function" ? a : b; try { cb(null, fsMod.lstatSync(toStr(p))); } catch (e) { cb(e); } }, @@ -7554,14 +7570,16 @@ inline constexpr char kBootstrapJS_[] = R"JS( }; const fsAsyncChown = (path, syscall, fn) => { const cb = fsMakeCallback(fn); + const denied = fsPermissionError("fs.write", path); + if (denied) { cb(denied); return; } let err = null; try { fsChownEnoent(path, syscall); } catch (e) { err = e; } G.queueMicrotask(() => cb(err)); }; - fsMod.lchownSync = (path, uid, gid) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsChownEnoent(path, "lchown"); }; - fsMod.lchown = (path, uid, gid, cb) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsAsyncChown(path, "lchown", cb); }; - fsMod.chownSync = (path, uid, gid) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsChownEnoent(path, "chown"); }; - fsMod.chown = (path, uid, gid, cb) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsAsyncChown(path, "chown", cb); }; + fsMod.lchownSync = (path, uid, gid) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsThrowPermission("fs.write", path); fsChownEnoent(path, "lchown"); }; + fsMod.lchown = (path, uid, gid, cb) => { fsMakeCallback(cb); validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsAsyncChown(path, "lchown", cb); }; + fsMod.chownSync = (path, uid, gid) => { validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsThrowPermission("fs.write", path); fsChownEnoent(path, "chown"); }; + fsMod.chown = (path, uid, gid, cb) => { fsMakeCallback(cb); validatePath(path); fsIntU32(uid, "uid"); fsIntU32(gid, "gid"); fsAsyncChown(path, "chown", cb); }; fsMod.fsyncSync = (fd) => { fsValidateFd(fd); fsCheckFd(fd, "fsync"); }; fsMod.fdatasyncSync = (fd) => { fsValidateFd(fd); fsCheckFd(fd, "fdatasync"); }; fsMod.fsync = (fd, cb) => { fsValidateFd(fd); fsAsyncFd(fd, "fsync", cb); }; @@ -7659,12 +7677,14 @@ inline constexpr char kBootstrapJS_[] = R"JS( else bits = stMode & 7; if ((m & ~bits) !== 0) throw fsErr("EACCES", "access", path2); }; - fsMod.accessSync = (p, mode) => { validatePath(p); fsValidAccessMode(mode); fsAccessCheck(p, mode); }; + fsMod.accessSync = (p, mode) => { validatePath(p); fsValidAccessMode(mode); fsThrowPermission("fs.read", p); fsAccessCheck(p, mode); }; fsMod.access = (p, mode, cb) => { validatePath(p); const fn = fsMakeCallback(typeof mode === "function" ? mode : cb); const m = typeof mode === "function" ? 0 : mode; fsValidAccessMode(m); + const denied = fsPermissionError("fs.read", p); + if (denied) { fn(denied); return; } let err = null; try { fsAccessCheck(p, m); } catch (e) { err = e; } G.queueMicrotask(() => fn(err)); @@ -8274,17 +8294,21 @@ inline constexpr char kBootstrapJS_[] = R"JS( fsMod._toUnixTimestamp = fsToUnixTimestamp; fsMod.utimesSync = (p, atime, mtime) => { validatePath(p); - F.utimes(toStr(p), fsToUnixTimestamp(atime, "atime"), fsToUnixTimestamp(mtime, "mtime")); + const a = fsToUnixTimestamp(atime, "atime"), m = fsToUnixTimestamp(mtime, "mtime"); + fsThrowPermission("fs.write", p); + F.utimes(toStr(p), a, m); }; fsMod.utimes = (p, atime, mtime, cb) => { + const fn = fsMakeCallback(cb); validatePath(p); const a = fsToUnixTimestamp(atime, "atime"), m = fsToUnixTimestamp(mtime, "mtime"); - const fn = fsMakeCallback(cb); + fsThrowPermission("fs.write", p); G.queueMicrotask(() => { try { F.utimes(toStr(p), a, m); fn(null); } catch (e) { fn(e); } }); }; fsMod.lutimesSync = (p, atime, mtime) => { validatePath(p); const a = fsToUnixTimestamp(atime, "atime"), m = fsToUnixTimestamp(mtime, "mtime"); + fsThrowPermission("fs.write", p); // lutimes must stamp the SYMLINK, never its target: F.lutimes is // utimensat(AT_SYMLINK_NOFOLLOW). Falling back to F.utimes followed the // link, so a dangling symlink reported ENOENT and a live one moved the @@ -8292,9 +8316,10 @@ inline constexpr char kBootstrapJS_[] = R"JS( F.lutimes(toStr(p), a, m); }; fsMod.lutimes = (p, atime, mtime, cb) => { + const fn = fsMakeCallback(cb); validatePath(p); const a = fsToUnixTimestamp(atime, "atime"), m = fsToUnixTimestamp(mtime, "mtime"); - const fn = fsMakeCallback(cb); + fsThrowPermission("fs.write", p); G.queueMicrotask(() => { try { fsMod.lutimesSync(p, a, m); fn(null); } catch (e) { fn(e); } }); }; fsMod.futimesSync = (fd, atime, mtime) => { @@ -9190,6 +9215,11 @@ inline constexpr char kBootstrapJS_[] = R"JS( const ret = fn.apply(this, args); inFrame = false; if (fired) { + // Node's binding permission rejection invokes FSReqCallback immediately. + // Keep ordinary fs completions on this queue, but do not move a native- + // boundary denial behind nextTick or promise jobs. + if (out && out[0] && out[0].code === "ERR_ACCESS_DENIED") + return real.apply(undefined, out); // The completion carries the async context of the CALL, captured here at // push time — node's rule, and the same thing process.nextTick does a few // hundred lines away in runtime/bindings_install.inc. A batched drain runs diff --git a/modules/jsc/src/builtins/node_permission.cppm b/modules/jsc/src/builtins/node_permission.cppm index 30178b5e..754c50bc 100644 --- a/modules/jsc/src/builtins/node_permission.cppm +++ b/modules/jsc/src/builtins/node_permission.cppm @@ -251,29 +251,6 @@ inline constexpr std::string_view kNodePermissionJS = R"JS( // while the model is on rather than trying to map an fd back to a path. The // list and the message are node's (lib/fs.js). const fsMod = (G.__mbunNativeModules || {})["fs"] || (G.__mbunNativeModules || {})["node:fs"]; - // node's fs wrappers convert a valid path-like value before it reaches - // node_file.cc's permission check. Keep this adapter deliberately narrow: - // invalid values still go to the original function and its full validator. - const permissionPath = (value) => { - if (typeof value === "string") return value; - if (isBuffer(value) || ArrayBuffer.isView(value)) { - try { return G.Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("utf8"); } - catch (e) { return undefined; } - } - if (value && typeof value === "object" && value.protocol === "file:") { - try { - const url = G.require && G.require("url"); - return url && typeof url.fileURLToPath === "function" ? url.fileURLToPath(value) : undefined; - } catch (e) { return undefined; } - } - return undefined; - }; - const deniedFsError = (scope, value, suffix = "") => { - const path = permissionPath(value); - if (path === undefined) return undefined; - const resource = path + suffix; - return PN.has(scope, resource) ? undefined : PN.denyError(scope, resource); - }; // EXACTLY node's list (lib/fs.js + internal/fs/promises.js): fsync, fdatasync, // fchmod, fchown, futimes. Notably NOT read/write/close — those take an fd that // could only have come from a gated open(), so gating them again would break @@ -281,61 +258,6 @@ inline constexpr std::string_view kNodePermissionJS = R"JS( // without adding any protection. const disabledUnderModel = ["fsync", "fdatasync", "fchmod", "fchown", "futimes"]; if (fsMod) { - // node node_file.cc keeps these checks outside the filesystem operation's - // async branch. mbun's load-bearing C++ gates already deny access, but the - // JS adapters used to mask access's denial as ENOENT and defer utimes / - // lutimes until a microtask. These thin wrappers restore node's public - // timing/error contract; direct calls to __mbunFsNative remain gated too. - const origAccessSync = fsMod.accessSync; - if (typeof origAccessSync === "function") { - fsMod.accessSync = function (path, ...rest) { - const err = deniedFsError("fs.read", path); - if (err) throw err; - return origAccessSync.call(this, path, ...rest); - }; - } - const origAccess = fsMod.access; - if (typeof origAccess === "function") { - fsMod.access = function (path, mode, callback) { - const cb = typeof mode === "function" ? mode : callback; - const err = typeof cb === "function" ? deniedFsError("fs.read", path) : undefined; - if (err) { G.queueMicrotask(() => cb(err)); return; } - return origAccess.apply(this, arguments); - }; - } - for (const name of ["utimes", "lutimes", "mkdir", "chmod"]) { - const original = fsMod[name]; - if (typeof original === "function") { - fsMod[name] = function (path, ...rest) { - const err = deniedFsError("fs.write", path); - if (err) throw err; - return original.call(this, path, ...rest); - }; - } - } - // chown/lchown are no-op compatibility stubs on this runtime, so there is - // no syscall capability to protect. They still report node's sync/callback - // refusal contracts while the model is enabled. - for (const name of ["chownSync", "lchownSync"]) { - const original = fsMod[name]; - if (typeof original === "function") { - fsMod[name] = function (path, ...rest) { - const err = deniedFsError("fs.write", path); - if (err) throw err; - return original.call(this, path, ...rest); - }; - } - } - for (const name of ["chown", "lchown"]) { - const original = fsMod[name]; - if (typeof original === "function") { - fsMod[name] = function (path, uid, gid, callback) { - const err = typeof callback === "function" ? deniedFsError("fs.write", path) : undefined; - if (err) { G.queueMicrotask(() => callback(err)); return; } - return original.apply(this, arguments); - }; - } - } const FileHandle = fsMod.promises && fsMod.promises.FileHandle; if (FileHandle && FileHandle.prototype && typeof FileHandle.prototype.chown === "function") { const original = FileHandle.prototype.chown; diff --git a/modules/jsc/tests/test_runtime_permission_fs_validation.cpp b/modules/jsc/tests/test_runtime_permission_fs_validation.cpp new file mode 100644 index 00000000..807a13da --- /dev/null +++ b/modules/jsc/tests/test_runtime_permission_fs_validation.cpp @@ -0,0 +1,89 @@ +// Permission fs adapter contract: public validators run before a path denial, +// and node's binding-level callback denials are delivered before the API +// returns. References: node lib/fs.js and src/node_file.cc. +import std; +import mbun.jsc.runtime; + +namespace { + +void fail(std::string_view message) { + std::println(std::cerr, "FAIL: {}", message); +} + +} // namespace + +int main() { + using namespace mbun::jsc::runtime; + + // Configure the model before the process-global runtime is first touched. + // Eval mode has no entry-point grant, so the fixed path below is denied. + set_permission_command_line({"--permission"}, /*hasEvalString=*/true, {}, {}); + + const auto result{eval_to_string(R"JS((() => { + const fs = require("fs"); + const path = "/tmp/mbun-permission-validation-denied"; + const checks = []; + const check = (condition, label) => { + if (!condition) throw new Error(label); + checks.push(label); + }; + const throwsCode = (label, code, fn) => { + let error; + try { fn(); } catch (caught) { error = caught; } + check(error && error.code === code, label + ":" + String(error && error.code)); + }; + + // Every secondary argument is invalid while `path` itself is valid but + // denied. The public validator must win over ERR_ACCESS_DENIED. + throwsCode("access mode", "ERR_INVALID_ARG_TYPE", () => fs.access(path, "bad", () => {})); + throwsCode("access callback", "ERR_INVALID_ARG_TYPE", () => fs.access(path, 0, 1)); + throwsCode("mkdir recursive", "ERR_INVALID_ARG_TYPE", + () => fs.mkdir(path, { recursive: "bad" }, () => {})); + throwsCode("mkdir mode", "ERR_INVALID_ARG_TYPE", () => fs.mkdir(path, { mode: {} }, () => {})); + throwsCode("mkdir callback", "ERR_INVALID_ARG_TYPE", () => fs.mkdir(path, {}, 1)); + throwsCode("chmod mode", "ERR_INVALID_ARG_TYPE", () => fs.chmod(path, {}, () => {})); + throwsCode("chmod callback", "ERR_INVALID_ARG_TYPE", () => fs.chmod(path, 0o600, 1)); + throwsCode("utimes atime", "ERR_INVALID_ARG_TYPE", () => fs.utimes(path, {}, 0, () => {})); + throwsCode("utimes callback", "ERR_INVALID_ARG_TYPE", () => fs.utimes(path, 0, 0, 1)); + throwsCode("lutimes mtime", "ERR_INVALID_ARG_TYPE", () => fs.lutimes(path, 0, {}, () => {})); + throwsCode("chown uid", "ERR_INVALID_ARG_TYPE", () => fs.chown(path, "bad", 0, () => {})); + throwsCode("chown callback", "ERR_INVALID_ARG_TYPE", () => fs.chown(path, 0, 0, 1)); + throwsCode("lchown gid", "ERR_INVALID_ARG_TYPE", () => fs.lchown(path, 0, "bad", () => {})); + + let protocolReads = 0; + const invalidPath = { get protocol() { ++protocolReads; throw new Error("protocol getter"); } }; + throwsCode("invalid path-like", "ERR_INVALID_ARG_TYPE", () => fs.accessSync(invalidPath)); + check(protocolReads === 0, "protocol getter not observed"); + + // Pinned node rejects these callback requests from the binding immediately, + // after validation and before the public API returns. + for (const name of ["access", "chown", "lchown"]) { + const order = ["before"]; + const callback = (error) => order.push("callback:" + String(error && error.code)); + if (name === "access") fs[name](path, 0, callback); + else fs[name](path, 0, 0, callback); + order.push("after"); + check(order.join(",") === "before,callback:ERR_ACCESS_DENIED,after", name + " ordering"); + } + + // These entries use node's synchronous permission macro even in callback + // form. Retain the corpus behavior that made the focused file green. + throwsCode("utimes denied", "ERR_ACCESS_DENIED", () => fs.utimes(path, 0, 0, () => {})); + throwsCode("lutimes denied", "ERR_ACCESS_DENIED", () => fs.lutimes(path, 0, 0, () => {})); + throwsCode("mkdir denied", "ERR_ACCESS_DENIED", () => fs.mkdir(path, () => {})); + throwsCode("chmod denied", "ERR_ACCESS_DENIED", () => fs.chmod(path, 0o600, () => {})); + + return String(checks.length); +})())JS")}; + + if (!result) { + fail(result.error()); + return 1; + } + if (*result != "22") { + fail(std::format("expected 22 checks, got {}", *result)); + return 1; + } + std::println("test_runtime_permission_fs_validation: ok"); + return 0; +} From bef02c61f0e484dff1cc89149c2b30b225089553 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:28:52 +0800 Subject: [PATCH 012/150] fix(permission): discard denial callback returns Invoke immediate permission-denial callbacks for their side effects while preserving the wrapped fs API return value. Extend the runtime regression to require undefined from access, chown, and lchown when callbacks return a value. Refs #84 and #80. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/bootstrap.cppm | 6 ++++-- .../test_runtime_permission_fs_validation.cpp | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/modules/jsc/src/builtins/bootstrap.cppm b/modules/jsc/src/builtins/bootstrap.cppm index 9721e8e6..40096152 100644 --- a/modules/jsc/src/builtins/bootstrap.cppm +++ b/modules/jsc/src/builtins/bootstrap.cppm @@ -9218,8 +9218,10 @@ inline constexpr char kBootstrapJS_[] = R"JS( // Node's binding permission rejection invokes FSReqCallback immediately. // Keep ordinary fs completions on this queue, but do not move a native- // boundary denial behind nextTick or promise jobs. - if (out && out[0] && out[0].code === "ERR_ACCESS_DENIED") - return real.apply(undefined, out); + if (out && out[0] && out[0].code === "ERR_ACCESS_DENIED") { + real.apply(undefined, out); + return ret; + } // The completion carries the async context of the CALL, captured here at // push time — node's rule, and the same thing process.nextTick does a few // hundred lines away in runtime/bindings_install.inc. A batched drain runs diff --git a/modules/jsc/tests/test_runtime_permission_fs_validation.cpp b/modules/jsc/tests/test_runtime_permission_fs_validation.cpp index 807a13da..769b4a95 100644 --- a/modules/jsc/tests/test_runtime_permission_fs_validation.cpp +++ b/modules/jsc/tests/test_runtime_permission_fs_validation.cpp @@ -59,11 +59,16 @@ int main() { // after validation and before the public API returns. for (const name of ["access", "chown", "lchown"]) { const order = ["before"]; - const callback = (error) => order.push("callback:" + String(error && error.code)); - if (name === "access") fs[name](path, 0, callback); - else fs[name](path, 0, 0, callback); + const callback = (error) => { + order.push("callback:" + String(error && error.code)); + return 42; + }; + const returned = name === "access" + ? fs[name](path, 0, callback) + : fs[name](path, 0, 0, callback); order.push("after"); check(order.join(",") === "before,callback:ERR_ACCESS_DENIED,after", name + " ordering"); + check(returned === undefined, name + " ignores callback return"); } // These entries use node's synchronous permission macro even in callback @@ -80,8 +85,8 @@ int main() { fail(result.error()); return 1; } - if (*result != "22") { - fail(std::format("expected 22 checks, got {}", *result)); + if (*result != "25") { + fail(std::format("expected 25 checks, got {}", *result)); return 1; } std::println("test_runtime_permission_fs_validation: ok"); From 41793d5d626cd966d4b99c9fdfbe9dc630997e30 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:42:29 +0800 Subject: [PATCH 013/150] test(async-hooks): wait for real timer deadline Replace the one-shot due-only timer drain with the runtime's bounded event-loop pump. This waits for the host timer's real 1ms deadline without an arbitrary sleep and makes the composite callback assertion deterministic.\n\nRefs: #85\nRefs: #80\n\nSigned-off-by: Sunrisepeak \nCo-authored-by: Codex (GPT-5) <> --- modules/jsc/tests/test_async_hooks.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/jsc/tests/test_async_hooks.cpp b/modules/jsc/tests/test_async_hooks.cpp index 4964b0ab..0eba0b44 100644 --- a/modules/jsc/tests/test_async_hooks.cpp +++ b/modules/jsc/tests/test_async_hooks.cpp @@ -83,9 +83,12 @@ int main() { })())JS", "schedule focused async callback boundaries"); expect_eval("0", "drain promise and microtask reactions"); - // Timers are real wall-clock now: a 0ms deadline is due immediately, so a - // single drain fires it (the old 1ms delay made this drain racy). - expect_eval("__mbun_drain_timers(20)", "drain due timers"); + // Host timers use real wall-clock deadlines and clamp a 0ms timeout to 1ms. + // A single non-waiting drain can run before that deadline. Use the runtime's + // bounded pump so it parks until the timer is due instead of racing it or + // hiding the race behind an arbitrary sleep. + mbun::jsc::runtime::pump_event_loop( + "globalThis.__asyncHooksSeen.includes('timeout:value')"); expect_eval("0", "drain timer-created reactions"); expect_num( R"JS((["then","catch","finally","microtask","nextTick","timeout","immediate","event"] From 9a0d06792dcd4689bdcc9a458e34c4f011076c77 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:43:31 +0800 Subject: [PATCH 014/150] chore(async-hooks): record fix attribution Record the issue references and attribution for the preceding timer-deadline test correction. Refs: #85 Refs: #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> From 6f9369696ec65bd14d8ba1bb196c4dde2a9b98a2 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 17:55:09 +0800 Subject: [PATCH 015/150] research(compat): record W43 Wave A batch one - #80 - #85 Record seven focused Node gains, composed member and impact gates, the frozen Wave-A binary, honest lane misses, and batch-two dispatch. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../docs/20260802-corpus-coverage-w43-plan.md | 18 +++---- .agents/docs/20260802-corpus-coverage-w43.md | 49 +++++++++++++++---- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md index d1a33534..b814ea6f 100644 --- a/.agents/docs/20260802-corpus-coverage-w43-plan.md +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -358,11 +358,11 @@ git commit --author='Sunrisepeak ' \ - Consumes: frozen Task 2 binary and exact Task 4 manifests. - Produces: three issue-linked worker branches, each with Red evidence, upstream source mapping, Green evidence, Refactor proof, and one reviewable commit. -- [ ] **Step 1: Create three isolated worktrees from the same coordinator checkpoint** +- [x] **Step 1: Create three isolated worktrees from the same coordinator checkpoint** Use `tools/integration/worktree_setup.sh` only on three new paths/branches: `w43/a1-zlib-buffer`, `w43/a2-assert`, and `w43/a3-permission`. Copy the exact manifest into each prompt; do not share a build target. -- [ ] **Step 2: Dispatch three workers simultaneously** +- [x] **Step 2: Dispatch three workers simultaneously** Each worker first derives the same frozen binary path: @@ -386,7 +386,7 @@ python3 tools/integration/node_corpus_runner.py \ Worker A2 runs the same command with manifest `w43-a2-node-assert.txt` and output `target/integration/w43-a2-before`. Worker A3 uses `w43-a3-node-permission.txt` and `target/integration/w43-a3-before`. Each worker reads the relevant pinned `compat/node/lib/` or `compat/node/src/` implementation before editing, creates or links the exact defect issue, implements one source-coherent cause, builds through `build_or_die.sh`, repeats the same manifest against the new binary, serially re-runs new greens, runs the relevant JSC member test, and commits with builder/co-author trailers. -- [ ] **Step 3: Reject black-box or unproven results** +- [x] **Step 3: Reject black-box or unproven results** Reject a worker branch if its report lacks the upstream source location, a stable before failure, exact changed paths, after categories, serial new-green proof, or remaining red files. Do not accept a branch solely because its build passed. @@ -399,7 +399,7 @@ Reject a worker branch if its report lacks the upstream source location, a stabl - Consumes: three reviewed worker commits from Task 5. - Produces: one composed coordinator tree with zero focused regressions and measured batch-1 delta. -- [ ] **Step 1: Review each branch before integration** +- [x] **Step 1: Review each branch before integration** ```bash git diff --stat HEAD...w43/a1-zlib-buffer @@ -412,7 +412,7 @@ git log --format=full -1 w43/a3-permission Confirm no worker modified `compat/`, shared metrics, protected surfaces, or another lane's source boundary. -- [ ] **Step 2: Merge accepted branches normally with attributed merge commits** +- [x] **Step 2: Merge accepted branches normally with attributed merge commits** ```bash GIT_AUTHOR_NAME=Sunrisepeak GIT_AUTHOR_EMAIL=speakshen@163.com \ @@ -439,7 +439,7 @@ git merge --no-ff w43/a3-permission \ Do not squash, cherry-pick, amend, or rebase; a rejected lane is omitted and reported. -- [ ] **Step 3: Build the composed tree and derive impact lists** +- [x] **Step 3: Build the composed tree and derive impact lists** ```bash W43_A_BIN=$(bash tools/integration/build_or_die.sh) @@ -455,7 +455,7 @@ python3 tools/integration/impact_gate.py \ --explain ``` -- [ ] **Step 4: Run composed batch-1 focused and member gates** +- [x] **Step 4: Run composed batch-1 focused and member gates** Run A1, A2, and A3 separately against `"$W43_WAVE_A_BIN"` with the Node runner, their committed manifest, `--jobs 3`, `--timeout 30`, and distinct `target/integration/w43-a*-composed` output directories. Then repeat every newly green file with `--jobs 1`. Run: @@ -478,11 +478,11 @@ Any stable green→non-green result blocks batch 2. - Consumes: Task 6 composed checkpoint and A4/A5 manifests. - Produces: two issue-linked, source-driven branches with combined target +6 to +10. -- [ ] **Step 1: Create two new worktrees on the Task 6 checkpoint** +- [x] **Step 1: Create two new worktrees on the Task 6 checkpoint** Branches: `w43/a4-bun-napi` and `w43/a5-bun-test-runner`. -- [ ] **Step 2: Dispatch both workers simultaneously** +- [x] **Step 2: Dispatch both workers simultaneously** Each worker derives the shared pre-edit binary and A4 runs: diff --git a/.agents/docs/20260802-corpus-coverage-w43.md b/.agents/docs/20260802-corpus-coverage-w43.md index a86ebbfe..028392f5 100644 --- a/.agents/docs/20260802-corpus-coverage-w43.md +++ b/.agents/docs/20260802-corpus-coverage-w43.md @@ -10,10 +10,11 @@ Campaign branch: `agent/corpus-coverage-w43` ## Current status -Checkpoint 0 is the pre-implementation baseline. No W43 product source change -has started. The first five-hour sprint target remains net +30 to +45 fully -green files; the final campaign target remains the complete audited runnable -denominator. +Wave A batch 1 is integrated at `7e84e13`: its three Node lanes produced seven +focused new-green files with no focused or impact-list regression. A1 reached ++4, A2 was deliberately narrowed to a safe +1 below its +2 floor, and A3 +reached +2. This is not a post-Wave-A full-corpus result; checkpoint 0 remains +the authoritative full baseline until the next same-binary full run. Five logical lanes will be executed in rolling batches because this session has three physical worker slots. The coordinator alone owns builds, full corpus @@ -119,11 +120,11 @@ expects. | Lane | Corpus/owner | Fixed target | Actual | Issue | Commit | State | | --- | --- | ---: | ---: | --- | --- | --- | -| A1 | Node zlib/Buffer | +3 to +5 | — | — | — | not started | -| A2 | Node assert | +2 to +4 | — | — | — | not started | -| A3 | Node permission | +2 to +4 | — | — | — | not started | -| A4 | Bun N-API | +3 to +5 | — | — | — | not started | -| A5 | Bun test runner | +3 to +5 | — | — | — | not started | +| A1 | Node zlib/Buffer | +3 to +5 | +4 | #83 | `71b2c2b..bacd246` | accepted and composed | +| A2 | Node assert | +2 to +4 | +1 | #82 | `e7b8d99..0a102f6` | accepted safe partial; target missed | +| A3 | Node permission | +2 to +4 | +2 | #84 | `c48839f..bef02c6` | accepted and composed | +| A4 | Bun N-API | +3 to +5 | diagnosing +3 finalizer set | pending | — | implementation active | +| A5 | Bun test runner | +3 to +5 | diagnosing +1 +2 | pending | — | two explicit mechanisms active | All 49 literal paths were found in the fresh baseline and were non-green. A1, A2, and A3 contain respectively 5, 8, and 10 Node `fail` rows. A4 contains 18 @@ -148,3 +149,33 @@ Retired-approach gate: Worker probes, focused results, integration deltas, serial confirmations, and remaining reds are appended here only after coordinator review. Full-corpus numbers are updated only at the next same-binary full checkpoint. + +## Wave A batch 1 composed checkpoint + +| Evidence | Result | +| --- | --- | +| Coordinator head | `7e84e13efc1da8599aacd31daf28eb8603597c0f` | +| Frozen composed binary | Git common directory `w43/wave-a/mbun` | +| Binary SHA-256 | `4efb3448ebd7b2e34f0eda1e15dff74201aaae25ed7ce9a01ca607f21ca4b0b6` | +| Fresh build | `build_or_die.sh --no-cache`: pass | +| JSC member suite | 29 passed, 0 failed | +| Permission member suite | 1 passed, 0 failed; 150 checks, 0 failures | +| A1 manifest | 0/5 -> 4/5; four serial repeats passed | +| A2 manifest | 0/8 -> 1/8; one serial repeat passed | +| A3 manifest | 0/10 -> 2/10; two serial repeats passed from the pinned Node cwd | +| Focused total | 0/23 -> 7/23; no green-to-non-green move | +| Node impact run | 653 files; 421 pass, 149 fail, 73 skipped, 9 timeout, 1 OOM; diff gate found no regression | +| Bun impact run | 180 files; 100 green, 59 test-failure, 8 all-skipped, 7 timeout, 3 ahead-of-reference, 2 blocked-external, 1 OOM; diff gate found no regression | + +The first full JSC run exposed `test_async_hooks` intermittently missing only +the `timeout:value` observation. Ten-run isolation measured the pre-existing +one-millisecond deadline race across A1, A2, A3, and the composed tree. Issue +#85 replaced the one-shot due-only drain with the runtime's bounded event-loop +pump; the exact test then passed 10/10 and the full JSC suite passed 29/29. No +runtime assertion or product behavior was weakened. + +A1's only remaining manifest red is the independent `DEP0005` warning-delivery +case. A2 retains seven reds because review removed an incomplete handwritten +partial-deep comparator and did not expose unsupported `skipPrototype` +semantics. A3 retains eight separately diagnosed permission surfaces. These +remaining rows are not counted as gains or waived. From d222075e233df57f0899c88e2d1217f86b3349c8 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 18:10:14 +0800 Subject: [PATCH 016/150] fix(test): retain async rejection after done callback - #87 - #80 Track callback and returned-Promise completion independently so a synchronous done() cannot hide a later rejection. Add a real JSC runner regression for the dual-completion path. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/test_runner.cppm | 43 +++++++++++++++++++++++--- modules/jsc/tests/test_test_runner.cpp | 18 +++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/modules/jsc/src/test_runner.cppm b/modules/jsc/src/test_runner.cppm index f5558bf6..5e915508 100644 --- a/modules/jsc/src/test_runner.cppm +++ b/modules/jsc/src/test_runner.cppm @@ -1697,16 +1697,49 @@ inline constexpr std::string_view HARNESS = R"JS( // the body returned no promise, treat the body as complete — otherwise an // arity-1 test that never calls done would hang the whole file. bodyPromise = new Promise((resolve, reject) => { - let settled = false; - const done = (err) => { if (settled) return; settled = true; if (err) reject(err instanceof Error ? err : new Error(String(err))); else resolve(); }; - let r; try { r = t.fn(done); } catch (e) { done(e); return; } - if (r && typeof r.then === "function") { r.then(() => done(), (e) => done(e)); return; } + let completed = false, doneCalled = false, bodyReturned = false; + let promiseSettled = true; + const fail = (err) => { + if (completed) return; + completed = true; + reject(err instanceof Error ? err : new Error(String(err))); + }; + const maybeResolve = () => { + if (!completed && bodyReturned && doneCalled && promiseSettled) { + completed = true; + resolve(); + } + }; + const done = (err) => { + if (doneCalled || completed) return; + doneCalled = true; + if (err) fail(err); + else maybeResolve(); + }; + let r; + try { r = t.fn(done); } + catch (e) { fail(e); return; } + bodyReturned = true; + if (r && typeof r.then === "function") { + promiseSettled = false; + r.then(() => { promiseSettled = true; maybeResolve(); }, fail); + maybeResolve(); + return; + } + maybeResolve(); + if (completed) return; // Body returned synchronously without a promise. Give the pending // done() every queue it could be sitting in (see awaitDone); if it // still hasn't fired AND no timer is pending (which could call done via // the runner's timer pump), treat it as complete (arity-1 arg that // isn't a done callback). If timers ARE pending, wait for them. - (async () => { await awaitDone(() => settled); if (!settled && (!G.__mbunTimers || G.__mbunTimers.q.length === 0)) { settled = true; resolve(); } })(); + (async () => { + await awaitDone(() => doneCalled || completed); + if (!doneCalled && !completed && (!G.__mbunTimers || G.__mbunTimers.q.length === 0)) { + doneCalled = true; + maybeResolve(); + } + })(); }); } else { bodyPromise = Promise.resolve().then(() => { const r = t.fn(); return (r && typeof r.then === "function") ? r : undefined; }); diff --git a/modules/jsc/tests/test_test_runner.cpp b/modules/jsc/tests/test_test_runner.cpp index a397907b..2031b777 100644 --- a/modules/jsc/tests/test_test_runner.cpp +++ b/modules/jsc/tests/test_test_runner.cpp @@ -195,6 +195,24 @@ int main() { expect(r.ok && r.pass == 1 && r.fail == 0, "I: unref'd interval doesn't wedge the run"); } + // Scenario J — a done-style body may also return a Promise. Calling done() + // does not hide a later rejection from that Promise (regression: one shared + // settlement guard made this a false pass). + { + RunResult r{run_source( + "import { test } from \"bun:test\";\n" + "test('done then reject', async (done) => {\n" + " done();\n" + " await Promise.resolve();\n" + " throw new Error('late rejection');\n" + "});\n")}; + expect(r.ok, "J: run_source completes"); + expect_eq(r.pass, 0, "J: done does not hide returned Promise rejection"); + expect_eq(r.fail, 1, "J: returned Promise rejection fails the test"); + expect(r.body.find("late rejection") != std::string::npos, + "J: returned Promise rejection is reported"); + } + if (gFailed > 0) { std::println("test_test_runner: {} failed", gFailed); return 1; From 444e49579e2c8b26500b0e571a96a11e77b74e3b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 18:17:15 +0800 Subject: [PATCH 017/150] fix(test): backpatch mocks through canonical cache keys - #88 - #80 Retry late mock cache lookups through a canonical package path without loading unknown modules, then fan replacements through the existing named-import subscriptions. Add a symlinked-package member regression. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/test_runner.cppm | 22 ++++++++++++-- modules/jsc/tests/test_test_runner.cpp | 42 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/modules/jsc/src/test_runner.cppm b/modules/jsc/src/test_runner.cppm index 5e915508..4911c1f6 100644 --- a/modules/jsc/src/test_runner.cppm +++ b/modules/jsc/src/test_runner.cppm @@ -1082,8 +1082,25 @@ inline constexpr std::string_view HARNESS = R"JS( G.__mbunNativeModules["node:" + name] = native; // A module already in the CommonJS cache is mutated rather than replaced: // its identity is what every existing binding and namespace points at. - let live; - try { live = G.__mbun_module_cache_get(key); } catch (e) {} + let live, liveKey = key; + try { live = G.__mbun_module_cache_get(liveKey); } catch (e) {} + // Package managers commonly expose node_modules entries as symlinks. + // require.resolve() preserves that lexical path, while the native loader + // canonicalises before inserting into moduleCache_. On a lexical miss, + // retry the pure cache lookup with the real path; do not require() here, + // because registering a mock must never evaluate an unloaded module. + if (live === undefined && key.charCodeAt(0) === 47 /* / */) { + const fs = G.__mbunNativeModules && (G.__mbunNativeModules.fs || G.__mbunNativeModules["node:fs"]); + if (fs && typeof fs.realpathSync === "function") { + try { + const canonical = fs.realpathSync(key); + if (canonical !== key) { + liveKey = canonical; + live = G.__mbun_module_cache_get(liveKey); + } + } catch (e) {} + } + } if (live !== undefined && live !== null && (typeof live === "object" || typeof live === "function") && m !== null && typeof m === "object") { const subs = G.__mbun_link_subs ? G.__mbun_link_subs.get(live) : undefined; for (const k of Object.keys(m)) { @@ -1093,6 +1110,7 @@ inline constexpr std::string_view HARNESS = R"JS( if (list) for (let i = 0; i < list.length; i++) { try { list[i](v); } catch (e) {} } } mocks.set(key, live); + if (liveKey !== key) mocks.set(liveKey, live); } else { mocks.set(key, m); } diff --git a/modules/jsc/tests/test_test_runner.cpp b/modules/jsc/tests/test_test_runner.cpp index 2031b777..370a67ac 100644 --- a/modules/jsc/tests/test_test_runner.cpp +++ b/modules/jsc/tests/test_test_runner.cpp @@ -213,6 +213,48 @@ int main() { "J: returned Promise rejection is reported"); } + // Scenario K — package-manager symlinks make require.resolve() return a + // lexical path while the native loader caches the canonical target. A late + // mock must still find that loaded exports object and fan the replacement + // out through the named-import subscription. + { + const auto nonce{std::chrono::steady_clock::now().time_since_epoch().count()}; + const std::filesystem::path root{ + std::filesystem::temp_directory_path() / + std::format("mbun-test-runner-late-mock-{}", nonce)}; + const std::filesystem::path packageRoot{ + root / "node_modules/.store/pkg/node_modules/pkg"}; + std::filesystem::create_directories(packageRoot); + std::filesystem::create_directory_symlink(".store/pkg/node_modules/pkg", + root / "node_modules/pkg"); + { + std::ofstream out{packageRoot / "package.json"}; + out << R"({"name":"pkg","version":"1.0.0","main":"index.js"})"; + } + { + std::ofstream out{packageRoot / "index.js"}; + out << "export const value = () => 'original';\n"; + } + { + std::ofstream out{root / "subject.ts"}; + out << "import { value } from 'pkg';\n" + "export const observed = () => value();\n"; + } + const std::filesystem::path testFile{root / "late-mock.test.ts"}; + { + std::ofstream out{testFile}; + out << "import { expect, mock, test } from 'bun:test';\n" + "import { observed } from './subject.ts';\n" + "mock.module(require.resolve('pkg'), () => ({ value: () => 'mocked' }));\n" + "test('late mock', () => expect(observed()).toBe('mocked'));\n"; + } + RunResult r{mbun::jsc::test_runner::run_file(testFile.string())}; + expect(r.ok, "K: symlink-package test completes"); + expect_eq(r.pass, 1, "K: late mock updates the loaded named import"); + expect_eq(r.fail, 0, "K: symlinked cache identity does not strand the original"); + std::filesystem::remove_all(root); + } + if (gFailed > 0) { std::println("test_test_runner: {} failed", gFailed); return 1; From 29e12e3511026b9aa8383b35944e8a7e2cffb98c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 18:25:20 +0800 Subject: [PATCH 018/150] fix(napi): deliver deferred finalizer callbacks Run queued N-API finalizers while holding the JSC API lock, start each callback from a clean exception boundary, and transfer callback errors through the existing process uncaught path. Refs: #86 Refs: #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/runtime/napi/mbun_napi.h | 67 +++++++++++++++++--- modules/jsc/tests/test_runtime_structure.cpp | 36 +++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/modules/jsc/src/runtime/napi/mbun_napi.h b/modules/jsc/src/runtime/napi/mbun_napi.h index 4670ea75..3ac4a476 100644 --- a/modules/jsc/src/runtime/napi/mbun_napi.h +++ b/modules/jsc/src/runtime/napi/mbun_napi.h @@ -536,23 +536,72 @@ class ScopedHandleScope { HandleScopeRec* m_rec{nullptr}; }; -// Drain the deferred (GC-time) finalizer queue. Only called from event-loop -// pump points / env cleanup — never from inside GC. +// A finalizer is a new callback boundary. A VM exception that JS already +// caught can still be visible through the top exception scope when the native +// pump starts; letting NAPI_PREAMBLE observe it makes the finalizer's first API +// call fail with napi_pending_exception. Start clean, like bun's Finalizer::run +// entering from an event-loop task rather than from the previous addon call. +inline void prepareFinalizerCallback(napi_env env) { + if (env == nullptr) return; + auto catcher = DECLARE_TOP_EXCEPTION_SCOPE(env->vm()); + catcher.clearException(); + env->clearPendingException(); +} + +// Hand an error raised by a finalizer to the same process-level uncaught path +// used by timer/I/O callbacks. That path offers process capture/listeners first +// and arms Runtime's fatal channel only when nobody claims the error. +inline void dispatchFinalizerException(napi_env env, JSC::JSValue error) { + if (env == nullptr || error.isEmpty()) return; + JSC::JSGlobalObject* globalObject{env->globalObject()}; + JSContextRef ctx{toRef(globalObject)}; + JSObjectRef global{JSContextGetGlobalObject(ctx)}; + JSStringRef name{JSStringCreateWithUTF8CString("__mbun_uncaught")}; + JSValueRef lookupException{nullptr}; + JSValueRef candidate{JSObjectGetProperty(ctx, global, name, &lookupException)}; + JSStringRelease(name); + if (lookupException != nullptr || candidate == nullptr || + !JSValueIsObject(ctx, candidate)) { + return; + } + JSObjectRef handler{JSValueToObject(ctx, candidate, nullptr)}; + if (handler == nullptr || !JSObjectIsFunction(ctx, handler)) return; + JSValueRef argument{toRef(globalObject, error)}; + JSValueRef dispatchException{nullptr}; + JSObjectCallAsFunction(ctx, handler, nullptr, 1, &argument, &dispatchException); +} + +inline void dispatchFinalizerExceptions(napi_env env) { + if (env == nullptr) return; + JSC::VM& vm{env->vm()}; + auto catcher = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::Strong vmException; + if (JSC::Exception* exception{catcher.exception()}) { + vmException.set(vm, exception->value()); + catcher.clearException(); + } + JSC::Strong napiException; + if (env->hasPendingException()) { + napiException.set(vm, env->pendingException()); + env->clearPendingException(); + } + if (vmException) dispatchFinalizerException(env, vmException.get()); + if (napiException) dispatchFinalizerException(env, napiException.get()); +} + +// Drain the deferred (GC-time) finalizer task queue. Only called from +// event-loop pump points / env cleanup — never from inside GC. inline void drainPendingFinalizers() { auto& state = NapiState::singleton(); while (!state.pendingFinalizers.empty()) { std::vector batch = std::move(state.pendingFinalizers); state.pendingFinalizers.clear(); for (const PendingFinalizer& fin : batch) { + JSC::JSLockHolder locker{fin.env->vm()}; ScopedHandleScope scope; + prepareFinalizerCallback(fin.env); fin.cb(fin.env, fin.data, fin.hint); - // each finalizer starts from a clean exception state (napi.h - // clearExceptionsBetweenFinalizers; Node never chains them) - if (fin.env) { - auto catcher = DECLARE_TOP_EXCEPTION_SCOPE(fin.env->vm()); - catcher.clearException(); - fin.env->clearPendingException(); - } + dispatchFinalizerExceptions(fin.env); } } } diff --git a/modules/jsc/tests/test_runtime_structure.cpp b/modules/jsc/tests/test_runtime_structure.cpp index 56a08290..ffd169d7 100644 --- a/modules/jsc/tests/test_runtime_structure.cpp +++ b/modules/jsc/tests/test_runtime_structure.cpp @@ -33,6 +33,22 @@ std::size_t line_count(std::string_view source) { + (!source.empty() && source.back() != '\n' ? 1U : 0U); } +std::string_view function_body(std::string_view source, std::string_view signature) { + const auto start{source.find(signature)}; + if (start == std::string_view::npos) return {}; + const auto open{source.find('{', start + signature.size())}; + if (open == std::string_view::npos) return {}; + std::size_t depth{}; + for (std::size_t i{open}; i < source.size(); ++i) { + if (source[i] == '{') { + ++depth; + } else if (source[i] == '}' && --depth == 0) { + return source.substr(open + 1, i - open - 1); + } + } + return {}; +} + // Collect the filenames (relative to runtime/) referenced by #include lines in // `source`. Accepts both `#include "runtime/x.inc"` (from runtime.cppm) and // `#include "x.inc"` (from a slice inside runtime/). @@ -91,6 +107,26 @@ int main() { "runtime/" + name + " respects line budget even when unregistered"); } + // A deferred N-API finalizer is a fresh callback boundary. A previously + // caught addon exception must not poison its NAPI_PREAMBLE, while an error + // raised by the finalizer itself must reach the shared uncaught channel. + // Keep these two sides together: clearing after the callback made both + // ordinary finalizers and throwing finalizers silently disappear (#86). + const auto napi{read_source(runtimeDir / "napi" / "mbun_napi.h")}; + const auto drain{function_body(napi, "inline void drainPendingFinalizers()")}; + check(!drain.empty(), "deferred N-API finalizer drain exists"); + const auto lock{drain.find("JSC::JSLockHolder locker{fin.env->vm()}")}; + const auto prepare{drain.find("prepareFinalizerCallback(fin.env)")}; + const auto invoke{drain.find("fin.cb(fin.env, fin.data, fin.hint)")}; + const auto dispatch{drain.find("dispatchFinalizerExceptions(fin.env)")}; + check(lock != std::string_view::npos && lock < invoke, + "N-API finalizer holds the JSC API lock while calling addon code"); + check(prepare != std::string_view::npos && prepare < invoke, + "N-API finalizer starts from a clean callback exception state"); + check(invoke != std::string_view::npos && dispatch != std::string_view::npos && + dispatch > invoke, + "N-API finalizer transfers its own exception to shared uncaught handling"); + if (gFailed != 0) { std::println(std::cerr, "test_runtime_structure: {} failed", gFailed); return 1; From d9fdc85f6c0eccea6e6ea1c66a94b575bac6723d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 18:48:33 +0800 Subject: [PATCH 019/150] fix(napi): fail closed on finalizer dispatch Arm the shared fatal channel directly when the configurable uncaught dispatcher is absent or throws, stop the finalizer drain after fallback, and dispatch a dual-stored exception only once. Refs: #86 Refs: #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/runtime/napi/mbun_napi.h | 89 +++++++++++++++++++----- modules/jsc/tests/test_runtime_smoke.cpp | 35 ++++++++++ 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/modules/jsc/src/runtime/napi/mbun_napi.h b/modules/jsc/src/runtime/napi/mbun_napi.h index 3ac4a476..897b615f 100644 --- a/modules/jsc/src/runtime/napi/mbun_napi.h +++ b/modules/jsc/src/runtime/napi/mbun_napi.h @@ -548,11 +548,31 @@ inline void prepareFinalizerCallback(napi_env env) { env->clearPendingException(); } -// Hand an error raised by a finalizer to the same process-level uncaught path -// used by timer/I/O callbacks. That path offers process capture/listeners first -// and arms Runtime's fatal channel only when nobody claims the error. -inline void dispatchFinalizerException(napi_env env, JSC::JSValue error) { +// The JS dispatcher is intentionally writable (workers and node:domain wrap +// it), so dispatch itself can fail. Bypass that user-configurable slot and arm +// the fatal channel's backing properties directly: the native pump reads them +// after every phase, and putDirect cannot run a hostile setter. +inline void armFinalizerFatal(napi_env env, JSC::JSValue error, int status) { if (env == nullptr || error.isEmpty()) return; + JSC::VM& vm{env->vm()}; + JSC::Strong rooted{vm, error}; + auto catcher = DECLARE_TOP_EXCEPTION_SCOPE(vm); + catcher.clearException(); + env->clearPendingException(); + JSC::JSGlobalObject* globalObject{env->globalObject()}; + JSC::JSArray* fatal{JSC::constructEmptyArray(globalObject, nullptr, 1)}; + fatal->putDirectIndex(globalObject, 0, rooted.get()); + globalObject->putDirect(vm, JSC::Identifier::fromString(vm, "__mbun_fatal"_s), fatal); + globalObject->putDirect(vm, JSC::Identifier::fromString(vm, "__mbun_fatal_status"_s), + JSC::jsNumber(status)); +} + +// Hand an error raised by a finalizer to the same process-level uncaught path +// used by timer/I/O callbacks. Return false after fail-closed fallback so the +// drain stops: a later finalizer must not clear the fatal error at its fresh +// callback boundary. +inline bool dispatchFinalizerException(napi_env env, JSC::JSValue error) { + if (env == nullptr || error.isEmpty()) return true; JSC::JSGlobalObject* globalObject{env->globalObject()}; JSContextRef ctx{toRef(globalObject)}; JSObjectRef global{JSContextGetGlobalObject(ctx)}; @@ -560,33 +580,66 @@ inline void dispatchFinalizerException(napi_env env, JSC::JSValue error) { JSValueRef lookupException{nullptr}; JSValueRef candidate{JSObjectGetProperty(ctx, global, name, &lookupException)}; JSStringRelease(name); - if (lookupException != nullptr || candidate == nullptr || - !JSValueIsObject(ctx, candidate)) { - return; + if (lookupException != nullptr) { + armFinalizerFatal(env, toJS(globalObject, lookupException), 7); + return false; + } + if (candidate == nullptr || !JSValueIsObject(ctx, candidate)) { + armFinalizerFatal(env, error, 1); + return false; } JSObjectRef handler{JSValueToObject(ctx, candidate, nullptr)}; - if (handler == nullptr || !JSObjectIsFunction(ctx, handler)) return; + if (handler == nullptr || !JSObjectIsFunction(ctx, handler)) { + armFinalizerFatal(env, error, 1); + return false; + } JSValueRef argument{toRef(globalObject, error)}; JSValueRef dispatchException{nullptr}; JSObjectCallAsFunction(ctx, handler, nullptr, 1, &argument, &dispatchException); + if (dispatchException != nullptr) { + armFinalizerFatal(env, toJS(globalObject, dispatchException), 7); + return false; + } + return true; } -inline void dispatchFinalizerExceptions(napi_env env) { - if (env == nullptr) return; +inline bool dispatchFinalizerExceptions(napi_env env) { + if (env == nullptr) return true; JSC::VM& vm{env->vm()}; auto catcher = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSC::Strong vmException; - if (JSC::Exception* exception{catcher.exception()}) { - vmException.set(vm, exception->value()); + JSC::Strong exception; + if (JSC::Exception* vmException{catcher.exception()}) { + exception.set(vm, vmException->value()); catcher.clearException(); } - JSC::Strong napiException; if (env->hasPendingException()) { - napiException.set(vm, env->pendingException()); + if (!exception) exception.set(vm, env->pendingException()); env->clearPendingException(); } - if (vmException) dispatchFinalizerException(env, vmException.get()); - if (napiException) dispatchFinalizerException(env, napiException.get()); + if (!exception) return true; + return dispatchFinalizerException(env, exception.get()); +} + +extern "C" __attribute__((visibility("hidden"))) void mbun_napi_test_dispatch_finalizer_error( + void* opaqueContext, const char* message) { + auto ctx = static_cast(opaqueContext); + JSC::JSGlobalObject* globalObject{toJS(ctx)}; + JSC::JSLockHolder locker{globalObject->vm()}; + JSStringRef text{JSStringCreateWithUTF8CString(message)}; + JSValueRef argument{JSValueMakeString(ctx, text)}; + JSStringRelease(text); + JSValueRef creationException{nullptr}; + JSObjectRef error{JSObjectMakeError(ctx, 1, &argument, &creationException)}; + if (creationException != nullptr || error == nullptr) return; + NapiEnv env{globalObject, NAPI_VERSION, "[finalizer dispatch test]"}; + JSC::JSValue errorValue{toJS(globalObject, error)}; + // Model the defensive dual-store case: one callback error can be visible + // through both the VM and napi_env. The drain must select and dispatch it + // once, not offer the same exception to process twice. + env.scheduleException(errorValue); + (void)env.throwPendingException(); + env.scheduleException(errorValue); + (void)dispatchFinalizerExceptions(&env); } // Drain the deferred (GC-time) finalizer task queue. Only called from @@ -601,7 +654,7 @@ inline void drainPendingFinalizers() { ScopedHandleScope scope; prepareFinalizerCallback(fin.env); fin.cb(fin.env, fin.data, fin.hint); - dispatchFinalizerExceptions(fin.env); + if (!dispatchFinalizerExceptions(fin.env)) return; } } } diff --git a/modules/jsc/tests/test_runtime_smoke.cpp b/modules/jsc/tests/test_runtime_smoke.cpp index d51e84b3..72c8b99c 100644 --- a/modules/jsc/tests/test_runtime_smoke.cpp +++ b/modules/jsc/tests/test_runtime_smoke.cpp @@ -5,6 +5,8 @@ import std; import mbun.jsc.runtime; +extern "C" void mbun_napi_test_dispatch_finalizer_error(void* context, const char* message); + namespace { int gFailed{0}; @@ -56,6 +58,39 @@ int main() { expect_num("Bun.TOML.parse('a = 41').a + 1", 42.0); expect_num("typeof Bun.nanoseconds() === 'number' ? 1 : 0", 1.0); +#if !defined(_WIN32) + // The native finalizer drain has already removed the VM/env exception when + // it reaches the process uncaught dispatcher. A broken/missing dispatcher + // must therefore arm the shared fatal channel itself rather than drop the + // finalizer error. A healthy dispatcher still receives it exactly once. + void* napiContext{mbun::jsc::runtime::dns_testing::js_context()}; + expect_num("(()=>{globalThis.__mbun_fatal=null;globalThis.__mbun_fatal_status=1;" + "globalThis.__mbun_uncaught=null;return 1})()", + 1.0); + mbun_napi_test_dispatch_finalizer_error(napiContext, "missing dispatcher"); + expect_num("globalThis.__mbun_fatal?.[0]?.message==='missing dispatcher'&&" + "globalThis.__mbun_fatal_status===1?1:0", + 1.0); + + expect_num("(()=>{globalThis.__mbun_fatal=null;globalThis.__mbun_fatal_status=1;" + "globalThis.__mbun_uncaught=()=>{throw new Error('dispatcher failed')};return 1})()", + 1.0); + mbun_napi_test_dispatch_finalizer_error(napiContext, "original finalizer error"); + expect_num("globalThis.__mbun_fatal?.[0]?.message==='dispatcher failed'&&" + "globalThis.__mbun_fatal_status===7?1:0", + 1.0); + + expect_num("(()=>{globalThis.__mbun_fatal=null;globalThis.__mbun_fatal_status=1;" + "globalThis.__mbunFinalizerDispatchCalls=0;" + "globalThis.__mbun_uncaught=()=>{globalThis.__mbunFinalizerDispatchCalls++;" + "return true};return 1})()", + 1.0); + mbun_napi_test_dispatch_finalizer_error(napiContext, "handled finalizer error"); + expect_num("globalThis.__mbunFinalizerDispatchCalls===1&&" + "globalThis.__mbun_fatal===null?1:0", + 1.0); +#endif + if (gFailed > 0) { std::println("test_runtime_smoke: {} failed", gFailed); return 1; From e9207a3fe4f71325fe67f33ef9f86dc3ad1ac5a4 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:03:00 +0800 Subject: [PATCH 020/150] fix(napi): stop finalizer batch on fatal dispatch Honor the shared uncaught dispatcher boolean result so a fatal false return stops the remaining finalizer batch without overwriting the fatal state. Cover two queued finalizers behaviorally. Refs: #86 Refs: #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/runtime/napi/mbun_napi.h | 32 ++++++++++++++++++++++-- modules/jsc/tests/test_runtime_smoke.cpp | 9 +++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/modules/jsc/src/runtime/napi/mbun_napi.h b/modules/jsc/src/runtime/napi/mbun_napi.h index 897b615f..f1af69c1 100644 --- a/modules/jsc/src/runtime/napi/mbun_napi.h +++ b/modules/jsc/src/runtime/napi/mbun_napi.h @@ -595,12 +595,19 @@ inline bool dispatchFinalizerException(napi_env env, JSC::JSValue error) { } JSValueRef argument{toRef(globalObject, error)}; JSValueRef dispatchException{nullptr}; - JSObjectCallAsFunction(ctx, handler, nullptr, 1, &argument, &dispatchException); + JSValueRef dispatchResult{ + JSObjectCallAsFunction(ctx, handler, nullptr, 1, &argument, &dispatchException)}; if (dispatchException != nullptr) { armFinalizerFatal(env, toJS(globalObject, dispatchException), 7); return false; } - return true; + if (dispatchResult == nullptr) { + armFinalizerFatal(env, error, 1); + return false; + } + // The shared dispatcher returns false after it has armed fatal state. As + // with tick/timer drains, stop this batch without overwriting that state. + return JSValueToBoolean(ctx, dispatchResult); } inline bool dispatchFinalizerExceptions(napi_env env) { @@ -659,6 +666,27 @@ inline void drainPendingFinalizers() { } } +static void testFailingFinalizer(napi_env env, void*, void*) { + (void)napi_throw_error(env, nullptr, "first finalizer failed"); +} + +static void testCountingFinalizer(napi_env, void* data, void*) { + ++*static_cast(data); +} + +extern "C" __attribute__((visibility("hidden"))) int mbun_napi_test_drain_two_finalizers( + void* opaqueContext) { + auto ctx = static_cast(opaqueContext); + JSC::JSGlobalObject* globalObject{toJS(ctx)}; + NapiEnv env{globalObject, NAPI_VERSION, "[finalizer drain test]"}; + int secondCalls{0}; + auto& queue{NapiState::singleton().pendingFinalizers}; + queue.push_back({&env, testFailingFinalizer, nullptr, nullptr}); + queue.push_back({&env, testCountingFinalizer, &secondCalls, nullptr}); + drainPendingFinalizers(); + return secondCalls; +} + // ── callback info (port: napi.h NAPICallFrame) ───────────────────────────── class NAPICallFrame { public: diff --git a/modules/jsc/tests/test_runtime_smoke.cpp b/modules/jsc/tests/test_runtime_smoke.cpp index 72c8b99c..d5f6872c 100644 --- a/modules/jsc/tests/test_runtime_smoke.cpp +++ b/modules/jsc/tests/test_runtime_smoke.cpp @@ -6,6 +6,7 @@ import std; import mbun.jsc.runtime; extern "C" void mbun_napi_test_dispatch_finalizer_error(void* context, const char* message); +extern "C" int mbun_napi_test_drain_two_finalizers(void* context); namespace { @@ -89,6 +90,14 @@ int main() { expect_num("globalThis.__mbunFinalizerDispatchCalls===1&&" "globalThis.__mbun_fatal===null?1:0", 1.0); + + expect_num("(()=>{globalThis.__mbun_fatal=null;globalThis.__mbun_fatal_status=1;" + "globalThis.__mbun_uncaught=(error)=>{globalThis.__mbun_fatal=[error];" + "return false};return 1})()", + 1.0); + expect(mbun_napi_test_drain_two_finalizers(napiContext) == 0, + "false uncaught result stops the remaining finalizer batch"); + expect_num("globalThis.__mbun_fatal?.[0]?.message==='first finalizer failed'?1:0", 1.0); #endif if (gFailed > 0) { From 78262d9184a486f3927e68f6c16cfd43cd359f5e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:18:18 +0800 Subject: [PATCH 021/150] research(compat): record W43 Wave A result - #80 Record the reviewed five-lane same-binary +13 focused result, serial and impact gates, remaining reds, and scope boundary before Wave B. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../docs/20260802-corpus-coverage-w43-plan.md | 8 +-- .agents/docs/20260802-corpus-coverage-w43.md | 71 +++++++++++++++++-- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md index b814ea6f..31760b50 100644 --- a/.agents/docs/20260802-corpus-coverage-w43-plan.md +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -501,7 +501,7 @@ python3 tools/integration/bun_corpus_runner.py \ A5 runs the same command with list `w43-a5-bun-test-runner.txt` and output `target/integration/w43-a5-before`. Each worker reads the relevant Bun/Node N-API or Bun test-runner upstream source before editing, creates/links the defect issue, implements one coherent cause, builds, repeats its list, serially proves new greens, runs the relevant JSC member test, and commits. -- [ ] **Step 3: Apply the same evidence rejection gate as Task 5** +- [x] **Step 3: Apply the same evidence rejection gate as Task 5** No upstream mechanism, no stable red, no serial proof, or any `compat/` edit means rejection. @@ -515,7 +515,7 @@ No upstream mechanism, no stable red, no serial proof, or any `compat/` edit mea - Consumes: accepted A4/A5 commits plus Task 6 tree. - Produces: full Wave A focused evidence, actual +N versus target +12 to +20, zero-regression verdict, and one PR comment. -- [ ] **Step 1: Review and merge A4 then A5 normally** +- [x] **Step 1: Review and merge A4 then A5 normally** Run branch diff/trailer checks and ensure source boundaries are disjoint, then: @@ -535,7 +535,7 @@ git merge --no-ff w43/a5-bun-test-runner \ -m 'Co-authored-by: Codex (GPT-5) <>' ``` -- [ ] **Step 2: Build and run the five-manifest union** +- [x] **Step 2: Build and run the five-manifest union** ```bash W43_WAVE_A_FINAL_BIN=$(bash tools/integration/build_or_die.sh) @@ -547,7 +547,7 @@ sha256sum "$W43_WAVE_B_BASE_BIN" Run A1–A3 with the Node runner and A4–A5 with the Bun runner, at `--jobs 3`; serially repeat every claimed new green. Run `tools/integration/build_lock.sh mcpp test -p jsc` and the diff-derived impact lists. -- [ ] **Step 3: Record target versus actual** +- [x] **Step 3: Record target versus actual** Append lane target, actual green delta, remaining classifications, issue, commit, member-test result, serial proof count, and elapsed time to the W43 ledger. State `Wave A target missed` if net delta is below 12; do not lower the target after observing results. diff --git a/.agents/docs/20260802-corpus-coverage-w43.md b/.agents/docs/20260802-corpus-coverage-w43.md index 028392f5..42288831 100644 --- a/.agents/docs/20260802-corpus-coverage-w43.md +++ b/.agents/docs/20260802-corpus-coverage-w43.md @@ -10,11 +10,13 @@ Campaign branch: `agent/corpus-coverage-w43` ## Current status -Wave A batch 1 is integrated at `7e84e13`: its three Node lanes produced seven -focused new-green files with no focused or impact-list regression. A1 reached -+4, A2 was deliberately narrowed to a safe +1 below its +2 floor, and A3 -reached +2. This is not a post-Wave-A full-corpus result; checkpoint 0 remains -the authoritative full baseline until the next same-binary full run. +Wave A is integrated at `d62163c`: its five logical lanes produced 13 focused +new-green files with no focused or impact-list classification regression. Node +contributed +7 and Bun +6. A1 reached +4, A2 was deliberately narrowed to a +safe +1 below its +2 floor, A3 reached +2, A4 reached +3 after two additive +independent-review fixes, and A5 reached +3 through two separately attributed +mechanisms. This is not a post-Wave-A full-corpus result; checkpoint 0 remains +the authoritative full baseline until the final same-binary full run. Five logical lanes will be executed in rolling batches because this session has three physical worker slots. The coordinator alone owns builds, full corpus @@ -123,8 +125,8 @@ expects. | A1 | Node zlib/Buffer | +3 to +5 | +4 | #83 | `71b2c2b..bacd246` | accepted and composed | | A2 | Node assert | +2 to +4 | +1 | #82 | `e7b8d99..0a102f6` | accepted safe partial; target missed | | A3 | Node permission | +2 to +4 | +2 | #84 | `c48839f..bef02c6` | accepted and composed | -| A4 | Bun N-API | +3 to +5 | diagnosing +3 finalizer set | pending | — | implementation active | -| A5 | Bun test runner | +3 to +5 | diagnosing +1 +2 | pending | — | two explicit mechanisms active | +| A4 | Bun N-API | +3 to +5 | +3 | #86 | `29e12e3..e9207a3` | accepted and composed after two additive review fixes | +| A5 | Bun test runner | +3 to +5 | +3 | #87, #88 | `d222075..444e495` | accepted and composed | All 49 literal paths were found in the fresh baseline and were non-green. A1, A2, and A3 contain respectively 5, 8, and 10 Node `fail` rows. A4 contains 18 @@ -179,3 +181,58 @@ case. A2 retains seven reds because review removed an incomplete handwritten partial-deep comparator and did not expose unsupported `skipPrototype` semantics. A3 retains eight separately diagnosed permission surfaces. These remaining rows are not counted as gains or waived. + +## Wave A final composed checkpoint + +| Evidence | Result | +| --- | --- | +| Coordinator product head | `d62163c92df55dc6126caf31f564dd2ebad1d5e5` | +| Frozen composed binary | Git common directory `w43/wave-b-base/mbun` | +| Binary SHA-256 | `445b40f7eae331c05fc5fbbd92f8cb816b37979c7b3339545b02a1525663bb0a` | +| Fresh coordinator build | `build_or_die.sh`: pass, key `5c54b97c0110b62e` | +| Version probe | mbun `2026.07.18.0`; Bun `1.3.14`; Node `v26.3.0` | +| Full JSC member gate | 29 passed, 0 failed | +| Five-manifest focused gate | 0/49 -> 13/49; Node +7, Bun +6 | +| Serial deciding gate | all five manifests repeated at jobs 1 with the same 13 greens | +| Node impact gate | 415 files; 273 pass, 87 fail, 44 skipped, 11 timeout before and after; no regression | +| Bun impact gate | 34 files; 14 green, 16 test-failure, 1 all-skipped, 1 blocked-external, 1 load-error, 1 timeout before and after; no regression | + +Wave A's fixed target was +12 to +20, so the measured +13 meets the wave target. +The original five-hour sprint target remains +30 to +45 and is not met by Wave +A alone. No timeout, OOM, crash, or exclusion classification moved in the 49 +focused rows. + +The composed before-to-after lane results are exact and same-binary: + +- A1: 0/5 -> 4/5, leaving the independent `DEP0005` warning-delivery row red. +- A2: 0/8 -> 1/8, retaining seven deliberately unwaived assert rows. +- A3: 0/10 -> 2/10, retaining eight separately diagnosed permission rows. +- A4: 0/18 -> 3/18, leaving 15 N-API/libuv rows as `test-failure`. +- A5: 0/8 -> 3/8, leaving five reporter/preload/parser rows as + `test-failure` and outside the accepted mechanisms. + +A4's first independent review found that a missing or throwing configurable +`__mbun_uncaught` dispatcher could consume a finalizer exception silently. +Additive commit `d9fdc85` arms the shared fatal channel directly: missing or +non-callable dispatch preserves the original error/status 1, while lookup or +handler failure preserves the nested error/status 7. Scoped rereview then found +that the standard dispatcher's false fatal result was ignored. Additive commit +`e9207a3` consumes that result and stops the remaining finalizer batch. Real +probes exit 1 and 7 respectively; a two-finalizer behavioral member test proves +the second callback does not run after fatal. Final rereview reported no +Critical or Important findings. + +A5 commit `d222075` keeps callback and returned-Promise completion independent, +so synchronous `done()` cannot hide a later rejection (+1). Commit `444e495` +bridges a lexical symlink path to the already-loaded canonical module-cache key +without evaluating an unloaded module, then uses the existing subscription +fan-out (+2). Both independent reviews reported no Critical or Important +findings. The canonical retry is proved on POSIX; Windows drive-letter/junction +identity remains unproved and is not claimed. + +The name-derived impact gate is intentionally not a full regression proof. It +cannot infer behavior-only reachability such as GC ordering or settlement +timing; the fixed manifests, serial repeats, native behavior tests, and final +full corpus run cover those boundaries. On the Bun impact list, classifications +were unchanged while `js/node/fs/fs.test.ts` improved by 11 passed tests; this +is a test-level move, not an additional green-file credit. From 0cbb4e19fc8b25eb0d4e58ee60cbbaa438027dd8 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:21:48 +0800 Subject: [PATCH 022/150] test(compat): freeze W43 Wave B worklists - #80 Record the measured post-Wave-A assignments, 52 literal paths, fixed +14 target, struck exclusions, and disjoint source ownership. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../docs/20260802-corpus-coverage-w43-plan.md | 10 +- .agents/docs/20260802-corpus-coverage-w43.md | 134 ++++++++++++++++++ tools/integration/manifests/w43-b1-repl.txt | 12 ++ .../manifests/w43-b2-crypto-webcrypto.txt | 12 ++ .../manifests/w43-b3-bun-third-party.txt | 8 ++ .../manifests/w43-b4-bun-cli-run.txt | 10 ++ .../integration/manifests/w43-b5-node-fs.txt | 10 ++ 7 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 tools/integration/manifests/w43-b1-repl.txt create mode 100644 tools/integration/manifests/w43-b2-crypto-webcrypto.txt create mode 100644 tools/integration/manifests/w43-b3-bun-third-party.txt create mode 100644 tools/integration/manifests/w43-b4-bun-cli-run.txt create mode 100644 tools/integration/manifests/w43-b5-node-fs.txt diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md index 31760b50..9bc961b3 100644 --- a/.agents/docs/20260802-corpus-coverage-w43-plan.md +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -551,7 +551,7 @@ Run A1–A3 with the Node runner and A4–A5 with the Bun runner, at `--jobs 3`; Append lane target, actual green delta, remaining classifications, issue, commit, member-test result, serial proof count, and elapsed time to the W43 ledger. State `Wave A target missed` if net delta is below 12; do not lower the target after observing results. -- [ ] **Step 4: Push and publish one checkpoint comment** +- [x] **Step 4: Push and publish one checkpoint comment** The comment contains source issues/commits, five lane targets→actuals, cumulative new greens, zero-regression result, timeout/OOM movement in the focused sets, physical concurrency, blockers, and the exact Wave B selection command. It explicitly says no post-Wave-A full corpus was run. @@ -565,7 +565,7 @@ The comment contains source issues/commits, five lane targets→actuals, cumulat - Consumes: Task 2 full baseline plus Task 8 integrated outcomes. - Produces: five literal, disjoint manifests assigned to REPL, combined crypto/webcrypto, Bun third-party, Bun CLI/run, and Node fs; combined target +12 to +22. -- [ ] **Step 1: Run the ranked planner with Wave A areas excluded** +- [x] **Step 1: Run the ranked planner with Wave A areas excluded** ```bash python3 tools/integration/wave_planner.py \ @@ -583,15 +583,15 @@ python3 tools/integration/wave_planner.py \ --exclude test-runner ``` -- [ ] **Step 2: Select only the spec-approved disjoint owners** +- [x] **Step 2: Select only the spec-approved disjoint owners** Use this priority order: Node REPL, one combined crypto+webcrypto owner, Bun `js/third_party`, Bun `cli/run`, Node fs. If the planner reports fewer than two actionable files for one owner, take the next ranked owner that does not overlap another source touch-set. Record the literal replacement and planner evidence in the ledger before dispatch. -- [ ] **Step 3: Write literal manifests from baseline rows** +- [x] **Step 3: Write literal manifests from baseline rows** Sort each owner's non-green rows by failed-assertion ratio and stable duration, cap each manifest at 12 files, exclude Wave A paths and struck approaches, then write the exact paths to `w43-b1` through `w43-b5`. Run every manifest against the integrated pre-Wave-B binary with `--jobs 1`; remove any now-green path and record it as an inherited Wave A gain rather than Wave B credit. -- [ ] **Step 4: Lock targets before workers start** +- [x] **Step 4: Lock targets before workers start** Assign +2 to +5 per Node lane and +2 to +4 per Bun lane, totaling +12 to +22. Targets are written to the ledger and PR checkpoint comment before implementation; they are not changed after results arrive. diff --git a/.agents/docs/20260802-corpus-coverage-w43.md b/.agents/docs/20260802-corpus-coverage-w43.md index 42288831..945f5c0f 100644 --- a/.agents/docs/20260802-corpus-coverage-w43.md +++ b/.agents/docs/20260802-corpus-coverage-w43.md @@ -236,3 +236,137 @@ timing; the fixed manifests, serial repeats, native behavior tests, and final full corpus run cover those boundaries. On the Bun impact list, classifications were unchanged while `js/node/fs/fs.test.ts` improved by 11 passed tests; this is a test-level move, not an additional green-file credit. + +Checkpoint 1 was published at PR comment `5157473592`. At comment time, GCC 16 +and LLVM 22 were running and macOS was queued on final head `78262d9`; no +current-head CI success was claimed. + +## Wave B locked worklists + +The post-Wave-A binary `445b40f7...63bb0a` reproduced every one of the 52 +literal candidates as non-green at jobs 1. No path was removed as an inherited +Wave A gain. The fixed targets are B1 +3, B2 +4, B3 +2, B4 +2, and B5 +3: +combined +14, split Node +10 and Bun +4. This is inside the plan's +12 to +22 +Wave B range. It is deliberately evidence-based rather than inflated to make +the sprint-level +30 floor appear reachable; Wave A actual plus the Wave B +target is +27, so the +30 to +45 sprint target requires Wave B to exceed its +fixed target by at least three files. + +The ranked planner returned Node REPL, Bun third-party, Node crypto, Node +webcrypto, and Node VM. The spec combines crypto+webcrypto under one owner and +uses the next required Bun CLI/run and Node fs owners. Node result rows contain +no assertion counters, so their literal order uses duration only as an honest +tie-breaker; Bun uses failed/ran ratio, then duration. A single baseline +duration is not a stability claim; the deciding jobs-1 screen above is. + +### B1 — Node REPL, target +3 + +Source owner: `node_repl.cppm` and directly relevant member tests only. The +struck SIGINT termination and inspector preview/reverse-search approaches are +excluded. + +```text +compat/node/test/parallel/test-repl-autolibs.js +compat/node/test/parallel/test-repl-uncaught-exception.js +compat/node/test/parallel/test-repl-pretty-custom-stack.js +compat/node/test/parallel/test-repl-tab-complete-computed-props.js +compat/node/test/parallel/test-repl-tab-complete-new-expression.js +compat/node/test/parallel/test-repl-user-error-handler.js +compat/node/test/parallel/test-repl-empty.js +compat/node/test/parallel/test-repl-multiline.js +compat/node/test/parallel/test-repl-colors.js +compat/node/test/parallel/test-repl-eval-error-after-close.js +compat/node/test/parallel/test-repl-completion-on-getters-disabled.js +compat/node/test/parallel/test-repl-tab-complete-nosideeffects.js +``` + +Jobs-1 Red: 12 `fail`, 0 pass. + +### B2 — Node crypto plus webcrypto, target +4 + +One owner covers only narrow validation/state/bridge gaps in the existing +crypto and webcrypto partitions. The exact key-object same-binary conflicts, +the reverted `getCurves()` expansion, and absent-algorithm/OpenSSL-capability +work are excluded. + +```text +compat/node/test/parallel/test-crypto-keygen.js +compat/node/test/parallel/test-crypto-authenticated.js +compat/node/test/parallel/test-crypto-key-objects-raw.js +compat/node/test/parallel/test-crypto-no-algorithm.js +compat/node/test/parallel/test-webcrypto-cryptokey-hidden-slots.js +compat/node/test/parallel/test-webcrypto-export-import-ec.js +compat/node/test/parallel/test-crypto-random.js +compat/node/test/parallel/test-crypto-secure-heap.js +compat/node/test/parallel/test-webcrypto-crypto-job-mode.js +compat/node/test/parallel/test-webcrypto-promise-prototype-pollution.mjs +compat/node/test/parallel/test-crypto-x509.js +compat/node/test/parallel/test-webcrypto-webidl.js +``` + +Jobs-1 Red: 12 `fail`, 0 pass. + +### B3 — Bun `js/third_party`, target +2 + +Source owner is the HTTP/network/stream boundary only; package installation, +bundler, N-API, and CLI are excluded. The already-landed DeferredWorkTimer and +live-binding approaches are not repeated. + +```text +compat/bun/test/js/third_party/express/express.json.test.ts +compat/bun/test/js/third_party/grpc-js/test-server.test.ts +compat/bun/test/js/third_party/express/res.sendFile.test.ts +compat/bun/test/js/third_party/grpc-js/test-resolver.test.ts +compat/bun/test/js/third_party/grpc-js/test-outlier-detection.test.ts +compat/bun/test/js/third_party/wpt-h2/run.test.ts +compat/bun/test/js/third_party/undici-h2/run.test.ts +compat/bun/test/js/third_party/hono/hello-world-fixture.test.ts +``` + +Jobs-1 Red: 8 `test-failure`; 169 passed, 40 failed, 249 ran, 1 expect. + +### B4 — Bun `cli/run`, target +2 + +Source owner is run/eval/quoting/workspace dispatch only. Install/autoinstall, +FUSE, inspector/profiler, and broad `bun pm` work are excluded. + +```text +compat/bun/test/cli/run/run-quote.test.ts +compat/bun/test/cli/run/as-node.test.ts +compat/bun/test/cli/run/transpiler-cache.test.ts +compat/bun/test/cli/run/run-eval.test.ts +compat/bun/test/cli/run/filter-workspace.test.ts +compat/bun/test/cli/run/esm-defineProperty.test.ts +compat/bun/test/cli/run/run-crash-handler.test.ts +compat/bun/test/cli/run/tsconfig-override.test.ts +compat/bun/test/cli/run/run-shell.test.ts +compat/bun/test/cli/run/multi-run.test.ts +``` + +Jobs-1 Red: 10 `test-failure`; 18 passed, 228 failed, 246 ran, 372 expects. + +### B5 — Node fs, target +3 + +Source owner is the existing fs builtin/runtime/member boundary. Permission +policy, the Buffer-size row, existing-Date timezone caching, and active-request +rewrites are excluded. + +```text +compat/node/test/parallel/test-fs-promises-file-handle-read-worker.js +compat/node/test/parallel/test-fs-promises.js +compat/node/test/parallel/test-fs-readdir-ucs2.js +compat/node/test/parallel/test-fs-access.js +compat/node/test/parallel/test-fs-existssync-memleak-longpath.js +compat/node/test/parallel/test-fs-syncwritestream.js +compat/node/test/parallel/test-fs-filehandle.js +compat/node/test/parallel/test-fs-promises-readfile.js +compat/node/test/parallel/test-fs-readdir-stack-overflow.js +compat/node/test/parallel/test-fs-glob.mjs +``` + +Jobs-1 Red: 10 `fail`, 0 pass. + +The five lists are path-disjoint and source ownership is disjoint by brief. If +a worker maps a row outside its owner, it drops that row rather than widening. +Node VM is the first replacement only if a lane becomes non-actionable before +dispatch; no replacement is currently required. diff --git a/tools/integration/manifests/w43-b1-repl.txt b/tools/integration/manifests/w43-b1-repl.txt new file mode 100644 index 00000000..8c12bd32 --- /dev/null +++ b/tools/integration/manifests/w43-b1-repl.txt @@ -0,0 +1,12 @@ +compat/node/test/parallel/test-repl-autolibs.js +compat/node/test/parallel/test-repl-uncaught-exception.js +compat/node/test/parallel/test-repl-pretty-custom-stack.js +compat/node/test/parallel/test-repl-tab-complete-computed-props.js +compat/node/test/parallel/test-repl-tab-complete-new-expression.js +compat/node/test/parallel/test-repl-user-error-handler.js +compat/node/test/parallel/test-repl-empty.js +compat/node/test/parallel/test-repl-multiline.js +compat/node/test/parallel/test-repl-colors.js +compat/node/test/parallel/test-repl-eval-error-after-close.js +compat/node/test/parallel/test-repl-completion-on-getters-disabled.js +compat/node/test/parallel/test-repl-tab-complete-nosideeffects.js diff --git a/tools/integration/manifests/w43-b2-crypto-webcrypto.txt b/tools/integration/manifests/w43-b2-crypto-webcrypto.txt new file mode 100644 index 00000000..01ddb5d1 --- /dev/null +++ b/tools/integration/manifests/w43-b2-crypto-webcrypto.txt @@ -0,0 +1,12 @@ +compat/node/test/parallel/test-crypto-keygen.js +compat/node/test/parallel/test-crypto-authenticated.js +compat/node/test/parallel/test-crypto-key-objects-raw.js +compat/node/test/parallel/test-crypto-no-algorithm.js +compat/node/test/parallel/test-webcrypto-cryptokey-hidden-slots.js +compat/node/test/parallel/test-webcrypto-export-import-ec.js +compat/node/test/parallel/test-crypto-random.js +compat/node/test/parallel/test-crypto-secure-heap.js +compat/node/test/parallel/test-webcrypto-crypto-job-mode.js +compat/node/test/parallel/test-webcrypto-promise-prototype-pollution.mjs +compat/node/test/parallel/test-crypto-x509.js +compat/node/test/parallel/test-webcrypto-webidl.js diff --git a/tools/integration/manifests/w43-b3-bun-third-party.txt b/tools/integration/manifests/w43-b3-bun-third-party.txt new file mode 100644 index 00000000..04d1b925 --- /dev/null +++ b/tools/integration/manifests/w43-b3-bun-third-party.txt @@ -0,0 +1,8 @@ +compat/bun/test/js/third_party/express/express.json.test.ts +compat/bun/test/js/third_party/grpc-js/test-server.test.ts +compat/bun/test/js/third_party/express/res.sendFile.test.ts +compat/bun/test/js/third_party/grpc-js/test-resolver.test.ts +compat/bun/test/js/third_party/grpc-js/test-outlier-detection.test.ts +compat/bun/test/js/third_party/wpt-h2/run.test.ts +compat/bun/test/js/third_party/undici-h2/run.test.ts +compat/bun/test/js/third_party/hono/hello-world-fixture.test.ts diff --git a/tools/integration/manifests/w43-b4-bun-cli-run.txt b/tools/integration/manifests/w43-b4-bun-cli-run.txt new file mode 100644 index 00000000..8ae78a04 --- /dev/null +++ b/tools/integration/manifests/w43-b4-bun-cli-run.txt @@ -0,0 +1,10 @@ +compat/bun/test/cli/run/run-quote.test.ts +compat/bun/test/cli/run/as-node.test.ts +compat/bun/test/cli/run/transpiler-cache.test.ts +compat/bun/test/cli/run/run-eval.test.ts +compat/bun/test/cli/run/filter-workspace.test.ts +compat/bun/test/cli/run/esm-defineProperty.test.ts +compat/bun/test/cli/run/run-crash-handler.test.ts +compat/bun/test/cli/run/tsconfig-override.test.ts +compat/bun/test/cli/run/run-shell.test.ts +compat/bun/test/cli/run/multi-run.test.ts diff --git a/tools/integration/manifests/w43-b5-node-fs.txt b/tools/integration/manifests/w43-b5-node-fs.txt new file mode 100644 index 00000000..ca87f5e2 --- /dev/null +++ b/tools/integration/manifests/w43-b5-node-fs.txt @@ -0,0 +1,10 @@ +compat/node/test/parallel/test-fs-promises-file-handle-read-worker.js +compat/node/test/parallel/test-fs-promises.js +compat/node/test/parallel/test-fs-readdir-ucs2.js +compat/node/test/parallel/test-fs-access.js +compat/node/test/parallel/test-fs-existssync-memleak-longpath.js +compat/node/test/parallel/test-fs-syncwritestream.js +compat/node/test/parallel/test-fs-filehandle.js +compat/node/test/parallel/test-fs-promises-readfile.js +compat/node/test/parallel/test-fs-readdir-stack-overflow.js +compat/node/test/parallel/test-fs-glob.mjs From 03505650289b577c36534a03ed316450b06d3183 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:22:15 +0800 Subject: [PATCH 023/150] chore(plan): mark W43 Wave B lists frozen - #80 Record the pre-dispatch manifest checkpoint after the jobs-1 Red screen. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .agents/docs/20260802-corpus-coverage-w43-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/docs/20260802-corpus-coverage-w43-plan.md b/.agents/docs/20260802-corpus-coverage-w43-plan.md index 9bc961b3..64a973d3 100644 --- a/.agents/docs/20260802-corpus-coverage-w43-plan.md +++ b/.agents/docs/20260802-corpus-coverage-w43-plan.md @@ -595,7 +595,7 @@ Sort each owner's non-green rows by failed-assertion ratio and stable duration, Assign +2 to +5 per Node lane and +2 to +4 per Bun lane, totaling +12 to +22. Targets are written to the ledger and PR checkpoint comment before implementation; they are not changed after results arrive. -- [ ] **Step 5: Commit the literal Wave B manifests before dispatch** +- [x] **Step 5: Commit the literal Wave B manifests before dispatch** ```bash git diff --check From eb4dd9f90d45ce42bd1564d05592fa595ea0ef2e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:39:35 +0800 Subject: [PATCH 024/150] fix(repl): tolerate unavailable legacy captures - #65 - #80 Restore Node REPL capture bookkeeping where supported and keep successful evaluation intact when JSC rejects its own legacy RegExp static accessors. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/node_repl.cppm | 34 +++++++++++++++++-- .../jsc/tests/test_node_compat_bridges.cpp | 6 ++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/modules/jsc/src/builtins/node_repl.cppm b/modules/jsc/src/builtins/node_repl.cppm index 52b29bd8..58467a6a 100644 --- a/modules/jsc/src/builtins/node_repl.cppm +++ b/modules/jsc/src/builtins/node_repl.cppm @@ -1216,6 +1216,32 @@ inline constexpr std::string_view kNodeReplJS = R"JS( setupExceptionCapture(); const savedRegExMatches = ["", "", "", "", "", "", "", "", "", ""]; + const regExMatchSeparator = "\u0000\u0000\u0000"; + const regExMatcher = new RegExp( + `^${regExMatchSeparator}(.*)${regExMatchSeparator}(.*)` + + `${regExMatchSeparator}(.*)${regExMatchSeparator}(.*)` + + `${regExMatchSeparator}(.*)${regExMatchSeparator}(.*)` + + `${regExMatchSeparator}(.*)${regExMatchSeparator}(.*)` + + `${regExMatchSeparator}(.*)$`); + + function saveRegExpMatches() { + try { + for (let idx = 1; idx < savedRegExMatches.length; idx += 1) { + savedRegExMatches[idx] = RegExp[`$${idx}`]; + } + } catch (captureError) { + // JSC currently exposes the legacy static captures through accessors + // whose receiver check rejects its own RegExp constructor (issue #65). + // That runtime defect must not replace an otherwise successful REPL + // evaluation with an unrelated TypeError. Keep the normal node path + // active for runtimes where the accessors are readable. + if (!(captureError instanceof TypeError) || + captureError.message !== + "RegExp.$N getters require RegExp constructor as |this|") { + throw captureError; + } + } + } eval_ = eval_ || defaultEval; @@ -1310,13 +1336,15 @@ inline constexpr std::string_view kNodeReplJS = R"JS( } } + // Restore the captures hidden by REPL bookkeeping before user code runs, + // matching node's default evaluator protocol. + regExMatcher.exec(savedRegExMatches.join(regExMatchSeparator)); + let finished = false; function finishExecution(e, r) { if (finished) return; finished = true; - for (let idx = 1; idx < savedRegExMatches.length; idx += 1) { - savedRegExMatches[idx] = RegExp[`$${idx}`]; - } + saveRegExpMatches(); cb(e, r); } diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 443cbf4d..6fa4ee5f 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -26,6 +26,12 @@ int main() { expect_number( "globalThis.__mbunDialect === 'node' ? 1 : 0", 1, "node compatibility probes run in the Node dialect"); + expect_number( + "(()=>{try{const repl=require('node:repl');const stream=require('node:stream');" + "const io=new stream.PassThrough();const server=repl.start({input:io,output:io,terminal:false,prompt:''});" + "let result=-1;server.eval('1+1\\n',server.context,'REPL1',(err,value)=>{result=err?0:value===2?1:0});" + "server.close();return result}catch{return -1}})()", + 1, "node REPL default evaluation completes despite unavailable legacy RegExp captures"); expect_number( "(()=>{try{const fs=require('node:fs');" "const p=require('node:util').promisify(fs.exists);" From bd0e29d05a8d3541d856ea63d76ac657881de97b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:44:56 +0800 Subject: [PATCH 025/150] fix(fetch): route explicit HTTP2 requests Honor Bun protocol: http2 and h2 fetch options through the existing node:http2 transport, and restore Http2ServerRequest as a real Readable so streamed request bodies complete. - #89 - #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/js_http2_part2.cppm | 70 +++++++---- modules/jsc/src/js_net_part2.cppm | 188 ++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 27 deletions(-) diff --git a/modules/jsc/src/js_http2_part2.cppm b/modules/jsc/src/js_http2_part2.cppm index b1abff21..7e7fa69b 100644 --- a/modules/jsc/src/js_http2_part2.cppm +++ b/modules/jsc/src/js_http2_part2.cppm @@ -1211,12 +1211,10 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( } // === Http2ServerRequest / Http2ServerResponse (createServer((req,res)) compat) === - // Translated from node lib/internal/http2/compat.js. The request keeps the - // Http2Stream's flowing-mode 'data' relay rather than a real Readable (mbun's - // Http2Stream is an EventEmitter, not a stream), but every observable member - // node's compat layer defines — header validation and its error codes, the - // socket proxy, statusCode/statusMessage semantics, writeHead's array form, - // trailers, informational responses — is ported as written there. + // Translated from node lib/internal/http2/compat.js. Http2ServerRequest is a + // real Readable over the underlying Http2Stream Duplex, matching node's + // compatibility layer: data starts flowing on the first _read(), backpressure + // pauses the stream, and the stream's END_STREAM pushes readable EOF. const HTTP_STATUS_CONTINUE = 100, HTTP_STATUS_EARLY_HINTS = 103; const HTTP_STATUS_EXPECTATION_FAILED = 417, HTTP_STATUS_METHOD_NOT_ALLOWED = 405; const kValidPseudoHeaders = new Set([":status", ":method", ":path", ":authority", ":scheme"]); @@ -1322,9 +1320,12 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( return stream._proxySocket; } - class Http2ServerRequest extends EE { + const H2RequestBase = (streamMod && typeof streamMod.Readable === "function") ? streamMod.Readable : EE; + const kHaveRequestReadable = H2RequestBase !== EE; + + class Http2ServerRequest extends H2RequestBase { constructor(stream, headers, options, rawHeaders) { - super(); + super(kHaveRequestReadable ? { autoDestroy: false, ...(options || {}) } : undefined); this._state = { closed: false, didRead: false }; this._headers = headers || {}; this._rawHeaders = rawHeaders || []; @@ -1332,21 +1333,39 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( this._rawTrailers = []; this._stream = stream; this._aborted = false; - this.readable = true; - this.readableEnded = false; - this.destroyed = false; + if (!kHaveRequestReadable) { + this.readable = true; + this.readableEnded = false; + this.destroyed = false; + } stream._proxySocket = null; stream._compatRequest = this; const self = this; - stream.on("data", (d) => self.emit("data", d)); stream.on("trailers", (trailers, flags, raw) => { Object.assign(self._trailers, trailers || {}); if (Array.isArray(raw)) for (const v of raw) self._rawTrailers.push(v); }); - stream.on("end", () => { self.readableEnded = true; self.readable = false; self.emit("end"); }); + stream.on("end", () => { + if (kHaveRequestReadable) self.push(null); + else { self.readableEnded = true; self.readable = false; self.emit("end"); } + }); stream.on("aborted", () => { if (!self._state.closed) { self._aborted = true; self.emit("aborted"); } }); - stream.on("close", () => { self._state.closed = true; stream._proxySocket = null; self.emit("close"); }); + stream.on("close", () => { + self._state.closed = true; + if (kHaveRequestReadable) { + self.push(null); + if (!self._state.didRead && !(self._readableState && self._readableState.resumeScheduled)) self.resume(); + } + stream._proxySocket = null; + self.emit("close"); + }); stream.on("timeout", () => self.emit("timeout")); + if (kHaveRequestReadable) { + self.on("pause", () => stream.pause()); + self.on("resume", () => stream.resume()); + } else { + stream.on("data", (d) => self.emit("data", d)); + } // node compat.js attaches onStreamError, a DELIBERATELY EMPTY handler: // "errors in compatibility mode are not forwarded to the request and // response objects". Without it the stream error a compat write-after-end @@ -1367,6 +1386,16 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( get httpVersion() { return "2.0"; } get socket() { return proxySocketOf(this._stream); } get connection() { return this.socket; } + _read() { + if (!kHaveRequestReadable) return; + if (!this._state.didRead) { + this._state.didRead = true; + const self = this; + this._stream.on("data", (chunk) => { if (!self.push(chunk)) self._stream.pause(); }); + } else { + G.queueMicrotask(() => this._stream.resume()); + } + } get method() { return this._headers[":method"]; } set method(method) { validateString(method, "method"); @@ -1377,20 +1406,7 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( get scheme() { return this._headers[":scheme"]; } get url() { return this._headers[":path"]; } set url(url) { this._headers[":path"] = url; } - setEncoding(enc) { this._stream.setEncoding(enc); return this; } setTimeout(msecs, callback) { if (!this._state.closed) this._stream.setTimeout(msecs, callback); return this; } - resume() { this._stream.resume(); this.emit("resume"); return this; } - pause() { this._stream.pause(); this.emit("pause"); return this; } - read() { return null; } - destroy(err) { if (this.destroyed) return this; this.destroyed = true; this._stream.destroy(err); return this; } - pipe(dest, options) { - const self = this; - const endDest = !(options && options.end === false); - this.on("data", (chunk) => { dest.write(chunk); }); - this.on("end", () => { if (endDest && typeof dest.end === "function") dest.end(); }); - try { if (typeof dest.emit === "function") dest.emit("pipe", self); } catch (e) {} - return dest; - } } class Http2ServerResponse extends EE { diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index 9bf21359..3d369f86 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2344,6 +2344,192 @@ export constexpr std::string_view kNetJS_part2 = R"JS( try { NN.close(e.fd); } catch (x) {} }; + // Bun fetch.rs maps an explicit protocol:"http2"/"h2" option to + // force_http2; HTTPContext then offers only h2 through ALPN and dispatches + // the request to its HTTP/2 client. The ordinary fetch path below is an H1 + // client and must not advertise h2: it serializes an HTTP/1.1 request. Reuse + // the already-installed node:http2 transport for the explicit-H2 surface + // instead of growing a second HPACK/frame implementation here (#89). + const h2AbortReason = (signal) => + signal && signal.reason !== undefined && signal.reason !== null + ? signal.reason + : new G.DOMException("The operation was aborted.", "AbortError"); + + async function readH2RequestBody(body, signal) { + if (body == null) return null; + if (signal && signal.aborted) { + if (body && typeof body.cancel === "function") { + try { await body.cancel(h2AbortReason(signal)); } catch (e) {} + } + throw h2AbortReason(signal); + } + if (G.Blob && body instanceof G.Blob) return new Uint8Array(await body.arrayBuffer()); + if (typeof body === "string" || body instanceof Uint8Array || + ArrayBuffer.isView(body) || body instanceof ArrayBuffer || + (body && body._u8 instanceof Uint8Array)) return u8(body).slice(); + + const reader = body && typeof body.getReader === "function" ? body.getReader() : null; + if (!reader) return u8(body).slice(); + const chunks = []; + let rejectAbort; + let onAbort; + const aborted = new Promise((resolve, reject) => { rejectAbort = reject; }); + if (signal && typeof signal.addEventListener === "function") { + onAbort = () => { + const why = h2AbortReason(signal); + try { Promise.resolve(reader.cancel(why)).catch(() => {}); } catch (e) {} + rejectAbort(why); + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + try { + for (;;) { + const step = signal ? await Promise.race([reader.read(), aborted]) : await reader.read(); + if (step.done) break; + const value = step.value; + if (!(value instanceof Uint8Array) && !ArrayBuffer.isView(value) && + !(value instanceof ArrayBuffer)) + throw new TypeError("ReadableStream yielded a non-BufferSource value"); + chunks.push(u8(value).slice()); + } + } finally { + if (signal && onAbort) { + try { signal.removeEventListener("abort", onAbort); } catch (e) {} + } + try { reader.releaseLock(); } catch (e) {} + } + return concatU8(chunks); + } + + async function doFetchH2(url, init, depth) { + if (depth > 20) throw mkErr("redirect count exceeded", "ERR_TOO_MANY_REDIRECTS"); + const h2 = M["http2"] || M["node:http2"]; + if (!h2 || typeof h2.connect !== "function") + throw mkErr("HTTP/2 fetch is unavailable", "HTTP2Unsupported"); + + const parsed = new G.URL(String(url)); + if (parsed.protocol !== "https:") + throw mkErr("HTTP/2 fetch requires HTTPS", "HTTP2Unsupported"); + const signal = init && init.signal; + if (signal && signal.aborted) throw h2AbortReason(signal); + const method = String((init && init.method) || "GET").toUpperCase(); + const originalBody = init && init.body; + const bodyIsStream = !!(originalBody && typeof originalBody.getReader === "function"); + const body = await readH2RequestBody(originalBody, signal); + + const requestHeaders = { + ":method": method, + ":scheme": "https", + ":authority": parsed.host, + ":path": parsed.pathname + parsed.search, + }; + let hasLength = false; + for (const pair of collectHeaders(init, null)) { + const name = String(pair[0]).toLowerCase(); + // RFC 9113 8.2.2: connection-specific fields are forbidden in H2. + if (name === "connection" || name === "proxy-connection" || + name === "keep-alive" || name === "transfer-encoding" || + name === "upgrade") continue; + if (name === "host") { requestHeaders[":authority"] = String(pair[1]); continue; } + if (name === "content-length") hasLength = true; + requestHeaders[name] = String(pair[1]); + } + if (!hasLength && body !== null) requestHeaders["content-length"] = String(body.length); + else if (!hasLength && (method === "POST" || method === "PUT" || method === "PATCH")) + requestHeaders["content-length"] = "0"; + + const tlsOpt = (init && init.tls) || {}; + const connectOptions = { + rejectUnauthorized: tlsOpt.rejectUnauthorized !== false && + !(G.process && G.process.env && G.process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0"), + }; + if (tlsOpt.ca !== undefined) connectOptions.ca = tlsOpt.ca; + if (tlsOpt.serverName !== undefined) connectOptions.servername = tlsOpt.serverName; + + return new Promise((resolve, reject) => { + let session; + let stream; + let responseHeaders = null; + let settled = false; + const responseChunks = []; + let onAbort; + const cleanup = () => { + if (signal && onAbort) { + try { signal.removeEventListener("abort", onAbort); } catch (e) {} + } + try { if (session && !session.destroyed) session.close(); } catch (e) {} + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + try { if (stream && !stream.destroyed) stream.destroy(); } catch (e) {} + reject(error); + }; + try { + session = h2.connect(parsed.origin, connectOptions); + session.once("error", fail); + stream = session.request(requestHeaders, { endStream: false }); + stream.once("error", fail); + stream.once("response", (headers) => { responseHeaders = headers; }); + stream.on("data", (chunk) => responseChunks.push(u8(chunk).slice())); + stream.once("end", () => { + if (settled) return; + settled = true; + cleanup(); + const status = Number(responseHeaders && responseHeaders[":status"]) || 200; + const headers = new G.Headers(); + if (responseHeaders) { + for (const name of Object.keys(responseHeaders)) { + if (name[0] === ":") continue; + const value = responseHeaders[name]; + if (Array.isArray(value)) for (const item of value) headers.append(name, String(item)); + else if (value !== undefined) headers.append(name, String(value)); + } + } + const bytes = concatU8(responseChunks); + const location = headers.get("location"); + if (location && (status === 301 || status === 302 || status === 303 || + status === 307 || status === 308)) { + const mode = (init && init.redirect) || "follow"; + if (mode === "error") { reject(new TypeError("fetch redirect is not allowed")); return; } + if (mode === "follow") { + if (bodyIsStream && status !== 303) { + reject(new TypeError("Cannot follow redirect with a streaming request body")); + return; + } + const next = new G.URL(location, parsed.href).href; + const nextInit = Object.assign({}, init); + if (status === 303 || ((status === 301 || status === 302) && method === "POST")) { + nextInit.method = "GET"; + nextInit.body = null; + const nextHeaders = new G.Headers(nextInit.headers || undefined); + nextHeaders.delete("content-length"); + nextHeaders.delete("content-type"); + nextInit.headers = nextHeaders; + } + doFetchH2(next, nextInit, depth + 1).then((res) => { + res.redirected = true; + resolve(res); + }, reject); + return; + } + } + const response = new G.Response(bytes, { status, statusText: "", headers }); + response.url = parsed.href; + response.redirected = depth > 0; + resolve(response); + }); + if (signal && typeof signal.addEventListener === "function") { + onAbort = () => fail(h2AbortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + } + if (body && body.length) stream.write(body); + stream.end(); + } catch (e) { fail(e); } + }); + } + // bun existing_socket HTTPContext.rs:790 -- a closed or errored slot is // dropped and the scan continues; it is never handed to a caller. const poolTake = (key) => { @@ -2388,6 +2574,8 @@ export constexpr std::string_view kNetJS_part2 = R"JS( } const m = /^(https?):\/\/([^/:?#]+)(?::(\d+))?([^#]*)/.exec(url); if (!m) return Promise.reject(new TypeError("fetch() URL is invalid: " + url)); + if (init.protocol === "http2" || init.protocol === "h2") + return doFetchH2(url, init, depth); const secure = m[1] === "https"; const host = m[2]; const port = m[3] ? +m[3] : (secure ? 443 : 80); From bff0b76e306f95e2d7f6dac668cd7a2cdedea32a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 19:49:19 +0800 Subject: [PATCH 026/150] fix(fetch): frame empty payload requests Match Bun H1 request construction by emitting Content-Length: 0 for methods that define request bodies, while preserving explicit headers and GET, HEAD, OPTIONS, and TRACE behavior. - #92 - #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/js_net_part2.cppm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index 3d369f86..2a16b8c4 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2628,6 +2628,8 @@ export constexpr std::string_view kNetJS_part2 = R"JS( if (!haveAccept) lines.push("Accept: */*"); for (const kv of hdrs) lines.push(kv[0] + ": " + kv[1]); if (bodyBytes && !haveCL) lines.push("Content-Length: " + bodyBytes.length); + else if (!haveCL && method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && method !== "TRACE") + lines.push("Content-Length: 0"); const reqBytes = bodyBytes ? concatU8([te.encode(lines.join("\r\n") + "\r\n\r\n"), bodyBytes]) : te.encode(lines.join("\r\n") + "\r\n\r\n"); From 1af50d6babc5654f7f3042d7bb61c5dc8ea54e37 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 20:14:43 +0800 Subject: [PATCH 027/150] fix(fetch): enforce HTTP2 redirect and TLS policy Strip origin-bound credentials before cross-origin redirects, forward client TLS credentials, and fail closed when this runtime cannot enforce a custom server identity callback. - #89 - #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/js_net_part2.cppm | 29 +++- .../tests/test_fetch_http2_regressions.cpp | 153 ++++++++++++++++++ 2 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 modules/jsc/tests/test_fetch_http2_regressions.cpp diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index 2a16b8c4..062769a4 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2439,11 +2439,19 @@ export constexpr std::string_view kNetJS_part2 = R"JS( requestHeaders["content-length"] = "0"; const tlsOpt = (init && init.tls) || {}; - const connectOptions = { + // mbun's node:http2 forwards its complete connect options object to + // tls.connect, so preserve every TLS credential/policy option instead of + // hand-picking CA and servername. The one exception is a custom identity + // callback: this runtime cannot enforce it when rejectUnauthorized is false + // (node:tls admits the peer before invoking the callback), so forced-H2 + // fetch must fail before sending a request rather than bypass user policy. + if (typeof tlsOpt.checkServerIdentity === "function") + throw mkErr("HTTP/2 fetch cannot enforce tls.checkServerIdentity", "HTTP2Unsupported"); + const connectOptions = Object.assign({}, tlsOpt, { rejectUnauthorized: tlsOpt.rejectUnauthorized !== false && !(G.process && G.process.env && G.process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0"), - }; - if (tlsOpt.ca !== undefined) connectOptions.ca = tlsOpt.ca; + }); + delete connectOptions.serverName; if (tlsOpt.serverName !== undefined) connectOptions.servername = tlsOpt.serverName; return new Promise((resolve, reject) => { @@ -2498,16 +2506,25 @@ export constexpr std::string_view kNetJS_part2 = R"JS( reject(new TypeError("Cannot follow redirect with a streaming request body")); return; } - const next = new G.URL(location, parsed.href).href; + const nextURL = new G.URL(location, parsed.href); + const next = nextURL.href; const nextInit = Object.assign({}, init); + const nextHeaders = new G.Headers(nextInit.headers || undefined); + // Fetch redirect step 13 / bun CROSS_ORIGIN_STRIPPED_REQUEST_HEADERS: + // credentials and an explicit Host belong only to the old origin. + if (nextURL.origin !== parsed.origin) { + nextHeaders.delete("authorization"); + nextHeaders.delete("proxy-authorization"); + nextHeaders.delete("cookie"); + nextHeaders.delete("host"); + } if (status === 303 || ((status === 301 || status === 302) && method === "POST")) { nextInit.method = "GET"; nextInit.body = null; - const nextHeaders = new G.Headers(nextInit.headers || undefined); nextHeaders.delete("content-length"); nextHeaders.delete("content-type"); - nextInit.headers = nextHeaders; } + nextInit.headers = nextHeaders; doFetchH2(next, nextInit, depth + 1).then((res) => { res.redirected = true; resolve(res); diff --git a/modules/jsc/tests/test_fetch_http2_regressions.cpp b/modules/jsc/tests/test_fetch_http2_regressions.cpp new file mode 100644 index 00000000..0dc6b4db --- /dev/null +++ b/modules/jsc/tests/test_fetch_http2_regressions.cpp @@ -0,0 +1,153 @@ +// Focused runtime regressions for Bun fetch({ protocol: "h2" }). +// Uses real TLS/HTTP2 servers and the pinned Bun harness certificate. +import std; +import mbun.jsc.runtime; + +namespace { + +int gFailed { 0 }; + +void expect(bool condition, std::string_view message) { + if (!condition) { + ++gFailed; + std::println(" FAIL: {}", message); + } +} + +} // namespace + +int main() { +#if !defined(_WIN32) + using namespace mbun::jsc::runtime; + + auto setup { eval(R"MJS( + globalThis.__h2SecurityDone = 0; + globalThis.__h2SecurityResult = ""; + globalThis.__h2SecurityError = ""; + (async () => { + try { + const http2 = require("node:http2"); + const fs = require("node:fs"); + const harnessPath = fs.realpathSync(process.cwd() + "/compat/bun/test/harness.ts"); + const harness = fs.readFileSync(harnessPath, "utf8"); + const tlsBlock = harness.slice(harness.indexOf("export const tls"), harness.indexOf("export const invalidTls")); + const tls = { + cert: JSON.parse(/cert: ("[^"]+")/.exec(tlsBlock)[1]), + key: JSON.parse(/key: ("[^"]+")/.exec(tlsBlock)[1]), + }; + const listen = (server) => new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const close = (server) => new Promise((resolve) => server.close(resolve)); + const origin = (server) => `https://localhost:${server.address().port}`; + const destination = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + let destinationHeaders; + destination.on("stream", (stream, headers) => { + destinationHeaders = headers; + stream.respond({ ":status": 200 }); + stream.end("redirect-ok"); + }); + await listen(destination); + + const source = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + source.on("stream", (stream) => { + stream.respond({ ":status": 302, location: origin(destination) + "/final" }); + stream.end("redirect-body"); + }); + await listen(source); + const redirected = await fetch(origin(source) + "/start", { + protocol: "h2", + tls: { rejectUnauthorized: false }, + headers: { + authorization: "Bearer secret", + "proxy-authorization": "Basic secret", + cookie: "session=secret", + host: "old-origin.invalid", + }, + }); + const redirectText = await redirected.text(); + const stripped = redirectText === "redirect-ok" && destinationHeaders && + destinationHeaders.authorization === undefined && + destinationHeaders["proxy-authorization"] === undefined && + destinationHeaders.cookie === undefined && + destinationHeaders[":authority"] === `localhost:${destination.address().port}`; + await close(source); + await close(destination); + + const identityServer = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + let identityRequests = 0; + identityServer.on("stream", (stream) => { + ++identityRequests; + stream.respond({ ":status": 200 }); + stream.end("must-not-resolve"); + }); + await listen(identityServer); + let identityRejected = false; + try { + await fetch(origin(identityServer), { + protocol: "h2", + tls: { + rejectUnauthorized: false, + checkServerIdentity() { return new Error("blocked-by-callback"); }, + }, + }); + } catch (error) { + identityRejected = error && /checkServerIdentity|HTTP\/2/i.test(String(error.message || error)); + } + await close(identityServer); + + const mtlsServer = http2.createSecureServer({ + key: tls.key, + cert: tls.cert, + ca: tls.cert, + requestCert: true, + rejectUnauthorized: true, + }); + mtlsServer.on("stream", (stream) => { + stream.respond({ ":status": 200 }); + stream.end("mtls-ok"); + }); + await listen(mtlsServer); + let mtlsText = ""; + try { + const response = await fetch(origin(mtlsServer), { + protocol: "h2", + tls: { ca: tls.cert, cert: tls.cert, key: tls.key }, + }); + mtlsText = await response.text(); + } catch (error) { + mtlsText = "rejected:" + String(error && (error.code || error.message || error)); + } + await close(mtlsServer); + + globalThis.__h2SecurityResult = [ + stripped ? "strip" : "leak", + identityRejected && identityRequests === 0 ? "identity" : "identity-bypass", + mtlsText, + ].join("|"); + } catch (error) { + globalThis.__h2SecurityError = String((error && error.stack) || error); + } + globalThis.__h2SecurityDone = 1; + })(); + )MJS") }; + expect(setup.has_value(), "HTTP2 security regression script evaluates"); + if (setup.has_value()) { + pump_event_loop("globalThis.__h2SecurityDone"); + const auto error { eval_to_string("globalThis.__h2SecurityError") }; + expect(error.has_value() && error->empty(), + "HTTP2 security regression ran without harness errors: " + error.value_or("")); + const auto result { eval_to_string("globalThis.__h2SecurityResult") }; + expect(result.has_value() && *result == "strip|identity|mtls-ok", + "HTTP2 redirect and TLS policies are enforced (got '" + result.value_or("") + "')"); + } +#endif + + if (gFailed != 0) { + std::println("test_fetch_http2_regressions: {} failed", gFailed); + return 1; + } + std::println("test_fetch_http2_regressions: ok"); + return 0; +} From ff08854723ff5f96ebb1cd45444d716a29451bca Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 20:24:27 +0800 Subject: [PATCH 028/150] fix(fetch): stream HTTP2 responses from headers Resolve forced-HTTP2 fetches at response metadata, follow redirects without draining their bodies, adapt stream backpressure/cancel/errors to a real ReadableStream, and decode supported content encodings incrementally. - #89 - #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/js_http2_part2.cppm | 1 + modules/jsc/src/js_net_part2.cppm | 107 ++++++++++--- .../tests/test_fetch_http2_regressions.cpp | 143 ++++++++++++++++++ 3 files changed, 233 insertions(+), 18 deletions(-) diff --git a/modules/jsc/src/js_http2_part2.cppm b/modules/jsc/src/js_http2_part2.cppm index 7e7fa69b..0088a329 100644 --- a/modules/jsc/src/js_http2_part2.cppm +++ b/modules/jsc/src/js_http2_part2.cppm @@ -1357,6 +1357,7 @@ export constexpr std::string_view kHttp2JS_part2 = R"JS( if (!self._state.didRead && !(self._readableState && self._readableState.resumeScheduled)) self.resume(); } stream._proxySocket = null; + stream._compatRequest = undefined; self.emit("close"); }); stream.on("timeout", () => self.emit("timeout")); diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index 062769a4..e698d0e8 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2457,34 +2457,51 @@ export constexpr std::string_view kNetJS_part2 = R"JS( return new Promise((resolve, reject) => { let session; let stream; - let responseHeaders = null; - let settled = false; - const responseChunks = []; + let headResolved = false; + let redirecting = false; + let bodyController = null; + let bodyDone = false; let onAbort; - const cleanup = () => { + const release = () => { if (signal && onAbort) { try { signal.removeEventListener("abort", onAbort); } catch (e) {} + onAbort = null; } try { if (session && !session.destroyed) session.close(); } catch (e) {} }; + const cancelTransport = () => { + try { + if (stream && !stream.destroyed) { + if (typeof stream.close === "function") stream.close(h2.constants.NGHTTP2_CANCEL); + else stream.destroy(); + } + } catch (e) { try { if (stream && !stream.destroyed) stream.destroy(); } catch (e2) {} } + release(); + }; + const failBody = (error) => { + if (bodyDone) return; + bodyDone = true; + try { if (bodyController) bodyController.error(error); } catch (e) {} + cancelTransport(); + }; const fail = (error) => { - if (settled) return; - settled = true; - cleanup(); - try { if (stream && !stream.destroyed) stream.destroy(); } catch (e) {} - reject(error); + if (redirecting) return; + if (!headResolved) { + headResolved = true; + cancelTransport(); + reject(error); + } else { + failBody(error); + } }; try { session = h2.connect(parsed.origin, connectOptions); session.once("error", fail); stream = session.request(requestHeaders, { endStream: false }); stream.once("error", fail); - stream.once("response", (headers) => { responseHeaders = headers; }); - stream.on("data", (chunk) => responseChunks.push(u8(chunk).slice())); - stream.once("end", () => { - if (settled) return; - settled = true; - cleanup(); + stream.once("aborted", () => fail(mkErr("HTTP/2 response stream was aborted", "ECONNRESET"))); + stream.once("response", (responseHeaders) => { + if (headResolved || redirecting) return; const status = Number(responseHeaders && responseHeaders[":status"]) || 200; const headers = new G.Headers(); if (responseHeaders) { @@ -2495,14 +2512,20 @@ export constexpr std::string_view kNetJS_part2 = R"JS( else if (value !== undefined) headers.append(name, String(value)); } } - const bytes = concatU8(responseChunks); const location = headers.get("location"); if (location && (status === 301 || status === 302 || status === 303 || status === 307 || status === 308)) { const mode = (init && init.redirect) || "follow"; - if (mode === "error") { reject(new TypeError("fetch redirect is not allowed")); return; } + if (mode === "error") { + headResolved = true; + cancelTransport(); + reject(new TypeError("fetch redirect is not allowed")); + return; + } if (mode === "follow") { if (bodyIsStream && status !== 303) { + headResolved = true; + cancelTransport(); reject(new TypeError("Cannot follow redirect with a streaming request body")); return; } @@ -2525,6 +2548,10 @@ export constexpr std::string_view kNetJS_part2 = R"JS( nextHeaders.delete("content-type"); } nextInit.headers = nextHeaders; + // WHATWG redirect fetch acts on response metadata. Do not drain an + // attacker-controlled or long-lived redirect body before following. + redirecting = true; + cancelTransport(); doFetchH2(next, nextInit, depth + 1).then((res) => { res.redirected = true; resolve(res); @@ -2532,9 +2559,53 @@ export constexpr std::string_view kNetJS_part2 = R"JS( return; } } - const response = new G.Response(bytes, { status, statusText: "", headers }); + + const rawBody = new G.ReadableStream({ + start(controller) { + bodyController = controller; + stream.on("data", (chunk) => { + if (bodyDone) return; + try { + controller.enqueue(u8(chunk).slice()); + if (controller.desiredSize !== null && controller.desiredSize <= 0 && + typeof stream.pause === "function") stream.pause(); + } catch (error) { failBody(error); } + }); + stream.once("end", () => { + if (bodyDone) return; + bodyDone = true; + try { controller.close(); } catch (e) {} + release(); + }); + stream.once("close", () => { + if (!bodyDone) failBody(mkErr("HTTP/2 response stream closed before END_STREAM", "ECONNRESET")); + }); + }, + pull() { + if (!bodyDone && stream && typeof stream.resume === "function") stream.resume(); + }, + cancel() { + if (!bodyDone) bodyDone = true; + cancelTransport(); + }, + }); + let responseBody = rawBody; + const encoding = String(headers.get("content-encoding") || "").trim().toLowerCase(); + if (encoding && encoding !== "identity" && (!init || init.decompress !== false)) { + const format = encoding === "x-gzip" ? "gzip" : encoding; + if (format === "gzip" || format === "deflate" || format === "br" || format === "zstd") { + try { responseBody = rawBody.pipeThrough(new G.DecompressionStream(format)); } + catch (error) { fail(error); return; } + headers.delete("content-encoding"); + headers.delete("content-length"); + } + } + let response; + try { response = new G.Response(responseBody, { status, statusText: "", headers }); } + catch (error) { fail(error); return; } response.url = parsed.href; response.redirected = depth > 0; + headResolved = true; resolve(response); }); if (signal && typeof signal.addEventListener === "function") { diff --git a/modules/jsc/tests/test_fetch_http2_regressions.cpp b/modules/jsc/tests/test_fetch_http2_regressions.cpp index 0dc6b4db..8bd1b25a 100644 --- a/modules/jsc/tests/test_fetch_http2_regressions.cpp +++ b/modules/jsc/tests/test_fetch_http2_regressions.cpp @@ -142,6 +142,149 @@ int main() { expect(result.has_value() && *result == "strip|identity|mtls-ok", "HTTP2 redirect and TLS policies are enforced (got '" + result.value_or("") + "')"); } + + auto streamingSetup { eval(R"MJS( + globalThis.__h2StreamingDone = 0; + globalThis.__h2StreamingResult = ""; + globalThis.__h2StreamingError = ""; + (async () => { + try { + const http2 = require("node:http2"); + const fs = require("node:fs"); + const zlib = require("node:zlib"); + const harnessPath = fs.realpathSync(process.cwd() + "/compat/bun/test/harness.ts"); + const harness = fs.readFileSync(harnessPath, "utf8"); + const tlsBlock = harness.slice(harness.indexOf("export const tls"), harness.indexOf("export const invalidTls")); + const tls = { + cert: JSON.parse(/cert: ("[^"]+")/.exec(tlsBlock)[1]), + key: JSON.parse(/key: ("[^"]+")/.exec(tlsBlock)[1]), + }; + const listen = (server) => new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const close = (server) => new Promise((resolve) => server.close(resolve)); + const origin = (server) => `https://localhost:${server.address().port}`; + const delay = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value)); + const fetchH2 = (url, init) => fetch(url, { + ...(init || {}), protocol: "h2", tls: { rejectUnauthorized: false }, + }); + const out = []; + + const delayed = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + delayed.on("stream", (stream) => { + stream.respond({ ":status": 200, "content-type": "text/event-stream" }); + stream.write("a"); + setTimeout(() => stream.end("b"), 350); + }); + await listen(delayed); + const delayedFetch = fetchH2(origin(delayed)); + const headWinner = await Promise.race([ + delayedFetch.then(() => "head"), + delay(150, "late"), + ]); + const delayedResponse = await delayedFetch; + const delayedText = await delayedResponse.text(); + out.push(headWinner, delayedText); + await close(delayed); + + const redirectDestination = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + redirectDestination.on("stream", (stream) => { + stream.respond({ ":status": 200 }); + stream.end("redirect-ok"); + }); + await listen(redirectDestination); + const redirectSource = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + let hangingRedirectStream; + redirectSource.on("stream", (stream) => { + hangingRedirectStream = stream; + stream.on("error", () => {}); + stream.respond({ ":status": 302, location: origin(redirectDestination) }); + stream.write("body-that-never-ends"); + }); + await listen(redirectSource); + const followed = fetchH2(origin(redirectSource)).then((response) => response.text(), () => "redirect-error"); + const redirectWinner = await Promise.race([followed, delay(250, "redirect-timeout")]); + out.push(redirectWinner); + if (hangingRedirectStream && !hangingRedirectStream.destroyed) hangingRedirectStream.close(http2.constants.NGHTTP2_CANCEL); + await close(redirectSource); + await close(redirectDestination); + + const gzip = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + gzip.on("stream", (stream) => { + const body = zlib.gzipSync("hello-h2"); + stream.respond({ + ":status": 200, + "content-encoding": "gzip", + "content-length": String(body.length), + }); + stream.end(body); + }); + await listen(gzip); + const gzipResponse = await fetchH2(origin(gzip)); + const gzipText = await gzipResponse.text(); + out.push(gzipText === "hello-h2" && + gzipResponse.headers.get("content-encoding") === null && + gzipResponse.headers.get("content-length") === null ? "gzip" : "raw-gzip"); + await close(gzip); + + const cancelServer = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + let cancelObserved = Promise.withResolvers(); + let cancelStream; + cancelServer.on("stream", (stream) => { + cancelStream = stream; + stream.on("error", () => {}); + stream.on("close", () => cancelObserved.resolve("cancel")); + stream.respond({ ":status": 200 }); + stream.write("event"); + }); + await listen(cancelServer); + const cancelResponse = await Promise.race([fetchH2(origin(cancelServer)), delay(250, null)]); + if (cancelResponse) { + await cancelResponse.body.cancel("stop"); + out.push(await Promise.race([cancelObserved.promise, delay(250, "cancel-timeout")])); + } else { + out.push("cancel-head-timeout"); + if (cancelStream && !cancelStream.destroyed) cancelStream.close(http2.constants.NGHTTP2_CANCEL); + } + await close(cancelServer); + + const errorServer = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + errorServer.on("stream", (stream) => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + stream.write("partial"); + setTimeout(() => stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR), 20); + }); + await listen(errorServer); + let bodyError = "body-resolved"; + try { + const errorResponse = await fetchH2(origin(errorServer)); + try { await errorResponse.text(); } + catch (error) { bodyError = "body-error"; } + } catch (error) { + bodyError = "fetch-error"; + } + out.push(bodyError); + await close(errorServer); + + globalThis.__h2StreamingResult = out.join("|"); + } catch (error) { + globalThis.__h2StreamingError = String((error && error.stack) || error); + } + globalThis.__h2StreamingDone = 1; + })(); + )MJS") }; + expect(streamingSetup.has_value(), "HTTP2 streaming regression script evaluates"); + if (streamingSetup.has_value()) { + pump_event_loop("globalThis.__h2StreamingDone"); + const auto error { eval_to_string("globalThis.__h2StreamingError") }; + expect(error.has_value() && error->empty(), + "HTTP2 streaming regression ran without harness errors: " + error.value_or("")); + const auto result { eval_to_string("globalThis.__h2StreamingResult") }; + expect(result.has_value() && *result == "head|ab|redirect-ok|gzip|cancel|body-error", + "HTTP2 fetch resolves on headers and streams decoded bodies (got '" + result.value_or("") + "')"); + } #endif if (gFailed != 0) { From 1bbdf40a41dc11cccf9ac31d5a9d28fa5cf2c79c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 20:29:56 +0800 Subject: [PATCH 029/150] fix(fetch): use one H1 request framing mode Drop caller Transfer-Encoding from the buffered HTTP/1 request path so the generated Content-Length framing cannot coexist with an incomplete chunked message. - #92 - #80 Co-authored-by: Codex (GPT-5) <> Signed-off-by: sunrisepeak --- modules/jsc/src/js_net_part2.cppm | 8 ++- .../tests/test_fetch_http2_regressions.cpp | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index e698d0e8..a0405d01 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2714,7 +2714,13 @@ export constexpr std::string_view kNetJS_part2 = R"JS( lines.push("User-Agent: " + (ovUA || ("Bun/" + ((G.Bun && G.Bun.version) || "1.0")))); } if (!haveAccept) lines.push("Accept: */*"); - for (const kv of hdrs) lines.push(kv[0] + ": " + kv[1]); + // This H1 client materializes every request body before serialization, so + // it is always bun's non-streaming build_request branch. That branch drops + // a caller Transfer-Encoding and emits one Content-Length framing mode; + // forwarding TE here produced TE: chunked + CL: 0 with no terminal chunk. + for (const kv of hdrs) { + if (kv[0].toLowerCase() !== "transfer-encoding") lines.push(kv[0] + ": " + kv[1]); + } if (bodyBytes && !haveCL) lines.push("Content-Length: " + bodyBytes.length); else if (!haveCL && method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && method !== "TRACE") lines.push("Content-Length: 0"); diff --git a/modules/jsc/tests/test_fetch_http2_regressions.cpp b/modules/jsc/tests/test_fetch_http2_regressions.cpp index 8bd1b25a..333df24c 100644 --- a/modules/jsc/tests/test_fetch_http2_regressions.cpp +++ b/modules/jsc/tests/test_fetch_http2_regressions.cpp @@ -285,6 +285,56 @@ int main() { expect(result.has_value() && *result == "head|ab|redirect-ok|gzip|cancel|body-error", "HTTP2 fetch resolves on headers and streams decoded bodies (got '" + result.value_or("") + "')"); } + + auto h1FramingSetup { eval(R"MJS( + globalThis.__h1FramingDone = 0; + globalThis.__h1FramingResult = ""; + globalThis.__h1FramingError = ""; + (async () => { + try { + const net = require("node:net"); + let wire = Buffer.alloc(0); + const server = net.createServer((socket) => { + socket.on("data", (chunk) => { + wire = Buffer.concat([wire, chunk]); + if (wire.indexOf("\r\n\r\n") !== -1) { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const response = await fetch(`http://127.0.0.1:${server.address().port}/`, { + method: "POST", + headers: { "transfer-encoding": "chunked" }, + }); + const text = await response.text(); + await new Promise((resolve) => server.close(resolve)); + const split = wire.indexOf("\r\n\r\n"); + const head = wire.subarray(0, split).toString("latin1").toLowerCase(); + const body = wire.subarray(split + 4); + globalThis.__h1FramingResult = text === "ok" && + !head.includes("transfer-encoding:") && + head.includes("content-length: 0") && body.length === 0 + ? "framed" : `bad:${head.replaceAll("\r\n", "|")}:body=${body.length}`; + } catch (error) { + globalThis.__h1FramingError = String((error && error.stack) || error); + } + globalThis.__h1FramingDone = 1; + })(); + )MJS") }; + expect(h1FramingSetup.has_value(), "H1 request framing regression script evaluates"); + if (h1FramingSetup.has_value()) { + pump_event_loop("globalThis.__h1FramingDone"); + const auto error { eval_to_string("globalThis.__h1FramingError") }; + expect(error.has_value() && error->empty(), + "H1 request framing regression ran without harness errors: " + error.value_or("")); + const auto result { eval_to_string("globalThis.__h1FramingResult") }; + expect(result.has_value() && *result == "framed", + "H1 buffered request emits one coherent framing mode (got '" + result.value_or("") + "')"); + } #endif if (gFailed != 0) { From 3fd27664f066c4a1a998d6594bb60c78627874a6 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 20:34:10 +0800 Subject: [PATCH 030/150] fix(repl): harden legacy capture bookkeeping - #65 - #80 Capture RegExp exec before user mutation and classify the intrinsic legacy-capture capability once, so exact-message user getters and unrelated failures remain observable. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/node_repl.cppm | 55 +++++++++++++------ modules/jsc/src/builtins/node_vm.cppm | 5 +- .../jsc/tests/test_node_compat_bridges.cpp | 33 ++++++++++- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/modules/jsc/src/builtins/node_repl.cppm b/modules/jsc/src/builtins/node_repl.cppm index 58467a6a..cc31acfc 100644 --- a/modules/jsc/src/builtins/node_repl.cppm +++ b/modules/jsc/src/builtins/node_repl.cppm @@ -56,6 +56,31 @@ inline constexpr std::string_view kNodeReplJS = R"JS( const { Console } = req("console"); const moduleMod = req("module"); const CJSModule = moduleMod.Module || moduleMod; + // Primordials used by node's RegExp.$1..$9 save/restore protocol. Capture + // and uncurry exec before user code can replace RegExp.prototype.exec. + const RegExpPrototypeExec = Function.prototype.call.bind(RegExp.prototype.exec); + const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const IntrinsicTypeError = TypeError; + const legacyCaptureGetters = new Array(10); + const legacyCaptureReadable = new Array(10).fill(true); + for (let idx = 1; idx < legacyCaptureGetters.length; idx += 1) { + const key = `$${idx}`; + const descriptor = ObjectGetOwnPropertyDescriptor(RegExp, key); + legacyCaptureGetters[idx] = descriptor && descriptor.get; + try { + void RegExp[key]; + } catch (captureError) { + // JSC issue #65 is an intrinsic capability failure. Classify it while + // the original accessor identity is known, before a user can install a + // getter with a spoofed copy of the same error text. + if (!(captureError instanceof IntrinsicTypeError) || + captureError.message !== + "RegExp.$N getters require RegExp constructor as |this|") { + throw captureError; + } + legacyCaptureReadable[idx] = false; + } + } // node internal/errors.js: an E() error carries kIsNodeError, so both // defaultPrepareStackTrace and NodeError#toString render the code into the @@ -475,7 +500,8 @@ inline constexpr std::string_view kNodeReplJS = R"JS( const startsWithBraceRegExp = /^\s*{/; const endsWithSemicolonRegExp = /;\s*$/; function isObjectLiteral(code) { - return startsWithBraceRegExp.test(code) && !endsWithSemicolonRegExp.test(code); + return RegExpPrototypeExec(startsWithBraceRegExp, code) !== null && + RegExpPrototypeExec(endsWithSemicolonRegExp, code) === null; } let nextREPLResourceNumber = 1; @@ -1225,21 +1251,15 @@ inline constexpr std::string_view kNodeReplJS = R"JS( `${regExMatchSeparator}(.*)$`); function saveRegExpMatches() { - try { - for (let idx = 1; idx < savedRegExMatches.length; idx += 1) { - savedRegExMatches[idx] = RegExp[`$${idx}`]; - } - } catch (captureError) { - // JSC currently exposes the legacy static captures through accessors - // whose receiver check rejects its own RegExp constructor (issue #65). - // That runtime defect must not replace an otherwise successful REPL - // evaluation with an unrelated TypeError. Keep the normal node path - // active for runtimes where the accessors are readable. - if (!(captureError instanceof TypeError) || - captureError.message !== - "RegExp.$N getters require RegExp constructor as |this|") { - throw captureError; - } + for (let idx = 1; idx < savedRegExMatches.length; idx += 1) { + const key = `$${idx}`; + const descriptor = ObjectGetOwnPropertyDescriptor(RegExp, key); + if (!legacyCaptureReadable[idx] && descriptor && + descriptor.get === legacyCaptureGetters[idx]) continue; + // A replaced getter or data property is user-observable state. Read + // it normally and propagate every error it raises, including one + // whose message happens to match issue #65. + savedRegExMatches[idx] = RegExp[key]; } } @@ -1338,7 +1358,8 @@ inline constexpr std::string_view kNodeReplJS = R"JS( // Restore the captures hidden by REPL bookkeeping before user code runs, // matching node's default evaluator protocol. - regExMatcher.exec(savedRegExMatches.join(regExMatchSeparator)); + RegExpPrototypeExec(regExMatcher, + savedRegExMatches.join(regExMatchSeparator)); let finished = false; function finishExecution(e, r) { diff --git a/modules/jsc/src/builtins/node_vm.cppm b/modules/jsc/src/builtins/node_vm.cppm index e7f7a07f..36c05b75 100644 --- a/modules/jsc/src/builtins/node_vm.cppm +++ b/modules/jsc/src/builtins/node_vm.cppm @@ -48,6 +48,7 @@ inline constexpr std::string_view kNodeVmJS = R"JS( const ObjectDefineProperty = Object.defineProperty; const gOPD = Object.getOwnPropertyDescriptor; const ownKeys = Reflect.ownKeys; + const RegExpPrototypeExec = Function.prototype.call.bind(RegExp.prototype.exec); const contexts = new WeakSet(); const records = new WeakMap(); @@ -549,7 +550,7 @@ inline constexpr std::string_view kNodeVmJS = R"JS( function parseSourceMapURL(code) { const re = /(?:^|\n)[ \t]*\/\/[#@][ \t]+sourceMappingURL=([^\s'"]+)[ \t]*(?=\n|$)/g; let m, last; - while ((m = re.exec(code)) !== null) last = m[1]; + while ((m = RegExpPrototypeExec(re, code)) !== null) last = m[1]; return last; } @@ -706,7 +707,7 @@ inline constexpr std::string_view kNodeVmJS = R"JS( function prepareDynImport(code, callback, getWrap) { const src = `${code}`; DYNIMPORT_RE.lastIndex = 0; - if (!DYNIMPORT_RE.test(src)) return null; + if (RegExpPrototypeExec(DYNIMPORT_RE, src) === null) return null; const id = dynNextId++; DYNIMPORT_RE.lastIndex = 0; dynRegistry[id] = makeDynImportHandler(callback, getWrap); diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 6fa4ee5f..cc2cfdb9 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -29,9 +29,36 @@ int main() { expect_number( "(()=>{try{const repl=require('node:repl');const stream=require('node:stream');" "const io=new stream.PassThrough();const server=repl.start({input:io,output:io,terminal:false,prompt:''});" - "let result=-1;server.eval('1+1\\n',server.context,'REPL1',(err,value)=>{result=err?0:value===2?1:0});" - "server.close();return result}catch{return -1}})()", - 1, "node REPL default evaluation completes despite unavailable legacy RegExp captures"); + "let calls=0,result=-1;server.eval('1+1\\n',server.context,'REPL1',(err,value)=>{calls++;result=err?0:value===2?1:0});" + "server.close();return calls===1&&result===1?1:0}catch{return -1}})()", + 1, "node REPL default evaluation calls back exactly once despite unavailable legacy RegExp captures"); + expect_number( + "(()=>{const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" + "const server=repl.start({input:io,output:io,terminal:false,prompt:'',useGlobal:true});" + "const original=RegExp.prototype.exec;let calls=0,value=-1,thrown='';" + "RegExp.prototype.exec=()=>{throw new Error('poison-exec')};" + "try{server.eval('1+1\\n',server.context,'REPL1',(err,result)=>{calls++;value=err?0:result})}" + "catch(err){thrown=err&&err.message}finally{RegExp.prototype.exec=original;server.close()}" + "return calls===1&&value===2&&thrown===''?1:0})()", + 1, "node REPL capture restoration uses the primordial RegExp exec"); + expect_number( + "(()=>{const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" + "const server=repl.start({input:io,output:io,terminal:false,prompt:''});" + "const original=Object.getOwnPropertyDescriptor(RegExp,'$1');" + "const marker=new TypeError('RegExp.$N getters require RegExp constructor as |this|');" + "let calls=0,thrown=null;Object.defineProperty(RegExp,'$1',{configurable:true,get(){throw marker}});" + "try{server.eval('1+1\\n',server.context,'REPL1',()=>{calls++})}catch(err){thrown=err}" + "finally{Object.defineProperty(RegExp,'$1',original);server.close()}" + "return calls===0&&thrown===marker?1:0})()", + 1, "node REPL propagates a user capture getter even when its message matches issue 65"); + expect_number( + "(()=>{try{void RegExp.$1}catch{return 1}" + "const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" + "const server=repl.start({input:io,output:io,terminal:false,prompt:'',useGlobal:true});" + "let first=0,second=0,value;server.eval('/(alpha)/.exec(\\\"alpha\\\");0\\n',server.context,'REPL1',(err)=>{first++;if(err)value=err});" + "/(outside)/.exec('outside');server.eval('RegExp.$1\\n',server.context,'REPL2',(err,result)=>{second++;value=err||result});" + "server.close();return first===1&&second===1&&value==='alpha'?1:0})()", + 1, "node REPL restores the prior capture before the next evaluation when legacy captures are supported"); expect_number( "(()=>{try{const fs=require('node:fs');" "const p=require('node:util').promisify(fs.exists);" From 6810721067d50ffef956e0d38dcfad87aa684d97 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 21:06:32 +0800 Subject: [PATCH 031/150] fix(fs): upgrade FILE stdio to writable streams - #80 - Fixes #93 Replace only FILE-backed stdout and stderr after node:stream is ready, preserving synchronous fd writes and stdio close ownership. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- .../src/builtins/node_stream_pipeline.cppm | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/modules/jsc/src/builtins/node_stream_pipeline.cppm b/modules/jsc/src/builtins/node_stream_pipeline.cppm index 51bb30ad..ed978aa3 100644 --- a/modules/jsc/src/builtins/node_stream_pipeline.cppm +++ b/modules/jsc/src/builtins/node_stream_pipeline.cppm @@ -1189,6 +1189,47 @@ inline constexpr std::string_view kNodeStreamPipelineJS = R"JS( def(["stream/promises"], Stream.promises); def(["stream/consumers"], R.require("internal/stream.consumers")); + // Node bootstrap uses internal/fs/sync_write_stream when fd 1/2 names a + // regular file. The native bootstrap has to publish process.stdout/stderr + // before node:stream exists, so upgrade only that FILE case now that the + // real Writable state machine is available. Keep the bootstrap writer as + // the byte-exact fd seam and leave TTY/PIPE/TCP objects untouched. + const installFileStdio = (name, fd) => { + const current = process && process[name]; + if (!current || current instanceof Stream.Writable) return; + let isFile = false; + try { + const fs = M["fs"] || M["node:fs"]; + isFile = !!(fs && fs.fstatSync(fd).isFile()); + } catch (e) {} + if (!isFile) return; + + let stdio; + stdio = new Stream.Writable({ + autoDestroy: true, + write(chunk, encoding, callback) { + try { current.write(chunk); callback(); } + catch (error) { callback(error); } + }, + destroy(error, callback) { + stdio.fd = null; + callback(error); + }, + }); + stdio.fd = fd; + stdio.readable = false; + stdio.autoClose = false; + stdio.isTTY = current.isTTY; + stdio._type = "fs"; + stdio.flush = typeof current.flush === "function" ? current.flush.bind(current) : (() => {}); + stdio.destroySoon = stdio.destroy; + process[name] = stdio; + const consoleSlot = name === "stdout" ? "_stdout" : "_stderr"; + if (G.console && G.console[consoleSlot] === current) G.console[consoleSlot] = stdio; + }; + installFileStdio("stdout", 1); + installFileStdio("stderr", 2); + // crypto's Hash/Hmac (builtins/markdown_web.cppm) are defined inside the // master builtins IIFE, where `Transform` lexically resolves to the bootstrap // load-order stub, so their prototype chain was linked before node:stream From e47c17d4dc8623025012e28d4125f73ff898bc87 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 21:08:00 +0800 Subject: [PATCH 032/150] fix(fetch): omit HTTP2 null response bodies Return bodyless responses for HEAD and Fetch null-body statuses, then cancel the private H2 stream so peer payload bytes cannot surface or remain buffered. - #89 - #80 Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/js_net_part2.cppm | 17 +++++++++++ .../tests/test_fetch_http2_regressions.cpp | 30 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/modules/jsc/src/js_net_part2.cppm b/modules/jsc/src/js_net_part2.cppm index a0405d01..db72ba04 100644 --- a/modules/jsc/src/js_net_part2.cppm +++ b/modules/jsc/src/js_net_part2.cppm @@ -2560,6 +2560,23 @@ export constexpr std::string_view kNetJS_part2 = R"JS( } } + // Fetch responses to HEAD and null-body statuses never expose a body, + // even if a peer sends payload bytes. This H2 fetch owns its session, + // so cancel the stream after resolving the metadata instead of leaving + // an unobservable payload buffered behind a synthetic ReadableStream. + if (method === "HEAD" || status === 204 || status === 205 || status === 304) { + let response; + try { response = new G.Response(null, { status, statusText: "", headers }); } + catch (error) { fail(error); return; } + response.url = parsed.href; + response.redirected = depth > 0; + headResolved = true; + bodyDone = true; + cancelTransport(); + resolve(response); + return; + } + const rawBody = new G.ReadableStream({ start(controller) { bodyController = controller; diff --git a/modules/jsc/tests/test_fetch_http2_regressions.cpp b/modules/jsc/tests/test_fetch_http2_regressions.cpp index 333df24c..b508cab7 100644 --- a/modules/jsc/tests/test_fetch_http2_regressions.cpp +++ b/modules/jsc/tests/test_fetch_http2_regressions.cpp @@ -188,6 +188,34 @@ int main() { out.push(headWinner, delayedText); await close(delayed); + const nullBody = http2.createSecureServer({ key: tls.key, cert: tls.cert }); + const nullBodyClosed = Promise.withResolvers(); + let nullBodyCloseCount = 0; + nullBody.on("stream", (stream, headers) => { + stream.on("error", () => {}); + stream.on("close", () => { + if (++nullBodyCloseCount === 2) nullBodyClosed.resolve("closed"); + }); + stream.respond({ ":status": headers[":path"] === "/no-content" ? 204 : 200 }); + }); + await listen(nullBody); + const noContentResponse = await fetchH2(origin(nullBody) + "/no-content"); + const noContentBodyIsNull = noContentResponse.body === null; + const noContentText = noContentBodyIsNull ? await noContentResponse.text() : "non-null"; + if (!noContentBodyIsNull) await noContentResponse.body.cancel(); + const headResponse = await fetchH2(origin(nullBody) + "/head", { method: "HEAD" }); + const headBodyIsNull = headResponse.body === null; + const headText = headBodyIsNull ? await headResponse.text() : "non-null"; + if (!headBodyIsNull) await headResponse.body.cancel(); + const nullBodyTransport = await Promise.race([ + nullBodyClosed.promise, + delay(250, "close-timeout"), + ]); + out.push(noContentBodyIsNull && noContentText === "" && + headBodyIsNull && headText === "" && nullBodyTransport === "closed" + ? "null-bodies" : "non-null-bodies"); + await close(nullBody); + const redirectDestination = http2.createSecureServer({ key: tls.key, cert: tls.cert }); redirectDestination.on("stream", (stream) => { stream.respond({ ":status": 200 }); @@ -282,7 +310,7 @@ int main() { expect(error.has_value() && error->empty(), "HTTP2 streaming regression ran without harness errors: " + error.value_or("")); const auto result { eval_to_string("globalThis.__h2StreamingResult") }; - expect(result.has_value() && *result == "head|ab|redirect-ok|gzip|cancel|body-error", + expect(result.has_value() && *result == "head|ab|null-bodies|redirect-ok|gzip|cancel|body-error", "HTTP2 fetch resolves on headers and streams decoded bodies (got '" + result.value_or("") + "')"); } From b119613552cfa6784338b1db0ddd45dba84c63d3 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 21:09:07 +0800 Subject: [PATCH 033/150] fix(fs): observe live fstat in FileHandle readFile - #80 - Fixes #94 Route FileHandle readFile sizing through the replaceable promise binding while retaining the existing read-to-EOF and abort behavior. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/bootstrap.cppm | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/jsc/src/builtins/bootstrap.cppm b/modules/jsc/src/builtins/bootstrap.cppm index 40096152..eac7ea60 100644 --- a/modules/jsc/src/builtins/bootstrap.cppm +++ b/modules/jsc/src/builtins/bootstrap.cppm @@ -8584,7 +8584,16 @@ inline constexpr char kBootstrapJS_[] = R"JS( // node kIoMaxLength: a file larger than 2**31-1 cannot be read into one // buffer (ERR_FS_FILE_TOO_LARGE, a RangeError). try { - const size = fsMod.fstatSync(fd).size; + // node's readFileHandle resolves the live binding row on every + // operation. Internal tests replace fstat to model files whose stat + // size is zero while reads still produce bytes, so bypassing this + // seam through fsMod.fstatSync loses both the hook and that contract. + const binding = typeof G.__mbunInternalBinding === "function" + ? G.__mbunInternalBinding("fs") : null; + const statFields = binding && typeof binding.fstat === "function" + ? await binding.fstat(fd, false, binding.kUsePromises) : null; + const size = statFields === null + ? fsMod.fstatSync(fd).size : Number(statFields[8]); if (size > 2147483647) { const e = new RangeError("File size (" + size + ") is greater than 2 GiB"); e.code = "ERR_FS_FILE_TOO_LARGE"; From d056b057f62f3ebdf793bf693898057bf7c3c578 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 21:14:54 +0800 Subject: [PATCH 034/150] fix(repl): close primordial regex paths - #65 - #80 Use captured RegExp execution for strict recovery and VM dynamic import rewriting, preserving callback and replacement behavior under user mutation. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/node_repl.cppm | 14 +++++---- modules/jsc/src/builtins/node_vm.cppm | 15 ++++++++-- .../jsc/tests/test_node_compat_bridges.cpp | 29 +++++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/modules/jsc/src/builtins/node_repl.cppm b/modules/jsc/src/builtins/node_repl.cppm index cc31acfc..2aca0167 100644 --- a/modules/jsc/src/builtins/node_repl.cppm +++ b/modules/jsc/src/builtins/node_repl.cppm @@ -56,8 +56,8 @@ inline constexpr std::string_view kNodeReplJS = R"JS( const { Console } = req("console"); const moduleMod = req("module"); const CJSModule = moduleMod.Module || moduleMod; - // Primordials used by node's RegExp.$1..$9 save/restore protocol. Capture - // and uncurry exec before user code can replace RegExp.prototype.exec. + // Primordial used by node's evaluator scans and RegExp.$1..$9 save/restore + // protocol. Capture and uncurry exec before user code can replace it. const RegExpPrototypeExec = Function.prototype.call.bind(RegExp.prototype.exec); const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const IntrinsicTypeError = TypeError; @@ -369,7 +369,9 @@ inline constexpr std::string_view kNodeReplJS = R"JS( lastQuoteContinued = false; while (i < n) { if (code[i] === "\\") { - if (/[\r\n\u2028\u2029]/.test(code[i + 1] || "")) lastQuoteContinued = true; + if (RegExpPrototypeExec(/[\r\n\u2028\u2029]/, code[i + 1] || "") !== null) { + lastQuoteContinued = true; + } i += 2; continue; } @@ -473,7 +475,8 @@ inline constexpr std::string_view kNodeReplJS = R"JS( function isRecoverableError(e, code) { // Wrap a leading `{` in parentheses first, exactly as node does, so an // incomplete object literal counts as recoverable. - if (/^\s*\{/.test(code) && isRecoverableError(e, `(${code}`)) return true; + if (RegExpPrototypeExec(/^\s*\{/, code) !== null && + isRecoverableError(e, `(${code}`)) return true; const err = parseThrows(code); if (err === null) return false; @@ -1336,7 +1339,8 @@ inline constexpr std::string_view kNodeReplJS = R"JS( if (err === null) { for (;;) { try { - if (self.replMode === REPL_MODE_STRICT && !/^\s*$/.test(code)) { + if (self.replMode === REPL_MODE_STRICT && + RegExpPrototypeExec(/^\s*$/, code) === null) { code = `'use strict'; void 0;\n${code}`; } script = new vm.Script(code, { filename: file, displayErrors: false }); diff --git a/modules/jsc/src/builtins/node_vm.cppm b/modules/jsc/src/builtins/node_vm.cppm index 36c05b75..e90cb3cb 100644 --- a/modules/jsc/src/builtins/node_vm.cppm +++ b/modules/jsc/src/builtins/node_vm.cppm @@ -49,6 +49,7 @@ inline constexpr std::string_view kNodeVmJS = R"JS( const gOPD = Object.getOwnPropertyDescriptor; const ownKeys = Reflect.ownKeys; const RegExpPrototypeExec = Function.prototype.call.bind(RegExp.prototype.exec); + const StringPrototypeSlice = Function.prototype.call.bind(String.prototype.slice); const contexts = new WeakSet(); const records = new WeakMap(); @@ -707,11 +708,19 @@ inline constexpr std::string_view kNodeVmJS = R"JS( function prepareDynImport(code, callback, getWrap) { const src = `${code}`; DYNIMPORT_RE.lastIndex = 0; - if (RegExpPrototypeExec(DYNIMPORT_RE, src) === null) return null; + let match = RegExpPrototypeExec(DYNIMPORT_RE, src); + if (match === null) return null; const id = dynNextId++; - DYNIMPORT_RE.lastIndex = 0; dynRegistry[id] = makeDynImportHandler(callback, getWrap); - return src.replace(DYNIMPORT_RE, DYN_REGISTRY + "[" + id + "]("); + const replacement = DYN_REGISTRY + "[" + id + "]("; + let rewritten = ""; + let cursor = 0; + do { + rewritten += StringPrototypeSlice(src, cursor, match.index) + replacement; + cursor = match.index + match[0].length; + match = RegExpPrototypeExec(DYNIMPORT_RE, src); + } while (match !== null); + return rewritten + StringPrototypeSlice(src, cursor); } class Script { diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index cc2cfdb9..d9643f5b 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -41,6 +41,35 @@ int main() { "catch(err){thrown=err&&err.message}finally{RegExp.prototype.exec=original;server.close()}" "return calls===1&&value===2&&thrown===''?1:0})()", 1, "node REPL capture restoration uses the primordial RegExp exec"); + expect_number( + "(()=>{const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" + "const server=repl.start({input:io,output:io,terminal:false,prompt:'',replMode:repl.REPL_MODE_STRICT});" + "const original=RegExp.prototype.exec;let calls=0,value=-1,thrown='';" + "RegExp.prototype.exec=()=>{throw new Error('poison-exec')};" + "try{server.eval('1+1\\n',server.context,'REPL1',(err,result)=>{calls++;value=err?0:result})}" + "catch(err){thrown=err&&err.message}finally{RegExp.prototype.exec=original;server.close()}" + "return calls===1&&value===2&&thrown===''?1:0})()", + 1, "node strict REPL whitespace detection uses the primordial RegExp exec"); + expect_number( + "(()=>{const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" + "const server=repl.start({input:io,output:io,terminal:false,prompt:'',replMode:repl.REPL_MODE_STRICT});" + "const original=RegExp.prototype.exec;let calls=0,firstSyntax=false,syntax=false,thrown='';" + "RegExp.prototype.exec=()=>{throw new Error('poison-exec')};" + "try{server.eval(\"\\x27foo\\\\\\n\",server.context,'REPL1',(err)=>{calls++;firstSyntax=err&&err.name==='SyntaxError'});" + "server.eval('const =\\n',server.context,'REPL2',(err)=>{calls++;syntax=err&&err.name==='SyntaxError'})}" + "catch(err){thrown=err&&err.message}finally{RegExp.prototype.exec=original;server.close()}" + "return calls===2&&firstSyntax&&syntax&&thrown===''?1:0})()", + 1, "node strict REPL error recovery uses the primordial RegExp exec"); + expect_number( + "(()=>{const vm=require('node:vm'),original=RegExp.prototype.exec;" + "let simple=-1,sourceMap=false,dynamic=false,thrown='';" + "RegExp.prototype.exec=()=>{throw new Error('poison-exec')};" + "try{simple=new vm.Script('1+1').runInThisContext();" + "new vm.Script('//# sourceMappingURL=x\\n1');sourceMap=true;" + "new vm.Script('import(\\\"x\\\")',{importModuleDynamically(){}});dynamic=true}" + "catch(err){thrown=err&&err.message}finally{RegExp.prototype.exec=original}" + "return simple===2&&sourceMap&&dynamic&&thrown===''?1:0})()", + 1, "node vm dynamic import rewriting uses the primordial RegExp exec"); expect_number( "(()=>{const repl=require('node:repl'),stream=require('node:stream'),io=new stream.PassThrough();" "const server=repl.start({input:io,output:io,terminal:false,prompt:''});" From d8f054687a3a6b4f2c32d29b82637bda9339bca6 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 21:25:48 +0800 Subject: [PATCH 035/150] fix(run): resolve extensionless node-shim entries - #80 - #96 Complete extensionless RunAsNodeCommand entries in Buns pinned module order while preserving the original argv spelling and bypassing package scripts and .bin dispatch. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- src/app.cppm | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/app.cppm b/src/app.cppm index 665b89d7..65d14d62 100644 --- a/src/app.cppm +++ b/src/app.cppm @@ -228,7 +228,8 @@ std::string resolve_entry_path(std::string_view script) { int report_run_target_not_found(std::string_view target); // Run a JS file with process.argv = [runtime, script, ...args] (Node/bun order). -int run_script(std::string_view script, std::span scriptArgs) { +int run_script(std::string_view script, std::span scriptArgs, + std::optional argvScript = std::nullopt) { // Best-effort, NON-FATAL bunfig.toml validation (ref bun // src/bunfig/arguments.rs load_config): emit a config error to stderr but // still run the script (exit unaffected). Parser + "expected string" type @@ -283,7 +284,7 @@ int run_script(std::string_view script, std::span script std::vector jsArgv; jsArgv.reserve(scriptArgs.size() + 2); jsArgv.emplace_back("mbun"); - jsArgv.emplace_back(entry); + jsArgv.emplace_back(argvScript.value_or(entry)); for (std::string_view a : scriptArgs) jsArgv.emplace_back(a); mbun::jsc::runtime::set_argv(std::move(jsArgv)); return mbun::jsc::runtime::run_file(entry); @@ -3156,7 +3157,36 @@ int exec_as_if_node(std::span args) { std::filesystem::path abs{std::filesystem::current_path(ec) / target}; if (!ec) target = abs.lexically_normal().string(); } - return run_script(target, args.subspan(i + 1)); + + // RunAsNodeCommand still boots through Bun's normal module resolver. For a + // missing extensionless positional that resolver tries the ESM entry order + // from resolver/options.rs before reporting not-found. Keep this completion + // at the node-shim dispatch point: ordinary `bun run` must retain its + // package-script/.bin lookup, while node mode must never enter either one. + // + // Only the load path gains the suffix. Bun preserves the spelling supplied + // by the user in process.argv[1], which as-node.test.ts pins explicitly. + std::string loadTarget{target}; + { + std::error_code ec{}; + const std::filesystem::path literal{target}; + const bool literalIsFile{std::filesystem::exists(literal, ec) && + !std::filesystem::is_directory(literal, ec)}; + if (!literal.has_extension() && !literalIsFile) { + static constexpr std::string_view NODE_ENTRY_EXTENSION_ORDER[]{ + ".tsx", ".jsx", ".mts", ".ts", ".mjs", ".js", ".cts", ".cjs", ".json"}; + for (const std::string_view extension : NODE_ENTRY_EXTENSION_ORDER) { + std::filesystem::path candidate{target + std::string{extension}}; + ec.clear(); + if (std::filesystem::exists(candidate, ec) && + !std::filesystem::is_directory(candidate, ec)) { + loadTarget = candidate.string(); + break; + } + } + } + } + return run_script(loadTarget, args.subspan(i + 1), target); } } // namespace mbun::app From 699fa78e44224b077609147d6951a024c28d2913 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 21:38:26 +0800 Subject: [PATCH 036/150] fix(run): honor explicit tsconfig overrides - #80 - #97 Parse both valued override spellings, publish an absolute config path to the existing runtime resolver, and prefer matching tsconfig hash aliases before package imports. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/runtime/api_impl.inc | 4 ++ modules/jsc/src/runtime/common.inc | 5 ++ modules/jsc/src/runtime/engine.inc | 90 ++++++++++++++---------- modules/resolver/src/resolver.cppm | 7 ++ modules/resolver/tests/test_resolver.cpp | 7 ++ src/app.cppm | 11 +++ src/main.cpp | 13 ++++ 7 files changed, 100 insertions(+), 37 deletions(-) diff --git a/modules/jsc/src/runtime/api_impl.inc b/modules/jsc/src/runtime/api_impl.inc index 232b8eda..52cf3b38 100644 --- a/modules/jsc/src/runtime/api_impl.inc +++ b/modules/jsc/src/runtime/api_impl.inc @@ -61,6 +61,10 @@ void add_env_file(std::string spec) { gEnvFiles.push_back(std::move(spec)); } // bunfig `preload`: modules to import before the entry point. void set_preloads(std::vector paths) { gPreloads = std::move(paths); } +// `--tsconfig-override `: explicit runtime resolver config, already made +// absolute by the CLI against the option-parse cwd. +void set_tsconfig_override(std::string path) { gTsconfigOverride = std::move(path); } + // `--user-agent `: default User-Agent for fetch (bun Arguments.rs:1062). void set_user_agent(std::string ua) { gUserAgent = std::move(ua); } diff --git a/modules/jsc/src/runtime/common.inc b/modules/jsc/src/runtime/common.inc index 87d987dc..4d8f0488 100644 --- a/modules/jsc/src/runtime/common.inc +++ b/modules/jsc/src/runtime/common.inc @@ -257,6 +257,11 @@ std::vector gEnvFiles{}; // a miss is fatal with `preload not found `). std::vector gPreloads{}; +// `--tsconfig-override ` — one process-wide explicit config for runtime +// resolution. The CLI makes it absolute against its parse cwd before the +// runtime starts, so imports never reinterpret it relative to their own file. +std::string gTsconfigOverride{}; + // `--user-agent ` — overrides the default fetch User-Agent. // ref: bun cli/Arguments.rs:1062 → bun_http::OVERRIDDEN_DEFAULT_USER_AGENT. std::string gUserAgent{}; diff --git a/modules/jsc/src/runtime/engine.inc b/modules/jsc/src/runtime/engine.inc index 3c808018..b9c768ac 100644 --- a/modules/jsc/src/runtime/engine.inc +++ b/modules/jsc/src/runtime/engine.inc @@ -1129,45 +1129,61 @@ public: return out; } - const mbun::resolver::TsconfigPaths* find_tsconfig_(const std::string& fromDir) { - std::error_code ec; - std::filesystem::path d{fromDir}; - for (int depth = 0; depth < 64; ++depth) { - const std::string cfg{(d / "tsconfig.json").string()}; - if (auto it{tsconfigCache_.find(cfg)}; it != tsconfigCache_.end()) { - return it->second ? &*it->second : nullptr; - } - if (std::filesystem::is_regular_file(d / "tsconfig.json", ec)) { - std::optional parsed; - if (auto content{read_file(cfg)}) { - parsed = mbun::resolver::parse_tsconfig(*content, d.string()); - // Follow `extends`: when this config declares no paths of its own - // but extends another, inherit the parent's baseDir + entries - // (a nested config that only `extends` a parent otherwise loses - // the parent's path aliases like "harness"). Bounded chain walk. - std::filesystem::path base{d}; - for (int hop = 0; parsed && parsed->entries.empty() && - !parsed->extends_from.empty() && hop < 16; ++hop) { - std::string ext{parsed->extends_from}; - if (!ext.ends_with(".json")) ext += "/tsconfig.json"; - std::filesystem::path parentCfg{(base / ext).lexically_normal()}; - std::error_code pec; - if (!std::filesystem::is_regular_file(parentCfg, pec)) break; - auto pcontent{read_file(parentCfg.string())}; - if (!pcontent) break; - base = parentCfg.parent_path(); - parsed = mbun::resolver::parse_tsconfig(*pcontent, base.string()); - } + const mbun::resolver::TsconfigPaths* load_tsconfig_(const std::filesystem::path& config) { + const std::string cfg{config.lexically_normal().string()}; + if (auto it{tsconfigCache_.find(cfg)}; it != tsconfigCache_.end()) { + return it->second ? &*it->second : nullptr; + } + + std::optional parsed; + std::error_code ec{}; + if (std::filesystem::is_regular_file(config, ec)) { + if (auto content{read_file(cfg)}) { + const std::filesystem::path configDir{config.parent_path()}; + parsed = mbun::resolver::parse_tsconfig(*content, configDir.string()); + // Follow `extends`: when this config declares no paths of its own + // but extends another, inherit the parent's baseDir + entries + // (a nested config that only `extends` a parent otherwise loses + // the parent's path aliases like "harness"). Bounded chain walk. + std::filesystem::path base{configDir}; + for (int hop{0}; parsed && parsed->entries.empty() && + !parsed->extends_from.empty() && hop < 16; ++hop) { + std::string ext{parsed->extends_from}; + if (!ext.ends_with(".json")) ext += "/tsconfig.json"; + std::filesystem::path parentCfg{(base / ext).lexically_normal()}; + std::error_code parentEc{}; + if (!std::filesystem::is_regular_file(parentCfg, parentEc)) break; + auto parentContent{read_file(parentCfg.string())}; + if (!parentContent) break; + base = parentCfg.parent_path(); + parsed = mbun::resolver::parse_tsconfig(*parentContent, base.string()); } - auto& slot{tsconfigCache_[cfg]}; - slot = std::move(parsed); - return slot ? &*slot : nullptr; } - std::filesystem::path parent{d.parent_path()}; - if (parent == d || parent.empty()) { - break; - } - d = std::move(parent); + } + auto& slot{tsconfigCache_[cfg]}; + slot = std::move(parsed); + return slot ? &*slot : nullptr; + } + + const mbun::resolver::TsconfigPaths* find_tsconfig_(const std::string& fromDir) { + // An explicit CLI config is process resolution context: it wins for + // every importing directory and uses its own absolute path as the cache + // key. With no override, retain the existing nearest-config walk. + if (!gTsconfigOverride.empty()) { + return load_tsconfig_(std::filesystem::path{gTsconfigOverride}); + } + + std::error_code ec{}; + std::filesystem::path dir{fromDir}; + for (int depth{0}; depth < 64; ++depth) { + const std::filesystem::path config{dir / "tsconfig.json"}; + const std::string cacheKey{config.lexically_normal().string()}; + if (tsconfigCache_.contains(cacheKey)) return load_tsconfig_(config); + ec.clear(); + if (std::filesystem::is_regular_file(config, ec)) return load_tsconfig_(config); + std::filesystem::path parent{dir.parent_path()}; + if (parent == dir || parent.empty()) break; + dir = std::move(parent); } return nullptr; } diff --git a/modules/resolver/src/resolver.cppm b/modules/resolver/src/resolver.cppm index 5640cfff..af81a46a 100644 --- a/modules/resolver/src/resolver.cppm +++ b/modules/resolver/src/resolver.cppm @@ -455,6 +455,13 @@ public: // package.json "imports" — internal "#" specifiers. if (specifier.front() == '#') { + // TypeScript path aliases may deliberately use the same prefix + // (for example "#/*"). Bun applies a matching tsconfig path before + // falling back to package.json imports; an unmatched alias still + // retains the package-imports error and resolution contract below. + if (opts_.tsconfig != nullptr) { + if (auto r{resolve_tsconfig_paths(specifier)}) return ok(*r); + } return resolve_imports(specifier, fromDir); } diff --git a/modules/resolver/tests/test_resolver.cpp b/modules/resolver/tests/test_resolver.cpp index 54faca7f..e99fec6f 100644 --- a/modules/resolver/tests/test_resolver.cpp +++ b/modules/resolver/tests/test_resolver.cpp @@ -154,6 +154,7 @@ MemoryFs build_fixture() { fs.add_file("/proj/js/bun/resolve/baz.js"); // foo/bar, @faasjs/baz fs.add_file("/proj/js/bun/resolve/bar/src/index.js"); // @faasjs/bar fs.add_file("/proj/js/bun/resolve/bar/larger-index.js"); // @faasjs/larger/bar + fs.add_file("/proj/src/utils/helpers.ts"); // #/* tsconfig alias // --- package.json "exports" subpath map (package-json-exports) --- fs.add_file("/proj/node_modules/package-json-exports/foo/bar.js"); @@ -289,6 +290,12 @@ int main() { "tsconfig * second target"); check_resolve(r, "@faasjs/larger/bar", DIR, "/proj/js/bun/resolve/bar/larger-index.js", "tsconfig longest-prefix wins"); + + TsconfigPaths hashAlias{ts}; + hashAlias.entries.emplace_back("#/*", std::vector{"./src/*"}); + auto hashResolver{make_resolver(fs, &hashAlias, ResolveKind::Import)}; + check_resolve(hashResolver, "#/utils/helpers", DIR, "/proj/src/utils/helpers.ts", + "tsconfig hash alias precedes package imports"); } // ============ package.json "exports" subpath map ============ diff --git a/src/app.cppm b/src/app.cppm index 65d14d62..8504eabf 100644 --- a/src/app.cppm +++ b/src/app.cppm @@ -2350,6 +2350,17 @@ void apply_cwd_flag(std::string_view dir) { } } +// Resolve --tsconfig-override at the CLI parsing boundary. The runtime must +// receive one stable absolute config path; resolving later from an importing +// module would incorrectly make the option depend on that modules directory. +void apply_tsconfig_override(std::string value) { + std::error_code ec{}; + std::filesystem::path config{value}; + if (!config.is_absolute()) config = std::filesystem::absolute(config, ec); + if (!ec) value = config.lexically_normal().string(); + mbun::jsc::runtime::set_tsconfig_override(std::move(value)); +} + // `--loader .ext:name` / `-l .ext:name`: install a process-wide extension→loader // override for the RUNTIME module loader. // diff --git a/src/main.cpp b/src/main.cpp index dc9f145a..0b3e0e8b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -312,6 +312,12 @@ int main(int argc, char* argv[]) { args.erase(args.begin(), args.begin() + static_cast(n)); continue; } + if (const std::size_t n{take_valued_flag(args, 0, "--tsconfig-override", + apply_tsconfig_override)}; + n > 0) { + args.erase(args.begin(), args.begin() + static_cast(n)); + continue; + } // `--loader .ext:name` / `-l .ext:name` — shared with run/test, not // build-only (see apply_loader_flag). if (const std::size_t n{take_valued_flag(args, 0, "--loader", apply_loader_flag)}; @@ -461,6 +467,13 @@ int main(int argc, char* argv[]) { args.begin() + static_cast(i + n)); continue; } + if (const std::size_t n{take_valued_flag(args, i, "--tsconfig-override", + apply_tsconfig_override)}; + n > 0) { + args.erase(args.begin() + static_cast(i), + args.begin() + static_cast(i + n)); + continue; + } if (const std::size_t n{take_valued_flag(args, i, "--loader", apply_loader_flag)}; n > 0) { args.erase(args.begin() + static_cast(i), From 5412a205a63da54ca5ff9f957ef75c116fd6578e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 21:43:38 +0800 Subject: [PATCH 037/150] fix(fs): preserve FileHandle fstat failures - #80 - Fixes #94 Propagate live fstat rejections through path-form close handling and observe aborts before the first read advances the handle cursor. Signed-off-by: Sunrisepeak Co-authored-by: Codex (GPT-5) <> --- modules/jsc/src/builtins/bootstrap.cppm | 37 ++++---- .../jsc/tests/test_node_compat_bridges.cpp | 87 +++++++++++++++++++ 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/modules/jsc/src/builtins/bootstrap.cppm b/modules/jsc/src/builtins/bootstrap.cppm index eac7ea60..fc1597a3 100644 --- a/modules/jsc/src/builtins/bootstrap.cppm +++ b/modules/jsc/src/builtins/bootstrap.cppm @@ -8583,23 +8583,26 @@ inline constexpr char kBootstrapJS_[] = R"JS( const fd = this._use("read"); // node kIoMaxLength: a file larger than 2**31-1 cannot be read into one // buffer (ERR_FS_FILE_TOO_LARGE, a RangeError). - try { - // node's readFileHandle resolves the live binding row on every - // operation. Internal tests replace fstat to model files whose stat - // size is zero while reads still produce bytes, so bypassing this - // seam through fsMod.fstatSync loses both the hook and that contract. - const binding = typeof G.__mbunInternalBinding === "function" - ? G.__mbunInternalBinding("fs") : null; - const statFields = binding && typeof binding.fstat === "function" - ? await binding.fstat(fd, false, binding.kUsePromises) : null; - const size = statFields === null - ? fsMod.fstatSync(fd).size : Number(statFields[8]); - if (size > 2147483647) { - const e = new RangeError("File size (" + size + ") is greater than 2 GiB"); - e.code = "ERR_FS_FILE_TOO_LARGE"; - throw e; - } - } catch (e) { if (e && e.code === "ERR_FS_FILE_TOO_LARGE") throw e; } + // node's readFileHandle resolves the live binding row on every + // operation. Internal tests replace fstat to model files whose stat + // size is zero while reads still produce bytes, so bypassing this + // seam through fsMod.fstatSync loses both the hook and that contract. + const binding = typeof G.__mbunInternalBinding === "function" + ? G.__mbunInternalBinding("fs") : null; + const statFields = binding && typeof binding.fstat === "function" + ? await binding.fstat(fd, false, binding.kUsePromises) : null; + // fstat is an asynchronous boundary in node. An abort raised from + // inside that operation must win before the first read advances the + // handle cursor, while an fstat rejection must remain the operation + // error so fsHandleFdClose can apply path-form close aggregation. + fsThrowIfAborted(signal); + const size = statFields === null + ? fsMod.fstatSync(fd).size : Number(statFields[8]); + if (size > 2147483647) { + const e = new RangeError("File size (" + size + ") is greater than 2 GiB"); + e.code = "ERR_FS_FILE_TOO_LARGE"; + throw e; + } const chunks = []; const tmp = Buffer.alloc(65536); let n; // Same synthetic-allocation guard readFileSync carries: a character // device stats as size 0, so this read-to-EOF loop never terminates on diff --git a/modules/jsc/tests/test_node_compat_bridges.cpp b/modules/jsc/tests/test_node_compat_bridges.cpp index 443cbf4d..c92e2de3 100644 --- a/modules/jsc/tests/test_node_compat_bridges.cpp +++ b/modules/jsc/tests/test_node_compat_bridges.cpp @@ -85,6 +85,93 @@ int main() { "try{x.fail(original)}catch(e){return e===original&&!Object.prototype.hasOwnProperty.call(e,'diff')?1:0}" "return 0}catch{return -1}})()", 1, "Assert methods preserve non-AssertionError objects"); + expect_number( + R"JS((()=>{ + globalThis.__fstatRejectDone=0; + globalThis.__fstatRejectOk=0; + (async()=>{ + const fs=require("node:fs"); + const fsp=fs.promises; + const dir=fs.mkdtempSync("/tmp/mbun-fstat-reject-"); + const path=dir+"/payload"; + const binding=globalThis.__mbunInternalBinding("fs"); + const originalFstat=binding.fstat; + const originalOpen=fsp.open; + const sentinel=new Error("sentinel fstat rejection"); + let closeCalls=0; + try { + fs.writeFileSync(path,"abc"); + fsp.open=async(...args)=>{ + const handle=await originalOpen(...args); + const originalClose=handle.close.bind(handle); + handle.close=async()=>{++closeCalls;return originalClose();}; + return handle; + }; + binding.fstat=async()=>{throw sentinel;}; + try { await fsp.readFile(path); } + catch(error) { + globalThis.__fstatRejectOk=(error===sentinel&&closeCalls===1)?1:0; + } + } finally { + binding.fstat=originalFstat; + fsp.open=originalOpen; + fs.rmSync(dir,{recursive:true,force:true}); + globalThis.__fstatRejectDone=1; + } + })().catch((error)=>{ + globalThis.__fstatRejectDetail=String(error&&error.stack||error); + globalThis.__fstatRejectDone=1; + }); + return 0; + })())JS", + 0, "schedule FileHandle live fstat rejection probe"); + mbun::jsc::runtime::pump_event_loop("globalThis.__fstatRejectDone===1"); + expect_number( + "globalThis.__fstatRejectOk", + 1, "FileHandle readFile propagates live fstat rejection and closes path handles"); + expect_number( + R"JS((()=>{ + globalThis.__fstatAbortDone=0; + globalThis.__fstatAbortOk=0; + (async()=>{ + const fs=require("node:fs"); + const dir=fs.mkdtempSync("/tmp/mbun-fstat-abort-"); + const path=dir+"/payload"; + const binding=globalThis.__mbunInternalBinding("fs"); + const originalFstat=binding.fstat; + const controller=new AbortController(); + let handle; + try { + fs.writeFileSync(path,"abc"); + handle=await fs.promises.open(path,"r"); + binding.fstat=async(...args)=>{ + controller.abort(); + return originalFstat.apply(binding,args); + }; + let aborted=false; + try { await handle.readFile({signal:controller.signal}); } + catch(error) { aborted=error&&error.name==="AbortError"; } + binding.fstat=originalFstat; + const probe=Buffer.alloc(1); + const result=await handle.read(probe,0,1,null); + globalThis.__fstatAbortOk=(aborted&&result.bytesRead===1&&probe[0]===97)?1:0; + } finally { + binding.fstat=originalFstat; + if(handle) await handle.close(); + fs.rmSync(dir,{recursive:true,force:true}); + globalThis.__fstatAbortDone=1; + } + })().catch((error)=>{ + globalThis.__fstatAbortDetail=String(error&&error.stack||error); + globalThis.__fstatAbortDone=1; + }); + return 0; + })())JS", + 0, "schedule FileHandle abort-from-fstat cursor probe"); + mbun::jsc::runtime::pump_event_loop("globalThis.__fstatAbortDone===1"); + expect_number( + "globalThis.__fstatAbortOk", + 1, "FileHandle readFile observes abort after fstat without advancing cursor"); if (failures != 0) { std::println("test_node_compat_bridges: {} failed", failures); From 2361ee5f8d3cae9eeb9679b4359a3cada8a4edc6 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 2 Aug 2026 22:10:27 +0800 Subject: [PATCH 038/150] fix(run): complete resolver context routing - #80 - #96 - #97 Defer explicit tsconfig paths until cwd is final, route the shared option through run, test, and build with required-value diagnostics, and resolve node-shim entries through load-as-file-or-directory while preserving argv. Co-authored-by: Codex (GPT-5) <> Signed-off-by: Sunrisepeak --- modules/bundler/src/vertical_slice.cppm | 9 +- modules/bundler/tests/test_vertical_slice.cpp | 16 ++++ modules/jsc/src/runtime/engine.inc | 28 +------ modules/resolver/src/resolver.cppm | 47 ++++++++++- modules/resolver/tests/test_resolver.cpp | 41 ++++++++++ src/app.cppm | 82 +++++++++++++------ src/cli.cppm | 58 ++++++++++++- src/main.cpp | 49 +++++++++-- tests/test_cli.cpp | 52 ++++++++++++ 9 files changed, 321 insertions(+), 61 deletions(-) diff --git a/modules/bundler/src/vertical_slice.cppm b/modules/bundler/src/vertical_slice.cppm index 8f042a9c..f6b9b122 100644 --- a/modules/bundler/src/vertical_slice.cppm +++ b/modules/bundler/src/vertical_slice.cppm @@ -98,6 +98,9 @@ struct BuildOptions { // runtime bridge passes an OS-backed filesystem so real entrypoints resolve. // Borrowed: must outlive the build_bundle() call. const mbun::resolver::FileSystem* fs{nullptr}; + // Explicit tsconfig alias map, parsed and owned by the caller for the + // duration of build_bundle(). Null keeps ordinary resolver behavior. + const mbun::resolver::TsconfigPaths* tsconfig{nullptr}; // Empty hooks = no plugins registered (the default), which keeps the pipeline // byte-identical to the pre-plugin path. OnResolveHook on_resolve{}; @@ -670,6 +673,7 @@ public: return std::unexpected(BuildError{"", "at least one entry point is required", 0}); } diskFs_ = options.fs; + tsconfig_ = options.tsconfig; onResolve_ = options.on_resolve ? &options.on_resolve : nullptr; onLoad_ = options.on_load ? &options.on_load : nullptr; jsxOptions_ = options.jsx; @@ -800,6 +804,7 @@ private: std::optional inputError_; // Borrowed disk fallback (BuildOptions::fs); null keeps the build memory-only. const mbun::resolver::FileSystem* diskFs_{nullptr}; + const mbun::resolver::TsconfigPaths* tsconfig_{nullptr}; // Borrowed plugin hooks (BuildOptions::on_resolve / on_load); null = no plugins. const OnResolveHook* onResolve_{nullptr}; const OnLoadHook* onLoad_{nullptr}; @@ -945,7 +950,9 @@ private: std::string{importer}, std::format("could not resolve {:?} from namespace {:?}", specifier, importerNs), 0}); } - mbun::resolver::Resolver resolver{overlay_fs_(), mbun::resolver::Options{}}; + mbun::resolver::Options resolverOptions{}; + resolverOptions.tsconfig = tsconfig_; + mbun::resolver::Resolver resolver{overlay_fs_(), std::move(resolverOptions)}; const mbun::resolver::ResolveResult resolved{resolver.resolve(specifier, dirname(importer))}; if (resolved.status == mbun::resolver::ResolveStatus::Success) { return Resolved{resolved.path, "file"}; diff --git a/modules/bundler/tests/test_vertical_slice.cpp b/modules/bundler/tests/test_vertical_slice.cpp index 7857363e..f04d2c53 100644 --- a/modules/bundler/tests/test_vertical_slice.cpp +++ b/modules/bundler/tests/test_vertical_slice.cpp @@ -433,6 +433,22 @@ void test_disk_entrypoint_and_imports() { if (shadowed) { check_contains(shadowed->code, "shadowed", "in-memory file shadows the on-disk file"); } + + // `--tsconfig-override` is a shared transpiler option in Bun's run/test/build + // tables. The build pipeline receives the same parsed alias map instead of + // accepting and then discarding the option at its CLI boundary. + { + mbun::bundler::Files aliasFiles{ + {"/alias/entry.ts", "import { answer } from '#/answer'; console.log(answer);"}, + {"/alias/src/answer.ts", "export const answer = 42;"}, + }; + mbun::resolver::TsconfigPaths tsconfig{ + .baseDir = "/alias", .entries = {{"#/*", {"./src/*"}}}}; + auto aliased{mbun::bundler::build_bundle( + {"/alias/entry.ts"}, aliasFiles, {.tsconfig = &tsconfig})}; + check(aliased.has_value(), "build routes explicit tsconfig aliases into resolver"); + if (aliased) check(aliased->moduleCount == 2, "tsconfig alias adds target module to graph"); + } fs::remove_all(dir, ec); } diff --git a/modules/jsc/src/runtime/engine.inc b/modules/jsc/src/runtime/engine.inc index b9c768ac..3294349f 100644 --- a/modules/jsc/src/runtime/engine.inc +++ b/modules/jsc/src/runtime/engine.inc @@ -1135,30 +1135,10 @@ public: return it->second ? &*it->second : nullptr; } - std::optional parsed; - std::error_code ec{}; - if (std::filesystem::is_regular_file(config, ec)) { - if (auto content{read_file(cfg)}) { - const std::filesystem::path configDir{config.parent_path()}; - parsed = mbun::resolver::parse_tsconfig(*content, configDir.string()); - // Follow `extends`: when this config declares no paths of its own - // but extends another, inherit the parent's baseDir + entries - // (a nested config that only `extends` a parent otherwise loses - // the parent's path aliases like "harness"). Bounded chain walk. - std::filesystem::path base{configDir}; - for (int hop{0}; parsed && parsed->entries.empty() && - !parsed->extends_from.empty() && hop < 16; ++hop) { - std::string ext{parsed->extends_from}; - if (!ext.ends_with(".json")) ext += "/tsconfig.json"; - std::filesystem::path parentCfg{(base / ext).lexically_normal()}; - std::error_code parentEc{}; - if (!std::filesystem::is_regular_file(parentCfg, parentEc)) break; - auto parentContent{read_file(parentCfg.string())}; - if (!parentContent) break; - base = parentCfg.parent_path(); - parsed = mbun::resolver::parse_tsconfig(*parentContent, base.string()); - } - } + auto loaded{mbun::resolver::load_tsconfig_override(os_fs(), cfg)}; + std::optional parsed{std::move(loaded.config)}; + if (!parsed && cfg == gTsconfigOverride && !loaded.error.empty()) { + std::println(std::cerr, "error: {}", loaded.error); } auto& slot{tsconfigCache_[cfg]}; slot = std::move(parsed); diff --git a/modules/resolver/src/resolver.cppm b/modules/resolver/src/resolver.cppm index af81a46a..55139b34 100644 --- a/modules/resolver/src/resolver.cppm +++ b/modules/resolver/src/resolver.cppm @@ -72,6 +72,10 @@ export struct Options { // parents. Default false (node's default, and what the isolated-linker // layouts below need). bool preserve_symlinks{false}; + // Optional caller-pinned LOAD_AS_FILE order. Empty retains the resolver's + // project/node_modules defaults; RunAsNodeCommand supplies Bun's main-entry + // order without changing ordinary import resolution. + std::vector extension_order; }; // ReResolve: `path` is a bare specifier (e.g. a package.json "imports" target @@ -425,6 +429,46 @@ export std::optional parse_tsconfig(std::string_view json, return ts; } +export struct TsconfigLoadResult { + std::optional config; + std::string error; +}; + +// Load an explicitly requested config. Unlike the nearest-tsconfig probe, an +// explicit path is a user assertion: missing/unreadable/malformed input must be +// reported rather than cached as an indistinguishable null result. +export TsconfigLoadResult load_tsconfig_override(const FileSystem& fs, + std::string_view configPath) { + const std::string path{paths::normalize(configPath)}; + if (!fs.file_exists || !fs.file_exists(path)) { + return {.error = std::format("Cannot find tsconfig file \"{}\"", path)}; + } + if (!fs.read_file) { + return {.error = std::format("Cannot read file \"{}\"", path)}; + } + auto content{fs.read_file(path)}; + if (!content) return {.error = std::format("Cannot read file \"{}\"", path)}; + + auto parsed{parse_tsconfig(*content, paths::dirname(path))}; + if (!parsed) return {.error = std::format("Cannot parse tsconfig file \"{}\"", path)}; + + // Preserve the runtime's existing bounded extends behavior. A leaf with its + // own paths wins; otherwise inherit the first parent that supplies them. + std::string current{path}; + for (int hop{0}; parsed->entries.empty() && !parsed->extends_from.empty() && hop < 16; ++hop) { + std::string parentSpec{parsed->extends_from}; + if (!parentSpec.ends_with(".json")) parentSpec += "/tsconfig.json"; + current = paths::join({paths::dirname(current), parentSpec}); + if (!fs.file_exists(current)) break; + auto parentContent{fs.read_file(current)}; + if (!parentContent) break; + auto parent{parse_tsconfig(*parentContent, paths::dirname(current))}; + if (!parent) break; + parsed = std::move(parent); + } + return {.config = std::move(parsed)}; +} + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -538,7 +582,8 @@ private: // Bun selects the node_modules order when the path being searched passes // through a node_modules directory. The resolver works in normalized // forward-slash paths, so the needle is "/node_modules/". - static std::span extensions_for(std::string_view path) { + std::span extensions_for(std::string_view path) const { + if (!opts_.extension_order.empty()) return opts_.extension_order; if (path.find("/node_modules/") != std::string_view::npos) { return kExtensionsNodeModules; } diff --git a/modules/resolver/tests/test_resolver.cpp b/modules/resolver/tests/test_resolver.cpp index e99fec6f..c7341519 100644 --- a/modules/resolver/tests/test_resolver.cpp +++ b/modules/resolver/tests/test_resolver.cpp @@ -64,6 +64,11 @@ void report_failure(std::string_view what) { } } +void check(bool condition, std::string_view what) { + ++gChecks; + if (!condition) report_failure(what); +} + // --------------------------------------------------------------------------- // In-memory filesystem fixture. Files are a set of absolute posix paths; // directories are derived from the file paths (every prefix ending in '/'). @@ -388,6 +393,42 @@ int main() { "/proj/node_modules/node-path-test/index.js", "bare pkg main upward"); } + // ============ node-shim entry LOAD_AS_FILE_OR_DIRECTORY ============ + // RunAsNodeCommand boots its positional through Bun's resolver. The load + // path uses the pinned entry extension order, while package.json main and + // directory index fallback remain ordinary LOAD_AS_DIRECTORY behavior. + { + MemoryFs entryFs; + entryFs.add_file("/entry/pkg/package.json", R"({"main":"./start"})"); + entryFs.add_file("/entry/pkg/start.jsx"); + entryFs.add_file("/entry/pkg/start.tsx"); + entryFs.add_file("/entry/index-only/index.mjs"); + + Options opts{}; + opts.extension_order = {".tsx", ".jsx", ".mts", ".ts", ".mjs", ".js", + ".cts", ".cjs", ".json"}; + Resolver entryResolver{entryFs.make(), std::move(opts)}; + check_resolve(entryResolver, "/entry/pkg", "/", "/entry/pkg/start.tsx", + "node entry directory package main + pinned extension order"); + check_resolve(entryResolver, "/entry/index-only", "/", "/entry/index-only/index.mjs", + "node entry directory index fallback"); + } + + // An explicit override is not a nullable nearest-config probe: missing and + // malformed files carry stable diagnostics to the CLI boundary. + { + MemoryFs configFs; + configFs.add_file("/config/invalid.json", "{ invalid"); + auto missing{load_tsconfig_override(configFs.make(), "/config/missing.json")}; + check(!missing.config.has_value(), "missing explicit tsconfig does not parse as null success"); + check(missing.error == "Cannot find tsconfig file \"/config/missing.json\"", + "missing explicit tsconfig diagnostic"); + auto invalid{load_tsconfig_override(configFs.make(), "/config/invalid.json")}; + check(!invalid.config.has_value(), "invalid explicit tsconfig does not parse as null success"); + check(invalid.error == "Cannot parse tsconfig file \"/config/invalid.json\"", + "invalid explicit tsconfig diagnostic"); + } + // ============ "browser" main-field override ============ { auto rNode{make_resolver(fs, &ts, ResolveKind::Require)}; diff --git a/src/app.cppm b/src/app.cppm index 8504eabf..4ea7da2e 100644 --- a/src/app.cppm +++ b/src/app.cppm @@ -990,12 +990,19 @@ void write_junit_report(const std::filesystem::path& outfile, // `mbun test [file|dir|filter]...`: discover the test files, run each through // mbun.jsc.test_runner, and print a bun-style per-file report + aggregate // summary. Returns the process exit code (0 all pass, 1 any fail / load error). -int run_test(std::span args) { +bool apply_tsconfig_override(std::string value); + +int run_test(std::span args, + std::optional inheritedTsconfig = std::nullopt) { mbun::cli::TestFlags flags { mbun::cli::parse_test(args) }; if (!flags.parseError.empty()) { std::println(std::cerr, "error: {}", flags.parseError); return 1; } + if (!flags.tsconfigOverride && inheritedTsconfig) { + flags.tsconfigOverride = std::string{*inheritedTsconfig}; + } + if (flags.tsconfigOverride && !apply_tsconfig_override(*flags.tsconfigOverride)) return 1; // bunfig.toml's [test] block supplies defaults the command line overrides // (ref: bun-ref/src/bunfig/bunfig.rs — load_config runs before Arguments' @@ -1954,7 +1961,8 @@ int emit_compiled_executable(const mbun::cli::BuildFlags& flags, const std::stri return 0; } -int run_build(std::span buildArgs) { +int run_build(std::span buildArgs, + std::optional inheritedTsconfig = std::nullopt) { const auto start{std::chrono::steady_clock::now()}; auto flags{mbun::cli::parse_build(buildArgs)}; @@ -1967,6 +1975,27 @@ int run_build(std::span buildArgs) { return 1; } + if (!flags.tsconfigOverride && inheritedTsconfig) { + flags.tsconfigOverride = std::string{*inheritedTsconfig}; + } + std::optional explicitTsconfig{}; + if (flags.tsconfigOverride) { + std::error_code ec{}; + const std::filesystem::path cwd{std::filesystem::current_path(ec)}; + if (ec) { + std::println(std::cerr, "error: Could not resolve --tsconfig-override"); + return 1; + } + const std::string path{ + mbun::cli::resolve_tsconfig_override_path(*flags.tsconfigOverride, cwd)}; + auto loaded{mbun::resolver::load_tsconfig_override(build_os_fs(), path)}; + if (!loaded.config) { + std::println(std::cerr, "error: {}", loaded.error); + return 1; + } + explicitTsconfig = std::move(loaded.config); + } + // ref: bun src/cli/Arguments.rs :1472-1488 — no entrypoints prints the banner, // the error and a short usage, then exits 1. if (flags.entryPoints.empty()) { @@ -2094,7 +2123,11 @@ int run_build(std::span buildArgs) { for (const std::string& entryPoint : entryPoints) { auto built{mbun::bundler::build_bundle( {entryPoint}, mbun::bundler::Files{}, - {.sourcemap = wantSourcemap, .fs = &fs, .jsx = jsx, .defines = defines})}; + {.sourcemap = wantSourcemap, + .fs = &fs, + .tsconfig = explicitTsconfig ? &*explicitTsconfig : nullptr, + .jsx = jsx, + .defines = defines})}; if (!built) { std::println(std::cerr, "error: {}", built.error().message); return 1; @@ -2353,12 +2386,21 @@ void apply_cwd_flag(std::string_view dir) { // Resolve --tsconfig-override at the CLI parsing boundary. The runtime must // receive one stable absolute config path; resolving later from an importing // module would incorrectly make the option depend on that modules directory. -void apply_tsconfig_override(std::string value) { +bool apply_tsconfig_override(std::string value) { std::error_code ec{}; - std::filesystem::path config{value}; - if (!config.is_absolute()) config = std::filesystem::absolute(config, ec); - if (!ec) value = config.lexically_normal().string(); + const std::filesystem::path cwd{std::filesystem::current_path(ec)}; + if (ec) { + std::println(std::cerr, "error: Could not resolve --tsconfig-override"); + return false; + } + value = mbun::cli::resolve_tsconfig_override_path(value, cwd); + const auto loaded{mbun::resolver::load_tsconfig_override(build_os_fs(), value)}; + if (!loaded.config) { + std::println(std::cerr, "error: {}", loaded.error); + return false; + } mbun::jsc::runtime::set_tsconfig_override(std::move(value)); + return true; } // `--loader .ext:name` / `-l .ext:name`: install a process-wide extension→loader @@ -3178,25 +3220,13 @@ int exec_as_if_node(std::span args) { // Only the load path gains the suffix. Bun preserves the spelling supplied // by the user in process.argv[1], which as-node.test.ts pins explicitly. std::string loadTarget{target}; - { - std::error_code ec{}; - const std::filesystem::path literal{target}; - const bool literalIsFile{std::filesystem::exists(literal, ec) && - !std::filesystem::is_directory(literal, ec)}; - if (!literal.has_extension() && !literalIsFile) { - static constexpr std::string_view NODE_ENTRY_EXTENSION_ORDER[]{ - ".tsx", ".jsx", ".mts", ".ts", ".mjs", ".js", ".cts", ".cjs", ".json"}; - for (const std::string_view extension : NODE_ENTRY_EXTENSION_ORDER) { - std::filesystem::path candidate{target + std::string{extension}}; - ec.clear(); - if (std::filesystem::exists(candidate, ec) && - !std::filesystem::is_directory(candidate, ec)) { - loadTarget = candidate.string(); - break; - } - } - } - } + mbun::resolver::Options entryOptions{}; + entryOptions.kind = mbun::resolver::ResolveKind::Import; + entryOptions.extension_order = { + ".tsx", ".jsx", ".mts", ".ts", ".mjs", ".js", ".cts", ".cjs", ".json"}; + mbun::resolver::Resolver entryResolver{build_os_fs(), std::move(entryOptions)}; + const auto resolved{entryResolver.resolve(target, std::filesystem::current_path().string())}; + if (resolved.status == mbun::resolver::ResolveStatus::Success) loadTarget = resolved.path; return run_script(loadTarget, args.subspan(i + 1), target); } diff --git a/src/cli.cppm b/src/cli.cppm index da822f79..1095cb55 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -343,6 +343,38 @@ std::vector derive_runtime_preloads(std::span value{}; + std::string parseError{}; +}; + +std::size_t take_tsconfig_override(std::span args, std::size_t i, + TsconfigOverrideArg& out) { + constexpr std::string_view flag{"--tsconfig-override"}; + const std::string_view arg{args[i]}; + if (arg.starts_with(flag) && arg.size() > flag.size() && arg[flag.size()] == '=') { + out.value = std::string{arg.substr(flag.size() + 1)}; + return 1; + } + if (arg != flag) return 0; + if (i + 1 >= args.size()) { + out.parseError = "Missing value for \"--tsconfig-override\""; + return 1; + } + out.value = std::string{args[i + 1]}; + return 2; +} + +std::string resolve_tsconfig_override_path(std::string_view value, + const std::filesystem::path& cwd) { + std::filesystem::path path{value}; + if (!path.is_absolute()) path = cwd / path; + return path.lexically_normal().string(); +} + // Flag names/arity are transcribed from bun's TEST_ONLY_PARAMS table // (ref: bun-ref/src/cli/Arguments.rs:560-615) and the semantics from the test // arm at :1647-1649 (--only-failures), :1788 (--randomize) and :1831-1841 @@ -403,6 +435,7 @@ struct TestFlags { // value (`../../src/jsx`) was silently swallowed as a dangling operand. std::optional jsxImportSource {}; // --jsx-import-source std::optional jsxRuntime {}; // --jsx-runtime + std::optional tsconfigOverride {}; // --tsconfig-override // Fatal parse problem (bad --seed / --jsx-runtime value); empty when clean. std::string parseError {}; @@ -462,6 +495,18 @@ TestFlags parse_test(std::span args) { out.randomize = true; continue; } + + if (name == "--tsconfig-override") { + TsconfigOverrideArg parsed{}; + const std::size_t consumed{take_tsconfig_override(args, i, parsed)}; + if (!parsed.parseError.empty()) { + out.parseError = std::move(parsed.parseError); + return out; + } + out.tsconfigOverride = std::move(parsed.value); + i += consumed - 1; + continue; + } if (name == "--only-failures") { out.onlyFailures = true; continue; @@ -565,6 +610,7 @@ struct BuildFlags { std::vector external {}; // -e/--external (ref: Arguments.rs :475) std::vector conditions {}; // --conditions (ref: Arguments.rs :515) + std::optional tsconfigOverride {}; // shared TRANSPILER_PARAMS_ // ── the shared transpiler flags (TRANSPILER_PARAMS_, Arguments.rs :139-182) ── // `--define K=V` / `-d K:V`. Both separators are accepted, first one wins, and @@ -634,7 +680,6 @@ inline constexpr std::array BUILD_VALUE_FLAGS { // TRANSPILER_PARAMS_ (Arguments.rs :139-181) — every `bun build` accepts these // too, because BUILD_PARAMS concatenates them (Arguments.rs :545). std::string_view { "--main-fields" }, std::string_view { "--extension-order" }, - std::string_view { "--tsconfig-override" }, std::string_view { "--drop" }, std::string_view { "--feature" }, std::string_view { "--jsx-factory" }, std::string_view { "--jsx-fragment" }, std::string_view { "--jsx-import-source" },std::string_view { "--jsx-runtime" }, @@ -744,6 +789,17 @@ BuildFlags parse_build(std::span args) { out.jsxSideEffects = true; continue; } + if (name == "--tsconfig-override") { + TsconfigOverrideArg parsed{}; + const std::size_t consumed{take_tsconfig_override(args, i, parsed)}; + if (!parsed.parseError.empty()) { + out.parseError = std::move(parsed.parseError); + return out; + } + out.tsconfigOverride = std::move(parsed.value); + i += consumed - 1; + continue; + } if (name == "--no-bundle") { out.noBundle = true; continue; diff --git a/src/main.cpp b/src/main.cpp index 0b3e0e8b..9d2e09d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -255,6 +255,7 @@ int main(int argc, char* argv[]) { // Strip leading global run flags so `mbun [flags]