Skip to content

feat: first-class Bun compatibility (core, timer, logger-console, loader, include) - #1

Open
ebowwa wants to merge 33 commits into
mainfrom
feat/bun-compat
Open

feat: first-class Bun compatibility (core, timer, logger-console, loader, include)#1
ebowwa wants to merge 33 commits into
mainfrom
feat/bun-compat

Conversation

@ebowwa

@ebowwa ebowwa commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Makes this fork a first-class Bun-compatible Cordis implementation while preserving the Node implementation, tests, and a clean merge path against cordiverse/cordis.

Verified under Bun 1.3.14 (1.3.14+0d9b296af): cordis core, @cordisjs/plugin-timer, @cordisjs/plugin-logger-console (node and browser exports), @cordisjs/plugin-loader, @cordisjs/plugin-include. @cordisjs/plugin-hmr remains Node-only by design (requires Node ESM internals).

Full details in docs/BUN_COMPATIBILITY.md, docs/BUN_PORT_PLAN.md, docs/BUN_PORT_STATUS.md, and docs/BUN_BENCH.md.

What's included

1. Bun behavioral suite — tests/bun/ (68 tests, 13 spec files)

58 core + 7 browser-export tests run on stock Bun; 3 are gated on the oven-sh/bun#32856 PR build and skip cleanly when it isn't installed (verified). Covers every required scenario: function/object/class plugins; sync & async effects; reverse disposal order; nested plugin cleanup; provide/inject (activation, removal+reactivation, replacement); isolated contexts; all five dispatch modes; timer cleanup; JSON & YAML config loading; dynamic TypeScript plugin loading; repeated load/dispose cycles with no duplicated listeners/timers; process shutdown with complete root-fiber disposal; supervisor restart ordering; the browser export of logger-console (method routing, identity pass-through, no DOM needed, zero node: specifiers); and (on the PR build) awaited disposal before --hot reactivation with no resource duplication.

2. Bun CLI & development supervisor

  • packages/core/bin.bun.js — Bun entrypoint mirroring bin.js, with graceful SIGINT/SIGTERM root-fiber disposal. Stores the root context on globalThis and disposes the previous root before booting a new one (the correct strategy for --hot's in-process re-evaluation; a no-op under --watch, where globalThis is reset per reload). When the runtime provides import.meta.hot, disposal is additionally driven by the runtime's awaited dispose phase — see item 3.
  • packages/core/bin.bun.watch.js — dev reload supervisor. Measured on Bun 1.3.14, per Bun's watch-mode docs: --watch is a hard restart (globalThis fresh per reload — old root unreachable, Cordis disposers never run; no public before-reload hook) and --hot is the in-process soft reload preserving globalThis, where live state duplicates across reloads on stock Bun. The supervisor restarts a child process instead: SIGTERM → complete root disposal → respawn, using public APIs only. Restart ordering (dispose before activate) is asserted by tests.

3. Upstream integration — oven-sh/bun#32856 (import.meta.hot for bun --hot)

This fork consumes the PR as a downstream integration fixture — no Bun fork or source changes:

  • PR build installed via bunx bun-pr 32856 (Aug 13, 2026 CI artifact)
  • bin.bun.js registers import.meta.hot.dispose(() => disposePrevious()) when available (no-op on stock Bun/Node)
  • tests/bun/hot.spec.ts proves: an async disposer (150 ms) completes before the next generation activates, timers never duplicate across 3 generations, a module the next generation no longer imports is disposed and its resources stop (the PR's removed-modules claim), a broken generation still disposes the old root and recovers on the next valid edit, and SIGINT exits 0
  • Result: every Cordis case — root-fiber disposal, async cleanup, dynamic plugins, removed plugins, repeated reloads, failed evaluation — passed on the first PR build tried, on macOS arm64 and Linux CI. No bug to report upstream. When the PR ships in a release, drop the PR-binary gating and document --hot as the supported in-process dev reload
  • Known --hot limitation (verified, documented): editing cordis.yml triggers no reload — config files are outside the module graph; the supervisor's fs.watch does catch config edits

4. CI matrix — .github/workflows/bun.yml

Pinned Bun 1.3.14 job (bun install → build → bun test tests/bun → Node suite), plus a best-effort bunx bun-pr 32856 install step and the bun#32856 integration suite (download failures leave those tests skipped; real failures fail CI) — alongside the untouched upstream build.yml.

5. Benchmarks & profiles — bench/ + docs/BUN_BENCH.md

  • Fork vs upstream control (cordiverse/cordis@8cc9e33 in a worktree, run identically): statistically indistinguishable under Node — consistent with zero runtime-source changes
  • Node vs Bun: Bun faster on every Cordis operation (~5x plugin lifecycle, ~3x timers, ~4x include boot, ~80x fresh TS module eval); timer-settle-bound workloads runtime-neutral
  • Cold start: Cordis core boot parity (~23 ms); Node's TS penalty is the tsx toolchain
  • Memory: no leak on either runtime over 600 load/dispose cycles
  • CPU profiles: top Cordis hotspot is reflect.ts proxy machinery on both runtimes

Changes vs upstream

Additive only — 39 files, 3711 insertions, 0 deletions, zero changes to any packages/*/src:

  • Root package.json: +cordis / @cordisjs/plugin-loader / @cordisjs/plugin-timer / @cordisjs/plugin-include devDeps (resolution under Bun's isolated workspace node_modules for bench and the loader's fallback import; no-op under Yarn hoisting) and +3 additive scripts (test:bun, start:bun, dev:bun)
  • packages/loader/package.json stays upstream-identical — declaring plugin-include there created a plugin-loader → plugin-include → plugin-loader cycle in yakumo-tsc's build graph, so the devDep lives at the root instead
  • New files only: tests/bun/**, bench/, docs/BUN_*.md, packages/core/bin.bun*.js, .github/workflows/bun.yml

No public Cordis semantics were changed. No Bun internals were accessed. No Bun-side defects were found (the two candidate findings reproduced identically on Node — documented as parity facts — and the stock-Bun --hot cleanup gap is being fixed upstream by bun#32856, which passed all Cordis cases here).

Test plan

  • bun test tests/bun — 68/68 pass (248 expect calls)
  • HOME=/tmp/... bun test tests/bun/hot.spec.ts — 0 pass / 3 skip / 0 fail (clean skip without the PR build)
  • tests/bun/hot.spec.ts stable across 3 consecutive runs
  • Node suite (vitest via yakumo) — 19 files / 163 tests pass
  • Both suites re-run green at every phase boundary
  • yakumo esbuild + tsc — exit 0 (re-run after every dependency-graph change)
  • bun install at root — 864 packages

ebowwa added 19 commits August 16, 2026 05:08
- tests/bun/: 57 bun:test specs covering core plugins/effects/events,
  provide/inject, isolation, timer, logger-console, loader+include (real
  config files, dynamic TS import, repeated cycles, patches) and SIGINT
  shutdown with full root-fiber disposal
- packages/loader: declare @cordisjs/plugin-include devDependency so the
  fallback import resolves under Bun's isolated workspace node_modules
  (mirrors the existing plugin-logger-console entry; no code change)
- docs/: phased plan and session status log

Node suite: 19 files / 163 tests passed. Bun suite: 57/57 passed on
Bun 1.3.14 (1.3.14+0d9b296af).
…docs

- packages/core/bin.bun.js: Bun entrypoint mirroring bin.js with graceful
  SIGINT/SIGTERM root-fiber disposal
- packages/core/bin.bun.watch.js: development supervisor. Measured on Bun
  1.3.14: --watch re-evaluates in-process with fresh globalThis/process
  (old root unreachable, disposers never run) and --hot duplicates live
  state across reloads. The supervisor restarts a child process instead:
  SIGTERM -> complete root disposal -> respawn, using public APIs only
- tests/bun/watch.spec.ts: asserts dispose-before-activate restart ordering
  across three generations plus clean SIGINT
- tests/bun/repros/: standalone scripts documenting the --watch/--hot
  findings (manual, excluded from test discovery)
- .github/workflows/bun.yml: pinned Bun 1.3.14 job alongside untouched
  upstream build.yml
- package.json scripts: test:bun / start:bun / dev:bun (additive)
- docs/BUN_COMPATIBILITY.md: package matrix, verified commands, reload
  decision, Cordis-vs-Bun change list

Phase boundary: bun test tests/bun 58/58; Node suite 19 files / 163 tests.
- bench/: runtime-neutral workloads (core ops, effects, events, inject,
  isolate, timers, loader tree updates, include boot, fresh TS module
  eval), cold-start variants (JS vs TS plugins), 600-cycle leak check,
  fixed-duration profile workload, cpuprofile aggregator, orchestrator
- docs/BUN_BENCH.md: full results. Control (fork vs upstream@8cc9e33 in a
  worktree): statistically indistinguishable under Node, as expected with
  zero src changes. Node vs Bun: Bun faster on every Cordis op (~5x plugin
  lifecycle, ~3x timers, ~4x include boot, ~80x TS module eval; timer-
  settle-bound workloads runtime-neutral). Cold start: core boot parity
  (~23ms), Node TS penalty is the tsx toolchain. Memory: no leak on either
  runtime. Profiles: top hotspot is reflect.ts proxy machinery on both.
- package.json: devDependencies for cordis/plugin-loader/plugin-timer so
  root-level bench files resolve workspace packages under Bun's isolated
  linker (no-op under Yarn hoisting)

Phase boundary: bun test tests/bun 58/58; Node suite 19 files/163 tests.
…ild cycle

The plugin-include devDependency added to packages/loader created a
plugin-loader -> plugin-include -> plugin-loader cycle in yakumo-tsc's
build graph (include peer-depends on loader), failing both the yarn build
job and the Bun job with 'circular dependency detected'.

packages/loader/package.json is now upstream-identical again; the
dependency lives in root devDependencies, which resolves via parent
walk-up under Bun's isolated linker, is a no-op under Yarn hoisting, and
is outside yakumo's package graph.

Verified: yakumo esbuild exit 0, yakumo tsc exit 0, bun test tests/bun
58/58, Node suite 163/163.
Focused upstream integration (no Bun fork, no runtime-source changes):

- docs: correct the --watch/--hot semantics to match Bun's official
  documentation and re-measurement on 1.3.14 — --watch is a hard restart
  (globalThis fresh, no handler accumulation), --hot is the in-process
  soft reload preserving globalThis (handlers/timers accumulate). The old
  Phase 5 notes had these attributes swapped; bin.bun.js's globalThis root
  guard was always the correct --hot strategy.
- bin.bun.js: register import.meta.hot.dispose(() => disposePrevious())
  when the runtime provides it (PR build) — runtime-awaited disposal
  before re-evaluation; no-op on stock Bun/Node. globalThis guard kept as
  defense-in-depth.
- tests/bun/hot.spec.ts: 2 PR-build-gated integration tests (skip cleanly
  when bun-32856 is absent, verified): awaited async disposal completes
  before reactivation, no resource duplication across 3 generations; broken
  generation still disposes the old root and recovers on next edit.
- CI: install the PR build best-effort (bunx bun-pr 32856) and run the
  hot suite; download failures leave the tests skipped, real failures fail.
- repros: hot-pr-dispose-order.ts, signal-handler-accumulation.ts; README
  corrected and extended.

bun test tests/bun: 60/60 (216 expect) · Node suite: 163/163 ·
yakumo esbuild+tsc: exit 0. No Bun-source changes needed; nothing to
report on PR #32856.
…epro

- bin.bun.watch.js header: --watch is a hard restart (no signal-handler
  accumulation — each generation starts clean); --hot is the in-process
  soft reload; 'only way' claim scoped to stock Bun now that bun#32856
  gives bin.bun.js an in-process disposal path.
- watch-timer-disposers.ts: SIGINT comment corrected — exactly one handler
  (the current generation's) fires under --watch; cross-references
  signal-handler-accumulation.ts for the --hot contrast.

Verified: watch.spec.ts 1/1; hot.spec.ts 2 more runs 0 fail (3 stable
runs total); bun.yml parses as valid YAML.
…dit gap

Third PR-gated hot test: when generation 2 of a plugin drops its import
of a helper module, the helper's import.meta.hot.dispose callback AND the
Cordis root-fiber disposal complete strictly before generation 2
activates, and the helper's timer never fires again — verifying the PR's
'disposal for modules no longer imported' claim downstream.

Also documents a verified --hot limitation: editing cordis.yml triggers
no reload (config files are outside the module graph); the supervisor's
fs.watch does catch config edits.

This completes Cordis's full case list against the PR build: root-fiber
disposal, async cleanup, dynamic plugins, removed plugins, repeated
reloads, failed evaluation — all pass.

bun test tests/bun: 61/61 (223 expect); hot.spec skips 3/3 without the
PR binary.
Owner-approved. Upstream activity remains a comment only — no Bun code,
clone, or defect report (nothing failed).
Upgrades the last 🟡 runtime cell of the compatibility matrix:
- loads lib/browser.js by path — the file the export map's default
  condition serves to non-node consumers (Bun's bare-specifier import
  correctly picks the node condition)
- wires via ctx.plugin (exercises the schemastery Config static)
- verifies error→console.error, warn→console.warn, else→console.log with
  the [T] name prefix; argument pass-through BY IDENTITY (the browser
  contract: the exporter never serializes); log levels; operation with
  document/window hard-absent
- declarative checks: export map routes non-node consumers to browser.js;
  shipped artifact contains zero node: specifiers

Also documents a verified nuance (NOT a Bun defect): bundling bare
@cordisjs/* imports from inside this repo resolves via root tsconfig
paths → packages/*/src, bypassing export maps, in both Bun.build and
esbuild regardless of target. Cross-checked against esbuild before
ruling out a bundler bug.

bun test tests/bun: 68/68 (248 expect calls)
Our upstream PR (a file:// URL's query is part of the module key) is the
missing public-API primitive for per-module reload on Bun — the
capability @cordisjs/plugin-hmr gets on Node from --expose-internals
internals.

- tests/bun/fixtures/selective-reload-driver.ts + selective-reload.spec.ts:
  capability-gated spec proving the full swap contract — fresh module
  instance per ?gen=N, old fiber disposed only after the new applies,
  root effects survive unduplicated, single disposal at shutdown.
  Probes the runner binary (BUN_QUERY_BUSTING_BIN or a local
  .upstream/bun debug build); stock Bun skips cleanly.
- repros/selective-reload.ts: manual narrative version.
- docs: Phase C section rewritten from 'deferred' to 'primitive done';
  suite now 69 tests (68 + the gated one); Phase 10 status recorded.

Verified: 69/69 with the bun#39426 debug build present; the full Cordis
suite also runs green on that build; skip path exercised on stock Bun.
ebowwa added 10 commits August 17, 2026 09:59
… + disclosure

Our #39426 partially duplicates robobun's earlier #35601 (same diagnosis,
broader coverage, also fixes #13391, but stalled/dirty since Jul 26).
Disclosed on both PRs; Cordis PR #2 unaffected (capability gating).
Lesson: run the is:pr <issue> prior-art search before opening a fix.
…lter, history, adjacent issues)

Map: #35601 (our dup, stalled), #35703 (fragments, claimed), #37702
(plugin prefilter vs query dots, adjacent non-conflicting), #16456
(merged Jan 2025 — introduced relative+query), #13391 (astro, covered),
#35345 (lcov re-import bug — becomes reachable via file:// queries once
either fix lands; cross-reference kept local), #7823 (mock.restore).

Verdict: neighborhood fully occupied by the Bun team's in-flight work;
no uncontested target remains. Our plays: fresh-on-main #39426,
downstream validation, Cordis PR #2 capability gating.
build/release/bun (the ~60-90MB usable binary) checked before the debug
build; env override unchanged.
…dling

- build:release from the PR branch → 1.4.0-canary.1+c16333e9e; preserved
  at .upstream/bin/bun-39426 (59 MB, git-excluded); build dir reclaimed
- verified: selective-reload spec + full 69/69 suite against it; real-app
  smoke (bin.bun.js boots, loads plugin, SIGINT root disposal, exit 0)
- spec: resolve() the BUN_QUERY_BUSTING_BIN override — the driver child
  runs from a temp cwd, so a relative path silently broke spawn (caught
  during release verification)
- docs: 'Running Cordis on the contributed Bun today' usage section;
  Phase 10 binary record incl. prior-art note re #35601
Same-day, two-pass re-run of the workload suite across fork-node,
stock Bun 1.3.14, and bun-39426 (release build of our oven-sh/bun#39426)
+ cold/leak on the contributed binary:

- bench/compare.mjs: regenerates the matrix from results/<label>.json
- bench/workloads.mjs: new 'selective-reload' workload (one full
  generation swap: query-busted fresh TS import + activate + graceful
  previous-fiber dispose) — the Phase C primitive as a metric
- docs/BUN_BENCH.md §6: the matrix, the degenerate-row markers, and the
  retraction — module-eval-ts on stock Bun measured 1 eval + 149 cache
  HITS (#21346), so the old ~80x was benchmarking the bug; honest ratio
  ~5x (verified a===b on distinct queries: stock true, fixed/node false)
- §6.2: real selective-reload cost on bun-39426 = 390-415 us/swap,
  ~4.1x faster than Node's honest equivalent
- §6.3: canary >= stock on all 15 honest workloads; leak parity
- BUN_COMPATIBILITY.md header updated to match (no more ~80x claim)
The clone is a standalone repo, not part of Cordis — it now sits at
~/Developer/bun alongside this repo. References updated: spec auto-detect
(sibling checkout first), repro commands, both docs. Capability chain now
also falls back to the preserved .upstream/bin/bun-39426 release binary,
so the selective-reload test activates with no env var on this machine.
Verified: spec passes via the preserved-binary fallback.
ebowwa added 4 commits August 17, 2026 12:24
…d for new work

- detection chain flipped: preserved .upstream/bin/bun-39426 now wins
  over sibling-checkout builds, so Cordis work stays on the verified
  release build even when ~/Developer/bun contains a build of another
  in-flight PR (BUN_QUERY_BUSTING_BIN overrides for explicit testing)
- ~/Developer/bun: on fresh origin/main (8bc4d2a8), closed-PR branch
  claude/file-url-query-module-keys preserved at c16333e9, worktree
  clean — the launchpad for the next claude/<topic> branch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant