From 06ae971bb0e3a0383ca652f3bc89eaf91717c2c6 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 05:08:27 -0700 Subject: [PATCH 01/33] feat(bun): add Bun behavioral test suite and port plan - 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/BUN_PORT_PLAN.md | 149 +++++++++++++++++++ docs/BUN_PORT_STATUS.md | 109 ++++++++++++++ packages/loader/package.json | 1 + tests/bun/core-effects.spec.ts | 135 +++++++++++++++++ tests/bun/core-events.spec.ts | 122 ++++++++++++++++ tests/bun/core-isolate.spec.ts | 115 +++++++++++++++ tests/bun/core-plugin.spec.ts | 131 +++++++++++++++++ tests/bun/core-services.spec.ts | 135 +++++++++++++++++ tests/bun/fixtures/app.yml | 2 + tests/bun/fixtures/relative/app.yml | 2 + tests/bun/fixtures/shutdown-app.ts | 64 +++++++++ tests/bun/fixtures/stateful-plugin.ts | 25 ++++ tests/bun/helpers.ts | 32 +++++ tests/bun/loader-include.spec.ts | 200 ++++++++++++++++++++++++++ tests/bun/loader-mock.spec.ts | 159 ++++++++++++++++++++ tests/bun/logger-console.spec.ts | 61 ++++++++ tests/bun/shutdown.spec.ts | 52 +++++++ tests/bun/timer.spec.ts | 161 +++++++++++++++++++++ 18 files changed, 1655 insertions(+) create mode 100644 docs/BUN_PORT_PLAN.md create mode 100644 docs/BUN_PORT_STATUS.md create mode 100644 tests/bun/core-effects.spec.ts create mode 100644 tests/bun/core-events.spec.ts create mode 100644 tests/bun/core-isolate.spec.ts create mode 100644 tests/bun/core-plugin.spec.ts create mode 100644 tests/bun/core-services.spec.ts create mode 100644 tests/bun/fixtures/app.yml create mode 100644 tests/bun/fixtures/relative/app.yml create mode 100644 tests/bun/fixtures/shutdown-app.ts create mode 100644 tests/bun/fixtures/stateful-plugin.ts create mode 100644 tests/bun/helpers.ts create mode 100644 tests/bun/loader-include.spec.ts create mode 100644 tests/bun/loader-mock.spec.ts create mode 100644 tests/bun/logger-console.spec.ts create mode 100644 tests/bun/shutdown.spec.ts create mode 100644 tests/bun/timer.spec.ts diff --git a/docs/BUN_PORT_PLAN.md b/docs/BUN_PORT_PLAN.md new file mode 100644 index 00000000..5362121a --- /dev/null +++ b/docs/BUN_PORT_PLAN.md @@ -0,0 +1,149 @@ +# Cordis Bun Port — Phased Implementation Plan + +This fork aims to be a **first-class Bun-compatible implementation** of Cordis while: + +- preserving the existing Node implementation and its test suite verbatim, +- keeping a clean merge path against `cordiverse/cordis` (no invasive conditionals in core), +- never changing public Cordis semantics to accommodate Bun, +- never touching undocumented Bun module-loader internals. + +## Recorded baseline (2026-08-16) + +| Item | Value | +| --- | --- | +| Fork HEAD at start | `8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4` (`chore: update readme (#45)`) | +| Divergence vs `upstream/main` | 0 / 0 (identical) | +| Upstream remote | `https://github.com/cordiverse/cordis.git` (added, fetched) | +| Origin | `https://github.com/ebowwa/cordis.git` | +| Working branch | `feat/bun-compat` | +| Node | `v26.4.0` (/opt/homebrew/bin/node) | +| npm | `11.17.0` | +| Yarn | pinned `yarn@4.14.1` in `package.json` — **not resolvable**: the configured registry's yarn dist-tags max out at `2.4.3` (no 4.x published there), and `corepack` is not shipped with Node 26.4.0 | +| Bun | `1.3.14` — revision `1.3.14+0d9b296af` (`~/.bun/bin/bun`) — **pinned target for this port** | +| Bun's reported `process.versions.node` | `24.3.0` | + +### Baseline commands and results (Node) + +``` +bun install # 864 packages, lockfile saved (locally ignored) +node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js esbuild # 11 outputs, exit 0 +node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js tsc # all .d.ts emitted, exit 0 +node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js vitest --import tsx + # 19 files / 163 tests PASSED, exit 0 +``` + +Note on tooling substitution: because Yarn 4.14.1 cannot be resolved from this +environment's registry, dependency installation is done with Bun (which the port +requires anyway) and yakumo/vitest/esbuild/tsc are invoked exactly as the +`yarn yakumo` script would invoke them (`node --expose-internals --import tsx +--import @cordisjs/unyaml node_modules/yakumo/lib/cli.js …`). The Node build and +test toolchain itself is unchanged; only the installer differs. `yarn.lock` is +not committed upstream and `bun.lock` is locally ignored, so the merge path is +unaffected. + +## Architectural findings (read-only audit) + +1. **`cordis` core (`packages/core`) has zero Node-specific imports in `src/`.** + It is pure JS + `cosmokit` + `@standard-schema/spec`. Expected to run under + Bun unmodified. + +2. **The single true Node seam is `packages/loader/src/internal.ts`.** + `ModuleLoader.fromInternal()` requires Node internals + (`internal/modules/esm/loader`, gated on `--expose-internals` or the optional + `node-addon-require-builtin` peer). Verified under Bun 1.3.14: the internal + require throws `MODULE_NOT_FOUND` (caught), so `fromInternal()` returns + `undefined`. + +3. **The loader already has a graceful fallback for that case.** + `EntryTree.import()` (`packages/loader/src/config/tree.ts`) checks + `ctx.loader.internal` and, when absent, resolves relative specifiers against + `ctx.baseUrl` and performs a standard `await import(...)`. Under Bun this + exercises Bun's native TS/ESM/CJS loader. **This is the runtime adapter seam + the port formalizes — no new conditional spaghetti is needed to make the + loader work.** + +4. **`@cordisjs/plugin-hmr` hard-requires Node internals** (`throw new + Error('--expose-internals is required for HMR service')` when + `loader.internal` is undefined) and manipulates the internal ESM `loadCache` + plus CJS `require.cache` directly. It is correctly classified **Node-only** + for now; Bun reload is provided by the Phase 5 progression instead. + +5. Node-API usage elsewhere is Bun-supported surface: + - `include`: `node:path`, `node:fs/promises`, `node:url` + `js-yaml` (pure JS) + - `logger-console`: `node:util.inspect`, `supports-color`, `schemastery` + - `timer`: globals only (`setTimeout`/`setInterval`/`Promise.withResolvers`) + - `create`: `node:fs/promises`, `node:child_process.execSync`, `node:stream` + - `loader/src/index.ts`: `process.env.CORDIS_SHARED` + - `loader/src/config/utils.ts`: `new Function` + `with`/`eval` (standard JS) + - `core/bin.js`: `node:url`, `process.cwd()` — plain ESM entry + +6. The root CLI entry `packages/core/bin.js` is runtime-neutral ESM (Context + + Loader + include on `./cordis.yml`). It is the basis for the Bun entrypoint. + +## Phases + +### Phase 0 — Audit and baseline (this document + `BUN_PORT_STATUS.md`) +Read-only audit, toolchain recording, upstream remote, baseline build/test, +working branch. **No behavioral changes.** + +### Phase 1 — Core under Bun +- Per-package smoke: install / build / import / basic runtime under Bun. +- Behavioral suite (`tests/bun/`, run with `bun test`) covering: function / + object / class plugins; sync and async effects; reverse disposal order; + nested plugin cleanup; provide/inject activation; dependency removal and + reactivation; provider replacement; isolated contexts; all dispatch modes + (`emit`/`parallel`/`serial`/`bail`/`waterfall`). + +### Phase 2 — Timer and logger-console under Bun +- Timer: `timeout`/`interval` (callback + iterable forms), `throttle`/ + `debounce`, disposal clears all pending timers. +- logger-console: console export with Bun's `node:util.inspect`, colors off in + CI. + +### Phase 3 — Loader and include under Bun +- JSON and YAML configuration loading; `evaluate`/`interpolate` (`__jsExpr`); + dynamic TypeScript plugin loading through the fallback import path; entry + update/disable; group and isolate; repeated load/dispose cycles asserting no + listener/timer duplication; full root-context disposal at process shutdown. + +### Phase 4 — Bun CLI/entrypoint + CI matrix +- `packages/core/bin.bun.js` (or verified reuse of `bin.js`) + npm script + aliases that do not disturb the Node scripts. +- GitHub Actions matrix job: Bun (pinned 1.3.14) alongside the existing Node + 24/26 jobs, running install + build + `bun test` behavioral suite. Existing + jobs untouched. + +### Phase 5 — Development reload progression +- **A.** `bun --watch` entrypoint with graceful root-fiber disposal before + process restart (SIGINT/SIGTERM handlers around `ctx.root.fiber.dispose()` / + root restart semantics). +- **B.** Evaluate `bun --hot`: document whether re-evaluation duplicates Cordis + state (registries, listeners, timers) — with a minimal repro if it does. +- **C.** Only if A+B are inadequate: design a selective-HMR runtime adapter + abstraction. Node's existing `ModuleLoader` internal adapter stays the Node + implementation; a Bun adapter would be built strictly on public APIs + (fs watching, `Bun.build` dependency metadata, content-hashed artifacts, + unique artifact paths, export validation before replacement, old-fiber config + preservation, dispose→activate ordering, rollback on failure). **Do not** + emulate Node's private ModuleLoader for Bun. + +## Bun contribution policy (restated) + +A fix goes to Bun only when: reduced to a standalone minimal repro outside +Cordis; failing on the latest Bun release; checked against Bun main when +practical; supported by Bun's public API; unsolvable in a Cordis adapter; after +searching existing Bun issues/PRs; with a regression test included. All Bun-side +work happens in a separate checkout/branch. No external PRs or publishes +without the owner's explicit approval. See `BUN_COMPATIBILITY.md` for the +running list of repros. + +## Invariants + +- The Node suite (`yarn test` equivalent) stays green at every phase boundary. +- The Bun suite must not weaken or delete failing tests — failures are fixed or + documented as blockers. +- Every phase ends by running both suites and recording results in + `BUN_PORT_STATUS.md`. diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md new file mode 100644 index 00000000..c24bb0f9 --- /dev/null +++ b/docs/BUN_PORT_STATUS.md @@ -0,0 +1,109 @@ +# Cordis Bun Port — Status Log + +Session log of completed work, exact commands, current failures, next action, +and commit SHAs. Update before ending or compacting any work session. + +## Environment (recorded once, 2026-08-16) + +- Fork HEAD at audit start: `8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4` +- `upstream` = `cordiverse/cordis`, fetched; `origin` = `ebowwa/cordis` +- Working branch: `feat/bun-compat` (created from `main` == `upstream/main`) +- Node `v26.4.0`, npm `11.17.0` +- Yarn: pinned `4.14.1` unavailable on this registry (max `2.4.3`); no + corepack in Node 26.4.0 → dependencies installed with Bun; yakumo/vitest + invoked directly with the same flags `package.json` scripts use +- **Bun pinned for this port: `1.3.14` (revision `1.3.14+0d9b296af`)** + +## Completed + +### Phase 0 — read-only audit (DONE) + +Commands and results: see git history of this file (first version) and +`BUN_PORT_PLAN.md`. Summary: + +``` +git status --porcelain=v1 -b # -> clean +git rev-parse HEAD # -> 8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4 +git remote add upstream https://github.com/cordiverse/cordis.git && git fetch upstream +git rev-list --left-right --count HEAD...upstream/main # -> 0 0 +bun install # 864 packages, exit 0 +yakumo esbuild / tsc (via node --import tsx ...) # exit 0 +yakumo vitest --import tsx # 19 files / 163 tests passed, exit 0 +``` + +Key findings: core has zero `node:*` imports; `ModuleLoader.fromInternal()` → +`undefined` under Bun; `EntryTree.import()` falls back to standard +`await import()`; hmr is Node-only by construction. + +### Phases 1–3 — Bun behavioral suite (DONE, 57/57 green) + +Suite: `tests/bun/` (run with `bun test tests/bun`), 10 spec files, 57 tests: + +- `core-plugin.spec.ts` — function/object/class plugins, invalid plugins, + nested listeners, root dispose idempotency, Service.init +- `core-effects.spec.ts` — sync/async effects, within-effect reverse disposal, + async-generator abort semantics, hook-snapshot parity across reload cycles +- `core-events.spec.ts` — on/once/emit/parallel/serial/bail/waterfall +- `core-services.spec.ts` — provide/inject activation, dependency removal + + reactivation, provider replacement, chained injects +- `core-isolate.spec.ts` — isolated contexts, shared labels, isolated events +- `timer.spec.ts` — timeout/interval (callback/promise/iterator), + throttle/debounce, full timer cleanup on dispose +- `logger-console.spec.ts` — render parity with Bun's `node:util.inspect` +- `loader-mock.spec.ts` — in-memory loader tree: init/update/self-update/ + self-dispose, intercept-await activation gating +- `loader-include.spec.ts` — real files: YAML + JSON config loading, relative + + absolute plugin references, dynamic TS plugin import, `__jsExpr` + evaluation, 3× disable/enable cycles with no duplicated listeners/timers, + patch-based disable +- `shutdown.spec.ts` — SIGINT → complete root-fiber disposal → exit 0 + +``` +$ bun test tests/bun + 57 pass + 0 fail + 188 expect() calls +Ran 57 tests across 10 files. [4.35s] +``` + +### Cordis changes made so far (vs upstream) + +1. `packages/loader/package.json` — added `@cordisjs/plugin-include` to + `devDependencies` (mirrors the existing `@cordisjs/plugin-logger-console` + entry). Reason: Bun installs isolated per-workspace `node_modules`; the + loader's fallback `import('@cordisjs/plugin-include')` executes from + `packages/loader`, where the package was not visible (only `hmr` declared + it). Under Yarn's hoisting this worked by accident. No runtime code + changed; Node behavior unchanged. +2. `tests/bun/**`, `docs/BUN_PORT_*.md` — new files only. + +### Verified Node↔Bun parity facts (documented for BUN_COMPATIBILITY.md) + +- Cross-fiber disposal order is async-depth ordered (`outer,inner,innermost`), + **byte-identical under Node v26.4.0 and Bun 1.3.14** — not LIFO across + fibers (upstream never asserts cross-fiber order; within a single effect it + is strict reverse). +- `!js` YAML shorthand: `js-yaml@4.3.1` rejects `!js '...'` under BOTH Node + and Bun ("unknown tag"); the working explicit form is + `! '...'` (what Include's dump writes). The `!js` + shorthand is only handled by `@cordisjs/unyaml` at test-import time. +- Bun resolves extensionless relative `.ts` imports (`./mod` → `mod.ts`) and + `file://` URL imports — the loader fallback path works unmodified. +- Bun's `util.inspect` output matches Node's for the logger-console formats + tested (`{ foo: 'bar' }`, `{ a: 1 }`). + +## Current failures + +None. Node suite re-run after the loader devDependency change: see below. + +## Next action + +Phase 4: Bun CLI/entrypoint verification (app-like fixture), root +`test:bun` script, Node+Bun CI matrix workflow. + +Phase 5: reload progression A (`bun --watch` + graceful root-fiber disposal), +B (`bun --hot` evaluation), C (HMR decision). + +## Commit SHAs + +(pending — first commit after this status update) diff --git a/packages/loader/package.json b/packages/loader/package.json index 43165b06..b3bdf969 100644 --- a/packages/loader/package.json +++ b/packages/loader/package.json @@ -35,6 +35,7 @@ "service" ], "devDependencies": { + "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-logger-console": "^1.0.0", "@types/js-yaml": "^4.0.9", "cordis": "^4.0.0-rc.8" diff --git a/tests/bun/core-effects.spec.ts b/tests/bun/core-effects.spec.ts new file mode 100644 index 00000000..06675553 --- /dev/null +++ b/tests/bun/core-effects.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'bun:test' +import { Context } from 'cordis' +import { spy, sleep, getHookSnapshot } from './helpers' + +describe('Bun / core: effects', () => { + it('sync effect disposer runs exactly once on plugin disposal', async () => { + const root = new Context() + const dispose = spy(() => {}) + const fiber = await root.plugin((ctx) => { + ctx.effect(() => dispose, 'test') + }) + expect(fiber.getEffects()).toEqual([{ label: 'test', children: [] }]) + expect(dispose.calls.length).toBe(0) + await fiber.dispose() + expect(dispose.calls.length).toBe(1) + await fiber.dispose() + expect(dispose.calls.length).toBe(1) + }) + + it('effects are disposed in reverse registration order', async () => { + const root = new Context() + const seq: number[] = [] + const dispose = root.effect(function* () { + yield () => seq.push(1) + yield () => seq.push(2) + yield () => seq.push(3) + }) + expect(seq).toEqual([]) + dispose() + expect(seq).toEqual([3, 2, 1]) + dispose() + expect(seq).toEqual([3, 2, 1]) + }) + + it('nested plugin cleanup is complete, idempotent, and Node-identical', async () => { + const seq: string[] = [] + const root = new Context() + const fiber = await root.plugin((ctx) => { + ctx.effect(() => () => seq.push('outer')) + ctx.plugin((ctx2) => { + ctx2.effect(() => () => seq.push('inner')) + ctx2.plugin((ctx3) => { + ctx3.effect(() => () => seq.push('innermost')) + }) + }) + }) + expect(seq).toEqual([]) + await fiber.dispose() + // Cross-fiber teardown is scheduled by async depth, so the observable + // order is outer-most sync disposers first — verified byte-identical + // under Node v26.4.0 (see docs/BUN_COMPATIBILITY.md). Within a single + // effect, disposers run in strict reverse (covered above). + expect(seq).toEqual(['outer', 'inner', 'innermost']) + // every disposer ran exactly once; repeated dispose is a no-op + await fiber.dispose() + expect(seq).toEqual(['outer', 'inner', 'innermost']) + }) + + it('async effect (promise of disposer)', async () => { + const seq: number[] = [] + const root = new Context() + const dispose = root.effect(async () => { + await sleep(20) + seq.push(1) + return () => seq.push(2) + }) + expect(seq).toEqual([]) + await dispose() + expect(seq).toEqual([1, 2]) + }) + + it('async generator effect: mid-segment dispose lets the current segment finish', async () => { + const seq: number[] = [] + const root = new Context() + const dispose = root.effect(async function* () { + await sleep(10) + seq.push(1) + yield () => seq.push(2) + await sleep(40) + seq.push(3) + yield () => seq.push(4) + }) + await sleep(15) // first segment done, second pending + expect(seq).toEqual([1]) + dispose() + // the pending segment completes, then its disposer and the earlier one + // run in reverse: [1, 3, 4, 2] + await sleep(80) + expect(seq).toEqual([1, 3, 4, 2]) + await sleep(40) + expect(seq).toEqual([1, 3, 4, 2]) + }) + + it('async generator effect collects all disposers in reverse when awaited', async () => { + const seq: number[] = [] + const root = new Context() + const dispose = root.effect(async function* () { + yield () => seq.push(2) + await sleep(10) + yield () => seq.push(4) + await sleep(10) + yield () => seq.push(6) + }) + seq.push(1) + await sleep(30) + seq.push(3) + await dispose() + expect(seq).toEqual([1, 3, 6, 4, 2]) + }) + + it('hook registry returns to its snapshot after dispose/reload cycles', async () => { + async function plugin(ctx: Context) { + ctx.on('custom-event', () => {}) + await ctx.plugin(async (ctx) => { + ctx.on('custom-event', () => {}) + await ctx.plugin((ctx) => { + ctx.on('custom-event', () => {}) + }) + }) + } + + const root = new Context() + const before = getHookSnapshot(root) + const fiber = await root.plugin(plugin) + const after = getHookSnapshot(root) + expect(after).not.toEqual(before) + + await fiber.dispose() + await sleep() + expect(getHookSnapshot(root)).toEqual(before) + + await root.plugin(plugin) + expect(getHookSnapshot(root)).toEqual(after) + }) +}) diff --git a/tests/bun/core-events.spec.ts b/tests/bun/core-events.spec.ts new file mode 100644 index 00000000..5147b29e --- /dev/null +++ b/tests/bun/core-events.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'bun:test' +import { Context } from 'cordis' +import { spy, sleep } from './helpers' + +describe('Bun / core: events', () => { + it('ctx.on() / dispose', () => { + const root = new Context() + const callback = spy(() => {}) + const dispose = root.on('custom-event', callback) + root.emit('custom-event') + expect(callback.calls.length).toBe(1) + dispose() + root.emit('custom-event') + expect(callback.calls.length).toBe(1) + }) + + it('ctx.once()', () => { + const root = new Context() + const callback = spy(() => {}) + root.once('custom-event', callback) + root.emit('custom-event') + root.emit('custom-event') + expect(callback.calls.length).toBe(1) + }) + + it('ctx.emit() is synchronous', async () => { + const root = new Context() + const seq: string[] = [] + root.on('custom-event', () => seq.push('listener')) + seq.push('before') + root.emit('custom-event') + seq.push('after') + expect(seq).toEqual(['before', 'listener', 'after']) + }) + + it('ctx.parallel() awaits all listeners and aggregates errors', async () => { + const root = new Context() + const callback = spy(async () => { + await sleep(5) + throw new Error('async') + }) + const dispose = root.on('custom-event', callback) + const error = await root.parallel('custom-event').catch(e => e) + expect(error).toBeInstanceOf(AggregateError) + expect(error.errors.map((e: Error) => e.message)).toEqual(['async']) + dispose() + + // a rejecting listener must not short-circuit a later one + const seq: string[] = [] + root.on('custom-event', async () => { throw new Error('a') }) + const d2 = root.on('custom-event', async () => { + await sleep(10) + seq.push('late') + }) + const err2 = await root.parallel('custom-event').catch(e => e) + expect(err2).toBeInstanceOf(AggregateError) + expect(seq).toEqual(['late']) + d2() + }) + + it('ctx.serial() awaits listeners in order and stops at first bail value', async () => { + const root = new Context() + const order: number[] = [] + root.on('custom-event', async () => { + await sleep(10) + order.push(1) + return undefined + }) + root.on('custom-event', async () => { + order.push(2) + return 'second' + }) + root.on('custom-event', () => { + order.push(3) + return 'third' + }) + const result = await root.serial('custom-event') + expect(order).toEqual([1, 2]) + expect(result).toBe('second') + }) + + it('ctx.bail() returns first bail-worthy result synchronously', () => { + const root = new Context() + root.on('custom-event', () => undefined) + root.on('custom-event', () => false) + // null, false and undefined never bail — dispatch continues to the end + expect(root.bail('custom-event')).toBeUndefined() + root.on('custom-event', () => 0, true) // prepended; 0 is a bail value + expect(root.bail('custom-event')).toBe(0) + }) + + it('ctx.waterfall() composes values through next()', () => { + const root = new Context() + root.on('custom-event', (value: number, next: () => number) => value + next()) + root.on('custom-event', (value: number, next: () => number) => value + next()) + expect(root.waterfall('custom-event', 1, () => 2)).toBe(4) + + // a listener that returns without calling next() short-circuits the rest + const root2 = new Context() + const late = spy(() => 99) + root2.on('custom-event', (value: number, next: () => number) => value + next()) + root2.on('custom-event', (value: number, next: () => number) => value + next()) + root2.on('custom-event', (value: number) => value) + root2.on('custom-event', late) + expect(root2.waterfall('custom-event', 1, () => 2)).toBe(3) + expect(late.calls.length).toBe(0) + }) + + it('event listeners registered inside a plugin are removed on dispose', async () => { + const root = new Context() + const callback = spy(() => {}) + const fiber = await root.plugin((ctx) => { + ctx.on('custom-event', callback) + }) + root.emit('custom-event') + expect(callback.calls.length).toBe(1) + callback.reset() + await fiber.dispose() + root.emit('custom-event') + expect(callback.calls.length).toBe(0) + }) +}) diff --git a/tests/bun/core-isolate.spec.ts b/tests/bun/core-isolate.spec.ts new file mode 100644 index 00000000..9f2e76a1 --- /dev/null +++ b/tests/bun/core-isolate.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'bun:test' +import { Context, Service } from 'cordis' +import { spy, sleep, event } from './helpers' + +describe('Bun / core: isolation', () => { + it('isolated contexts see distinct service instances', async () => { + const root = new Context() + const callback = spy(() => {}) + const dispose = spy(() => {}) + const plugin = { + inject: ['foo'], + apply: (ctx: Context) => { + callback() + return dispose + }, + } + + await root.plugin(plugin) + const ctx1 = root.isolate('foo') + await ctx1.plugin(plugin) + const ctx2 = root.isolate('foo') + await ctx2.plugin(plugin) + + const dispose0 = root.provide('foo', { bar: 100 }) + expect((root as any).foo).toEqual({ bar: 100 }) + expect((ctx1 as any).foo).toBeUndefined() + expect((ctx2 as any).foo).toBeUndefined() + await sleep() + expect(callback.calls.length).toBe(1) + expect(dispose.calls.length).toBe(0) + + const dispose1 = ctx1.provide('foo', { bar: 200 }) + expect((root as any).foo).toEqual({ bar: 100 }) + expect((ctx1 as any).foo).toEqual({ bar: 200 }) + expect((ctx2 as any).foo).toBeUndefined() + await sleep() + expect(callback.calls.length).toBe(2) + expect(dispose.calls.length).toBe(0) + + dispose0() + expect((root as any).foo).toBeUndefined() + expect((ctx1 as any).foo).toEqual({ bar: 200 }) + await sleep() + expect(callback.calls.length).toBe(2) + expect(dispose.calls.length).toBe(1) + + const dispose2 = ctx2.provide('foo', { bar: 300 }) + expect((ctx2 as any).foo).toEqual({ bar: 300 }) + await sleep() + expect(callback.calls.length).toBe(3) + expect(dispose.calls.length).toBe(1) + dispose2() + }) + + it('contexts sharing an isolation label share the service', async () => { + const root = new Context() + const callback = spy(() => {}) + const dispose = spy(() => {}) + const plugin = { + inject: ['foo'], + apply: (ctx: Context) => { + callback() + return dispose + }, + } + + const label = Symbol('test') + await root.plugin(plugin) + const ctx1 = root.isolate('foo', label) + await ctx1.plugin(plugin) + const ctx2 = root.isolate('foo', label) + await ctx2.plugin(plugin) + await sleep() + expect(callback.calls.length).toBe(0) + + const dispose0 = root.provide('foo', { bar: 100 }) + expect((ctx1 as any).foo).toBeUndefined() + await sleep() + expect(callback.calls.length).toBe(1) + + const dispose12 = ctx1.provide('foo', { bar: 200 }) + expect((ctx1 as any).foo).toEqual({ bar: 200 }) + expect((ctx2 as any).foo).toEqual({ bar: 200 }) + await sleep() + expect(callback.calls.length).toBe(3) + expect(dispose.calls.length).toBe(0) + + dispose12() + expect((ctx1 as any).foo).toBeUndefined() + await sleep() + expect(callback.calls.length).toBe(3) + expect(dispose.calls.length).toBe(2) + dispose0() + }) + + it('events emitted from an isolated service reach only matching listeners', async () => { + class Foo extends Service { + constructor(ctx: Context) { + super(ctx, 'foo') + this.ctx.emit(this, event) + } + } + + const root = new Context() + const ctx = root.isolate('foo') + const outer = spy(() => {}) + const inner = spy(() => {}) + root.on(event, outer) + ctx.on(event, inner) + await ctx.plugin(Foo) + + expect(outer.calls.length).toBe(0) + expect(inner.calls.length).toBe(1) + }) +}) diff --git a/tests/bun/core-plugin.spec.ts b/tests/bun/core-plugin.spec.ts new file mode 100644 index 00000000..de3528fc --- /dev/null +++ b/tests/bun/core-plugin.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'bun:test' +import { Context, Service } from 'cordis' +import { spy, event } from './helpers' + +describe('Bun / core: plugins', () => { + it('function plugin receives context and config', async () => { + const root = new Context() + let received: any + const callback = spy((ctx: Context, config: any) => { + received = config + // note: a plugin's return value is treated as an effect, so this + // deliberately returns undefined + }) + const options = { foo: 'bar' } + await root.plugin(callback, options) + expect(callback.calls.length).toBe(1) + expect(received).toEqual(options) + }) + + it('object plugin (apply method)', async () => { + const root = new Context() + const callback = spy(() => {}) + const options = { bar: 'foo' } + await root.plugin({ apply: callback }, options) + expect(callback.calls.length).toBe(1) + expect(callback.calls[0][1]).toEqual(options) + }) + + it('class plugin is constructed with context and config', async () => { + const root = new Context() + const options = { baz: 1 } + let received: any + class Klass { + constructor(ctx: Context, config: any) { + received = { ctx, config } + } + } + await root.plugin(Klass, options) + expect(received.config).toEqual(options) + expect(Context.is(received.ctx)).toBe(true) + }) + + it('named plugin appears in fiber name', async () => { + const root = new Context() + await root.plugin(function foo(ctx: Context) { + expect(ctx.fiber.name).toBe('foo') + }) + await root.plugin({ + name: 'bar', + apply(ctx: Context) { + expect(ctx.fiber.name).toBe('bar') + }, + }) + }) + + it('invalid plugins are rejected', () => { + const root = new Context() + expect(() => root.plugin(undefined as any)).toThrow() + expect(() => root.plugin({} as any)).toThrow() + expect(() => root.plugin({ apply: {} } as any)).toThrow() + }) + + it('nested plugins: listeners accumulate and are cleaned up together', async () => { + const plugin = async (ctx: Context) => { + ctx.on(event, callback) + await ctx.plugin(async (ctx) => { + ctx.on(event, callback) + await ctx.plugin((ctx) => { + ctx.on(event, callback) + }) + }) + } + + const root = new Context() + const callback = spy(() => {}) + root.on(event, callback) + const fiber = await root.plugin(plugin) + + expect(root.registry.size).toBe(3) + root.emit(event) + expect(callback.calls.length).toBe(4) + + callback.reset() + await fiber.dispose() + expect(root.registry.size).toBe(0) + root.emit(event) + expect(callback.calls.length).toBe(1) // only the root-level listener + + callback.reset() + await fiber.dispose() // idempotent + root.emit(event) + expect(callback.calls.length).toBe(1) + }) + + it('root dispose disposes everything and is idempotent', async () => { + const root = new Context() + const dispose = spy(() => {}) + const fiber = root.plugin(() => dispose) + expect(root.fiber.uid).toBe(0) + expect(fiber.uid).toBe(1) + expect(dispose.calls.length).toBe(0) + + await root.fiber.dispose() + expect(root.fiber.uid).toBe(0) + expect(fiber.uid).toBe(null) + expect(dispose.calls.length).toBe(1) + + await root.fiber.dispose() + expect(dispose.calls.length).toBe(1) + }) + + it('Service.init hook runs on activation and its disposer on disposal', async () => { + const start = spy(() => {}) + const stop = spy(() => {}) + + class Foo { + [Service.init]() { + start() + return stop + } + } + + const root = new Context() + const fiber = await root.plugin(Foo) + expect(start.calls.length).toBe(1) + expect(stop.calls.length).toBe(0) + await fiber.dispose() + expect(start.calls.length).toBe(1) + expect(stop.calls.length).toBe(1) + }) +}) diff --git a/tests/bun/core-services.spec.ts b/tests/bun/core-services.spec.ts new file mode 100644 index 00000000..0234cbb2 --- /dev/null +++ b/tests/bun/core-services.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'bun:test' +import { Context, Service } from 'cordis' +import { spy, sleep } from './helpers' + +describe('Bun / core: provide/inject', () => { + it('inject activation is deferred until the service is ready', async () => { + class Foo extends Service { + constructor(ctx: Context) { + super(ctx, 'foo') + } + + async [Service.init]() { + await new Promise(resolve => { + this.ctx.on('custom-event', resolve) + }) + } + } + + const root = new Context() + const callback = spy(() => {}) + root.inject(['foo'], callback) + expect(callback.calls.length).toBe(0) + + root.plugin(Foo) + await sleep() + expect(callback.calls.length).toBe(0) // blocked by Service.init + + root.emit('custom-event') + await sleep() + expect(callback.calls.length).toBe(1) + }) + + it('inject blocks while dependency is absent and activates when provided', async () => { + const root = new Context() + const callback = spy(() => {}) + + const fiber = root.inject(['foo'], callback) + await sleep() + expect(callback.calls.length).toBe(0) + expect(fiber.state).not.toBe('active' as any) + + const dispose = root.provide('foo', { bar: 1 }) + await sleep() + expect(callback.calls.length).toBe(1) + expect(root.foo).toEqual({ bar: 1 }) + + // dependency removal deactivates the injector + dispose() + await sleep() + expect(root.foo).toBeUndefined() + }) + + it('dependency removal and reactivation preserves callback semantics', async () => { + const root = new Context() + const events: string[] = [] + + root.inject(['foo'], (ctx) => { + events.push('start') + return () => events.push('stop') + }) + + const d1 = root.provide('foo', 1) + await sleep() + expect(events).toEqual(['start']) + + d1() + await sleep() + expect(events).toEqual(['start', 'stop']) + + const d2 = root.provide('foo', 2) + await sleep() + expect(events).toEqual(['start', 'stop', 'start']) + + d2() + await sleep() + expect(events).toEqual(['start', 'stop', 'start', 'stop']) + }) + + it('provider replacement restarts injectors with the new value', async () => { + const root = new Context() + const seen: any[] = [] + root.inject(['foo'], (ctx) => { + seen.push(ctx.foo) + }) + + const d1 = root.provide('foo', 'a') + await sleep() + d1() + const d2 = root.provide('foo', 'b') + await sleep() + d2() + + expect(seen).toEqual(['a', 'b']) + expect(root.foo).toBeUndefined() + }) + + it('multiple chained injects activate in dependency order', async () => { + const foo = spy(() => {}) + const bar = spy(() => {}) + const qux = spy(() => {}) + + class Foo extends Service { + static inject = ['qux'] + constructor(ctx: Context) { + super(ctx, 'foo') + } + [Service.init] = foo + } + + class Bar extends Service { + static inject = ['foo', 'qux'] + constructor(ctx: Context) { + super(ctx, 'bar') + } + [Service.init] = bar + } + + class Qux extends Service { + constructor(ctx: Context) { + super(ctx, 'qux') + } + [Service.init] = qux + } + + const root = new Context() + await root.plugin(Foo) + await root.plugin(Bar) + await root.plugin(Qux) + await sleep() + + expect(foo.calls.length).toBe(1) + expect(bar.calls.length).toBe(1) + expect(qux.calls.length).toBe(1) + }) +}) diff --git a/tests/bun/fixtures/app.yml b/tests/bun/fixtures/app.yml new file mode 100644 index 00000000..2b75ce64 --- /dev/null +++ b/tests/bun/fixtures/app.yml @@ -0,0 +1,2 @@ +- id: plugin + name: ./stateful-plugin.ts diff --git a/tests/bun/fixtures/relative/app.yml b/tests/bun/fixtures/relative/app.yml new file mode 100644 index 00000000..7b83ebd7 --- /dev/null +++ b/tests/bun/fixtures/relative/app.yml @@ -0,0 +1,2 @@ +- id: plugin + name: ../stateful-plugin.ts diff --git a/tests/bun/fixtures/shutdown-app.ts b/tests/bun/fixtures/shutdown-app.ts new file mode 100644 index 00000000..d99f2216 --- /dev/null +++ b/tests/bun/fixtures/shutdown-app.ts @@ -0,0 +1,64 @@ +/** + * Process-shutdown fixture: boots a real Loader + Include + plugin with + * listeners and timers, then on SIGINT performs a complete root-fiber + * disposal before exiting cleanly. + * + * Output contract (parsed by shutdown.spec.ts): + * ready + * events:[...] + * registry: + * exit:0 + */ +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +const ctx = new Context() +ctx.baseUrl = new URL('.', import.meta.url).href + +const events: string[] = [] + +await ctx.plugin(Loader) + +await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { + path: './app.yml', + enableLogs: false, + }, +}) + +// a plugin with nested children, listeners and a live interval +await ctx.plugin(function outer(ctx: Context) { + ctx.on('custom-event', () => {}) + ctx.effect(() => { + const timer = setInterval(() => {}, 10) + return () => { + clearInterval(timer) + events.push('outer-stop') + } + }) + ctx.plugin((ctx2) => { + ctx2.on('custom-event', () => {}) + ctx2.effect(() => { + const timer = setInterval(() => {}, 10) + return () => { + clearInterval(timer) + events.push('inner-stop') + } + }) + }) +}) + +process.on('SIGINT', async () => { + try { + await ctx.fiber.dispose() + } catch (error) { + console.log('dispose-error:' + (error as Error).message) + process.exit(1) + } + console.log('events:' + JSON.stringify(events)) + console.log('registry:' + ctx.registry.size) + process.exit(0) +}) + +console.log('ready') diff --git a/tests/bun/fixtures/stateful-plugin.ts b/tests/bun/fixtures/stateful-plugin.ts new file mode 100644 index 00000000..cd18fe97 --- /dev/null +++ b/tests/bun/fixtures/stateful-plugin.ts @@ -0,0 +1,25 @@ +import { Context } from 'cordis' + +/** + * Module-level state survives plugin reloads within one process (import + * cache), which is exactly what makes listener/timer duplication detectable: + * if an old listener or timer survived a dispose cycle, the counters would + * grow faster than the number of applies. + */ +export const state = { + applies: 0, + hits: 0, + ticks: 0, + lastConfig: undefined as any, +} + +export function apply(ctx: Context, config: any) { + state.applies++ + state.lastConfig = config + ctx.on('test/get-value', () => state.applies) + ctx.on('test/ping', () => { state.hits++ }) + ctx.effect(() => { + const timer = setInterval(() => { state.ticks++ }, 15) + return () => clearInterval(timer) + }) +} diff --git a/tests/bun/helpers.ts b/tests/bun/helpers.ts new file mode 100644 index 00000000..7ae31851 --- /dev/null +++ b/tests/bun/helpers.ts @@ -0,0 +1,32 @@ +/** + * Bun-native behavioral test helpers. + * + * Deliberately avoids `node:test` mocks and vitest utilities so that the only + * code under test is Cordis itself running on Bun. Timing assertions use real + * timers with generous margins (Bun's test runner has no fake timers). + */ + +export function spy any>(impl?: F) { + const calls: any[][] = [] + const wrapped = ((...args: any[]) => { + calls.push(args) + return impl?.(...args as any[]) + }) as F & { calls: any[][]; reset(): void } + wrapped.calls = calls + wrapped.reset = () => { calls.length = 0 } + return wrapped +} + +export function sleep(ms = 0) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export const event = 'custom-event' + +export function getHookSnapshot(ctx: any) { + const result: Record = {} + for (const [name, callbacks] of Object.entries(ctx.events._hooks)) { + if ((callbacks as any[]).length) result[name] = (callbacks as any[]).length + } + return result +} diff --git a/tests/bun/loader-include.spec.ts b/tests/bun/loader-include.spec.ts new file mode 100644 index 00000000..b84c4373 --- /dev/null +++ b/tests/bun/loader-include.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, afterAll } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context, Fiber } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LoggerConsole from '@cordisjs/plugin-logger-console' +import { sleep } from './helpers' +import { state as pluginState } from './fixtures/stateful-plugin' + +/** + * Real Include service under Bun: config files are written to a temp dir + * (so write cycles never dirty the repository) and reference the in-repo + * stateful plugin via absolute file URL. This exercises the loader's + * standard dynamic-import fallback (`loader.internal === undefined` under + * Bun) with Bun's native TypeScript transpilation. + */ + +const tempDirs: string[] = [] + +afterAll(async () => { + await Promise.all(tempDirs.map(dir => rm(dir, { recursive: true, force: true }))) +}) + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bun-')) + tempDirs.push(dir) + return dir +} + +const PLUGIN_URL = new URL('./fixtures/stateful-plugin.ts', import.meta.url).href + +async function setup(dir: string, config: { filename: string; content: string }) { + const ctx = new Context() + await ctx.plugin(LoggerConsole, { colors: 0 }) + const fiber = await ctx.plugin(Loader, { + baseUrl: pathToFileURL(dir).href + '/', + }) + const includeId = await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: './' + config.filename, enableLogs: false }, + }) + return { ctx, fiber, includeId } +} + +function resetPluginState() { + pluginState.applies = 0 + pluginState.hits = 0 + pluginState.ticks = 0 + pluginState.lastConfig = undefined +} + +describe('Bun / include + loader (real files)', () => { + it('loads a YAML config and a TypeScript plugin via dynamic import', async () => { + resetPluginState() + const dir = await makeTempDir() + await writeFile(join(dir, 'app.yml'), [ + `- id: plugin`, + ` name: ${PLUGIN_URL}`, + ` config:`, + ` greeting: hello`, + ].join('\n')) + + const { ctx, fiber } = await setup(dir, { filename: 'app.yml', content: '' }) + await sleep(150) + + expect(pluginState.applies).toBe(1) + expect(pluginState.lastConfig).toEqual({ greeting: 'hello' }) + expect(ctx.bail('test/get-value')).toBe(1) + + const ticks = pluginState.ticks + expect(ticks).toBeGreaterThan(0) + + await fiber.dispose() + await sleep(80) + const ticksAfter = pluginState.ticks + expect(ctx.bail('test/get-value')).toBeUndefined() + await sleep(80) + expect(pluginState.ticks).toBe(ticksAfter) // interval cleared + }, 20000) + + it('loads a JSON config', async () => { + resetPluginState() + const dir = await makeTempDir() + await writeFile(join(dir, 'app.json'), JSON.stringify([{ + id: 'plugin', + name: PLUGIN_URL, + }], null, 2)) + + const { ctx, fiber } = await setup(dir, { filename: 'app.json', content: '' }) + await sleep(150) + + expect(pluginState.applies).toBe(1) + expect(ctx.bail('test/get-value')).toBe(1) + + await fiber.dispose() + }, 20000) + + it('loads plugins referenced by relative path from the config file', async () => { + resetPluginState() + // read-only in-repo fixture; no write cycles are triggered + const fixturesDir = new URL('./fixtures/relative/', import.meta.url) + const ctx = new Context() + await ctx.plugin(LoggerConsole, { colors: 0 }) + const fiber = await ctx.plugin(Loader, { baseUrl: fixturesDir.href }) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: './app.yml', enableLogs: false }, + }) + await sleep(150) + + expect(pluginState.applies).toBe(1) + expect(ctx.bail('test/get-value')).toBe(1) + await fiber.dispose() + }, 20000) + + it('evaluates js expressions in YAML config', async () => { + resetPluginState() + const dir = await makeTempDir() + // the canonical explicit-tag form that Include itself writes on dump + // (verified identical under Node and Bun; the bare `!js` shorthand is + // only accepted by @cordisjs/unyaml at test-import time, not by the + // runtime reader) + await writeFile(join(dir, 'app.yml'), [ + `- id: plugin`, + ` name: ${PLUGIN_URL}`, + ` config:`, + ` stamp: ! '1000 + 24'`, + ].join('\n')) + + const { ctx, fiber } = await setup(dir, { filename: 'app.yml', content: '' }) + await sleep(150) + + expect(pluginState.lastConfig).toEqual({ stamp: 1024 }) + await fiber.dispose() + }, 20000) + + it('repeated disable/enable cycles do not duplicate listeners or timers', async () => { + resetPluginState() + const dir = await makeTempDir() + const configPath = join(dir, 'app.yml') + await writeFile(configPath, `- id: plugin\n name: ${PLUGIN_URL}\n`) + + const { ctx, fiber, includeId } = await setup(dir, { filename: 'app.yml', content: '' }) + await sleep(150) + expect(pluginState.applies).toBe(1) + + for (let i = 0; i < 3; i++) { + // disable (entry lives in the include subtree: :plugin) + await ctx.loader.update(includeId + ':plugin', { disabled: true }) + await sleep(120) + const appliesAfterDisable = pluginState.applies + const ticksAfterDisable = pluginState.ticks + ctx.emit('test/ping') + expect(pluginState.hits).toBe(i) // listener removed: no hit while disabled + await sleep(60) + expect(pluginState.ticks).toBe(ticksAfterDisable) // timer removed + + // re-enable + await ctx.loader.update(includeId + ':plugin', { disabled: null }) + await sleep(120) + expect(pluginState.applies).toBe(appliesAfterDisable + 1) + ctx.emit('test/ping') + expect(pluginState.hits).toBe(i + 1) // exactly one hit: no duplicates + + // ticks resumed: at least one new tick after re-enable + expect(pluginState.ticks).toBeGreaterThan(ticksAfterDisable) + } + + // file was written back with the final state (enabled -> no disabled key) + const content = await readFile(configPath, 'utf8') + expect(content).not.toContain('disabled') + + await fiber.dispose() + await sleep(100) + const finalTicks = pluginState.ticks + await sleep(100) + expect(pluginState.ticks).toBe(finalTicks) + }, 40000) + + it('patches can disable an entry in-memory', async () => { + resetPluginState() + const dir = await makeTempDir() + await writeFile(join(dir, 'app.yml'), `- id: plugin\n name: ${PLUGIN_URL}\n`) + + const ctx = new Context() + await ctx.plugin(LoggerConsole, { colors: 0 }) + const fiber = await ctx.plugin(Loader, { baseUrl: pathToFileURL(dir).href + '/' }) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: './app.yml', enableLogs: false, patches: [{ id: 'plugin', disabled: true }] }, + }) + await sleep(200) + + expect(pluginState.applies).toBe(0) + expect(ctx.bail('test/get-value')).toBeUndefined() + await fiber.dispose() + }, 20000) +}) diff --git a/tests/bun/loader-mock.spec.ts b/tests/bun/loader-mock.spec.ts new file mode 100644 index 00000000..eb6b43db --- /dev/null +++ b/tests/bun/loader-mock.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeAll } from 'bun:test' +import { Context, FiberState, Plugin } from 'cordis' +import { Dict } from 'cosmokit' +import { EntryOptions, Group, Loader } from '@cordisjs/plugin-loader' +import { spy, sleep } from './helpers' + +type Wrapped = { apply: (...args: any[]) => any } + +class MockLoader extends Loader { + public data: EntryOptions[] = [] + public modules: Dict = Object.create(null) + + constructor(ctx: Context) { + super(ctx) + ctx.on('internal/get', (ctx, prop, error, next) => { + if (!ctx.fiber.runtime && prop === 'loader') { + return ctx.get(prop) + } + return next() + }) + } + + write() { + this.data = this.root.data + } + + async read(data: any) { + this.data = data + await this.root.update(data) + await this.await() + } + + async import(name: string) { + if (name === '@cordisjs/plugin-group') { + return Group + } + return this.modules[name] + } + + mock(name: string, plugin: (...args: any[]) => any) { + const wrapped = spy(plugin) + return this.modules[name] = { apply: wrapped, name } as any + } + + countActive(name: string) { + const plugin = this.modules[name] + return this.ctx.registry.has(plugin) ? this.ctx.registry.get(plugin)!.fibers.length : 0 + } +} + +describe('Bun / loader (in-memory tree)', () => { + const root = new Context() + + let loader: MockLoader + let foo: Wrapped + let bar: Wrapped + let qux: Wrapped + + beforeAll(async () => { + await root.plugin(MockLoader as any) + loader = root.loader as any + + foo = loader.mock('foo', (ctx: Context) => ctx.on('internal/update', () => {})) + bar = loader.mock('bar', (ctx: Context) => ctx.on('internal/update', () => {})) + qux = loader.mock('qux', (ctx: Context) => ctx.on('internal/update', () => {})) + }) + + it('initiates entries, groups and disabled entries', async () => { + await loader.read([{ + id: '1', + name: 'foo', + }, { + id: '2', + name: '@cordisjs/plugin-group', + config: [{ + id: '3', + name: 'bar', + config: { a: 1 }, + }, { + id: '4', + name: 'qux', + disabled: true, + }], + }]) + + expect(loader.countActive('foo')).toBe(1) + expect(loader.countActive('bar')).toBe(1) + expect(loader.countActive('qux')).toBe(0) + }) + + it('updates entries: disables removed, enables previously disabled', async () => { + await loader.read([{ + id: '1', + name: 'foo', + }, { + id: '4', + name: 'qux', + }]) + + expect(loader.countActive('foo')).toBe(1) + expect(loader.countActive('bar')).toBe(0) + expect(loader.countActive('qux')).toBe(1) + }) + + it('plugin self-update persists config into the tree', async () => { + loader.store['1']!.fiber!.update({ a: 3 }) + await sleep() + expect(loader.data).toEqual([{ + id: '1', + name: 'foo', + config: { a: 3 }, + }, { + id: '4', + name: 'qux', + }]) + }) + + it('plugin self-dispose marks the entry disabled', async () => { + loader.store['1']!.fiber!.dispose() + await sleep() + expect(loader.data).toEqual([{ + id: '1', + name: 'foo', + disabled: true, + config: { a: 3 }, + }, { + id: '4', + name: 'qux', + }]) + }) + + it('intercept await blocks activation until tasks settle', async () => { + const root2 = new Context() + await root2.plugin(MockLoader as any) + const loader2 = root2.loader as any as MockLoader + + const { promise, resolve } = Promise.withResolvers() + loader2.mock('foo', () => promise) + const barPlugin = loader2.mock('bar', (ctx: Context) => ctx.on('internal/update', () => true)) + Object.assign(barPlugin, { inject: ['never'] }) + loader2.mock('qux', () => {}) + + const fooId = await loader2.create({ name: 'foo' }) + const quxId = await loader2.create({ + name: 'qux', + inject: { loader: true }, + intercept: { loader: { await: true } }, + }) + await sleep() + + expect(loader2.store[fooId].fiber.state).toBe(FiberState.LOADING) + expect(loader2.store[quxId].fiber.state).toBe(FiberState.PENDING) + + resolve() + await sleep() + expect(loader2.store[fooId].fiber.state).toBe(FiberState.ACTIVE) + expect(loader2.store[quxId].fiber.state).toBe(FiberState.ACTIVE) + }) +}) diff --git a/tests/bun/logger-console.spec.ts b/tests/bun/logger-console.spec.ts new file mode 100644 index 00000000..d5a79dcb --- /dev/null +++ b/tests/bun/logger-console.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll } from 'bun:test' +import { Context, Message } from 'cordis' +import { ConsoleExporter } from '@cordisjs/plugin-logger-console' + +// Real timers mean the rendered `+Nms` diff is nondeterministic; strip it +// before comparing. Everything else must match the Node output byte-for-byte. + +let data: string + +function render(msg: Message) { + return data += exporter.render(msg).replace(/\+\d+ms/, '+Nms') + '\n' +} + +let exporter: ConsoleExporter +let ctx: Context + +describe('Bun / logger-console', () => { + beforeAll(() => { + ctx = new Context() + exporter = new ConsoleExporter(ctx, { colors: 0, showDiff: true, showTime: '' }) + exporter.export = render + // silence real console output during the suite + ;(exporter as any).print = () => {} + }) + + it('formats plain messages with level and label', () => { + data = '' + ctx.logger('test').info('hello') + expect(data).toBe('[I] test hello +Nms\n') + }) + + it('formats errors without stack', () => { + data = '' + const inner = new Error('message') + inner.stack = undefined + ctx.logger('test').error(inner) + expect(data).toBe('[E] test message +Nms\n') + }) + + it('formats objects via util.inspect (Bun node:util)', () => { + data = '' + ctx.logger('test').info({ foo: 'bar' }) + expect(data).toBe("[I] test { foo: 'bar' } +Nms\n") + }) + + it('supports %o / %O formatters', () => { + data = '' + ctx.logger('test').info('%o', { a: 1 }) + expect(data).toBe('[I] test { a: 1 } +Nms\n') + }) + + it('respects log levels', () => { + data = '' + const logger = ctx.logger('test') + logger.debug('hidden') + expect(data).toBe('') + logger.level = 3 + logger.debug('shown') + expect(data).toBeTruthy() + }) +}) diff --git a/tests/bun/shutdown.spec.ts b/tests/bun/shutdown.spec.ts new file mode 100644 index 00000000..c6058218 --- /dev/null +++ b/tests/bun/shutdown.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'bun:test' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +describe('Bun / process shutdown with complete root disposal', () => { + it('SIGINT triggers full root-fiber disposal and a clean exit', async () => { + const fixture = fileURLToPath(new URL('./fixtures/shutdown-app.ts', import.meta.url)) + const child = spawn(process.execPath, [fixture], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + }) + + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + + // wait for readiness + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('app did not become ready; stdout=' + stdout + ' stderr=' + stderr)), 15000) + child.stdout.on('data', chunk => { + if (stdout.includes('ready')) { + clearTimeout(timer) + resolve() + } + }) + }) + + child.kill('SIGINT') + + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('app did not exit; stdout=' + stdout)), 15000) + child.on('exit', code => { + clearTimeout(timer) + resolve(code) + }) + }) + + expect(code).toBe(0) + + // all disposers ran; order follows async depth (outer's own sync + // disposers before the nested child's chain) — verified Node-identical + const eventsLine = stdout.split('\n').find(line => line.startsWith('events:')) + expect(eventsLine).toBeDefined() + expect(JSON.parse(eventsLine!.slice('events:'.length))).toEqual(['outer-stop', 'inner-stop']) + + // every plugin fiber was disposed from the registry + const registryLine = stdout.split('\n').find(line => line.startsWith('registry:')) + expect(registryLine).toBeDefined() + expect(registryLine!.slice('registry:'.length)).toBe('0') + }, 40000) +}) diff --git a/tests/bun/timer.spec.ts b/tests/bun/timer.spec.ts new file mode 100644 index 00000000..974bd065 --- /dev/null +++ b/tests/bun/timer.spec.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'bun:test' +import { Context } from 'cordis' +import Timer from '@cordisjs/plugin-timer' +import { spy, sleep } from './helpers' + +// Real timers (bun:test has no fake timers); delays are short but the +// assertions use wide margins so scheduling jitter cannot flip them. + +async function withContext(callback: (ctx: Context) => Promise | void) { + const ctx = new Context() + await ctx.plugin(Timer) + await ctx.plugin({ inject: ['timer'], apply: callback }) + return ctx +} + +describe('Bun / timer service', () => { + it('ctx.timeout() invokes once and never again', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + ctx.timeout(callback, 20) + expect(callback.calls.length).toBe(0) + await sleep(60) + expect(callback.calls.length).toBe(1) + await sleep(40) + expect(callback.calls.length).toBe(1) + }) + }) + + it('ctx.timeout() dispose prevents the callback', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + const dispose = ctx.timeout(callback, 30) + dispose() + await sleep(80) + expect(callback.calls.length).toBe(0) + }) + }) + + it('ctx.timeout() promise form resolves after the delay', async () => { + await withContext(async (ctx) => { + let resolved = false + ctx.timeout(20).then(() => { resolved = true }) + expect(resolved).toBe(false) + await sleep(60) + expect(resolved).toBe(true) + }) + }) + + it('ctx.interval() ticks repeatedly and stops after dispose', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + const dispose = ctx.interval(callback, 20) + await sleep(110) + expect(callback.calls.length).toBeGreaterThanOrEqual(3) + dispose() + const count = callback.calls.length + await sleep(80) + expect(callback.calls.length).toBe(count) + }) + }) + + it('ctx.interval() async iterator completes on manual return', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + const iterator = ctx.interval(20) + const done = spy(() => {}) + const failed = spy(() => {}) + ;(async () => { + try { + for await (const _ of iterator) callback() + } catch { + failed() + return + } + done() + })() + await sleep(70) + expect(callback.calls.length).toBeGreaterThanOrEqual(2) + iterator.return!() + await sleep(60) + const count = callback.calls.length + expect(done.calls.length).toBe(1) + expect(failed.calls.length).toBe(0) + await sleep(60) + expect(callback.calls.length).toBe(count) + }) + }) + + it('ctx.interval() async iterator rejects on context disposal', async () => { + const ctx = await withContext(async () => {}) + const callback = spy(() => {}) + const iterator = ctx.interval(20) + const done = spy(() => {}) + const failed = spy(() => {}) + ;(async () => { + try { + for await (const _ of iterator) callback() + } catch { + failed() + return + } + done() + })() + await sleep(60) + expect(callback.calls.length).toBeGreaterThanOrEqual(1) + await ctx.fiber.dispose() + await sleep(60) + const count = callback.calls.length + expect(failed.calls.length).toBe(1) + expect(done.calls.length).toBe(0) + await sleep(60) + expect(callback.calls.length).toBe(count) + }) + + it('ctx.throttle() leading + trailing execution', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + const throttled = ctx.throttle(callback, 50) + throttled() + expect(callback.calls.length).toBe(1) + await sleep(20) + throttled() // within window, schedules trailing + expect(callback.calls.length).toBe(1) + await sleep(60) + expect(callback.calls.length).toBe(2) + await sleep(100) + expect(callback.calls.length).toBe(2) + }) + }) + + it('ctx.debounce() collapses repeated calls into one', async () => { + await withContext(async (ctx) => { + const callback = spy(() => {}) + const debounced = ctx.debounce(callback, 40) + debounced() + await sleep(20) + debounced() + await sleep(20) + debounced() + expect(callback.calls.length).toBe(0) + await sleep(80) + expect(callback.calls.length).toBe(1) + }) + }) + + it('disposing the context clears every pending timer', async () => { + const ctx = await withContext(async () => {}) + const timeout = spy(() => {}) + const interval = spy(() => {}) + const debounced = spy(() => {}) + ctx.timeout(timeout, 2000) + ctx.interval(interval, 2000) + const d = ctx.debounce(debounced, 2000) + d() + await ctx.fiber.dispose() + await sleep(60) + expect(timeout.calls.length).toBe(0) + expect(interval.calls.length).toBe(0) + expect(debounced.calls.length).toBe(0) + }) +}) From a8499bd9f0ad87276d59378dc293ae1b2d33fffe Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 05:34:55 -0700 Subject: [PATCH 02/33] feat(bun): CLI entrypoints, dev supervisor, CI matrix, compatibility 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. --- .github/workflows/bun.yml | 54 +++++ docs/BUN_COMPATIBILITY.md | 231 ++++++++++++++++++++++ docs/BUN_PORT_STATUS.md | 120 +++++------ package.json | 3 + packages/core/bin.bun.js | 73 +++++++ packages/core/bin.bun.watch.js | 100 ++++++++++ tests/bun/repros/README.md | 50 +++++ tests/bun/repros/hot-state-duplication.ts | 7 + tests/bun/repros/watch-globals-reset.ts | 6 + tests/bun/repros/watch-timer-disposers.ts | 12 ++ tests/bun/watch.spec.ts | 109 ++++++++++ 11 files changed, 689 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/bun.yml create mode 100644 docs/BUN_COMPATIBILITY.md create mode 100644 packages/core/bin.bun.js create mode 100644 packages/core/bin.bun.watch.js create mode 100644 tests/bun/repros/README.md create mode 100644 tests/bun/repros/hot-state-duplication.ts create mode 100644 tests/bun/repros/watch-globals-reset.ts create mode 100644 tests/bun/repros/watch-timer-disposers.ts create mode 100644 tests/bun/watch.spec.ts diff --git a/.github/workflows/bun.yml b/.github/workflows/bun.yml new file mode 100644 index 00000000..c6194ea5 --- /dev/null +++ b/.github/workflows/bun.yml @@ -0,0 +1,54 @@ +name: Bun + +# Bun-native verification alongside the existing Node toolchain (build.yml is +# intentionally untouched to preserve the upstream merge path). +# +# Pinned Bun: 1.3.14 (revision 1.3.14+0d9b296af) — the release this port is +# verified against. Update the pin only together with docs/BUN_COMPATIBILITY.md. + +on: + push: + branches: [main, feat/bun-compat] + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + bun-version: ['1.3.14'] + + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.bun-version }} + + - name: Install (bun) + run: bun install + + - name: Build (node + yakumo) + run: |- + node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js esbuild + node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js tsc + + - name: Bun behavioral suite + run: bun test tests/bun + + - name: Node suite stays green alongside + run: |- + node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js vitest --import tsx diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md new file mode 100644 index 00000000..c3830095 --- /dev/null +++ b/docs/BUN_COMPATIBILITY.md @@ -0,0 +1,231 @@ +# Cordis on Bun — Compatibility Documentation + +Status: **core, timer, logger-console, loader and include are verified +first-class under Bun.** hmr is Node-only by design. Everything else is +classified below. + +- Verified Bun release: **1.3.14** (revision `1.3.14+0d9b296af`, macOS arm64) +- Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) +- Behavioral proof: `bun test tests/bun` — 58 tests across 11 spec files + covering the scenarios listed in "What is verified" below. +- Compatibility is **not** claimed from installation or typechecking alone; + every claim below maps to an executable test or a recorded command. + +## Package compatibility matrix + +Legend: ✅ verified · 🟡 works but not behaviorally verified · ❌ incompatible / +Node-only · ⬜ not yet tested + +| Package | install (bun) | build | import | tests (bun) | runtime | Node-specific APIs | Bun incompatibilities | Fix owner | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `cordis` (core) | ✅ | ✅ (esbuild+tsc) | ✅ | ✅ 57-test suite | ✅ full | **none in src** | none found | — | +| `@cordisjs/plugin-timer` | ✅ | ✅ | ✅ | ✅ timer.spec | ✅ | globals only (`setTimeout`, `Promise.withResolvers`) | none found | — | +| `@cordisjs/plugin-logger-console` | ✅ | ✅ | ✅ (node export; browser export untested) | ✅ logger-console.spec | ✅ | `node:util.inspect`, `supports-color` | none found — `util.inspect` output byte-identical for tested formats | — | +| `@cordisjs/plugin-loader` | ✅ | ✅ | ✅ | ✅ loader-mock + loader-include specs | ✅ | `node:module` (optional, degrades), `process.env` | none found; `ModuleLoader.fromInternal()` returns `undefined` → documented fallback `import()` path is used | — (fallback is upstream design) | +| `@cordisjs/plugin-include` | ✅ | ✅ | ✅ | ✅ loader-include spec | ✅ | `node:path`, `node:fs/promises`, `node:url`, `js-yaml` | none found | — | +| `@cordisjs/plugin-hmr` | ✅ | ✅ | ✅ | ❌ by design | ❌ **Node-only** | `--expose-internals` ESM `loadCache`, CJS `require.cache`, `node:module` | constructor fails fast: `--expose-internals is required for HMR service` (no Bun internals access — per policy, not attempted) | Cordis (Phase C adapter) or stay Node-only | +| `@cordisjs/plugin-group` | ✅ | ✅ | ✅ | 🟡 (via loader-mock `Group` usage) | 🟡 | none | none found | — | +| `@cordisjs/utils` | ✅ | ✅ | ✅ | ⬜ standalone | 🟡 (pure JS over core) | none | none found | — | +| `create-cordis` | ✅ | ✅ | ✅ | ⬜ (interactive CLI) | 🟡 | `node:fs/promises`, `node:child_process.execSync`, `node:stream.Readable` | untested at runtime; `execSync` of yarn is expected to work only if yarn exists | — | +| repo toolchain (yakumo/vitest/eslint) | ✅ install | ✅ via `node --import tsx` | n/a | ✅ Node suite runs under Node as before | n/a | `--expose-internals` (Node) | running the *Node* suite under bun is not supported (tsx/vitest pools) — not needed; Bun has its own suite | — | + +Notes on the matrix: + +- "install (bun)" was verified with `bun install` at the repo root + (864 packages) and per-package workspace linking. +- "build" is the shared yakumo esbuild+tsc pipeline executed under Node; + building under Bun's bundler is neither required nor claimed. +- `@cordisjs/plugin-loader` needed one **devDependency addition** + (`@cordisjs/plugin-include`) — see "Cordis changes" below. This is an + install-layout issue, not a Bun defect. + +## What is verified (behavioral, under `bun test tests/bun`) + +`tests/bun/` — 58 tests, all passing on Bun 1.3.14: + +- **Plugins**: function / object / class plugins, config passing, invalid + plugins rejected, nested plugin trees, idempotent root dispose, + `Service.init` lifecycle (`core-plugin.spec.ts`) +- **Effects**: sync disposers, within-effect reverse disposal order, async + (promise) effects, async-generator effects including mid-segment abort, + hook-registry snapshot parity across load/dispose cycles + (`core-effects.spec.ts`) +- **Events**: `on`/`once`/`emit` (synchronous), `parallel` (AggregateError, + no short-circuit), `serial` (order + bail), `bail` (null/false/undefined + never bail), `waterfall` (`next()` composition + short-circuit) + (`core-events.spec.ts`) +- **provide/inject**: deferred activation via `Service.init`, activation on + provide, deactivation on removal, reactivation, provider replacement, + chained injects (`core-services.spec.ts`) +- **Isolation**: distinct service instances per isolated context, shared + labels, isolated event dispatch (`core-isolate.spec.ts`) +- **Timer**: timeout (callback/promise), interval (callback/async-iterator: + return/throw/break/dispose), throttle (leading+trailing), debounce, full + timer cleanup on context disposal (`timer.spec.ts`) +- **logger-console**: render parity for messages, stackless errors, objects, + `%o` formatters, log levels — using Bun's `node:util.inspect` + (`logger-console.spec.ts`) +- **Loader + include**: in-memory tree (init/update/self-update/self-dispose, + intercept-`await` gating); real files: YAML and JSON config loading, + relative and absolute plugin references, **dynamic TypeScript plugin + loading** via the standard `import()` fallback with Bun's transpiler, + `__jsExpr` evaluation, entry disable/enable patches, **3× disable/enable + cycles with no duplicated listeners or timers** (`loader-mock.spec.ts`, + `loader-include.spec.ts`) +- **Process shutdown**: SIGINT → complete root-fiber disposal (registry + empties, all disposers run exactly once) → exit 0 (`shutdown.spec.ts`) +- **Development reload**: supervisor restart contract — old root disposed + before new activation, no resource duplication, clean SIGINT + (`watch.spec.ts`) + +Commands and recorded results: + +``` +$ bun --version && bun --revision +1.3.14 +1.3.14+0d9b296af + +$ bun install +864 packages installed [5.83s] + +$ node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js esbuild # exit 0 +$ node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js tsc # exit 0 + +$ bun test tests/bun + 58 pass / 0 fail / 198 expect() calls + +$ node --expose-internals --import tsx --import @cordisjs/unyaml \ + node_modules/yakumo/lib/cli.js vitest --import tsx + 19 files / 163 tests passed # Node suite unchanged and green +``` + +## Using Cordis on Bun + +```bash +bun install # in your application + +# production-style entrypoint (same layout as bin.js): +bun path/to/packages/core/bin.bun.js +# reads ./cordis.yml, loads plugins (TypeScript included), handles +# SIGINT/SIGTERM with full root-fiber disposal + +# development with graceful reload: +bun path/to/packages/core/bin.bun.watch.js +``` + +Minimal application: + +```yaml +# cordis.yml +- id: timer + name: '@cordisjs/plugin-timer' +- id: my-plugin + name: ./my-plugin.ts +``` + +```ts +// my-plugin.ts — plain TypeScript, loaded via dynamic import +import { Context } from 'cordis' + +export function apply(ctx: Context) { + ctx.on('some-event', () => {}) + ctx.interval(() => {/* ... */}, 1000) +} +``` + +## Development reload decision (Phase 5) + +**Decision: whole-process supervisor (`bin.bun.watch.js`) is the supported +Bun reload mechanism. Selective HMR is deferred — see below.** + +Measured on Bun 1.3.14 (each claim reproduced with standalone scripts): + +1. `bun --watch` re-evaluates the entry **in-process** (same pid): + - module state is cleared, but **`globalThis` and `process` are fresh on + every reload** — the new evaluation cannot reach the previous Cordis + root context; + - pending **timers from the previous evaluation are removed by Bun**, but + *without* running Cordis disposers (no cleanup of listeners, sockets, + watchers, or user callbacks); + - **signal handlers from previous evaluations persist and accumulate**; + - there is **no public before-reload hook**, so Cordis-level graceful + disposal cannot be implemented from inside the reloaded module. +2. `bun --hot` re-evaluates the changed module *and* importers: previous + evaluations' **timers and listeners keep running** — Cordis state + (registries, effects) is **duplicated** across reloads. Confirmed by + interleaved output from two generations. + +Therefore: + +- **A (implemented, tested)**: `packages/core/bin.bun.watch.js` supervises a + child process running `bin.bun.js`; on file change it sends SIGTERM, the + child performs a complete root-fiber disposal, exits 0, and is respawned. + Restart ordering (dispose → activate) is asserted by `tests/bun/watch.spec.ts`. +- **B (evaluated, documented above)**: `bun --hot` is unsuitable as-is. +- **C (deferred)**: selective HMR would require a runtime-adapter abstraction + (Node adapter = current `ModuleLoader` internals; Bun adapter built on + public APIs: fs watching, `Bun.build` dependency metadata, content-hashed + artifacts, unique artifact import paths, export validation, old-fiber + config preservation, dispose→activate ordering, rollback). This is only + worth building if whole-process reload proves inadequate in practice — + per the port's success criteria it is optional. + +## Known behavior nuances (Node ↔ Bun parity) + +These are **not** incompatibilities — behavior is identical on both runtimes; +recorded because they surprised the port itself: + +- **Cross-fiber disposal order is async-depth order** (a parent's own sync + disposers run before its nested child fiber's chain), *not* LIFO across + fibers. Verified byte-identical on Node v26.4.0 and Bun 1.3.14. Within a + single effect, disposers run in strict reverse order on both runtimes. +- **`!js` YAML shorthand** (`stamp: !js '...'`) is rejected by `js-yaml@4` + on *both* runtimes ("unknown tag"). The canonical explicit form — + `! '...'`, which Include itself writes on dump — + works everywhere. The bare `!js` shorthand only parses via + `@cordisjs/unyaml` at test-import time. +- **Bun resolves extensionless relative `.ts` imports** (`./plugin` → + `plugin.ts`) and `file://` URL imports, so the loader's fallback path and + Include configs referencing `./plugin.ts` work unmodified. +- Bun reports `process.versions.node` = `24.3.0`; `internal/modules/*` + requires fail with `MODULE_NOT_FOUND`, which the loader already treats as + "internals unavailable" (falling back to standard `import()`). + +## Cordis changes versus Bun changes + +**Cordis-side (this fork, branch `feat/bun-compat`):** + +1. `packages/loader/package.json`: added `@cordisjs/plugin-include` to + `devDependencies` (mirrors existing `@cordisjs/plugin-logger-console` + entry). Under Bun's isolated workspace `node_modules`, the loader's + fallback `import('@cordisjs/plugin-include')` executes from + `packages/loader` where that package was not previously visible. Under + Yarn hoisting this worked by accident. No runtime code changed. +2. `packages/core/bin.bun.js`, `packages/core/bin.bun.watch.js`: Bun + entrypoint + development supervisor (additive; not in the published + `files` list; Node's `bin.js` untouched). +3. `tests/bun/**`: Bun-native behavioral suite (does not affect the Node + suite). +4. Root `package.json` scripts: `test:bun`, `start:bun`, `dev:bun` + (additive). +5. `.github/workflows/bun.yml`: Bun CI alongside the untouched Node CI. +6. `docs/BUN_PORT_*.md`, this file. + +**Bun-side: none.** The contribution policy was never triggered: no defect +qualifying under the seven conditions was found. The two candidate findings +(cross-fiber disposal order; `!js` tag handling) reproduced identically on +Node and are therefore Cordis/js-yaml semantics, not Bun bugs. The +`--watch`/`--hot` reload behaviors are documented platform semantics with a +public-API workaround (supervisor), not defects against documented behavior. + +## Limitations + +- `@cordisjs/plugin-hmr` does not run under Bun and fails fast with a clear + error. Use the supervisor for development reload. +- The browser export of logger-console and the `create-cordis` scaffolder + are untested under Bun (classified 🟡/⬜ above). +- The Bun behavioral suite uses real timers; timing assertions carry wide + margins. The Node suite remains the source of truth for fake-timer + precision cases. diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index c24bb0f9..d3a14b20 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -16,94 +16,62 @@ and commit SHAs. Update before ending or compacting any work session. ## Completed -### Phase 0 — read-only audit (DONE) +### Phase 0 — read-only audit (DONE) — commit `06ae971` -Commands and results: see git history of this file (first version) and -`BUN_PORT_PLAN.md`. Summary: +### Phases 1–3 — Bun behavioral suite (DONE, 57/57) — commit `06ae971` -``` -git status --porcelain=v1 -b # -> clean -git rev-parse HEAD # -> 8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4 -git remote add upstream https://github.com/cordiverse/cordis.git && git fetch upstream -git rev-list --left-right --count HEAD...upstream/main # -> 0 0 -bun install # 864 packages, exit 0 -yakumo esbuild / tsc (via node --import tsx ...) # exit 0 -yakumo vitest --import tsx # 19 files / 163 tests passed, exit 0 -``` +### Phase 4 — CLI, scripts, CI, compatibility docs (DONE) + +- `packages/core/bin.bun.js` — Bun entrypoint (same behavior as `bin.js` + + graceful SIGINT/SIGTERM root disposal). Verified manually: loads + `cordis.yml`, writes back config, exits 0. +- `packages/core/bin.bun.watch.js` — development supervisor (see Phase 5A). +- Root `package.json` scripts (additive): `test:bun`, `start:bun`, `dev:bun`. +- `.github/workflows/bun.yml` — Bun 1.3.14 job: bun install → node+yakumo + build → `bun test tests/bun` → Node suite. Upstream `build.yml` untouched. +- `docs/BUN_COMPATIBILITY.md` — package-by-package matrix, verified + commands, reload decision, Cordis-vs-Bun change list. + +### Phase 5A — `--watch` progression, evaluated and delivered (DONE) + +Empirical findings on Bun 1.3.14 (standalone repros in the doc): -Key findings: core has zero `node:*` imports; `ModuleLoader.fromInternal()` → -`undefined` under Bun; `EntryTree.import()` falls back to standard -`await import()`; hmr is Node-only by construction. - -### Phases 1–3 — Bun behavioral suite (DONE, 57/57 green) - -Suite: `tests/bun/` (run with `bun test tests/bun`), 10 spec files, 57 tests: - -- `core-plugin.spec.ts` — function/object/class plugins, invalid plugins, - nested listeners, root dispose idempotency, Service.init -- `core-effects.spec.ts` — sync/async effects, within-effect reverse disposal, - async-generator abort semantics, hook-snapshot parity across reload cycles -- `core-events.spec.ts` — on/once/emit/parallel/serial/bail/waterfall -- `core-services.spec.ts` — provide/inject activation, dependency removal + - reactivation, provider replacement, chained injects -- `core-isolate.spec.ts` — isolated contexts, shared labels, isolated events -- `timer.spec.ts` — timeout/interval (callback/promise/iterator), - throttle/debounce, full timer cleanup on dispose -- `logger-console.spec.ts` — render parity with Bun's `node:util.inspect` -- `loader-mock.spec.ts` — in-memory loader tree: init/update/self-update/ - self-dispose, intercept-await activation gating -- `loader-include.spec.ts` — real files: YAML + JSON config loading, relative - + absolute plugin references, dynamic TS plugin import, `__jsExpr` - evaluation, 3× disable/enable cycles with no duplicated listeners/timers, - patch-based disable -- `shutdown.spec.ts` — SIGINT → complete root-fiber disposal → exit 0 +- `bun --watch` re-evaluates the entry in-process (same pid); `globalThis` + and `process` are FRESH per reload → new evaluation cannot reach the old + Cordis root; timers are cleared by Bun without running disposers; old + signal handlers accumulate; no public before-reload hook. +- `bun --hot`: previous generations' timers/listeners keep running → + Cordis state duplicates across reloads (confirmed by interleaved output). + +Delivered: supervisor `packages/core/bin.bun.watch.js` (public APIs only: +`node:fs.watch`, `node:child_process`) — on change: SIGTERM child → child +performs complete root-fiber disposal → exit 0 → respawn. Dispose-before- +activate ordering asserted by `tests/bun/watch.spec.ts`. + +Phase 5B verdict: `--hot` unsuitable (duplication, above). Phase 5C +(selective HMR): deferred — optional per success criteria; adapter design +sketch recorded in BUN_COMPATIBILITY.md. + +### Full-suite phase boundary results ``` -$ bun test tests/bun - 57 pass - 0 fail - 188 expect() calls -Ran 57 tests across 10 files. [4.35s] +bun test tests/bun # 58 pass / 0 fail / 198 expect() calls +node ... yakumo vitest --import tsx # 19 files / 163 tests passed (see run log) ``` -### Cordis changes made so far (vs upstream) - -1. `packages/loader/package.json` — added `@cordisjs/plugin-include` to - `devDependencies` (mirrors the existing `@cordisjs/plugin-logger-console` - entry). Reason: Bun installs isolated per-workspace `node_modules`; the - loader's fallback `import('@cordisjs/plugin-include')` executes from - `packages/loader`, where the package was not visible (only `hmr` declared - it). Under Yarn's hoisting this worked by accident. No runtime code - changed; Node behavior unchanged. -2. `tests/bun/**`, `docs/BUN_PORT_*.md` — new files only. - -### Verified Node↔Bun parity facts (documented for BUN_COMPATIBILITY.md) - -- Cross-fiber disposal order is async-depth ordered (`outer,inner,innermost`), - **byte-identical under Node v26.4.0 and Bun 1.3.14** — not LIFO across - fibers (upstream never asserts cross-fiber order; within a single effect it - is strict reverse). -- `!js` YAML shorthand: `js-yaml@4.3.1` rejects `!js '...'` under BOTH Node - and Bun ("unknown tag"); the working explicit form is - `! '...'` (what Include's dump writes). The `!js` - shorthand is only handled by `@cordisjs/unyaml` at test-import time. -- Bun resolves extensionless relative `.ts` imports (`./mod` → `mod.ts`) and - `file://` URL imports — the loader fallback path works unmodified. -- Bun's `util.inspect` output matches Node's for the logger-console formats - tested (`{ foo: 'bar' }`, `{ a: 1 }`). - ## Current failures -None. Node suite re-run after the loader devDependency change: see below. +None. ## Next action -Phase 4: Bun CLI/entrypoint verification (app-like fixture), root -`test:bun` script, Node+Bun CI matrix workflow. - -Phase 5: reload progression A (`bun --watch` + graceful root-fiber disposal), -B (`bun --hot` evaluation), C (HMR decision). +- Push branch / open PR is owner-gated (explicit approval required). +- Optional future work: Phase C selective HMR adapter if whole-process + reload proves inadequate; browser export of logger-console under Bun; + `create-cordis` runtime verification. ## Commit SHAs -(pending — first commit after this status update) +- `06ae971` — Phase 0–3: Bun behavioral suite + plan/status docs + loader + devDependency fix +- (pending) — Phase 4–5: entrypoints, supervisor, CI, compatibility docs diff --git a/package.json b/package.json index 4a6852b4..b14eee4a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "yakumo": "node --expose-internals --import tsx --import @cordisjs/unyaml node_modules/yakumo/lib/cli.js", "build": "yarn yakumo esbuild && yarn yakumo tsc", "test": "yarn yakumo vitest --import tsx", + "test:bun": "bun test tests/bun", + "start:bun": "bun packages/core/bin.bun.js", + "dev:bun": "bun packages/core/bin.bun.watch.js", "test:text": "shx rm -rf coverage && yarn test --coverage --coverage.reporter text", "test:json": "shx rm -rf coverage && yarn test --coverage --coverage.reporter json", "test:html": "shx rm -rf coverage && yarn test --coverage --coverage.reporter html" diff --git a/packages/core/bin.bun.js b/packages/core/bin.bun.js new file mode 100644 index 00000000..46086568 --- /dev/null +++ b/packages/core/bin.bun.js @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Cordis CLI entrypoint for Bun. + * + * Behavior identical to `bin.js`, plus reload-safe lifecycle management for + * `bun --watch` / `bun --hot`: + * + * - Bun re-evaluates the entry module in the same process on watch reloads: + * module state is cleared but `globalThis` state and live timers SURVIVE. + * Without disposal, every reload leaks the previous root fiber's effects + * (timers, listeners) forever (verified on Bun 1.3.14; see + * docs/BUN_COMPATIBILITY.md). + * - Therefore each evaluation first disposes the root context stored under a + * well-known `globalThis` symbol, and only then boots the new one. + * - SIGINT/SIGTERM dispose the current root completely before exiting. + * + * The Node entrypoint (`bin.js`) is intentionally untouched. + */ + +import { Context } from 'cordis' +import { pathToFileURL } from 'node:url' +import Loader from '@cordisjs/plugin-loader' + +/** globalThis slot holding the current root Context across re-evaluations */ +const ROOT = Symbol.for('cordis.bun.root') +/** guard so signal handlers are registered exactly once per process */ +const SIGNALS = Symbol.for('cordis.bun.signals') + +async function disposePrevious() { + const previous = globalThis[ROOT] + if (!previous) return + globalThis[ROOT] = undefined + try { + await previous.fiber.dispose() + } catch (error) { + console.error('[cordis] failed to dispose previous root context') + console.error(error) + } +} + +if (!globalThis[SIGNALS]) { + globalThis[SIGNALS] = true + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { + const root = globalThis[ROOT] + if (root) { + globalThis[ROOT] = undefined + try { + await root.fiber.dispose() + } catch (error) { + console.error('[cordis] failed to dispose root context on ' + signal) + console.error(error) + } + } + process.exit(0) + }) + } +} + +// disposal of the old root strictly precedes activation of the new one +await disposePrevious() + +const ctx = new Context() +globalThis[ROOT] = ctx +ctx.baseUrl = pathToFileURL(process.cwd()).href + '/' + +await ctx.plugin(Loader) +await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { + path: './cordis.yml', + }, +}) diff --git a/packages/core/bin.bun.watch.js b/packages/core/bin.bun.watch.js new file mode 100644 index 00000000..4b73d1fe --- /dev/null +++ b/packages/core/bin.bun.watch.js @@ -0,0 +1,100 @@ +#!/usr/bin/env bun +/** + * Cordis development supervisor for Bun — Phase 5A of the Bun port. + * + * Why a supervisor instead of plain `bun --watch` (measured on Bun 1.3.14, + * see docs/BUN_COMPATIBILITY.md): + * + * - `bun --watch` re-evaluates the entry in-process: every reload gets a + * fresh `globalThis`/`process`, so the new evaluation cannot reach the + * previous Cordis root context — Cordis disposers never run on reload. + * - Bun clears pending *timers* from previous evaluations, but without + * invoking disposers, and old signal handlers accumulate. + * - `bun --hot` keeps previous evaluations' timers and listeners running, + * duplicating Cordis state (verified). + * + * The supervisor spawns the real entrypoint as a child process and restarts + * it on file changes. Restart = SIGTERM → the child disposes its complete + * root fiber (timers, listeners, services) → exits 0 → respawn. This is the + * only way to get genuine graceful disposal per reload using public APIs. + * + * Usage: bun packages/core/bin.bun.watch.js [child args...] + * (run from your application directory, like bin.js / bin.bun.js) + */ + +import { spawn } from 'node:child_process' +import { watch } from 'node:fs' +import { relative, resolve } from 'node:path' + +const DEBOUNCE = 100 +const SHUTDOWN_TIMEOUT = 5000 +const IGNORED = ['node_modules', '.git'] + +const entry = resolve(new URL('.', import.meta.url).pathname, 'bin.bun.js') +const rootDir = process.cwd() + +let child +let stopping = false +let restartTimer + +function isIgnored(path) { + const rel = relative(rootDir, path) + return IGNORED.some(prefix => rel === prefix || rel.startsWith(prefix + '/')) +} + +function start() { + child = spawn(process.execPath, [entry, ...process.argv.slice(2)], { + stdio: 'inherit', + cwd: rootDir, + }) + child.on('exit', (code, signal) => { + if (stopping) return + if (signal) { + // crashed or killed outside of a restart — restart like a watcher would + console.log(`[cordis] child exited with ${signal}, restarting`) + start() + } + }) + return child +} + +function stop(signal = 'SIGTERM') { + return new Promise((done) => { + if (!child || child.exitCode !== null) return done() + const timer = setTimeout(() => { + child.kill('SIGKILL') + done() + }, SHUTDOWN_TIMEOUT) + child.once('exit', () => { + clearTimeout(timer) + done() + }) + child.kill(signal) + }) +} + +const watcher = watch(rootDir, { recursive: true }, (_, filename) => { + if (restartTimer) clearTimeout(restartTimer) + const path = resolve(rootDir, String(filename)) + if (isIgnored(path)) return + restartTimer = setTimeout(async () => { + restartTimer = undefined + console.log('[cordis] change detected in', String(filename), '— restarting') + await stop() + start() + }, DEBOUNCE) +}) + +async function shutdown(signal) { + if (stopping) return + stopping = true + if (restartTimer) clearTimeout(restartTimer) + watcher.close() + await stop(signal) + process.exit(0) +} + +process.on('SIGINT', () => shutdown('SIGINT')) +process.on('SIGTERM', () => shutdown('SIGTERM')) + +start() diff --git a/tests/bun/repros/README.md b/tests/bun/repros/README.md new file mode 100644 index 00000000..29e69731 --- /dev/null +++ b/tests/bun/repros/README.md @@ -0,0 +1,50 @@ +# Standalone Bun behavior reproductions + +Manual scripts (not run by `bun test`) that document Bun 1.3.14 platform +behaviors the reload decision in `docs/BUN_COMPATIBILITY.md` is based on. +Each is self-contained; run it, edit the file as instructed, observe. + +## watch-globals-reset.ts + +Demonstrates `bun --watch` reload semantics: + +``` +bun --watch tests/bun/repros/watch-globals-reset.ts +# then append a comment line to the file (content-hash watch) a few times +``` + +Observed (1.3.14): same pid every reload; module re-evaluates; both +`globalThis` and `process` custom properties are **reset to fresh values** +on every reload — so a re-evaluated entry cannot reach the previous +Cordis root context. + +## watch-timer-disposers.ts + +Demonstrates that pending timers from previous evaluations disappear on +reload **without their clearing callbacks running**: + +``` +bun --watch tests/bun/repros/watch-timer-disposers.ts +# append comment lines between observations +``` + +Observed: tick density stays constant (no timer stacking — Bun removes old +timers), but the `clearInterval` disposer closure never runs (no +"cleared" output on reload). + +## hot-state-duplication.ts + +Demonstrates `bun --hot` duplicating live state across re-evaluations: + +``` +bun --hot tests/bun/repros/hot-state-duplication.ts +# then edit the marker string in the file and save +``` + +Observed: after an edit, output lines from BOTH the old and the new +evaluation interleave forever (old timers/listeners keep running) — Cordis +state would duplicate on every reload. + +These behaviors motivated the supervisor design +(`packages/core/bin.bun.watch.js`) instead of relying on `--watch`/`--hot` +for Cordis development reload. diff --git a/tests/bun/repros/hot-state-duplication.ts b/tests/bun/repros/hot-state-duplication.ts new file mode 100644 index 00000000..7af175b9 --- /dev/null +++ b/tests/bun/repros/hot-state-duplication.ts @@ -0,0 +1,7 @@ +// Repro: bun --hot keeps previous evaluations' timers running (state duplication). +// Run: bun --hot tests/bun/repros/hot-state-duplication.ts +// Then change the marker string below and save; observe interleaved output. +export let marker = 'v1' + +console.log(`[repro] main evaluated, marker=${marker}`) +setInterval(() => console.log(`[repro] alive, marker=${marker}`), 400) diff --git a/tests/bun/repros/watch-globals-reset.ts b/tests/bun/repros/watch-globals-reset.ts new file mode 100644 index 00000000..a28f4bce --- /dev/null +++ b/tests/bun/repros/watch-globals-reset.ts @@ -0,0 +1,6 @@ +// Repro: bun --watch resets globalThis and process per reload. +// Run: bun --watch tests/bun/repros/watch-globals-reset.ts +// Then append a comment line to this file (touch alone does not trigger). +const runs = (globalThis as any).__RUNS__ = ((globalThis as any).__RUNS__ ?? 0) + 1 +const processRuns = (process as any).__RUNS__ = ((process as any).__RUNS__ ?? 0) + 1 +console.log(`[repro] pid=${process.pid} globalThis_runs=${runs} process_runs=${processRuns}`) diff --git a/tests/bun/repros/watch-timer-disposers.ts b/tests/bun/repros/watch-timer-disposers.ts new file mode 100644 index 00000000..f5e2385c --- /dev/null +++ b/tests/bun/repros/watch-timer-disposers.ts @@ -0,0 +1,12 @@ +// Repro: bun --watch removes previous evaluations' pending timers without +// running their clear callbacks. +// Run: bun --watch tests/bun/repros/watch-timer-disposers.ts +// Then append comment lines and watch tick density vs "cleared" output. +const gen = (globalThis as any).__GEN__ = ((globalThis as any).__GEN__ ?? 0) + 1 +console.log(`[repro] eval #${gen} (globalThis.__GEN__ resets each reload)`) +setInterval(() => console.log(`[repro] tick from eval #${gen}`), 300) +// the disposer below never runs on reload — Bun drops the raw timer +process.on('SIGINT', () => { + console.log(`[repro] SIGINT: only signal handlers from OLD evaluations may still run`) + process.exit(0) +}) diff --git a/tests/bun/watch.spec.ts b/tests/bun/watch.spec.ts new file mode 100644 index 00000000..3049ec75 --- /dev/null +++ b/tests/bun/watch.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'bun:test' +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Phase 5A: development reload via the Bun supervisor + * (`packages/core/bin.bun.watch.js`). + * + * Asserts the restart contract: on file change the previous generation's + * root fiber is fully disposed (its disposers run) BEFORE the next + * generation activates, and resources never duplicate. + */ + +const SUPERVISOR = fileURLToPath(new URL('../../packages/core/bin.bun.watch.js', import.meta.url)) +const REPO_ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))) + +const PLUGIN = [ + "import { Context } from 'cordis'", + '', + 'export function apply(ctx: Context) {', + " console.log('[app] applied gen=' + GEN)", + ' ctx.effect(() => {', + " const t = setInterval(() => console.log('[app] tick'), 150)", + ' return () => { clearInterval(t); console.log(\'[app] timer-cleared gen=\' + GEN) }', + ' })', + '}', + '', + "declare const GEN: number", +].join('\n') + +describe('Bun / development reload (supervisor)', () => { + it('file change disposes the old root before activating the new one', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bun-watch-')) + try { + await symlink(join(REPO_ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir') + await writeFile(join(dir, 'cordis.yml'), '- id: plugin\n name: ./plugin.ts\n') + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 1\n' + PLUGIN) + + const child = spawn(process.execPath, [SUPERVISOR], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }) + + let stdout = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stdout += c }) + + await waitFor(() => stdout.includes('applied gen=1')) + + // edit the plugin: generation 2 + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 2\n' + PLUGIN) + await waitFor(() => stdout.includes('applied gen=2')) + + // generation 3 + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 3\n' + PLUGIN) + await waitFor(() => stdout.includes('applied gen=3')) + + // graceful shutdown + child.kill('SIGINT') + const code = await new Promise(res => child.on('exit', res)) + + const lines = stdout.split('\n').filter(l => l.startsWith('[app]')) + + // every generation activated exactly once + expect(lines.filter(l => l.includes('applied gen=1')).length).toBe(1) + expect(lines.filter(l => l.includes('applied gen=2')).length).toBe(1) + expect(lines.filter(l => l.includes('applied gen=3')).length).toBe(1) + + // disposal strictly precedes the next activation + const clear1 = lines.findIndex(l => l.includes('timer-cleared gen=1')) + const apply2 = lines.findIndex(l => l.includes('applied gen=2')) + const clear2 = lines.findIndex(l => l.includes('timer-cleared gen=2')) + const apply3 = lines.findIndex(l => l.includes('applied gen=3')) + expect(clear1).toBeGreaterThan(-1) + expect(clear1).toBeLessThan(apply2) + expect(clear2).toBeGreaterThan(-1) + expect(clear2).toBeLessThan(apply3) + + // the final generation is disposed by SIGINT + expect(lines.some(l => l.includes('timer-cleared gen=3'))).toBe(true) + + // config file survived unchanged (no write-back corruption) + expect(await readFile(join(dir, 'cordis.yml'), 'utf8')).toBe('- id: plugin\n name: ./plugin.ts\n') + + expect(code).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 30000) +}) + +function waitFor(predicate: () => boolean, timeout = 15000) { + return new Promise((resolve, reject) => { + const started = Date.now() + const check = setInterval(() => { + if (predicate()) { + clearInterval(check) + resolve() + } else if (Date.now() - started > timeout) { + clearInterval(check) + reject(new Error('waitFor timed out')) + } + }, 50) + }) +} From e3cc3f9e9cc9f82b7f2352ca24dbed2efe59db5c Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 05:35:30 -0700 Subject: [PATCH 03/33] docs(bun): record phase 4-5 commit SHAs and final status --- docs/BUN_PORT_STATUS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index d3a14b20..1d1780c1 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -74,4 +74,7 @@ None. - `06ae971` — Phase 0–3: Bun behavioral suite + plan/status docs + loader devDependency fix -- (pending) — Phase 4–5: entrypoints, supervisor, CI, compatibility docs +- `a8499bd` — Phase 4–5: entrypoints, supervisor, CI, compatibility docs + +Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; +push/PR is owner-gated). From f98c663160e7c8e1b4837ac44fb5e206f1c6b50b Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 10:24:44 -0700 Subject: [PATCH 04/33] bench(bun): performance, memory and CPU profile comparison vs upstream - 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. --- bench/cold-loader.mjs | 22 +++ bench/cold-min.mjs | 8 + bench/leak.mjs | 61 +++++++ bench/main.mjs | 10 ++ bench/parse-profile.mjs | 35 ++++ bench/profile.mjs | 46 ++++++ bench/run.mjs | 236 ++++++++++++++++++++++++++ bench/workloads.mjs | 340 ++++++++++++++++++++++++++++++++++++++ docs/BUN_BENCH.md | 137 +++++++++++++++ docs/BUN_COMPATIBILITY.md | 3 + docs/BUN_PORT_STATUS.md | 34 ++++ package.json | 3 + 12 files changed, 935 insertions(+) create mode 100644 bench/cold-loader.mjs create mode 100644 bench/cold-min.mjs create mode 100644 bench/leak.mjs create mode 100644 bench/main.mjs create mode 100644 bench/parse-profile.mjs create mode 100644 bench/profile.mjs create mode 100644 bench/run.mjs create mode 100644 bench/workloads.mjs create mode 100644 docs/BUN_BENCH.md diff --git a/bench/cold-loader.mjs b/bench/cold-loader.mjs new file mode 100644 index 00000000..f76a2b09 --- /dev/null +++ b/bench/cold-loader.mjs @@ -0,0 +1,22 @@ +// cold-start app: Loader + Include + 3 TypeScript plugins from config dir +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +const dir = process.argv[2] +const ctx = new Context() +ctx.baseUrl = new URL('.', pathFromDir(dir)).href + +const fiber = await ctx.plugin(Loader) +await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: './cordis.yml', enableLogs: false }, +}) +await ctx.loader.await() +console.log('READY') +await new Promise(r => setTimeout(r, 5)) +await fiber.dispose() +process.exit(0) + +function pathFromDir(d) { + return 'file://' + (d.endsWith('/') ? d : d + '/') +} diff --git a/bench/cold-min.mjs b/bench/cold-min.mjs new file mode 100644 index 00000000..b8684e1e --- /dev/null +++ b/bench/cold-min.mjs @@ -0,0 +1,8 @@ +// cold-start app: minimal (Context + 3 plugins) +import { Context } from 'cordis' +const ctx = new Context() +const p = c => { c.on('x', () => {}) } +await ctx.plugin(p); await ctx.plugin(p); await ctx.plugin(p) +console.log('READY') +await new Promise(r => setTimeout(r, 5)) +process.exit(0) diff --git a/bench/leak.mjs b/bench/leak.mjs new file mode 100644 index 00000000..3e9e73ef --- /dev/null +++ b/bench/leak.mjs @@ -0,0 +1,61 @@ +/** + * Memory-leak check: many plugin load/dispose cycles, then verify the event + * hook registry and plugin registry return to empty and measure rss growth. + */ +import { Context } from 'cordis' + +const CYCLES = 600 + +function gc() { + if (typeof Bun !== 'undefined') Bun.gc(false) + else if (globalThis.gc) globalThis.gc() +} + +function snapshot(label) { + gc(); gc() + const m = process.memoryUsage() + return { label, rss: m.rss, heapUsed: m.heapUsed ?? 0 } +} + +const root = new Context() +const plugin = ctx => { + ctx.on('bench-event', () => {}) + ctx.on('bench-event', () => {}) + ctx.effect(() => { + const t = setInterval(() => {}, 1 << 28) + return () => clearInterval(t) + }) + ctx.effect(() => () => {}) +} + +// warmup +for (let i = 0; i < 50; i++) { + const f = await root.plugin(plugin) + await f.dispose() +} +await sleep(5) + +const before = snapshot('before') +for (let i = 0; i < CYCLES; i++) { + const f = await root.plugin(plugin) + await f.dispose() +} +await sleep(5) +const after = snapshot('after') + +const hooks = Object.entries(root.events._hooks) + .filter(([, v]) => v.length) +const report = { + runtime: typeof Bun !== 'undefined' ? 'bun ' + Bun.version : 'node ' + process.version, + cycles: CYCLES, + rssBefore: before.rss, + rssAfter: after.rss, + rssGrowthMB: +((after.rss - before.rss) / 1048576).toFixed(2), + heapGrowthMB: +((after.heapUsed - before.heapUsed) / 1048576).toFixed(2), + registryEmpty: root.registry.size === 0, + leftoverHooks: hooks.map(([k, v]) => `${k}:${v.length}`), +} + +console.log('@@LEAK ' + JSON.stringify(report)) + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } diff --git a/bench/main.mjs b/bench/main.mjs new file mode 100644 index 00000000..b159a8a7 --- /dev/null +++ b/bench/main.mjs @@ -0,0 +1,10 @@ +import { runWorkloads } from './workloads.mjs' + +const filter = process.argv.includes('--filter') + ? process.argv[process.argv.indexOf('--filter') + 1] + : '' + +process.stderr.write(`[bench] runtime=${typeof Bun !== 'undefined' ? 'bun ' + Bun.version : 'node ' + process.version}\n`) +const results = await runWorkloads(filter) +// machine-readable line for the orchestrator +console.log('@@RESULTS ' + JSON.stringify(results)) diff --git a/bench/parse-profile.mjs b/bench/parse-profile.mjs new file mode 100644 index 00000000..1873ea48 --- /dev/null +++ b/bench/parse-profile.mjs @@ -0,0 +1,35 @@ +/** + * Aggregate a Chrome .cpuprofile by self time (hitCount) and print the top N. + * Works for both node --cpu-prof and bun --cpu-prof output. + */ +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' + +const file = process.argv[2] +const top = Number(process.argv[3] ?? 15) + +const profile = JSON.parse(readFileSync(file, 'utf8')) +const nodes = profile.nodes ?? [] + +let totalHits = 0 +const byKey = new Map() +for (const node of nodes) { + const hits = node.hitCount ?? 0 + if (!hits) continue + totalHits += hits + const cf = node.callFrame ?? {} + const fn = cf.functionName || '(anonymous)' + const url = cf.url ? basename(new URL(cf.url, 'file://').pathname) : '(native)' + const key = `${fn} @ ${url}` + byKey.set(key, (byKey.get(key) ?? 0) + hits) +} + +const entries = [...byKey.entries()].sort((a, b) => b[1] - a[1]).slice(0, top) +const duration = (profile.timeDeltas ?? []).reduce((a, b) => a + b, 0) / 1000 + +console.log(`profile: ${basename(file)}`) +console.log(`samples: ${totalHits} hits, ~${duration.toFixed(0)}ms sampled`) +for (const [key, hits] of entries) { + const pct = ((hits / totalHits) * 100).toFixed(1).padStart(5) + console.log(` ${pct}% ${hits.toString().padStart(7)} ${key}`) +} diff --git a/bench/profile.mjs b/bench/profile.mjs new file mode 100644 index 00000000..5dc68d32 --- /dev/null +++ b/bench/profile.mjs @@ -0,0 +1,46 @@ +/** + * Fixed-duration mixed workload for CPU profiling + * (`node --cpu-prof` / `bun --cpu-prof`). Runs ~2s of realistic Cordis work. + */ +import { Context } from 'cordis' + +const DURATION_MS = 2000 +const start = performance.now() + +const emitRoot = new Context() +for (let i = 0; i < 10; i++) emitRoot.on('bench-event', () => {}) + +const pluginRoot = new Context() +const plugin = ctx => { + ctx.on('bench-event', () => {}) + ctx.effect(() => () => {}) +} + +const injectRoot = new Context() +injectRoot.inject(['bench-svc'], () => {}) + +let rounds = 0 +while (performance.now() - start < DURATION_MS) { + for (let i = 0; i < 2000; i++) emitRoot.emit('bench-event') + for (let i = 0; i < 30; i++) { + const d = pluginRoot.effect(function* () { + yield () => {} + yield () => {} + yield () => {} + }) + d() + } + for (let i = 0; i < 10; i++) { + const f = await pluginRoot.plugin(plugin) + await f.dispose() + } + for (let i = 0; i < 10; i++) { + const d = injectRoot.provide('bench-svc', i) + await 0 + d() + await 0 + } + await Promise.resolve() + rounds++ +} +console.log('@@PROFILE rounds=' + rounds) diff --git a/bench/run.mjs b/bench/run.mjs new file mode 100644 index 00000000..02f18693 --- /dev/null +++ b/bench/run.mjs @@ -0,0 +1,236 @@ +/** + * Benchmark orchestrator (run under Node). + * + * Subcommands: + * run --runtime node|bun --label NAME [--filter substr] + * cold (both runtimes: interpreter, minimal app, loader app) + * leak --runtime node|bun + * profile --runtime node|bun --label NAME + * compare LABEL... (merged table from bench/results/*.json) + */ +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdtemp, writeFile, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const RESULTS = join(HERE, 'results') +mkdirSync(RESULTS, { recursive: true }) + +const BUN_BIN = process.env.BUN_BIN ?? join(homedir(), '.bun', 'bin', 'bun') + +function runtimeCmd(runtime) { + if (runtime === 'node') return { cmd: 'node', base: ['--import', 'tsx'] } + return { cmd: BUN_BIN, base: [] } +} + +function runCapture(runtime, args) { + const { cmd, base } = runtimeCmd(runtime) + return new Promise((resolve, reject) => { + const child = spawn(cmd, [...base, ...args], { + cwd: dirname(HERE), + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }) + let stdout = '', stderr = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stderr += c }) + child.on('exit', code => code === 0 + ? resolve({ stdout, stderr }) + : reject(new Error(`${runtime} ${args.join(' ')} exited ${code}\n${stdout}\n${stderr}`))) + child.on('error', reject) + }) +} + +function fmtTable(rows, headers) { + const widths = headers.map((h, i) => + Math.max(h.length, ...rows.map(r => String(r[i]).length))) + const line = cells => cells.map((c, i) => String(c).padEnd(widths[i])).join(' | ') + return [line(headers), ...rows.map(line)].join('\n') +} + +async function cmdRun(runtime, label, filter) { + const args = [join(HERE, 'main.mjs')] + if (filter) args.push('--filter', filter) + process.stdout.write(`[run] ${label} (${runtime}) workloads…\n`) + const { stdout, stderr } = await runCapture(runtime, args) + const marker = stdout.split('\n').find(l => l.startsWith('@@RESULTS')) + if (!marker) throw new Error('no results marker\n' + stderr) + const results = JSON.parse(marker.slice('@@RESULTS '.length)) + writeFileSync(join(RESULTS, `${label}.json`), JSON.stringify(results, null, 2)) + printWorkloads(label, results) +} + +function printWorkloads(label, results) { + const rows = results.map(r => [r.name, r.ops, r.batches, r.medianMs.toFixed(2), r.usPerOp.toFixed(2)]) + console.log('\n' + fmtTable(rows, ['workload', 'ops/batch', 'batches', 'median ms', 'us/op']) + `\n -> saved results/${label}.json\n`) +} + +function timeToReady(cmd, args, cwd) { + return new Promise((resolve, reject) => { + const t0 = performance.now() + const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + child.stdout.on('data', c => { + out += c + if (out.includes('READY')) { + child.kill('SIGKILL') + resolve(performance.now() - t0) + } + }) + child.stderr.on('data', c => { out += c }) + child.on('exit', code => { + if (out.includes('READY')) resolve(performance.now() - t0) + else reject(new Error(`no READY (exit ${code}): ${cmd} ${args.join(' ')}\n${out.slice(0, 2000)}`)) + }) + child.on('error', reject) + }) +} + +function median(arr) { + const s = [...arr].sort((a, b) => a - b) + return s[Math.floor(s.length / 2)] +} + +async function measureN(n, fn) { + const times = [] + for (let i = 0; i < n; i++) times.push(await fn()) + return { median: median(times), min: Math.min(...times), max: Math.max(...times) } +} + +async function cmdCold() { + const N = 10 + const rows = [] + const variants = [] + + // interpreter baseline + variants.push({ name: 'interp -e READY', node: ['node', ['-e', "console.log('READY')"]], bun: [BUN_BIN, ['-e', "console.log('READY')"]] }) + // minimal cordis app (no TS involved -> no tsx preload for node) + variants.push({ name: 'cold-min (Context+3 plugins)', node: ['node', [join(HERE, 'cold-min.mjs')]], bun: [BUN_BIN, [join(HERE, 'cold-min.mjs')]] }) + + const tempDirs = [] + const loaderVariants = [ + { suffix: 'mjs', name: 'cold-loader-js (3 JS plugins)', ts: false }, + { suffix: 'ts', name: 'cold-loader-ts (3 TS plugins)', ts: true }, + ] + for (const { suffix, name, ts } of loaderVariants) { + const cfgDir = await mkdtemp(join(tmpdir(), 'cordis-cold-')) + tempDirs.push(cfgDir) + // let plain Node resolve 'cordis' from the temp dir (Bun ignores this) + await symlink(join(dirname(HERE), 'node_modules'), join(cfgDir, 'node_modules'), 'dir') + const lines = [] + for (let i = 0; i < 3; i++) { + await writeFile(join(cfgDir, `plugin-${i}.${suffix}`), "import { Context } from 'cordis'\nexport function apply(ctx: Context) { ctx.on('x', () => {}) }\n") + lines.push(`- id: p${i}`, ` name: ./plugin-${i}.${suffix}`) + } + await writeFile(join(cfgDir, 'cordis.yml'), lines.join('\n') + '\n') + const nodeArgs = [...(ts ? ['--import', 'tsx'] : []), join(HERE, 'cold-loader.mjs'), cfgDir] + variants.push({ + name, + node: ['node', nodeArgs], + bun: [BUN_BIN, [join(HERE, 'cold-loader.mjs'), cfgDir]], + }) + } + + for (const v of variants) { + const row = [v.name] + for (const rt of ['node', 'bun']) { + const [cmd, args] = v[rt] + const r = await measureN(N, () => timeToReady(cmd, args, dirname(HERE))) + row.push(r.median.toFixed(1), `${r.min.toFixed(1)}–${r.max.toFixed(1)}`) + } + rows.push(row) + } + await Promise.all(tempDirs.map(d => rm(d, { recursive: true, force: true }))) + console.log('\n' + fmtTable(rows, ['cold start (ms, median of 10)', 'node med', 'node range', 'bun med', 'bun range']) + '\n') +} + +async function cmdLeak(runtime) { + const { stdout, stderr } = await runCapture(runtime, [join(HERE, 'leak.mjs'), '--expose-gc'].filter(a => !(runtime === 'bun' && a === '--expose-gc'))) + const marker = stdout.split('\n').find(l => l.startsWith('@@LEAK')) + if (!marker) throw new Error('no leak marker\n' + stderr) + const r = JSON.parse(marker.slice('@@LEAK '.length)) + const rows = [ + ['runtime', r.runtime], + ['cycles', r.cycles], + ['rss growth (MB)', r.rssGrowthMB], + ['heap growth (MB)', r.heapGrowthMB], + ['registry empty', r.registryEmpty], + ['leftover hooks', r.leftoverHooks.length ? r.leftoverHooks.join(',') : 'none'], + ] + console.log('\n' + fmtTable(rows, ['leak check', 'value']) + '\n') + writeFileSync(join(RESULTS, `leak-${runtime}.json`), JSON.stringify(r, null, 2)) +} + +async function cmdProfile(runtime, label) { + const dir = join(RESULTS, `prof-${label}`) + rmSync(dir, { recursive: true, force: true }) + mkdirSync(dir, { recursive: true }) + const { cmd, base } = runtimeCmd(runtime) + const args = [...base] + if (runtime === 'node') args.push('--cpu-prof', '--cpu-prof-dir=' + dir) + else args.push('--cpu-prof', '--cpu-prof-dir=' + dir) + args.push(join(HERE, 'profile.mjs')) + await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { cwd: dirname(HERE), stdio: 'inherit' }) + child.on('exit', code => code === 0 ? resolve() : reject(new Error(`profile exited ${code}`))) + }) + const files = readdirSync(dir).filter(f => f.endsWith('.cpuprofile')) + console.log(`\n[profile] ${label}: ${files.length} profile(s) in results/prof-${label}/`) +} + +async function cmdCompare(labels) { + const datas = labels.map(l => ({ + label: l, + results: JSON.parse(readFileSync(join(RESULTS, `${l}.json`), 'utf8')), + })) + const names = datas[0].results.map(r => r.name) + const rows = [] + for (const name of names) { + const row = [name] + let first = null, last = null + for (const d of datas) { + const r = d.results.find(x => x.name === name) + const v = r ? r.usPerOp.toFixed(2) : '—' + row.push(v) + if (r) { + if (first === null) first = r.usPerOp + last = r.usPerOp + } + } + row.push(first ? (last / first).toFixed(2) + 'x' : '—') + rows.push(row) + } + const headers = ['workload', ...labels.map(l => l + ' µs/op'), `${labels[labels.length - 1]} / ${labels[0]}`] + console.log('\n' + fmtTable(rows, headers) + '\n') +} + +const [sub, ...rest] = process.argv.slice(2) +const arg = (name) => { + const i = rest.indexOf('--' + name) + return i >= 0 ? rest[i + 1] : undefined +} + +switch (sub) { + case 'run': + await cmdRun(arg('runtime'), arg('label'), arg('filter')) + break + case 'cold': + await cmdCold() + break + case 'leak': + await cmdLeak(arg('runtime')) + break + case 'profile': + await cmdProfile(arg('runtime'), arg('label')) + break + case 'compare': + await cmdCompare(rest) + break + default: + console.error('usage: node bench/run.mjs run|cold|leak|profile|compare …') + process.exit(1) +} diff --git a/bench/workloads.mjs b/bench/workloads.mjs new file mode 100644 index 00000000..81cee528 --- /dev/null +++ b/bench/workloads.mjs @@ -0,0 +1,340 @@ +/** + * In-process benchmark workloads for Cordis, runnable under Node and Bun + * from the same source. Plain ESM JavaScript; no test-framework deps. + * + * Each workload: setup() -> warmup batches -> timed batches -> teardown(). + * Report per-op time from the MEDIAN batch (robust to scheduler noise). + */ + +import { Context, Service } from 'cordis' +import Timer from '@cordisjs/plugin-timer' +import { Loader, Group } from '@cordisjs/plugin-loader' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +class BenchLoader extends Loader { + static modules = {} + + write() {} + async read(data) { + await this.root.update(data) + await this.await() + } + async import(name) { + if (name === '@cordisjs/plugin-group') return Group + return BenchLoader.modules[name] + } +} + +const PLUGIN_TS = [ + "import { Context } from 'cordis'", + 'export function apply(ctx: Context) {', + " ctx.on('bench-event', () => {})", + '}', +].join('\n') + +export const workloads = [ + { + name: 'context-create', + ops: 2000, batches: 5, warmup: 1, + batch() { + const arr = new Array(this.ops) + for (let i = 0; i < this.ops; i++) arr[i] = new Context() + }, + }, + { + name: 'plugin-lifecycle', + ops: 1000, batches: 5, warmup: 1, + setup() { + const root = new Context() + const plugin = ctx => { + ctx.on('bench-event', () => {}) + ctx.effect(() => () => {}) + } + return { root, plugin } + }, + async batch({ root, plugin }) { + for (let i = 0; i < this.ops; i++) { + const fiber = await root.plugin(plugin) + await fiber.dispose() + } + }, + }, + { + name: 'plugin-bulk-500', + ops: 500, batches: 5, warmup: 1, + setup() { + const root = new Context() + const child = ctx => { ctx.on('bench-event', () => {}) } + const parent = async ctx => { + const tasks = [] + for (let i = 0; i < 500; i++) tasks.push(ctx.plugin(child)) + await Promise.all(tasks) + } + return { root, parent } + }, + async batch({ root, parent }) { + const fiber = await root.plugin(parent) + await fiber.dispose() + await sleep(1) + }, + }, + { + name: 'effects-sync-5', + ops: 10000, batches: 5, warmup: 1, + setup() { return { root: new Context() } }, + batch({ root }) { + const disposers = new Array(this.ops) + for (let i = 0; i < this.ops; i++) { + disposers[i] = root.effect(function* () { + yield () => {} + yield () => {} + yield () => {} + yield () => {} + yield () => {} + }) + } + for (const d of disposers) d() + }, + }, + { + name: 'effects-async', + ops: 2000, batches: 5, warmup: 1, + setup() { return { root: new Context() } }, + async batch({ root }) { + for (let i = 0; i < this.ops; i++) { + const d = root.effect(async () => { + await sleep(0) + return () => {} + }) + await d() + } + }, + }, + { + name: 'emit-10-listeners', + ops: 20000, batches: 5, warmup: 1, + setup() { + const root = new Context() + for (let i = 0; i < 10; i++) root.on('bench-event', () => {}) + return { root } + }, + batch({ root }) { + for (let i = 0; i < this.ops; i++) root.emit('bench-event') + }, + }, + { + name: 'parallel-10-async', + ops: 1000, batches: 5, warmup: 1, + setup() { + const root = new Context() + for (let i = 0; i < 10; i++) root.on('bench-event', async () => {}) + return { root } + }, + async batch({ root }) { + for (let i = 0; i < this.ops; i++) await root.parallel('bench-event') + }, + }, + { + name: 'serial-5', + ops: 2000, batches: 5, warmup: 1, + setup() { + const root = new Context() + for (let i = 0; i < 5; i++) root.on('bench-event', async () => {}) + return { root } + }, + async batch({ root }) { + for (let i = 0; i < this.ops; i++) await root.serial('bench-event') + }, + }, + { + name: 'bail-5', + ops: 50000, batches: 3, warmup: 1, + setup() { + const root = new Context() + root.on('bench-event', () => {}) + root.on('bench-event', () => {}) + root.on('bench-event', () => 'x') + root.on('bench-event', () => {}) + root.on('bench-event', () => {}) + return { root } + }, + batch({ root }) { + for (let i = 0; i < this.ops; i++) root.bail('bench-event') + }, + }, + { + name: 'waterfall-5', + ops: 20000, batches: 5, warmup: 1, + setup() { + const root = new Context() + for (let i = 0; i < 5; i++) { + root.on('bench-event', (v, next) => v + next()) + } + return { root } + }, + batch({ root }) { + for (let i = 0; i < this.ops; i++) root.waterfall('bench-event', 1, () => 2) + }, + }, + { + name: 'inject-cycles', + ops: 1000, batches: 5, warmup: 1, + setup() { + const root = new Context() + root.inject(['bench-svc'], () => {}) + return { root } + }, + async batch({ root }) { + for (let i = 0; i < this.ops; i++) { + const d = root.provide('bench-svc', i) + await sleep(0) + d() + await sleep(0) + } + }, + }, + { + name: 'isolate-provide', + ops: 1000, batches: 5, warmup: 1, + setup() { + const root = new Context() + const ctxs = [] + for (let i = 0; i < 100; i++) { + const ctx = root.isolate('bench-svc') + ctx.inject(['bench-svc'], () => {}) + ctxs.push(ctx) + } + return { root, ctxs } + }, + async batch({ ctxs }) { + for (let i = 0; i < this.ops; i++) { + const d = ctxs[i % 100].provide('bench-svc', i) + await sleep(0) + d() + await sleep(0) + } + }, + }, + { + name: 'timer-effect', + ops: 5000, batches: 5, warmup: 1, + async setup() { + const root = new Context() + await root.plugin(Timer) + return { root } + }, + batch({ root }) { + const disposers = new Array(this.ops) + for (let i = 0; i < this.ops; i++) disposers[i] = root.timeout(() => {}, 1e9) + for (const d of disposers) d() + }, + }, + { + name: 'loader-mock-50', + ops: 300, batches: 5, warmup: 1, + async setup() { + const root = new Context() + for (let i = 0; i < 50; i++) { + BenchLoader.modules[`p${i}`] = { apply: ctx => { ctx.on('bench-event', () => {}) } } + } + await root.plugin(BenchLoader) + const loader = root.loader + const base = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, name: `p${i}` })) + return { loader, base } + }, + async batch({ loader, base }) { + // 6 tree updates of 50 entries each = 300 entry transitions + await loader.read(base) + for (let round = 0; round < 5; round++) { + const next = base.map((e, i) => ({ + ...e, + disabled: (i + round) % 2 === 0 ? true : null, + })) + await loader.read(next) + } + }, + }, + { + name: 'include-boot-20-plugins', + ops: 1, batches: 8, warmup: 1, + async setup() { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bench-')) + const lines = [] + for (let i = 0; i < 20; i++) { + await writeFile(join(dir, `plugin-${i}.ts`), PLUGIN_TS, 'utf8') + lines.push(`- id: p${i}`, ' name: ./plugin-' + i + '.ts') + } + await writeFile(join(dir, 'cordis.yml'), lines.join('\n') + '\n', 'utf8') + return { dir } + }, + async batch({ dir }) { + const ctx = new Context() + const fiber = await ctx.plugin(Loader, { + baseUrl: pathToFileURL(dir).href + '/', + }) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: './cordis.yml', enableLogs: false }, + }) + await ctx.loader.await() + await fiber.dispose() + await sleep(1) + }, + async teardown({ dir }) { + await rm(dir, { recursive: true, force: true }) + }, + }, + { + name: 'module-eval-ts', + ops: 150, batches: 4, warmup: 1, + async setup() { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bench-mod-')) + const file = join(dir, 'plugin.ts') + await writeFile(file, PLUGIN_TS, 'utf8') + return { url: pathToFileURL(file).href } + }, + async batch({ url }, batchIndex) { + for (let i = 0; i < this.ops; i++) { + // cache-busting query: forces transpile + eval of a fresh module + await import(/* @vite-ignore */ `${url}?b${batchIndex}i${i}`) + } + }, + async teardown({ url }) { + const dir = new URL('.', url).pathname + await rm(dir, { recursive: true, force: true }) + }, + }, +] + +export async function runWorkloads(filter) { + const results = [] + for (const w of workloads) { + if (filter && !w.name.includes(filter)) continue + const state = await w.setup?.() ?? {} + for (let i = 0; i < (w.warmup ?? 1); i++) await w.batch(state, -1 - i) + const times = [] + for (let i = 0; i < w.batches; i++) { + const t0 = performance.now() + await w.batch(state, i) + times.push(performance.now() - t0) + } + await w.teardown?.(state) + const sorted = [...times].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + results.push({ + name: w.name, + ops: w.ops, + batches: w.batches, + timesMs: times.map(t => +t.toFixed(3)), + medianMs: +median.toFixed(3), + usPerOp: +(median * 1000 / w.ops).toFixed(3), + }) + process.stderr.write(` done: ${w.name} (${(median * 1000 / w.ops).toFixed(2)} us/op)\n`) + } + return results +} diff --git a/docs/BUN_BENCH.md b/docs/BUN_BENCH.md new file mode 100644 index 00000000..d8ca84b6 --- /dev/null +++ b/docs/BUN_BENCH.md @@ -0,0 +1,137 @@ +# Cordis Benchmarks — Bun vs Node, fork vs upstream + +Measurements taken 2026-08-16 on the development machine (Apple silicon +macOS 26.5.2, arm64). **Absolute numbers are indicative only** — this is a +laptop, not an isolated benchmark environment. Relative comparisons and +ranges are the meaningful output. Two full passes were taken per +configuration; the tables show best/worst across passes. + +- Node `v26.4.0` (with `--import tsx` for TypeScript paths) +- Bun `1.3.14` (`1.3.14+0d9b296af`) +- "fork" = this repository (`feat/bun-compat`), "upstream" = + `cordiverse/cordis@8cc9e33` in a temporary git worktree, built and run + identically +- The fork has **zero source changes** vs upstream (`git diff upstream/main + -- packages/*/src` is empty), so fork-vs-upstream is a control group + +Reproduce with: + +```bash +node bench/run.mjs run --runtime node --label fork-node +node bench/run.mjs run --runtime bun --label bun +node bench/run.mjs cold +node bench/run.mjs leak --runtime node && node bench/run.mjs leak --runtime bun +node bench/run.mjs profile --runtime node --label node +node bench/run.mjs profile --runtime bun --label bun +node bench/parse-profile.mjs bench/results/prof-node/*.cpuprofile +``` + +## 1. Fork vs upstream (control, both Node) — parity + +Back-to-back alternating runs of the noisiest workload (`plugin-lifecycle`) +show overlapping ranges — fork 735–849 µs/op vs upstream 690–741 µs/op with +crossovers — i.e. differences are within the ±15 % machine run-to-run +variance, not systematic. Conclusion: **the fork introduces no performance +change under Node** (expected: no runtime code was modified). + +Full single-pass tables live in `bench/results/*.json`; every workload +overlapped between fork and upstream within variance except where noted in +§2. + +## 2. Node vs Bun (fork) — in-process workloads + +µs/op, best / worst of two full passes (lower is better): + +| workload | fork node | upstream node | bun | node / bun | +| --- | --- | --- | --- | --- | +| context-create | 1600 / 1643 | 1419 / 1546 | 159 / 229 | **10.1x** | +| plugin-lifecycle (apply+dispose) | 686 / 724 | 537 / 570 | 137 / 186 | **5.0x** | +| plugin-bulk-500 (apply 500, dispose) | 600 / 634 | 585 / 626 | 128 / 172 | **4.7x** | +| effects-sync-5 (5 disposers) | 72 / 74 | 79 / 84 | 14 / 19 | **5.2x** | +| effects-async (await settle) | 1217 / 1244 | 1236 / 1255 | 1217 / 1292 | 1.0x | +| emit (10 listeners) | 18 / 23 | 22 / 25 | 16 / 19 | 1.2x | +| parallel (10 async listeners) | 26 / 36 | 37 / 39 | 21 / 28 | 1.2x | +| serial (5 listeners) | 20 / 24 | 23 / 24 | 11 / 16 | **1.8x** | +| bail (5 listeners) | 19 / 21 | 18 / 20 | 8 / 9 | **2.4x** | +| waterfall (5 listeners) | 30 / 32 | 26 / 28 | 16 / 17 | **1.9x** | +| inject-cycles (provide/settle/remove) | 2943 / 3146 | 2579 / 2584 | 2539 / 2543 | 1.2x | +| isolate-provide | 3062 / 3316 | 2884 / 3583 | 2756 / 2832 | 1.1x | +| timer-effect (create+clear timeout) | 124 / 138 | 155 / 216 | 41 / 44 | **3.0x** | +| loader-mock-50 (tree updates) | 366 / 384 | 333 / 478 | 151 / 206 | **2.4x** | +| include-boot-20-plugins (ms/boot) | 66 / 71 | 60 / 129 | 15.6 / 18.7 | **4.2x** | +| module-eval-ts (fresh TS module) | 1202 / 2385 | 1215 / 4111 | 15 / 18 | **~80x** | + +Reading: + +- **Bun is faster on every Cordis operation**; the largest wins are exactly + where a Bun-first deployment would care: plugin apply/dispose ~5x, timer + bookkeeping ~3x, declarative-loader tree updates ~2.4x, config-boot ~4x. +- `effects-async`, `inject-cycles`, `isolate-provide` are **dominated by + `setTimeout(0)` settle latency** (≈1.2–1.3 ms per tick on both runtimes + under macOS) — they measure event-loop timer floor, not Cordis, and are + effectively runtime-neutral. +- `module-eval-ts` imports a fresh `.ts` module per op (cache-busted URL). + Node's path goes through tsx (esbuild RPC round-trip per module); Bun + transpiles natively in-process. This is the single biggest practical + difference: **loading many TypeScript plugins is ~2 orders of magnitude + cheaper under Bun**. + +## 3. Cold start (spawn → READY, median of 10) + +| variant | node | bun | +| --- | --- | --- | +| interpreter baseline (`-e "console.log"`) | 137 ms | 41 ms | +| Context + 3 plugins (JS) | 160 ms | 63 ms | +| Loader + Include + 3 **JS** plugins | 224 ms | 131 ms | +| Loader + Include + 3 **TS** plugins | 621 ms | 133 ms | + +Decomposition (subtracting the previous row): + +- Cordis core import + boot: **≈23 ms (Node) vs ≈22 ms (Bun)** — parity; + Cordis itself is runtime-neutral at startup. +- Loader + Include + config read + 3 dynamic imports: ≈63 ms (Node) vs + ≈68 ms (Bun) — parity. +- TypeScript under Node adds **≈400 ms** (tsx preload + transpile); under + Bun, TS costs the same as JS. Node's entire cold-start penalty in the TS + case is the transpiler toolchain, not Cordis. + +## 4. Memory — 600 plugin load/dispose cycles + +| metric | node | bun | +| --- | --- | --- | +| registry after cycles | empty | empty | +| leftover event hooks | `internal/listener:1, internal/update:1` (root's own permanent hooks — expected) | same | +| rss growth | +26.7 MB | +38.8 MB | +| heap growth | +5.2 MB | +0.3 MB | + +No listener/timer accumulation on either runtime (corroborated by the +behavioral no-duplication tests). rss/heap accounting differs between V8 +and JSC and includes allocator retention; neither shows per-cycle growth +proportional to the 600 cycles. + +## 5. CPU profiles (fixed 2s mixed workload) + +Top self-time frames: + +- **Node**: `(anonymous) @ reflect.ts` 34 %, tsx loader plumbing + (`makeSyncRequest`, `waitForWorker` @ hooks) ~11 %, GC 5 %, then + `emit @ events.ts`, `trace @ reflect.ts`, `getPropertyDescriptor @ utils.ts`. +- **Bun**: native/JIT frames (`(host)`, `getOwnPropertyDescriptor`) ~39 %, + then `isSpecialProperty @ reflect.ts` 8 %, `get @ utils.ts` 7 %, + `emit @ events.ts`, `getTraceable @ utils.ts`. + +Common finding: the top Cordis-internal hotspot is **`packages/core/src/reflect.ts` +(proxy machinery)** on both runtimes — a shared optimization target, not a +Bun-specific issue. Node's profile additionally pays for tsx's +loader-thread RPC when TypeScript is involved. + +## Caveats + +- Single developer machine; background processes cause the observed ±15 % + run-to-run spread (visible in the ranges above). +- Bun numbers use Bun's JIT warm state after per-workload warmup batches, + same protocol as Node. +- `include-boot`/`module-eval` touch the filesystem (tmpdir); both runtimes + use the same tmpdir. +- The Node side always preloads tsx (required for the TS workloads); for + pure-JS workloads tsx is inert after startup. diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index c3830095..503723d6 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -8,6 +8,9 @@ classified below. - Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) - Behavioral proof: `bun test tests/bun` — 58 tests across 11 spec files covering the scenarios listed in "What is verified" below. +- Performance proof: see **`docs/BUN_BENCH.md`** — fork ≡ upstream under + Node (control), and Bun is faster on every Cordis operation (up to ~5x + plugin lifecycle, ~4x config boot, ~80x TS module eval). - Compatibility is **not** claimed from installation or typechecking alone; every claim below maps to an executable test or a recorded command. diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 1d1780c1..36ec2b64 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -59,6 +59,40 @@ bun test tests/bun # 58 pass / 0 fail / 198 expect() calls node ... yakumo vitest --import tsx # 19 files / 163 tests passed (see run log) ``` +### Phase 6 — benchmark & profile comparison vs the original (DONE) + +Motivation: owner asked whether we had profiled/benchmarked against the +original. Answer had been no (behavioral verification only); this phase adds +`bench/` + `docs/BUN_BENCH.md`. + +What was measured (2 full passes each; ranges in the doc): + +- **Control — fork vs upstream** (`cordiverse/cordis@8cc9e33` in a git + worktree, built + run identically under Node): statistically + indistinguishable; alternating A/B on the noisiest workload showed + overlapping ranges (fork 735–849 vs upstream 690–741 µs/op). Expected: + zero runtime-source changes. +- **Node vs Bun (fork)**: Bun faster on every Cordis operation — + context/plugin/effects machinery ~5x, timers ~3x, loader tree updates + ~2.4x, Include boot ~4x, fresh TS module eval ~80x (tsx RPC vs native + transpile). Timer-settle-dominated workloads (async effects, inject + cycles) are runtime-neutral (macOS `setTimeout(0)` floor). +- **Cold start**: Cordis core boot ≈23 ms on Node vs ≈22 ms on Bun (parity); + Node's TS penalty (~400 ms) is entirely the tsx toolchain. +- **Memory**: 600 load/dispose cycles — registry empty, no leftover + listeners/timers on either runtime (heap growth: node +5.2 MB, bun + +0.3 MB; rss accounting differs between V8/JSC). +- **CPU profiles** (`--cpu-prof` both runtimes): top Cordis-internal + hotspot is `packages/core/src/reflect.ts` proxy machinery on BOTH + runtimes — shared optimization target, not a Bun issue. + +Cordis change required by benching (additive): root `package.json` gained +`cordis`, `@cordisjs/plugin-loader`, `@cordisjs/plugin-timer` in +`devDependencies` so root-level bench files resolve workspace packages +under Bun's isolated linker (under Yarn hoisting this is a no-op). + +Reproduce: see the command block at the top of `docs/BUN_BENCH.md`. + ## Current failures None. diff --git a/package.json b/package.json index b14eee4a..b177a851 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,12 @@ }, "devDependencies": { "@cordisjs/eslint-config": "^1.1.1", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@cordisjs/plugin-timer": "^1.1.2", "@cordisjs/unyaml": "^2.0.3", "@types/node": "^25.3.5", "@vitest/coverage-v8": "^4.1.5", + "cordis": "^4.0.0-rc.8", "esbuild": "^0.28.0", "eslint": "^8.57.1", "shx": "^0.4.0", From 7602bab80fda37d9f035a77705bd71781280a28d Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 10:25:56 -0700 Subject: [PATCH 05/33] docs(bun): record phase 6 commit SHA --- docs/BUN_PORT_STATUS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 36ec2b64..0c50f532 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -109,6 +109,9 @@ None. - `06ae971` — Phase 0–3: Bun behavioral suite + plan/status docs + loader devDependency fix - `a8499bd` — Phase 4–5: entrypoints, supervisor, CI, compatibility docs +- `e3cc3f9` — docs: phase 4–5 status recording +- `f98c663` — Phase 6: benchmarks, memory/CPU profiles, fork-vs-upstream + control comparison (docs/BUN_BENCH.md) Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; push/PR is owner-gated). From 10b2df7bc02e3ba790fd32d5436af47ea2e9b071 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 11:19:33 -0700 Subject: [PATCH 06/33] fix(ci): move plugin-include devDependency to root to break yakumo build 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. --- docs/BUN_COMPATIBILITY.md | 25 ++++++++++++++++--------- docs/BUN_PORT_STATUS.md | 20 ++++++++++++++++++++ package.json | 1 + packages/loader/package.json | 1 - 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index 503723d6..5964aaf1 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -38,9 +38,10 @@ Notes on the matrix: (864 packages) and per-package workspace linking. - "build" is the shared yakumo esbuild+tsc pipeline executed under Node; building under Bun's bundler is neither required nor claimed. -- `@cordisjs/plugin-loader` needed one **devDependency addition** - (`@cordisjs/plugin-include`) — see "Cordis changes" below. This is an - install-layout issue, not a Bun defect. +- `@cordisjs/plugin-loader` needed a resolution fix for Bun's isolated + workspace linking — solved at the **repo root**, not in the loader package + (see "Cordis changes" below). This is an install-layout issue, not a Bun + defect. ## What is verified (behavioral, under `bun test tests/bun`) @@ -200,12 +201,18 @@ recorded because they surprised the port itself: **Cordis-side (this fork, branch `feat/bun-compat`):** -1. `packages/loader/package.json`: added `@cordisjs/plugin-include` to - `devDependencies` (mirrors existing `@cordisjs/plugin-logger-console` - entry). Under Bun's isolated workspace `node_modules`, the loader's - fallback `import('@cordisjs/plugin-include')` executes from - `packages/loader` where that package was not previously visible. Under - Yarn hoisting this worked by accident. No runtime code changed. +1. Root `package.json`: added `@cordisjs/plugin-include` (plus `cordis`, + `@cordisjs/plugin-loader`, `@cordisjs/plugin-timer` for `bench/`) to + `devDependencies`. Under Bun's isolated workspace `node_modules`, the + loader's fallback `import('@cordisjs/plugin-include')` executes from + `packages/loader`; with the package declared at the repo root it resolves + via the parent-directory walk-up (root `node_modules`). Under Yarn + hoisting this is a no-op. + **Note:** declaring it inside `packages/loader/package.json` instead + would create a `plugin-loader → plugin-include → plugin-loader` cycle in + yakumo-tsc's build graph (`Error: circular dependency detected`) — + deliberately avoided; `packages/loader/package.json` stays + upstream-identical. 2. `packages/core/bin.bun.js`, `packages/core/bin.bun.watch.js`: Bun entrypoint + development supervisor (additive; not in the published `files` list; Node's `bin.js` untouched). diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 0c50f532..9f66424e 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -93,6 +93,26 @@ under Bun's isolated linker (under Yarn hoisting this is a no-op). Reproduce: see the command block at the top of `docs/BUN_BENCH.md`. +## CI incident & fix (post-PR #1) + +PR #1 CI failed on both the `build` (yarn) job and the Bun job: +`yakumo-tsc` aborted with **"circular dependency detected"** — the +`@cordisjs/plugin-include` devDependency I had added to +`packages/loader/package.json` introduced a +`plugin-loader → plugin-include → plugin-loader` cycle in yakumo's build +graph (include peer-depends on loader). Process miss: after that edit I +re-ran the test suites but not the yakumo build. + +Fix: `packages/loader/package.json` reverted to **upstream-identical**; the +dependency moved to the **root** `package.json` devDependencies (module +resolution from `packages/loader` walks up to root `node_modules`; the root +is outside yakumo's package graph, so no cycle). Re-verified locally in +order: yakumo esbuild (exit 0), yakumo tsc (exit 0), `bun test tests/bun` +58/58, Node suite 163/163. + +Lesson recorded: **run the full build, not only the suites, after any +dependency-graph change.** + ## Current failures None. diff --git a/package.json b/package.json index b177a851..5762e660 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@cordisjs/eslint-config": "^1.1.1", + "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@cordisjs/plugin-timer": "^1.1.2", "@cordisjs/unyaml": "^2.0.3", diff --git a/packages/loader/package.json b/packages/loader/package.json index b3bdf969..43165b06 100644 --- a/packages/loader/package.json +++ b/packages/loader/package.json @@ -35,7 +35,6 @@ "service" ], "devDependencies": { - "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-logger-console": "^1.0.0", "@types/js-yaml": "^4.0.9", "cordis": "^4.0.0-rc.8" From fdfe2e9d87c9094a27dffd316e32fc317aac0eab Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 12:20:38 -0700 Subject: [PATCH 07/33] feat(bun): integrate oven-sh/bun#32856 import.meta.hot for --hot reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/bun.yml | 11 + docs/BUN_COMPATIBILITY.md | 162 ++++++++++--- docs/BUN_PORT_STATUS.md | 65 ++++- packages/core/bin.bun.js | 30 ++- tests/bun/hot.spec.ts | 226 ++++++++++++++++++ tests/bun/repros/README.md | 52 +++- tests/bun/repros/hot-pr-dispose-order.ts | 24 ++ .../bun/repros/signal-handler-accumulation.ts | 17 ++ 8 files changed, 522 insertions(+), 65 deletions(-) create mode 100644 tests/bun/hot.spec.ts create mode 100644 tests/bun/repros/hot-pr-dispose-order.ts create mode 100644 tests/bun/repros/signal-handler-accumulation.ts diff --git a/.github/workflows/bun.yml b/.github/workflows/bun.yml index c6194ea5..a403939a 100644 --- a/.github/workflows/bun.yml +++ b/.github/workflows/bun.yml @@ -48,6 +48,17 @@ jobs: - name: Bun behavioral suite run: bun test tests/bun + # Downstream integration fixture for oven-sh/bun#32856 + # (import.meta.hot for bun --hot). Install is best-effort: PR artifact + # availability varies, and tests/bun/hot.spec.ts skips cleanly when the + # binary is absent — but a real failure in it fails CI. + - name: Install bun#32856 PR build (best effort) + run: bunx bun-pr 32856 + continue-on-error: true + + - name: bun#32856 integration suite + run: bun test tests/bun/hot.spec.ts + - name: Node suite stays green alongside run: |- node --expose-internals --import tsx --import @cordisjs/unyaml \ diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index 5964aaf1..b6827bd2 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -6,8 +6,10 @@ classified below. - Verified Bun release: **1.3.14** (revision `1.3.14+0d9b296af`, macOS arm64) - Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) -- Behavioral proof: `bun test tests/bun` — 58 tests across 11 spec files - covering the scenarios listed in "What is verified" below. +- Behavioral proof: `bun test tests/bun` — 60 tests across 12 spec files + covering the scenarios listed in "What is verified" below (2 of them are + gated on the oven-sh/bun#32856 PR build and skip cleanly when it is not + installed). - Performance proof: see **`docs/BUN_BENCH.md`** — fork ≡ upstream under Node (control), and Bun is faster on every Cordis operation (up to ~5x plugin lifecycle, ~4x config boot, ~80x TS module eval). @@ -45,7 +47,7 @@ Notes on the matrix: ## What is verified (behavioral, under `bun test tests/bun`) -`tests/bun/` — 58 tests, all passing on Bun 1.3.14: +`tests/bun/` — 60 tests (58 on stock Bun 1.3.14 + 2 PR-build-gated): - **Plugins**: function / object / class plugins, config passing, invalid plugins rejected, nested plugin trees, idempotent root dispose, @@ -78,9 +80,14 @@ Notes on the matrix: `loader-include.spec.ts`) - **Process shutdown**: SIGINT → complete root-fiber disposal (registry empties, all disposers run exactly once) → exit 0 (`shutdown.spec.ts`) -- **Development reload**: supervisor restart contract — old root disposed +- **Development reload (supervisor)**: restart contract — old root disposed before new activation, no resource duplication, clean SIGINT (`watch.spec.ts`) +- **Development reload (bun#32856 PR build, `--hot` + `import.meta.hot`)**: + awaited root disposal completes before reactivation (async disposer), + resources never duplicate across reloads, failed evaluation still disposes + the old root and recovers on the next edit + (`hot.spec.ts`, skips when the PR binary is absent) Commands and recorded results: @@ -98,7 +105,12 @@ $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js tsc # exit 0 $ bun test tests/bun - 58 pass / 0 fail / 198 expect() calls + 60 pass / 0 fail / 216 expect() calls # 58 on stock Bun + 2 PR-gated hot + # tests when the bun#32856 build + # is installed (they skip otherwise) + +$ HOME=/tmp/no-such-home bun test tests/bun/hot.spec.ts # PR binary hidden + 0 pass / 2 skip / 0 fail $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js vitest --import tsx @@ -115,8 +127,12 @@ bun path/to/packages/core/bin.bun.js # reads ./cordis.yml, loads plugins (TypeScript included), handles # SIGINT/SIGTERM with full root-fiber disposal -# development with graceful reload: +# development with graceful reload (hard restart per file change): bun path/to/packages/core/bin.bun.watch.js + +# development with in-process reload — requires a Bun build shipping +# oven-sh/bun#32856 (import.meta.hot); until then resources would duplicate: +bun --hot path/to/packages/core/bin.bun.js ``` Minimal application: @@ -139,27 +155,36 @@ export function apply(ctx: Context) { } ``` -## Development reload decision (Phase 5) - -**Decision: whole-process supervisor (`bin.bun.watch.js`) is the supported -Bun reload mechanism. Selective HMR is deferred — see below.** - -Measured on Bun 1.3.14 (each claim reproduced with standalone scripts): - -1. `bun --watch` re-evaluates the entry **in-process** (same pid): - - module state is cleared, but **`globalThis` and `process` are fresh on - every reload** — the new evaluation cannot reach the previous Cordis - root context; - - pending **timers from the previous evaluation are removed by Bun**, but - *without* running Cordis disposers (no cleanup of listeners, sockets, - watchers, or user callbacks); - - **signal handlers from previous evaluations persist and accumulate**; - - there is **no public before-reload hook**, so Cordis-level graceful - disposal cannot be implemented from inside the reloaded module. -2. `bun --hot` re-evaluates the changed module *and* importers: previous - evaluations' **timers and listeners keep running** — Cordis state - (registries, effects) is **duplicated** across reloads. Confirmed by - interleaved output from two generations. +## Development reload semantics (Phase 5, re-verified) + +**Decision: on stock Bun (≤ 1.3.14), the whole-process supervisor +(`bin.bun.watch.js`) is the supported reload mechanism. With +[oven-sh/bun#32856](https://github.com/oven-sh/bun/pull/32856) +(`import.meta.hot` for `bun --hot`), in-process reload becomes viable — see +"Upstream integration: bun#32856" below.** + +Per [Bun's watch-mode documentation](https://bun.sh/docs/runtime/watch-mode), +`--watch` is a hard restart and `--hot` is an in-process soft reload that +preserves `globalThis`. Re-verified on Bun 1.3.14 with standalone probes +(a `globalThis` generation counter, a ticking interval, and a signal handler, +two file edits per run): + +1. `bun --watch` **restarts the runtime**: on every reload `globalThis` is + fresh (the counter resets to 1), the module registry is cleared, and + pending timers are removed *without* running Cordis disposers. A signal + handler registered in generation 1 fires **exactly once** after three + reloads — handlers do **not** accumulate. (Implementation detail: the OS + pid happens to stay the same on 1.3.14 — the restart happens in place — + but no JS state survives it.) There is **no public before-reload hook**, + so Cordis-level graceful disposal cannot run on reload; only the + supervisor's SIGTERM path disposes. +2. `bun --hot` **re-evaluates the changed module and its importers + in-process**, preserving `globalThis` (the counter increments + 1 → 2 → 3 across reloads): previous generations' **timers and signal + listeners keep running** — after three reloads, one signal fired **three** + registered handlers — so Cordis state (registries, effects) is + **duplicated** across reloads. On 1.3.14 `import.meta.hot` is `undefined` + in *both* modes, so user code had no cleanup hook. Therefore: @@ -167,7 +192,18 @@ Therefore: child process running `bin.bun.js`; on file change it sends SIGTERM, the child performs a complete root-fiber disposal, exits 0, and is respawned. Restart ordering (dispose → activate) is asserted by `tests/bun/watch.spec.ts`. -- **B (evaluated, documented above)**: `bun --hot` is unsuitable as-is. +- **B (evaluated)**: `bun --hot` on stock 1.3.14 is unsuitable as-is — no + `import.meta.hot`, and resources accumulate. `bin.bun.js` nevertheless + carries the correct defensive strategy for this mode: it stores the root + context on `globalThis` and disposes the previous root before activating + the new one, so a `--hot` re-evaluation of the entrypoint cannot leak + whole roots. (Under `--watch` the `globalThis` slot is always empty at + boot, so the guard is a harmless no-op there.) +- **Bun PR #32856** implements `import.meta.hot` for `bun --hot`: + awaited `dispose()` callbacks run to completion **before** re-evaluation, + per-module `hot.data` persists across reloads, timers/listeners can be + cleaned up, and modules no longer imported are disposed too. Cordis-side + validation of the PR build lives in `tests/bun/hot.spec.ts` (see below). - **C (deferred)**: selective HMR would require a runtime-adapter abstraction (Node adapter = current `ModuleLoader` internals; Bun adapter built on public APIs: fs watching, `Bun.build` dependency metadata, content-hashed @@ -176,6 +212,52 @@ Therefore: worth building if whole-process reload proves inadequate in practice — per the port's success criteria it is optional. +## Upstream integration: oven-sh/bun#32856 + +[PR #32856](https://github.com/oven-sh/bun/pull/32856) ("Implement +import.meta.hot for bun --hot") gives `bun --hot` the missing cleanup hook: +awaited `dispose()` callbacks that run to completion **before** +re-evaluation, persistent per-module `hot.data`, and disposal of modules the +next generation no longer imports. Cordis consumes this PR as a downstream +integration fixture — it does not reimplement module reloading and needs no +Bun fork or source changes. + +**Installing the PR build (no Bun checkout required):** + +```bash +bunx bun-pr 32856 +bun-32856 --version # 1.4.0 (PR CI artifact) +bun-32856 --hot packages/core/bin.bun.js # in your app dir +``` + +`tests/bun/hot.spec.ts` locates the binary automatically (exact +`bun-32856` alias or `bun--pr32856` in `~/.bun/bin`, or the +`BUN_PR_BIN` environment variable) and skips when absent. + +**Verified against the PR build (probes + `tests/bun/hot.spec.ts`):** + +- `import.meta.hot` is an object under `--hot` (and `undefined` without the + flag — unchanged plain-run behavior). +- `hot.data` persists across reloads (read `{"gen":1}` in generation 2). +- Editing a **dynamically imported** module reloads the whole reachable + graph: dispose callbacks of the entry *and* the plugin run — awaited — + strictly before any re-evaluation. This is exactly Cordis's + dispose-before-activate contract. +- With `bin.bun.js` registering `import.meta.hot.dispose(() => + disposePrevious())`: a 150 ms async disposer completes before the next + generation activates; no timer duplication across 3 generations; a broken + generation still gets the old root disposed first and the next valid edit + recovers; SIGINT exits 0. + +**Result: no failure to report.** Every Cordis case passed on the first PR +build tried (artifact from the Aug 13, 2026 CI run); steps 3–5 of the +integration plan (reduce a failure, clone/build Bun) were not triggered. + +**When the PR ships in a release**: drop the PR-binary gating in +`hot.spec.ts`, re-pin the CI Bun version, and document `--hot` as the +supported in-process development reload (the supervisor remains the +`--watch`-style hard-restart option). + ## Known behavior nuances (Node ↔ Bun parity) These are **not** incompatibilities — behavior is identical on both runtimes; @@ -215,20 +297,26 @@ recorded because they surprised the port itself: upstream-identical. 2. `packages/core/bin.bun.js`, `packages/core/bin.bun.watch.js`: Bun entrypoint + development supervisor (additive; not in the published - `files` list; Node's `bin.js` untouched). -3. `tests/bun/**`: Bun-native behavioral suite (does not affect the Node - suite). + `files` list; Node's `bin.js` untouched). `bin.bun.js` registers + `import.meta.hot.dispose` when the runtime provides it (PR #32856 + integration; no-op on stock Bun/Node). +3. `tests/bun/**`: Bun-native behavioral suite, including the PR-build-gated + `hot.spec.ts` (does not affect the Node suite). 4. Root `package.json` scripts: `test:bun`, `start:bun`, `dev:bun` (additive). 5. `.github/workflows/bun.yml`: Bun CI alongside the untouched Node CI. 6. `docs/BUN_PORT_*.md`, this file. -**Bun-side: none.** The contribution policy was never triggered: no defect -qualifying under the seven conditions was found. The two candidate findings -(cross-fiber disposal order; `!js` tag handling) reproduced identically on -Node and are therefore Cordis/js-yaml semantics, not Bun bugs. The -`--watch`/`--hot` reload behaviors are documented platform semantics with a -public-API workaround (supervisor), not defects against documented behavior. +**Bun-side: no source changes.** The contribution policy was never +triggered: no defect qualifying under the seven conditions was found. The two +candidate findings (cross-fiber disposal order; `!js` tag handling) +reproduced identically on Node and are therefore Cordis/js-yaml semantics, +not Bun bugs. The stock-Bun `--watch`/`--hot` reload behaviors are documented +platform semantics with public-API workarounds (supervisor; the +`globalThis` root guard), not defects against documented behavior — and the +remaining `--hot` gap (no cleanup hook) is being fixed upstream by +oven-sh/bun#32856, which this fork consumes as an integration fixture (see +"Upstream integration" above; all Cordis cases passed, nothing to report). ## Limitations diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 9f66424e..929e7e13 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -34,28 +34,66 @@ and commit SHAs. Update before ending or compacting any work session. ### Phase 5A — `--watch` progression, evaluated and delivered (DONE) -Empirical findings on Bun 1.3.14 (standalone repros in the doc): - -- `bun --watch` re-evaluates the entry in-process (same pid); `globalThis` - and `process` are FRESH per reload → new evaluation cannot reach the old - Cordis root; timers are cleared by Bun without running disposers; old - signal handlers accumulate; no public before-reload hook. -- `bun --hot`: previous generations' timers/listeners keep running → - Cordis state duplicates across reloads (confirmed by interleaved output). +Empirical findings on Bun 1.3.14 (standalone repros; **semantics corrected +2026-08-16** — the original notes misattributed `--watch` as an in-process +re-evaluation; per Bun's docs and re-measurement `--watch` is a hard restart +and `--hot` is the in-process soft reload): + +- `bun --watch` **restarts the runtime**: `globalThis` is fresh per reload + (generation counter resets), module registry cleared, timers removed + *without* running Cordis disposers; signal handlers do NOT accumulate + (one handler fires once after three reloads). No public before-reload + hook → graceful Cordis disposal impossible from inside the reloaded + module. +- `bun --hot`: `globalThis` survives (counter increments), previous + generations' timers/listeners keep running (one signal fired three + handlers after three reloads) → Cordis state duplicates; and on 1.3.14 + `import.meta.hot` is `undefined` in both modes, so no cleanup hook. Delivered: supervisor `packages/core/bin.bun.watch.js` (public APIs only: `node:fs.watch`, `node:child_process`) — on change: SIGTERM child → child performs complete root-fiber disposal → exit 0 → respawn. Dispose-before- activate ordering asserted by `tests/bun/watch.spec.ts`. -Phase 5B verdict: `--hot` unsuitable (duplication, above). Phase 5C -(selective HMR): deferred — optional per success criteria; adapter design -sketch recorded in BUN_COMPATIBILITY.md. +Phase 5B verdict (1.3.14): `--hot` unsuitable on stock Bun (no +`import.meta.hot`, duplication). Phase 5C (selective HMR): deferred — +optional per success criteria; adapter design sketch recorded in +BUN_COMPATIBILITY.md. + +### Phase 7 — upstream integration with oven-sh/bun#32856 (DONE) + +Focused downstream-integration project (owner-directed; no Bun fork): + +- Installed the PR build without building Bun: `bunx bun-pr 32856` → + `~/.bun/bin/bun-fe4557d630980a13ae56fb04d8660732d7c30439-pr32856` + (reports `1.4.0`; artifact from the Aug 13 CI run). +- Verified the PR's claims against Cordis use cases with standalone probes: + `import.meta.hot` is an object under `--hot`; `hot.data` persists across + reloads; dispose callbacks for the entry AND dynamically imported modules + run, awaited, strictly before re-evaluation; editing a dynamically + imported plugin triggers reload of the whole reachable graph. +- `packages/core/bin.bun.js` now registers + `import.meta.hot.dispose(() => disposePrevious())` when the runtime + provides `import.meta.hot` (no-op on stock Bun/Node where it is + `undefined`): runtime-driven awaited disposal before re-evaluation, + stronger than the module-top `globalThis` guard, which remains as + defense-in-depth. +- `tests/bun/hot.spec.ts` (2 tests, PR-build-gated, skip cleanly when the + binary is absent): (a) async disposer (150 ms) completes before the next + generation activates, timers never duplicate across 3 generations, clean + SIGINT; (b) broken generation: old root still disposed first, no leaked + ticks, recovery on next valid edit, exit 0. +- Results: `bun test tests/bun` 60/60 (58 prior + 2 new; with the PR binary + absent the 2 new tests skip — verified via `HOME=/tmp/... bun test`); + Node suite 19 files / 163 tests unchanged. No Bun-source changes were + needed → **no Bun PR comment required**; nothing failed against the PR + build. ### Full-suite phase boundary results ``` -bun test tests/bun # 58 pass / 0 fail / 198 expect() calls +bun test tests/bun # 60 pass / 0 fail / 216 expect() calls + # (58 before Phase 7; +2 PR-gated hot tests) node ... yakumo vitest --import tsx # 19 files / 163 tests passed (see run log) ``` @@ -120,6 +158,9 @@ None. ## Next action - Push branch / open PR is owner-gated (explicit approval required). +- When oven-sh/bun#32856 merges and ships in a release, drop the + PR-binary gating in `tests/bun/hot.spec.ts` and re-pin the CI Bun version; + the supervisor stays as the `--watch`-equivalent for hard restarts. - Optional future work: Phase C selective HMR adapter if whole-process reload proves inadequate; browser export of logger-console under Bun; `create-cordis` runtime verification. diff --git a/packages/core/bin.bun.js b/packages/core/bin.bun.js index 46086568..5cf261e8 100644 --- a/packages/core/bin.bun.js +++ b/packages/core/bin.bun.js @@ -3,15 +3,20 @@ * Cordis CLI entrypoint for Bun. * * Behavior identical to `bin.js`, plus reload-safe lifecycle management for - * `bun --watch` / `bun --hot`: + * `bun --hot` (and harmless under `bun --watch`): * - * - Bun re-evaluates the entry module in the same process on watch reloads: - * module state is cleared but `globalThis` state and live timers SURVIVE. - * Without disposal, every reload leaks the previous root fiber's effects - * (timers, listeners) forever (verified on Bun 1.3.14; see + * - Per Bun's documented semantics, `--hot` is an in-process soft reload + * that PRESERVES `globalThis`, while `--watch` is a hard restart that + * resets all runtime state (verified on Bun 1.3.14; see * docs/BUN_COMPATIBILITY.md). - * - Therefore each evaluation first disposes the root context stored under a - * well-known `globalThis` symbol, and only then boots the new one. + * - Under `--hot`, re-evaluating the entry would otherwise leak the previous + * root fiber's effects (timers, listeners) forever. Therefore each + * evaluation first disposes the root context stored under a well-known + * `globalThis` symbol, and only then boots the new one. (With + * `import.meta.hot` — oven-sh/bun#32856 — disposal can additionally hook + * the runtime's own dispose phase.) + * - Under `--watch` the `globalThis` slot is always empty at boot (state was + * reset), so the guard is a no-op. * - SIGINT/SIGTERM dispose the current root completely before exiting. * * The Node entrypoint (`bin.js`) is intentionally untouched. @@ -38,6 +43,17 @@ async function disposePrevious() { } } +// When the runtime provides import.meta.hot (bun --hot with +// oven-sh/bun#32856), disposal is driven by the runtime's own dispose phase: +// callbacks are awaited to completion BEFORE the module is re-evaluated. This +// is strictly stronger than the globalThis guard below — cleanup finishes +// before any new-generation module code runs — and keeps working even if the +// new evaluation throws partway through. +// On stock Bun / Node import.meta.hot is undefined, so this is skipped. +if (import.meta.hot) { + import.meta.hot.dispose(() => disposePrevious()) +} + if (!globalThis[SIGNALS]) { globalThis[SIGNALS] = true for (const signal of ['SIGINT', 'SIGTERM']) { diff --git a/tests/bun/hot.spec.ts b/tests/bun/hot.spec.ts new file mode 100644 index 00000000..13f85d07 --- /dev/null +++ b/tests/bun/hot.spec.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'bun:test' +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Upstream integration: oven-sh/bun#32856 (`import.meta.hot` for `bun --hot`). + * + * Cordis consumes the PR build — it does not reimplement module reloading. + * The binary is fetched with `bunx bun-pr 32856` (no Bun checkout needed). + * + * Proves, against the PR build only: + * 1. root-fiber disposal COMPLETES before the next generation activates — + * including an async disposer (the PR's awaited-dispose claim); + * 2. resources never duplicate across reloads (timers of generation N stop + * before generation N+1 starts); + * 3. clean SIGINT shutdown of the final generation. + * + * Skipped when the PR binary is not installed (stock Bun: `import.meta.hot` + * is undefined there — see docs/BUN_COMPATIBILITY.md "Development reload + * semantics"). + */ + +const ENTRY = fileURLToPath(new URL('../../packages/core/bin.bun.js', import.meta.url)) +const REPO_ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))) + +const PLUGIN = [ + "import { Context } from 'cordis'", + '', + 'export function apply(ctx: Context) {', + " console.log('[app] applied gen=' + GEN)", + ' ctx.effect(() => {', + " const t = setInterval(() => console.log('[app] tick gen=' + GEN), 150)", + ' return async () => {', + " console.log('[app] cleanup-start gen=' + GEN)", + ' await new Promise(r => setTimeout(r, 150))', + ' clearInterval(t)', + " console.log('[app] cleanup-done gen=' + GEN)", + ' }', + ' })', + '}', + '', + 'declare const GEN: number', +].join('\n') + +function findPrBinary(): string | undefined { + // 1. explicit override + if (process.env.BUN_PR_BIN && existsSync(process.env.BUN_PR_BIN)) { + return process.env.BUN_PR_BIN + } + // 2. the alias bun-pr installs into ~/.bun/bin + const candidates = [join(homedir(), '.bun', 'bin')] + for (const dir of candidates) { + if (!existsSync(dir)) continue + const exact = join(dir, 'bun-32856') + if (existsSync(exact)) return exact + // 3. the full sha-named binary bun--pr32856 + const match = readdirSync(dir).find(name => /^bun-[0-9a-f]{40}-pr32856$/.test(name)) + if (match) return join(dir, match) + } + return undefined +} + +const PR_BIN = findPrBinary() + +/** does this binary provide import.meta.hot under --hot? */ +function hasHotSupport(bin: string): boolean { + const dir = mkdtempSync(join(tmpdir(), 'cordis-bun-hot-probe-')) + try { + const file = join(dir, 'probe.ts') + writeFileSync(file, 'console.log("HOT:" + typeof (import.meta as any).hot)\n') + const res = spawnSync(bin, ['--hot', file], { encoding: 'utf8', timeout: 20000 }) + return (res.stdout + '').includes('HOT:object') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +// imported at top of file; skip logic uses findPrBinary()/hasHotSupport() + +describe('Bun / development reload (bun#32856 import.meta.hot)', () => { + it.skipIf(!PR_BIN || !hasHotSupport(PR_BIN))( + 'awaited disposal completes before reactivation; resources never duplicate', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bun-hot-')) + try { + await symlink(join(REPO_ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir') + await writeFile(join(dir, 'cordis.yml'), '- id: plugin\n name: ./plugin.ts\n') + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 1\n' + PLUGIN) + + const child = spawn(PR_BIN!, ['--hot', ENTRY], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }) + + let stdout = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stdout += c }) + + await waitFor(() => stdout.includes('applied gen=1')) + // let generation 1 tick at least once before editing + await waitFor(() => stdout.includes('tick gen=1')) + + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 2\n' + PLUGIN) + await waitFor(() => stdout.includes('applied gen=2')) + await waitFor(() => stdout.includes('tick gen=2')) + + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 3\n' + PLUGIN) + await waitFor(() => stdout.includes('applied gen=3')) + await waitFor(() => stdout.includes('tick gen=3')) + + child.kill('SIGINT') + const code = await new Promise(res => child.on('exit', res)) + await sleep(200) // let trailing ticks surface + + const lines = stdout.split('\n').filter(l => l.startsWith('[app]')) + const idx = (needle: string) => lines.findIndex(l => l.includes(needle)) + + // every generation activated exactly once + for (const gen of [1, 2, 3]) { + expect(lines.filter(l => l.includes(`applied gen=${gen}`)).length).toBe(1) + } + + // awaited disposal: cleanup STARTS and COMPLETES before the next + // activation — the disposer awaits a 150ms timer, so this can only + // pass if the runtime awaits the dispose phase before re-evaluation + expect(idx('cleanup-start gen=1')).toBeGreaterThan(-1) + expect(idx('cleanup-done gen=1')).toBeGreaterThan(-1) + expect(idx('cleanup-done gen=1')).toBeLessThan(idx('applied gen=2')) + expect(idx('cleanup-done gen=2')).toBeGreaterThan(-1) + expect(idx('cleanup-done gen=2')).toBeLessThan(idx('applied gen=3')) + + // resources never duplicate: after generation N+1 activates, no + // timer of generation N fires again + const applied2 = idx('applied gen=2') + const applied3 = idx('applied gen=3') + expect(lines.slice(applied2).some(l => l.includes('tick gen=1'))).toBe(false) + expect(lines.slice(applied3).some(l => l.includes('tick gen=1'))).toBe(false) + expect(lines.slice(applied3).some(l => l.includes('tick gen=2'))).toBe(false) + + // the final generation is disposed by SIGINT + expect(lines.some(l => l.includes('cleanup-done gen=3'))).toBe(true) + + // config file survived unchanged (no write-back corruption) + expect(await readFile(join(dir, 'cordis.yml'), 'utf8')).toBe('- id: plugin\n name: ./plugin.ts\n') + + expect(code).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 60000) + + it.skipIf(!PR_BIN || !hasHotSupport(PR_BIN))( + 'failed evaluation keeps the previous root disposed and recovers on next edit', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bun-hot-broken-')) + try { + await symlink(join(REPO_ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir') + await writeFile(join(dir, 'cordis.yml'), '- id: plugin\n name: ./plugin.ts\n') + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 1\n' + PLUGIN) + + const child = spawn(PR_BIN!, ['--hot', ENTRY], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }) + + let stdout = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stdout += c }) + + await waitFor(() => stdout.includes('applied gen=1')) + await waitFor(() => stdout.includes('tick gen=1')) + + // introduce a syntax error: the next generation must fail to + // evaluate, but generation 1 must have been disposed first. + // (No error text is printed by bun --hot here — the observable is + // disposal happening with no `applied gen=2` following it.) + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 2\nthis is not valid ts !!\n' + PLUGIN) + await waitFor(() => stdout.includes('cleanup-done gen=1')) + const disposedAt = stdout.length + await sleep(600) + + // no generation-1 ticks since the failed reload: the old root was + // disposed before evaluation of the broken generation + expect(stdout.slice(disposedAt).includes('tick gen=1')).toBe(false) + // and the broken generation never activated + expect(stdout.includes('applied gen=2')).toBe(false) + + // recover with a valid generation 3 + await writeFile(join(dir, 'plugin.ts'), 'const GEN = 3\n' + PLUGIN) + await waitFor(() => stdout.includes('applied gen=3')) + await waitFor(() => stdout.includes('tick gen=3')) + expect(stdout.includes('applied gen=2')).toBe(false) + + child.kill('SIGINT') + const code = await new Promise(res => child.on('exit', res)) + expect(code).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 60000) +}) + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function waitFor(predicate: () => boolean, timeout = 15000) { + return new Promise((resolve, reject) => { + const started = Date.now() + const check = setInterval(() => { + if (predicate()) { + clearInterval(check) + resolve() + } else if (Date.now() - started > timeout) { + clearInterval(check) + reject(new Error('waitFor timed out')) + } + }, 50) + }) +} diff --git a/tests/bun/repros/README.md b/tests/bun/repros/README.md index 29e69731..2ba2d5a8 100644 --- a/tests/bun/repros/README.md +++ b/tests/bun/repros/README.md @@ -13,10 +13,12 @@ bun --watch tests/bun/repros/watch-globals-reset.ts # then append a comment line to the file (content-hash watch) a few times ``` -Observed (1.3.14): same pid every reload; module re-evaluates; both -`globalThis` and `process` custom properties are **reset to fresh values** -on every reload — so a re-evaluated entry cannot reach the previous -Cordis root context. +Observed (1.3.14): the module re-evaluates and both `globalThis` and +`process` custom properties are **reset to fresh values** on every reload — +`--watch` is a **hard restart** (per Bun's docs), so a re-evaluated entry +cannot reach the previous Cordis root context. (On 1.3.14 the OS pid stays +the same — the restart happens in place — but that is an implementation +detail; no JS state survives it.) ## watch-timer-disposers.ts @@ -28,9 +30,9 @@ bun --watch tests/bun/repros/watch-timer-disposers.ts # append comment lines between observations ``` -Observed: tick density stays constant (no timer stacking — Bun removes old -timers), but the `clearInterval` disposer closure never runs (no -"cleared" output on reload). +Observed: tick density stays constant (no timer stacking — the restart +discards old timers), but the `clearInterval` disposer closure never runs +(no "cleared" output on reload). ## hot-state-duplication.ts @@ -45,6 +47,38 @@ Observed: after an edit, output lines from BOTH the old and the new evaluation interleave forever (old timers/listeners keep running) — Cordis state would duplicate on every reload. +## signal-handler-accumulation.ts + +Side-by-side proof that `--hot` preserves `globalThis` (handlers accumulate) +while `--watch` resets it (they do not): + +``` +bun --hot tests/bun/repros/signal-handler-accumulation.ts # or --watch +# append two comment lines, then: kill -USR2 +``` + +Observed (1.3.14): under `--hot` the generation counter increments and one +signal fires one handler **per generation** (three after two edits); under +`--watch` the counter resets and exactly one handler fires. + +## hot-pr-dispose-order.ts + +Demonstrates what oven-sh/bun#32856 (`import.meta.hot` for `bun --hot`) +changes — requires the PR build: + +``` +bunx bun-pr 32856 +bun-32856 --hot tests/bun/repros/hot-pr-dispose-order.ts +# then append comment lines a few times +``` + +Observed (PR build, Aug 13 2026 artifact): `import.meta.hot` is an object; +`dispose` callbacks run — awaited — strictly **before** the module is +re-evaluated; `hot.data` persists across reloads. The old timer still ticks +until the dispose callback cleans it up, which is exactly the hook Cordis +uses (`packages/core/bin.bun.js` disposes the root fiber there). + These behaviors motivated the supervisor design -(`packages/core/bin.bun.watch.js`) instead of relying on `--watch`/`--hot` -for Cordis development reload. +(`packages/core/bin.bun.watch.js`) for stock Bun, and the +`import.meta.hot`-driven in-process reload for Bun builds shipping bun#32856 +(see `tests/bun/hot.spec.ts`). diff --git a/tests/bun/repros/hot-pr-dispose-order.ts b/tests/bun/repros/hot-pr-dispose-order.ts new file mode 100644 index 00000000..5c734572 --- /dev/null +++ b/tests/bun/repros/hot-pr-dispose-order.ts @@ -0,0 +1,24 @@ +// Repro: oven-sh/bun#32856 (import.meta.hot for bun --hot) — dispose runs +// BEFORE re-evaluation, and hot.data persists across reloads. +// Run: bun-32856 --hot tests/bun/repros/hot-pr-dispose-order.ts +// (bun-32856 is installed by `bunx bun-pr 32856`; on stock Bun +// import.meta.hot is undefined and this only prints the eval line.) +// Then append a comment line to this file (content-hash watch) a few times. +const gen = (globalThis as any).__gen = ((globalThis as any).__gen ?? 0) + 1 +const hot = (import.meta as any).hot +console.log(`[repro] eval gen=${gen} hot=${hot ? 'object' : String(hot)} data=${hot ? JSON.stringify(hot.data) : '-'}`) +if (hot) { + hot.data.gen = gen + hot.dispose(() => { + console.log(`[repro] dispose gen=${gen} data=${JSON.stringify(hot.data)}`) + }) +} +setInterval(() => console.log(`[repro] tick gen=${gen}`), 250) +// Observed on the PR build (bun-1.4.0-pr32856, Aug 13 2026 artifact): +// eval gen=1 ... tick gen=1 ... +// dispose gen=1 data={"gen":1} <- before the next eval, awaited +// eval gen=2 data={"gen":1} <- hot.data survived the reload +// tick gen=1 / tick gen=2 ... <- old timer still runs: the dispose +// callback must clean it up (Cordis +// does this via root-fiber disposal; +// see packages/core/bin.bun.js) diff --git a/tests/bun/repros/signal-handler-accumulation.ts b/tests/bun/repros/signal-handler-accumulation.ts new file mode 100644 index 00000000..da095b96 --- /dev/null +++ b/tests/bun/repros/signal-handler-accumulation.ts @@ -0,0 +1,17 @@ +// Repro: signal handlers accumulate under `bun --hot` but NOT under +// `bun --watch` (Bun 1.3.14) — --hot is the in-process soft reload that +// preserves globalThis, --watch is a hard restart. +// Run: bun --hot tests/bun/repros/signal-handler-accumulation.ts +// or: bun --watch tests/bun/repros/signal-handler-accumulation.ts +// Then append a comment line to this file twice, and send the signal: +// kill -USR2 +const gen = (globalThis as any).__gen = ((globalThis as any).__gen ?? 0) + 1 +process.on('SIGUSR2', () => console.log(`[repro] sigusr2 handled by gen=${gen}`)) +console.log(`[repro] eval gen=${gen} pid=${process.pid} sigusr2-listeners=${process.listenerCount('SIGUSR2')}`) +setInterval(() => {}, 1000) +// Observed on 1.3.14 after two edits (three generations): +// --watch: eval gen resets to 1 each reload (globalThis fresh); the signal +// fires ONE handler. +// --hot: eval gen increments 1 -> 2 -> 3 (globalThis survives); the +// signal fires THREE handlers — one per generation. This is the +// leak oven-sh/bun#32856 gives user code the hook to clean up. From a46f37847461faaac745a255fca6066563bb5423 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 12:20:48 -0700 Subject: [PATCH 08/33] docs(bun): record phase 7 commit SHA --- docs/BUN_PORT_STATUS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 929e7e13..7cb39232 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -173,6 +173,10 @@ None. - `e3cc3f9` — docs: phase 4–5 status recording - `f98c663` — Phase 6: benchmarks, memory/CPU profiles, fork-vs-upstream control comparison (docs/BUN_BENCH.md) +- `10b2df7` — fix(ci): move plugin-include devDependency to root (post-PR #1) +- `fdfe2e9` — Phase 7: oven-sh/bun#32856 integration (hot.spec.ts, + bin.bun.js import.meta.hot.dispose, corrected --watch/--hot docs, CI + best-effort PR install, repros) Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; push/PR is owner-gated). From c678d87fb84437844869f14b3b0af81030aa3760 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 20:43:37 -0700 Subject: [PATCH 09/33] docs(bun): finish --watch/--hot correction in supervisor header and repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- packages/core/bin.bun.watch.js | 29 ++++++++++++++--------- tests/bun/repros/watch-timer-disposers.ts | 7 ++++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/core/bin.bun.watch.js b/packages/core/bin.bun.watch.js index 4b73d1fe..fd7d7350 100644 --- a/packages/core/bin.bun.watch.js +++ b/packages/core/bin.bun.watch.js @@ -2,21 +2,28 @@ /** * Cordis development supervisor for Bun — Phase 5A of the Bun port. * - * Why a supervisor instead of plain `bun --watch` (measured on Bun 1.3.14, - * see docs/BUN_COMPATIBILITY.md): + * Why a supervisor instead of plain `bun --watch` / `bun --hot` (measured on + * Bun 1.3.14, see docs/BUN_COMPATIBILITY.md): * - * - `bun --watch` re-evaluates the entry in-process: every reload gets a - * fresh `globalThis`/`process`, so the new evaluation cannot reach the - * previous Cordis root context — Cordis disposers never run on reload. - * - Bun clears pending *timers* from previous evaluations, but without - * invoking disposers, and old signal handlers accumulate. - * - `bun --hot` keeps previous evaluations' timers and listeners running, - * duplicating Cordis state (verified). + * - `bun --watch` is a **hard restart**: `globalThis` is fresh after every + * reload, so the new evaluation cannot reach the previous Cordis root + * context — Cordis disposers never run on reload. Pending timers are + * discarded without their clearing callbacks, and there is no public + * before-reload hook. (Signal handlers do NOT accumulate — each + * generation starts from clean state; on 1.3.14 the pid merely stays + * the same, but no JS state survives the restart.) + * - `bun --hot` is the **in-process soft reload**: `globalThis` survives + * and previous generations' timers and listeners keep running, + * duplicating Cordis state. On a Bun build shipping import.meta.hot + * (oven-sh/bun#32856), `bin.bun.js` performs graceful in-process + * disposal instead — prefer `bun --hot bin.bun.js` there. On stock Bun + * this supervisor remains the supported reload mechanism. * * The supervisor spawns the real entrypoint as a child process and restarts * it on file changes. Restart = SIGTERM → the child disposes its complete - * root fiber (timers, listeners, services) → exits 0 → respawn. This is the - * only way to get genuine graceful disposal per reload using public APIs. + * root fiber (timers, listeners, services) → exits 0 → respawn. On stock + * Bun this is the only way to get genuine graceful disposal per reload + * using public APIs. * * Usage: bun packages/core/bin.bun.watch.js [child args...] * (run from your application directory, like bin.js / bin.bun.js) diff --git a/tests/bun/repros/watch-timer-disposers.ts b/tests/bun/repros/watch-timer-disposers.ts index f5e2385c..86bbd1ed 100644 --- a/tests/bun/repros/watch-timer-disposers.ts +++ b/tests/bun/repros/watch-timer-disposers.ts @@ -5,8 +5,11 @@ const gen = (globalThis as any).__GEN__ = ((globalThis as any).__GEN__ ?? 0) + 1 console.log(`[repro] eval #${gen} (globalThis.__GEN__ resets each reload)`) setInterval(() => console.log(`[repro] tick from eval #${gen}`), 300) -// the disposer below never runs on reload — Bun drops the raw timer +// the disposer below never runs on reload — Bun drops the raw timer. +// Under --watch exactly ONE handler fires (the current generation's — old +// handlers are gone with the reset state; see +// signal-handler-accumulation.ts for the --hot contrast). process.on('SIGINT', () => { - console.log(`[repro] SIGINT: only signal handlers from OLD evaluations may still run`) + console.log(`[repro] SIGINT: handled once, by the CURRENT generation's handler`) process.exit(0) }) From 76f46689bdf1351b2544d45b612d1ce4ff207293 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Sun, 16 Aug 2026 20:43:47 -0700 Subject: [PATCH 10/33] docs(bun): record c678d87 SHA --- docs/BUN_PORT_STATUS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 7cb39232..4cbd00fd 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -177,6 +177,10 @@ None. - `fdfe2e9` — Phase 7: oven-sh/bun#32856 integration (hot.spec.ts, bin.bun.js import.meta.hot.dispose, corrected --watch/--hot docs, CI best-effort PR install, repros) +- `a46f378` — docs: phase 7 commit SHA record +- `c678d87` — docs: finish --watch/--hot correction (bin.bun.watch.js + header, watch-timer-disposers.ts SIGINT comment); hot.spec stability + confirmed over 3 runs Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; push/PR is owner-gated). From 770134d28c9aeb02d59596c561ab1a5e122c4b85 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 00:39:22 -0700 Subject: [PATCH 11/33] test(bun): cover bun#32856 removed-module disposal; document config-edit gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/BUN_COMPATIBILITY.md | 39 +++++++++++----- docs/BUN_PORT_STATUS.md | 18 +++++--- tests/bun/hot.spec.ts | 95 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 19 deletions(-) diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index b6827bd2..79071630 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -6,8 +6,8 @@ classified below. - Verified Bun release: **1.3.14** (revision `1.3.14+0d9b296af`, macOS arm64) - Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) -- Behavioral proof: `bun test tests/bun` — 60 tests across 12 spec files - covering the scenarios listed in "What is verified" below (2 of them are +- Behavioral proof: `bun test tests/bun` — 61 tests across 12 spec files + covering the scenarios listed in "What is verified" below (3 of them are gated on the oven-sh/bun#32856 PR build and skip cleanly when it is not installed). - Performance proof: see **`docs/BUN_BENCH.md`** — fork ≡ upstream under @@ -47,7 +47,7 @@ Notes on the matrix: ## What is verified (behavioral, under `bun test tests/bun`) -`tests/bun/` — 60 tests (58 on stock Bun 1.3.14 + 2 PR-build-gated): +`tests/bun/` — 61 tests (58 on stock Bun 1.3.14 + 3 PR-build-gated): - **Plugins**: function / object / class plugins, config passing, invalid plugins rejected, nested plugin trees, idempotent root dispose, @@ -85,8 +85,9 @@ Notes on the matrix: (`watch.spec.ts`) - **Development reload (bun#32856 PR build, `--hot` + `import.meta.hot`)**: awaited root disposal completes before reactivation (async disposer), - resources never duplicate across reloads, failed evaluation still disposes - the old root and recovers on the next edit + resources never duplicate across reloads, a module the next generation no + longer imports is disposed and its resources stop, failed evaluation still + disposes the old root and recovers on the next edit (`hot.spec.ts`, skips when the PR binary is absent) Commands and recorded results: @@ -105,12 +106,12 @@ $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js tsc # exit 0 $ bun test tests/bun - 60 pass / 0 fail / 216 expect() calls # 58 on stock Bun + 2 PR-gated hot + 61 pass / 0 fail / 223 expect() calls # 58 on stock Bun + 3 PR-gated hot # tests when the bun#32856 build # is installed (they skip otherwise) $ HOME=/tmp/no-such-home bun test tests/bun/hot.spec.ts # PR binary hidden - 0 pass / 2 skip / 0 fail + 0 pass / 3 skip / 0 fail $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js vitest --import tsx @@ -131,7 +132,9 @@ bun path/to/packages/core/bin.bun.js bun path/to/packages/core/bin.bun.watch.js # development with in-process reload — requires a Bun build shipping -# oven-sh/bun#32856 (import.meta.hot); until then resources would duplicate: +# oven-sh/bun#32856 (import.meta.hot); until then resources would duplicate. +# NOTE: config (cordis.yml) edits are NOT watched in this mode — only +# modules; use the supervisor above if you edit configs often: bun --hot path/to/packages/core/bin.bun.js ``` @@ -248,9 +251,23 @@ bun-32856 --hot packages/core/bin.bun.js # in your app dir generation activates; no timer duplication across 3 generations; a broken generation still gets the old root disposed first and the next valid edit recovers; SIGINT exits 0. - -**Result: no failure to report.** Every Cordis case passed on the first PR -build tried (artifact from the Aug 13, 2026 CI run); steps 3–5 of the +- **Removed modules**: when generation 2 of the plugin drops its import of a + helper module, the helper's own `import.meta.hot.dispose` callback runs — + and the Cordis root-fiber disposal completes — both strictly before + generation 2 activates, and the helper's timer never fires again. + This is the PR's "disposal for modules no longer imported" claim, verified + against Cordis. + +**Known `--hot` limitation (verified on the PR build):** editing +`cordis.yml` triggers **no reload** — config files are not part of the +module graph (observed: zero output after a config edit). To reload on +config changes, use the supervisor (`bin.bun.watch.js`, whose `fs.watch` +does catch them) or touch a watched module. + +**Result: no failure to report.** Every Cordis case — root-fiber disposal, +async cleanup, dynamic plugins, removed plugins, repeated reloads, failed +evaluation — passed on the first PR build tried (artifact from the Aug 13, +2026 CI run, verified on macOS arm64 and Linux in CI); steps 3–5 of the integration plan (reduce a failure, clone/build Bun) were not triggered. **When the PR ships in a release**: drop the PR-binary gating in diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 4cbd00fd..f4fd6919 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -78,13 +78,19 @@ Focused downstream-integration project (owner-directed; no Bun fork): `undefined`): runtime-driven awaited disposal before re-evaluation, stronger than the module-top `globalThis` guard, which remains as defense-in-depth. -- `tests/bun/hot.spec.ts` (2 tests, PR-build-gated, skip cleanly when the +- `tests/bun/hot.spec.ts` (3 tests, PR-build-gated, skip cleanly when the binary is absent): (a) async disposer (150 ms) completes before the next generation activates, timers never duplicate across 3 generations, clean SIGINT; (b) broken generation: old root still disposed first, no leaked - ticks, recovery on next valid edit, exit 0. -- Results: `bun test tests/bun` 60/60 (58 prior + 2 new; with the PR binary - absent the 2 new tests skip — verified via `HOME=/tmp/... bun test`); + ticks, recovery on next valid edit, exit 0; (c) **removed module**: when + gen 2 drops a helper import, the helper's `import.meta.hot.dispose` AND + the Cordis root-fiber disposal complete strictly before gen 2 activates, + and the helper's timer never fires again (the PR's removed-modules claim). +- Also verified on the PR build: editing `cordis.yml` triggers NO reload + (config files are outside the module graph) — documented as a `--hot` + limitation; the supervisor's `fs.watch` does catch config edits. +- Results: `bun test tests/bun` 61/61 (58 prior + 3 new; with the PR binary + absent the 3 new tests skip — verified via `HOME=/tmp/... bun test`); Node suite 19 files / 163 tests unchanged. No Bun-source changes were needed → **no Bun PR comment required**; nothing failed against the PR build. @@ -92,8 +98,8 @@ Focused downstream-integration project (owner-directed; no Bun fork): ### Full-suite phase boundary results ``` -bun test tests/bun # 60 pass / 0 fail / 216 expect() calls - # (58 before Phase 7; +2 PR-gated hot tests) +bun test tests/bun # 61 pass / 0 fail / 223 expect() calls + # (58 before Phase 7; +3 PR-gated hot tests) node ... yakumo vitest --import tsx # 19 files / 163 tests passed (see run log) ``` diff --git a/tests/bun/hot.spec.ts b/tests/bun/hot.spec.ts index 13f85d07..f645a14c 100644 --- a/tests/bun/hot.spec.ts +++ b/tests/bun/hot.spec.ts @@ -12,12 +12,19 @@ import { fileURLToPath } from 'node:url' * Cordis consumes the PR build — it does not reimplement module reloading. * The binary is fetched with `bunx bun-pr 32856` (no Bun checkout needed). * - * Proves, against the PR build only: + * Proves, against the PR build only (all of Cordis's hot-reload cases): * 1. root-fiber disposal COMPLETES before the next generation activates — * including an async disposer (the PR's awaited-dispose claim); * 2. resources never duplicate across reloads (timers of generation N stop * before generation N+1 starts); - * 3. clean SIGINT shutdown of the final generation. + * 3. a module the next generation no longer imports is disposed too, and + * its resources stop (the PR's removed-modules claim); + * 4. clean SIGINT shutdown of the final generation. + * + * Known --hot limitation (verified): editing cordis.yml triggers NO reload — + * the config file is not part of the module graph. Use the supervisor + * (bin.bun.watch.js, whose fs.watch does catch config edits) or touch a + * watched module. * * Skipped when the PR binary is not installed (stock Bun: `import.meta.hot` * is undefined there — see docs/BUN_COMPATIBILITY.md "Development reload @@ -204,6 +211,90 @@ describe('Bun / development reload (bun#32856 import.meta.hot)', () => { await rm(dir, { recursive: true, force: true }) } }, 60000) + + it.skipIf(!PR_BIN || !hasHotSupport(PR_BIN))( + 'module no longer imported by the next generation is disposed and stops', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'cordis-bun-hot-removed-')) + try { + await symlink(join(REPO_ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir') + await writeFile(join(dir, 'cordis.yml'), '- id: plugin\n name: ./plugin.ts\n') + + // helper.ts owns a timer via ctx.effect AND its own + // import.meta.hot.dispose — gen 2 of plugin.ts drops the import. + const HELPER = [ + 'const gen = (globalThis as any).__hgen = ((globalThis as any).__hgen ?? 0) + 1', + 'console.log(`[helper] loaded gen=${gen}`)', + 'const hot: any = (import.meta as any).hot', + 'if (hot) hot.dispose(() => console.log(`[helper] hot-disposed gen=${gen}`))', + 'export function registerHelper(ctx: any) {', + ' ctx.effect(() => {', + ' const t = setInterval(() => console.log(`[helper] tick gen=${gen}`), 150)', + ' return () => { clearInterval(t); console.log(`[helper] effect-cleaned gen=${gen}`) }', + ' })', + '}', + ].join('\n') + const PLUGIN_WITH = [ + "import { registerHelper } from './helper.ts'", + 'export function apply(ctx: any) {', + " console.log('[app] applied gen=1')", + ' registerHelper(ctx)', + '}', + ].join('\n') + const PLUGIN_WITHOUT = [ + 'export function apply(ctx: any) {', + " console.log('[app] applied gen=2')", + '}', + ].join('\n') + + await writeFile(join(dir, 'helper.ts'), HELPER) + await writeFile(join(dir, 'plugin.ts'), PLUGIN_WITH) + + const child = spawn(PR_BIN!, ['--hot', ENTRY], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }) + + let stdout = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stdout += c }) + + await waitFor(() => stdout.includes('applied gen=1')) + await waitFor(() => stdout.includes('tick gen=1')) + + // gen 2 removes the helper import: bun#32856 claims modules the + // next generation no longer imports are disposed too + await writeFile(join(dir, 'plugin.ts'), PLUGIN_WITHOUT) + await waitFor(() => stdout.includes('applied gen=2')) + await sleep(600) + + child.kill('SIGINT') + const code = await new Promise(res => child.on('exit', res)) + + const lines = stdout.split('\n') + const idx = (needle: string) => lines.findIndex(l => l.includes(needle)) + + // the no-longer-imported module's dispose callback ran... + expect(idx('[helper] hot-disposed gen=1')).toBeGreaterThan(-1) + // ...and the Cordis root-fiber disposal completed... + expect(idx('[helper] effect-cleaned gen=1')).toBeGreaterThan(-1) + // ...both strictly before the new generation activated + expect(idx('[helper] hot-disposed gen=1')).toBeLessThan(idx('applied gen=2')) + expect(idx('[helper] effect-cleaned gen=1')).toBeLessThan(idx('applied gen=2')) + + // and the helper's timer never fired again afterwards + const applied2 = idx('applied gen=2') + expect(lines.slice(applied2).some(l => l.includes('tick gen=1'))).toBe(false) + + // gen 2 applied exactly once + expect(lines.filter(l => l.includes('applied gen=2')).length).toBe(1) + + expect(code).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 60000) }) function sleep(ms: number) { From 39bac402eeb8791e88cdd30a28a81381a079b56f Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 00:39:30 -0700 Subject: [PATCH 12/33] docs(bun): record 770134d SHA --- docs/BUN_PORT_STATUS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index f4fd6919..029fcfd0 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -187,6 +187,8 @@ None. - `c678d87` — docs: finish --watch/--hot correction (bin.bun.watch.js header, watch-timer-disposers.ts SIGINT comment); hot.spec stability confirmed over 3 runs +- `770134d` — test: third PR-gated hot test (removed-module disposal) + + documented cordis.yml-edit gap; 61/61 Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; push/PR is owner-gated). From 2c072530f1974ddc60b263ec6b2dcc524627d402 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 00:45:54 -0700 Subject: [PATCH 13/33] docs(bun): record downstream-validation comment on oven-sh/bun#32856 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-approved. Upstream activity remains a comment only — no Bun code, clone, or defect report (nothing failed). --- docs/BUN_PORT_STATUS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 029fcfd0..802473c9 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -92,8 +92,12 @@ Focused downstream-integration project (owner-directed; no Bun fork): - Results: `bun test tests/bun` 61/61 (58 prior + 3 new; with the PR binary absent the 3 new tests skip — verified via `HOME=/tmp/... bun test`); Node suite 19 files / 163 tests unchanged. No Bun-source changes were - needed → **no Bun PR comment required**; nothing failed against the PR + needed → **no defect report required**; nothing failed against the PR build. +- Downstream-validation comment posted on the PR (owner-approved): + — + the six verified cases + the non-module-file note. This is the extent of + upstream activity: a comment, not code. ### Full-suite phase boundary results From 565fa270ce483e28cbae56efc1a0db8c00dd93b3 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 01:02:51 -0700 Subject: [PATCH 14/33] test(bun): verify logger-console browser export under Bun (7 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/BUN_COMPATIBILITY.md | 40 +++++-- docs/BUN_PORT_STATUS.md | 38 ++++++- tests/bun/logger-console-browser.spec.ts | 131 +++++++++++++++++++++++ 3 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 tests/bun/logger-console-browser.spec.ts diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index 79071630..03759742 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -6,7 +6,7 @@ classified below. - Verified Bun release: **1.3.14** (revision `1.3.14+0d9b296af`, macOS arm64) - Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) -- Behavioral proof: `bun test tests/bun` — 61 tests across 12 spec files +- Behavioral proof: `bun test tests/bun` — 68 tests across 13 spec files covering the scenarios listed in "What is verified" below (3 of them are gated on the oven-sh/bun#32856 PR build and skip cleanly when it is not installed). @@ -25,7 +25,7 @@ Node-only · ⬜ not yet tested | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `cordis` (core) | ✅ | ✅ (esbuild+tsc) | ✅ | ✅ 57-test suite | ✅ full | **none in src** | none found | — | | `@cordisjs/plugin-timer` | ✅ | ✅ | ✅ | ✅ timer.spec | ✅ | globals only (`setTimeout`, `Promise.withResolvers`) | none found | — | -| `@cordisjs/plugin-logger-console` | ✅ | ✅ | ✅ (node export; browser export untested) | ✅ logger-console.spec | ✅ | `node:util.inspect`, `supports-color` | none found — `util.inspect` output byte-identical for tested formats | — | +| `@cordisjs/plugin-logger-console` | ✅ | ✅ | ✅ (node and browser exports) | ✅ logger-console + logger-console-browser specs | ✅ | `node:util.inspect`, `supports-color` (node build only — the browser build imports neither) | none found — `util.inspect` output byte-identical for tested formats; browser build needs only a `console` | — | | `@cordisjs/plugin-loader` | ✅ | ✅ | ✅ | ✅ loader-mock + loader-include specs | ✅ | `node:module` (optional, degrades), `process.env` | none found; `ModuleLoader.fromInternal()` returns `undefined` → documented fallback `import()` path is used | — (fallback is upstream design) | | `@cordisjs/plugin-include` | ✅ | ✅ | ✅ | ✅ loader-include spec | ✅ | `node:path`, `node:fs/promises`, `node:url`, `js-yaml` | none found | — | | `@cordisjs/plugin-hmr` | ✅ | ✅ | ✅ | ❌ by design | ❌ **Node-only** | `--expose-internals` ESM `loadCache`, CJS `require.cache`, `node:module` | constructor fails fast: `--expose-internals is required for HMR service` (no Bun internals access — per policy, not attempted) | Cordis (Phase C adapter) or stay Node-only | @@ -47,7 +47,7 @@ Notes on the matrix: ## What is verified (behavioral, under `bun test tests/bun`) -`tests/bun/` — 61 tests (58 on stock Bun 1.3.14 + 3 PR-build-gated): +`tests/bun/` — 68 tests (58 core + 7 browser-export + 3 PR-build-gated): - **Plugins**: function / object / class plugins, config passing, invalid plugins rejected, nested plugin trees, idempotent root dispose, @@ -71,6 +71,15 @@ Notes on the matrix: - **logger-console**: render parity for messages, stackless errors, objects, `%o` formatters, log levels — using Bun's `node:util.inspect` (`logger-console.spec.ts`) +- **logger-console browser export**: loaded by path (`lib/browser.js`, the + file the export map's `default` condition serves to non-node consumers); + plugin wiring via `ctx.plugin`; error→`console.error`, + warn→`console.warn`, else→`console.log` with the `[T] name` prefix; + arguments passed through **by identity** (the exporter never serializes — + native console does the inspecting); levels respected; works with + `document`/`window` hard-absent; declaratively: export map routes + non-node consumers to `browser.js`, and the shipped artifact contains + zero `node:` specifiers (`logger-console-browser.spec.ts`) - **Loader + include**: in-memory tree (init/update/self-update/self-dispose, intercept-`await` gating); real files: YAML and JSON config loading, relative and absolute plugin references, **dynamic TypeScript plugin @@ -106,9 +115,10 @@ $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js tsc # exit 0 $ bun test tests/bun - 61 pass / 0 fail / 223 expect() calls # 58 on stock Bun + 3 PR-gated hot - # tests when the bun#32856 build - # is installed (they skip otherwise) + 68 pass / 0 fail / 248 expect() calls # 58 core + 7 browser-export on + # stock Bun + 3 PR-gated hot tests + # when the bun#32856 build is + # installed (they skip otherwise) $ HOME=/tmp/no-such-home bun test tests/bun/hot.spec.ts # PR binary hidden 0 pass / 3 skip / 0 fail @@ -292,6 +302,17 @@ recorded because they surprised the port itself: - **Bun resolves extensionless relative `.ts` imports** (`./plugin` → `plugin.ts`) and `file://` URL imports, so the loader's fallback path and Include configs referencing `./plugin.ts` work unmodified. +- **Bundling from inside this repo resolves workspace packages via + `tsconfig.json` `paths` (`@cordisjs/plugin-*` → `packages/*/src`), which + takes precedence over the packages' export maps.** Both `Bun.build` and + esbuild behave this way, and both therefore select the *node* source when + bundling a bare `@cordisjs/*` import from within the repo — regardless of + `target: 'browser'`. This is repo-config behavior, not a Bun defect and + not export-map behavior: the published package's export map (`node` → + `lib/index.js`, `default` → `lib/browser.js`) is what real-world browser + bundlers consume, and it is verified declaratively in + `logger-console-browser.spec.ts`. (Bun's `--conditions` is *additive*, so + it cannot remove the `node` condition either.) - Bun reports `process.versions.node` = `24.3.0`; `internal/modules/*` requires fail with `MODULE_NOT_FOUND`, which the loader already treats as "internals unavailable" (falling back to standard `import()`). @@ -339,8 +360,11 @@ oven-sh/bun#32856, which this fork consumes as an integration fixture (see - `@cordisjs/plugin-hmr` does not run under Bun and fails fast with a clear error. Use the supervisor for development reload. -- The browser export of logger-console and the `create-cordis` scaffolder - are untested under Bun (classified 🟡/⬜ above). +- The `create-cordis` scaffolder is untested under Bun (classified ⬜ above). - The Bun behavioral suite uses real timers; timing assertions carry wide margins. The Node suite remains the source of truth for fake-timer precision cases. +- The logger-console **browser** export is verified under Bun with a + `console` and no DOM — i.e. as the universal build it is. It is not + verified inside an actual browser engine (that would be a web-testrunner + concern, not a Bun-runtime one). diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 802473c9..bd0f4254 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -102,8 +102,8 @@ Focused downstream-integration project (owner-directed; no Bun fork): ### Full-suite phase boundary results ``` -bun test tests/bun # 61 pass / 0 fail / 223 expect() calls - # (58 before Phase 7; +3 PR-gated hot tests) +bun test tests/bun # 68 pass / 0 fail / 248 expect() calls + # (58 core + 7 browser-export + 3 PR-gated) node ... yakumo vitest --import tsx # 19 files / 163 tests passed (see run log) ``` @@ -161,6 +161,29 @@ order: yakumo esbuild (exit 0), yakumo tsc (exit 0), `bun test tests/bun` Lesson recorded: **run the full build, not only the suites, after any dependency-graph change.** +### Phase 8 — logger-console browser export under Bun (DONE) + +Owner-selected follow-up (upgrades a 🟡 matrix cell to ✅): + +- `tests/bun/logger-console-browser.spec.ts` (7 tests): loads + `lib/browser.js` by path (the export map's `default`-condition file — + under Bun the bare specifier correctly picks the `node` condition), + wires it via `ctx.plugin`, and verifies: error/warn/log method routing + with `[T] name` prefix; argument pass-through **by identity**; log + levels; operation with `document`/`window` hard-absent; plus declarative + checks — export map routes non-node consumers to `browser.js`, shipped + artifact has zero `node:` specifiers. The browser build's only + environment requirement is a `console`. +- Near-miss recorded (NOT a Bun defect): bundling bare `@cordisjs/*` + imports from inside this repo resolves via root `tsconfig.json` `paths` + → `packages/*/src`, bypassing export maps — `Bun.build` and esbuild both + pick the node source regardless of `target: 'browser'`. Verified with a + standalone probe against both tools before discarding the "Bun ignores + browser target" hypothesis. Documented under "Known behavior nuances". + (Bun's `--conditions` is additive and cannot remove the `node` + condition.) +- Results: `bun test tests/bun` 68/68 (61 + 7 new, all stock-Bun). + ## Current failures None. @@ -193,6 +216,11 @@ None. confirmed over 3 runs - `770134d` — test: third PR-gated hot test (removed-module disposal) + documented cordis.yml-edit gap; 61/61 - -Branch: `feat/bun-compat` (2 commits ahead of `8cc9e33` == `upstream/main`; -push/PR is owner-gated). +- `39bac40` — docs: record 770134d SHA +- `2c07253` — docs: record downstream-validation comment on oven-sh/bun#32856 +- (this commit) — Phase 8: logger-console browser-export verification + (logger-console-browser.spec.ts, 7 tests) + tsconfig-paths bundling + nuance; 68/68 + +Branch: `feat/bun-compat`, pushed; open as ebowwa/cordis#1 (mergeable, +CI green incl. bun#32856 integration suite). diff --git a/tests/bun/logger-console-browser.spec.ts b/tests/bun/logger-console-browser.spec.ts new file mode 100644 index 00000000..df89c4ca --- /dev/null +++ b/tests/bun/logger-console-browser.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { Context } from 'cordis' +// The browser entry is loaded BY PATH, deliberately: under Bun the `node` +// export condition matches for the bare specifier, which selects lib/index.js. +// lib/browser.js is exactly the file a browser/web bundler consumes via the +// export map's `default` condition — verified declaratively at the bottom. +import { ConsoleExporter } from '../../packages/logger-console/lib/browser.js' +import { spy } from './helpers' + +/** + * Browser export of @cordisjs/plugin-logger-console, verified under Bun. + * + * The browser build is a "universal" build: its only environment requirement + * is a `console` (it routes `[TYPE] name` + raw args to console.log/warn/error + * and leaves inspection to the native console — no node:util.inspect, no + * supports-color, no DOM usage). Verified here: + * + * 1. plugin wiring through ctx.plugin (exercises the schemastery Config + * static, which is a real runtime dependency of the browser build); + * 2. level routing: error → console.error, warn → console.warn, + * everything else → console.log, with the `[T] name` prefix + * (this Cordis version's logger levels are info/warn/error/debug — + * there is no `success` shortcut); + * 3. argument pass-through BY IDENTITY (the browser contract — the exporter + * must not serialize; the console does the inspecting); + * 4. log levels respected (debug suppressed at the default level); + * 5. declaratively: the export map selects browser.js for non-node + * consumers, and the shipped browser artifact contains zero `node:` + * specifiers. + */ + +const log = spy() +const warn = spy() +const error = spy() +const original = { log: console.log, warn: console.warn, error: console.error } + +let ctx: Context + +describe('Bun / logger-console (browser export)', () => { + beforeAll(() => { + console.log = log as any + console.warn = warn as any + console.error = error as any + ctx = new Context() + ctx.plugin(ConsoleExporter) + }) + + afterAll(async () => { + Object.assign(console, original) + await ctx?.fiber.dispose() + }) + + it('registers through ctx.plugin and routes info to console.log', () => { + log.reset() + ctx.logger('test').info('hello') + expect(log.calls.length).toBe(1) + expect(log.calls[0][0]).toBe('[I] test') + expect(log.calls[0][1]).toBe('hello') + }) + + it('routes error to console.error and warn to console.warn', () => { + error.reset(); warn.reset(); log.reset() + ctx.logger('test').error('boom') + ctx.logger('test').warn('careful') + expect(error.calls.length).toBe(1) + expect(error.calls[0][0]).toBe('[E] test') + expect(error.calls[0][1]).toBe('boom') + expect(warn.calls.length).toBe(1) + expect(warn.calls[0][0]).toBe('[W] test') + expect(warn.calls[0][1]).toBe('careful') + expect(log.calls.length).toBe(0) + }) + + it('passes arguments through by identity — the console does the inspecting', () => { + log.reset() + const obj = { foo: 'bar' } + const err = new Error('native') + ctx.logger('test').info('meta', obj, err, 42) + expect(log.calls.length).toBe(1) + const args = log.calls[0] + expect(args.length).toBe(5) + expect(args[0]).toBe('[I] test') + // untouched references, not serialized strings: + expect(args[2]).toBe(obj) + expect(args[3]).toBe(err) + expect(args[4]).toBe(42) + }) + + it('respects log levels', () => { + log.reset() + const logger = ctx.logger('test') + logger.debug('hidden') + expect(log.calls.length).toBe(0) + logger.level = 3 + logger.debug('shown') + expect(log.calls.length).toBe(1) + expect(log.calls[0][1]).toBe('shown') + }) + + it('never touches document/window globals', () => { + // the browser build must not require a DOM: exercise it with these + // globals hard-absent, not merely undefined + const desc = Object.getOwnPropertyDescriptor(globalThis, 'document') + try { + // @ts-expect-error scrubbing the global on purpose + delete (globalThis as any).document + // @ts-expect-error scrubbing the global on purpose + delete (globalThis as any).window + log.reset() + ctx.logger('test').info('no dom needed') + expect(log.calls.length).toBe(1) + } finally { + if (desc) Object.defineProperty(globalThis, 'document', desc) + } + }) + + it('export map selects the browser entry for non-node consumers', async () => { + const pkg = JSON.parse(await readFile(new URL('../../packages/logger-console/package.json', import.meta.url), 'utf8')) + expect(pkg.exports['.'].node).toBe('./lib/index.js') + expect(pkg.exports['.'].default).toBe('./lib/browser.js') + }) + + it('shipped browser artifact contains no node: specifiers', async () => { + const source = await readFile(new URL('../../packages/logger-console/lib/browser.js', import.meta.url), 'utf8') + expect(source.includes('node:')).toBe(false) + // and it really is the browser implementation, not the node one + expect(source.includes('message.type === "error" ? "error"')).toBe(true) + expect(source.includes('inspect')).toBe(false) + }) +}) From af3375399352b9947465cb0126c2a3e3d4e0a3fd Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 01:03:01 -0700 Subject: [PATCH 15/33] docs(bun): record 565fa27 SHA --- docs/BUN_PORT_STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index bd0f4254..ecdbd31d 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -218,7 +218,7 @@ None. documented cordis.yml-edit gap; 61/61 - `39bac40` — docs: record 770134d SHA - `2c07253` — docs: record downstream-validation comment on oven-sh/bun#32856 -- (this commit) — Phase 8: logger-console browser-export verification +- `565fa27` — Phase 8: logger-console browser-export verification (logger-console-browser.spec.ts, 7 tests) + tsconfig-paths bundling nuance; 68/68 From 1a677711bf2ef4c5b24c8c99ce2deed1eb03f7d7 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 02:31:22 -0700 Subject: [PATCH 16/33] =?UTF-8?q?docs(bun):=20record=20phase=209=20?= =?UTF-8?q?=E2=80=94=20upstream=20code=20contribution=20oven-sh/bun#39426?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUN_PORT_STATUS.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index ecdbd31d..5b87bd5e 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -99,6 +99,41 @@ Focused downstream-integration project (owner-directed; no Bun fork): the six verified cases + the non-module-file note. This is the extent of upstream activity: a comment, not code. +### Phase 9 — upstream code contribution to oven-sh/bun (DONE) + +Owner then elected full Bun-contributor work (Bun-only, decoupled from +Cordis). Recon over the HMR/module-reload area surfaced +[oven-sh/bun#21346](https://github.com/oven-sh/bun/issues/21346): +`import()` of a `file://` URL with distinct query strings returns the SAME +cached module instance (relative specifiers with queries work correctly). +Confirmed by standalone repro on Node v26.4.0 (3/3 distinct) vs Bun 1.3.14 +and the bun#32856 PR build (1/3 — cached). This is also the exact +public-API primitive Cordis's deferred Phase C selective-HMR adapter +depends on. + +Root cause: the module loader decoded `file://` specifiers to a path via +`WTF::URL::fileSystemPath()` (pathname only — query dropped) before +building the module key, in `moduleLoaderResolve` (static imports), +`moduleLoaderImportModule` (dynamic imports), and Rust +`do_resolve_with_args` (`Bun.resolveSync` / `import.meta.resolve`). + +Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): +- Fix at all three sites, mirroring the query-preservation pattern the + same code already used for referrers. `URL__pathFromFileURL` untouched + (path API; stripping is correct there). +- 3 tests added to the existing `test/js/bun/resolve/import-query.test.ts` + (dynamic/static `file://`+query distinct instances; `Bun.resolveSync` + keeps the query). +- Verification per repo rules: new tests fail under `USE_SYSTEM_BUN=1`; + 18/18 pass via `bun bd test`; full `test/js/bun/resolve/` failure set + identical to unmodified-main baseline (6 pre-existing debug-build + timeouts) — zero regressions. +- Toolchain note: llvm@21/cmake/ninja/rust installed; first debug build + ≈40 min on this machine; PR branch prefix `claude/` is a repo CI + requirement. +- **Open as [oven-sh/bun#39426](https://github.com/oven-sh/bun/pull/39426)** + (owner-approved), Fixes #21346. + ### Full-suite phase boundary results ``` From 45bb35ea944b0bf93a6a42b431c707069069efac Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 08:40:32 -0700 Subject: [PATCH 17/33] docs(bun): record #39426 review follow-up (resolveSync coverage) --- docs/BUN_PORT_STATUS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 5b87bd5e..5d694422 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -133,6 +133,14 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): requirement. - **Open as [oven-sh/bun#39426](https://github.com/oven-sh/bun/pull/39426)** (owner-approved), Fixes #21346. +- Post-open review follow-up (`c16333e9`): CodeRabbit flagged missing + `import.meta.resolve` coverage. Investigation showed + `import.meta.resolve` handles `file://` via URL joining (never hits the + changed code — already correct; guard test added), while + `import.meta.resolveSync` routes through `Bun__resolveSync` and HAD the + same dropped query — now covered and fixed by the same change. + 20/20 in the file; reply posted on the PR. Buildkite matrix pending at + last check (no `bk` CLI/token locally; GitHub status is the signal). ### Full-suite phase boundary results From 5c43ad971c2c7dd5edf76b1dac3b7e2348bd0614 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 08:45:00 -0700 Subject: [PATCH 18/33] =?UTF-8?q?docs(bun):=20record=20#39426=20CI=20state?= =?UTF-8?q?=20=E2=80=94=20head=20build=20blocked=20on=20fork=20approval=20?= =?UTF-8?q?gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUN_PORT_STATUS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 5d694422..4c260f5e 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -139,8 +139,14 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): changed code — already correct; guard test added), while `import.meta.resolveSync` routes through `Bun__resolveSync` and HAD the same dropped query — now covered and fixed by the same change. - 20/20 in the file; reply posted on the PR. Buildkite matrix pending at - last check (no `bk` CLI/token locally; GitHub status is the signal). + 20/20 in the file; reply posted on the PR. +- CI state (2026-08-17 ~16:00Z): build 99960 (first commit) ran ~6h then + was auto-superseded (`cancel_reason: build_skipping`) when build 100030 + dispatched for `c16333e9`. Build 100030 is **`blocked`** — Buildkite's + approval gate for fork/first-time contributors; a maintainer must + approve CI to run. No test failures anywhere; GitHub statuses remain + "pending" until the gate clears. Monitoring signal: the PR's + buildkite/bun badge, or `builds/100030.json` (public, unauthenticated). ### Full-suite phase boundary results From c28a360248308faab1a474f424a07b75a4521c11 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 09:18:01 -0700 Subject: [PATCH 19/33] feat(bun): unlock Phase C selective plugin reload via bun#39426 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/BUN_COMPATIBILITY.md | 49 ++++++-- docs/BUN_PORT_STATUS.md | 22 ++++ tests/bun/fixtures/selective-reload-driver.ts | 50 ++++++++ tests/bun/repros/README.md | 17 +++ tests/bun/repros/selective-reload.ts | 56 +++++++++ tests/bun/selective-reload.spec.ts | 119 ++++++++++++++++++ 6 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 tests/bun/fixtures/selective-reload-driver.ts create mode 100644 tests/bun/repros/selective-reload.ts create mode 100644 tests/bun/selective-reload.spec.ts diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index 03759742..6715513f 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -6,10 +6,10 @@ classified below. - Verified Bun release: **1.3.14** (revision `1.3.14+0d9b296af`, macOS arm64) - Verified Node releases: v26.4.0 (locally), v24/v26 (upstream CI unchanged) -- Behavioral proof: `bun test tests/bun` — 68 tests across 13 spec files - covering the scenarios listed in "What is verified" below (3 of them are - gated on the oven-sh/bun#32856 PR build and skip cleanly when it is not - installed). +- Behavioral proof: `bun test tests/bun` — 69 tests across 14 spec files + covering the scenarios listed in "What is verified" below (3 gated on the + oven-sh/bun#32856 PR build, 1 gated on a bun#39426-capable build; all 4 + skip cleanly when their binary is absent). - Performance proof: see **`docs/BUN_BENCH.md`** — fork ≡ upstream under Node (control), and Bun is faster on every Cordis operation (up to ~5x plugin lifecycle, ~4x config boot, ~80x TS module eval). @@ -47,7 +47,8 @@ Notes on the matrix: ## What is verified (behavioral, under `bun test tests/bun`) -`tests/bun/` — 68 tests (58 core + 7 browser-export + 3 PR-build-gated): +`tests/bun/` — 69 tests (58 core + 7 browser-export + 3 #32856-gated + 1 +#39426-gated): - **Plugins**: function / object / class plugins, config passing, invalid plugins rejected, nested plugin trees, idempotent root dispose, @@ -115,10 +116,11 @@ $ node --expose-internals --import tsx --import @cordisjs/unyaml \ node_modules/yakumo/lib/cli.js tsc # exit 0 $ bun test tests/bun - 68 pass / 0 fail / 248 expect() calls # 58 core + 7 browser-export on - # stock Bun + 3 PR-gated hot tests - # when the bun#32856 build is - # installed (they skip otherwise) + 69 pass / 0 fail / 262 expect() calls # 58 core + 7 browser-export on + # stock Bun; +3 #32856-gated and + # +1 #39426-gated when capable + # builds are installed (skip + # otherwise) $ HOME=/tmp/no-such-home bun test tests/bun/hot.spec.ts # PR binary hidden 0 pass / 3 skip / 0 fail @@ -225,6 +227,35 @@ Therefore: worth building if whole-process reload proves inadequate in practice — per the port's success criteria it is optional. +## Selective HMR on Bun (Phase C, unblocked) + +Selective per-plugin reload — the capability `@cordisjs/plugin-hmr` gets on +Node from `--expose-internals` module-loader internals — is **now possible +on Bun via public APIs**, using the primitive fixed by +[oven-sh/bun#39426](https://github.com/oven-sh/bun/pull/39426) +(a `file://` URL's query is part of the module key): + +```ts +// fresh module instance per generation, in-process, public API only: +const mod = await import(`${pathToFileURL(pluginPath).href}?gen=${n}`) +const fiber = await root.plugin(mod) // activate under the root +await previous.dispose() // graceful swap: disposers run, + // timers stop, root keeps running +``` + +Proof: `tests/bun/selective-reload.spec.ts` (+ its driver fixture) asserts +the full contract — fresh instance per generation, old fiber disposed only +after the new one applies, root effects survive unduplicated, single +disposal at shutdown. It is **capability-gated** (probes the runner binary; +skips on stock Bun) and passes against a local build of bun#39426 +(`bun test tests/bun` = 69/69 with the build present, 68 + skip without). +Manual narrative version: `tests/bun/repros/selective-reload.ts`. + +What remains for a production `--hot`-class selective HMR service (still +deferred): file watching mapped to per-module generations, config/state +preservation across swaps, rollback on failed activation. The module-swap +primitive itself is done. + ## Upstream integration: oven-sh/bun#32856 [PR #32856](https://github.com/oven-sh/bun/pull/32856) ("Implement diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 4c260f5e..7ba1280c 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -233,6 +233,28 @@ Owner-selected follow-up (upgrades a 🟡 matrix cell to ✅): condition.) - Results: `bun test tests/bun` 68/68 (61 + 7 new, all stock-Bun). +### Phase 10 — Phase C selective HMR unblocked by our own PR (DONE) + +Owner asked "can we use it with our cordis?" — answered at three levels: + +1. **Drop-in compatibility**: the full Cordis suite runs green on the + bun#39426 debug build — 68/68 (all stock tests, incl. the 3 #32856-gated + ones skipped for lack of that binary's alias). +2. **The previously-impossible use case**: selective plugin reload now + works in-process on public APIs — `import(fileURL + "?gen=N")` for a + fresh instance + `root.plugin(mod)` + `previousFiber.dispose()` for a + graceful swap. Verified by transcript: fresh instance per generation, + old fiber disposed only after the new applies, root effects survive + unduplicated, single disposal at shutdown. +3. **Locked in**: `tests/bun/selective-reload.spec.ts` + + `fixtures/selective-reload-driver.ts`, capability-gated (probes the + runner for query-busting; stock Bun → clean skip). 69/69 with the + capable build present. Manual narrative: `repros/selective-reload.ts`. + +Still deferred for a production selective-HMR service: fs watching → +generations, config preservation, rollback. The module-swap primitive is +done. + ## Current failures None. diff --git a/tests/bun/fixtures/selective-reload-driver.ts b/tests/bun/fixtures/selective-reload-driver.ts new file mode 100644 index 00000000..c4d4f76f --- /dev/null +++ b/tests/bun/fixtures/selective-reload-driver.ts @@ -0,0 +1,50 @@ +// Driver for tests/bun/selective-reload.spec.ts — run by a Bun build that +// includes oven-sh/bun#39426 (file:// query in module keys). +// Prints a tagged transcript; the spec asserts on its ordering. +import { Context } from 'cordis' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const PLUGIN = [ + 'const gen = (globalThis as any).__gen = ((globalThis as any).__gen ?? 0) + 1', + 'export const captured = (globalThis as any).__token', + 'export function apply(ctx: any) {', + ' console.log(`[plugin] apply gen=${gen} captured=${captured}`)', + ' ctx.effect(() => {', + ' const t = setInterval(() => console.log(`[plugin] tick gen=${gen}`), 150)', + ' return () => { clearInterval(t); console.log(`[plugin] disposed gen=${gen}`) }', + ' })', + '}', +].join('\n') + +const dir = await mkdtemp(join(tmpdir(), 'sel-plugin-')) +await writeFile(join(dir, 'plugin.ts'), PLUGIN) + +const pluginURL = pathToFileURL(join(dir, 'plugin.ts')).href +const root = new Context() + +// root-owned effect: must survive the plugin swap, unduplicated +root.effect(() => { + const t = setInterval(() => console.log('[resident] tick'), 150) + return () => { clearInterval(t); console.log('[resident] disposed') } +}) + +async function loadGeneration(n: number) { + // requires bun#39426: each ?gen=N is a fresh module instance + const mod = await import(`${pluginURL}?gen=${n}`) + return root.plugin(mod) +} + +const gen1 = await loadGeneration(1) +await new Promise(r => setTimeout(r, 400)) +console.log('--- swap ---') +;(globalThis as any).__token = 'reloaded' +const gen2 = await loadGeneration(2) +await gen1.dispose() +await new Promise(r => setTimeout(r, 400)) +console.log('--- shutdown ---') +await gen2.dispose() +await root.fiber.dispose() +process.exit(0) diff --git a/tests/bun/repros/README.md b/tests/bun/repros/README.md index 2ba2d5a8..ff864386 100644 --- a/tests/bun/repros/README.md +++ b/tests/bun/repros/README.md @@ -82,3 +82,20 @@ These behaviors motivated the supervisor design (`packages/core/bin.bun.watch.js`) for stock Bun, and the `import.meta.hot`-driven in-process reload for Bun builds shipping bun#32856 (see `tests/bun/hot.spec.ts`). + +## selective-reload.ts + +Demonstrates Phase C selective plugin reload — swap ONE plugin generation +in-process while the root keeps running — using the `file://` query +cache-busting fixed by [oven-sh/bun#39426](https://github.com/oven-sh/bun/pull/39426): + +``` +.upstream/bun/build/debug/bun-debug tests/bun/repros/selective-reload.ts +# (or any Bun with the fix; stock Bun will re-import the CACHED module — +# the bug the PR fixes — and the swap assertion becomes meaningless) +``` + +Observed (bun#39426 debug build): `apply gen=2 captured=reloaded` (fresh +instance) → `disposed gen=1` (graceful old-fiber disposal) → resident ticks +continue unduplicated → only gen=2 ticks afterwards. The asserted version +lives in `tests/bun/selective-reload.spec.ts` (capability-gated). diff --git a/tests/bun/repros/selective-reload.ts b/tests/bun/repros/selective-reload.ts new file mode 100644 index 00000000..58592854 --- /dev/null +++ b/tests/bun/repros/selective-reload.ts @@ -0,0 +1,56 @@ +// Selective plugin reload on Bun — the Phase C primitive, enabled by +// oven-sh/bun#39426 (a file:// URL's query is part of the module key). +// Run with a Bun that includes the fix: +// .upstream/bun/build/debug/bun-debug tests/bun/repros/selective-reload.ts +// +// What this proves, per line of output: +// - [resident] ticks continue across the reload, unduplicated → root untouched +// - gen=2's module is a FRESH instance (captured=reloaded) → query busting +// - gen=1's disposer ran and its timer stopped → graceful swap +// - everything happens in-process, no restart +import { Context } from 'cordis' +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const dir = await mkdtemp(join(tmpdir(), 'cordis-selective-')) +await writeFile(join(dir, 'plugin.ts'), ` +const gen = (globalThis as any).__gen = ((globalThis as any).__gen ?? 0) + 1 +export const captured = (globalThis as any).__token +export function apply(ctx: any) { + console.log(\`[plugin] apply gen=\${gen} captured=\${captured}\`) + ctx.effect(() => { + const t = setInterval(() => console.log(\`[plugin] tick gen=\${gen}\`), 150) + return () => { clearInterval(t); console.log(\`[plugin] disposed gen=\${gen}\`) } + }) +} +`) + +const pluginURL = pathToFileURL(join(dir, 'plugin.ts')).href +const root = new Context() + +// resident plugin: must keep ticking, exactly once, across the reload +root.effect(() => { + const t = setInterval(() => console.log('[resident] tick'), 150) + return () => { clearInterval(t); console.log('[resident] disposed') } +}) + +async function loadGeneration(n: number) { + // query-busting: each ?gen=N is a FRESH module instance (needs #39426) + const mod = await import(`${pluginURL}?gen=${n}`) + return root.plugin(mod) +} + +const gen1 = await loadGeneration(1) +await new Promise(r => setTimeout(r, 400)) + +console.log('--- selective reload: swap the plugin, keep the root ---') +;(globalThis as any).__token = 'reloaded' +const gen2 = await loadGeneration(2) // new instance + activate +await gen1.dispose() // graceful: old timer cleared, disposer ran +await new Promise(r => setTimeout(r, 400)) + +console.log('--- shutdown ---') +await gen2.dispose() +await root.fiber.dispose() diff --git a/tests/bun/selective-reload.spec.ts b/tests/bun/selective-reload.spec.ts new file mode 100644 index 00000000..62d379c9 --- /dev/null +++ b/tests/bun/selective-reload.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'bun:test' +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +/** + * Selective plugin reload via file:// query cache-busting. + * + * Requires a Bun build shipping oven-sh/bun#39426 ("keep a file:// URL's + * query in module keys") — before it, `import(url + "?v=N")` returned the + * same cached module instance for every query, so per-module reload via + * public APIs was impossible on Bun (the reason selective HMR was deferred + * to Phase C in docs/BUN_COMPATIBILITY.md). + * + * The driver (`fixtures/selective-reload-driver.ts`) swaps one plugin + * generation in-process while the root keeps running; this spec asserts the + * transcript's ordering: + * 1. `?gen=N` yields a FRESH module instance per generation + * (`captured=reloaded` proves a new evaluation); + * 2. the old plugin's fiber is disposed AFTER the new one applies and its + * timers stop — only the new generation ticks afterwards; + * 3. root-owned effects keep ticking across the swap, unduplicated, and + * are disposed exactly once, at shutdown. + * + * Runner selection is by capability probe, not version: BUN_QUERY_BUSTING_BIN + * env var, else a local `.upstream/bun/build/debug/bun-debug` checkout. + * Skips cleanly when no capable binary exists (stock Bun, CI). + */ + +const REPO_ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))) +const DRIVER = fileURLToPath(new URL('./fixtures/selective-reload-driver.ts', import.meta.url)) + +function candidateBin(): string | undefined { + if (process.env.BUN_QUERY_BUSTING_BIN) return process.env.BUN_QUERY_BUSTING_BIN + const local = join(REPO_ROOT, '.upstream/bun/build/debug/bun-debug') + return existsSync(local) ? local : undefined +} + +/** capability probe: does import(url+query) bypass the module cache? */ +function hasQueryBusting(bin: string): boolean { + const dir = mkdtempSync(join(tmpdir(), 'cordis-qb-probe-')) + try { + const file = join(dir, 'm.ts') + writeFileSync(file, 'export const t = Date.now()\n') + const script = ` + const u = new URL(${JSON.stringify(pathToFileURL(file).href)}) + const a = await import(u.href + "?v=1") + await new Promise(r => setTimeout(r, 30)) + const b = await import(u.href + "?v=2") + console.log("QB:" + (a.t !== b.t)) + ` + const res = spawnSync(bin, ['-e', script], { + encoding: 'utf8', + timeout: 30000, + env: { ...process.env, BUN_DEBUG_QUIET_LOGS: '1' }, + }) + return (res.stdout + '').includes('QB:true') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const BIN = candidateBin() + +describe('Bun / selective plugin reload (bun#39426 query cache-busting)', () => { + it.skipIf(!BIN || !hasQueryBusting(BIN!))( + 'query-busted generation swap disposes only the old plugin fiber', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'cordis-selective-')) + try { + await symlink(join(REPO_ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir') + + const child = spawn(BIN!, [DRIVER], { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, BUN_DEBUG_QUIET_LOGS: '1', NO_COLOR: '1' }, + }) + let stdout = '' + child.stdout.on('data', c => { stdout += c }) + child.stderr.on('data', c => { stdout += c }) + const code = await new Promise(res => child.on('exit', res)) + + const lines = stdout.split('\n').filter(l => l.startsWith('[') || l.startsWith('---')) + const idx = (needle: string) => lines.findIndex(l => l.includes(needle)) + + // generation 1 activated and ticked before the swap + expect(idx('apply gen=1')).toBeGreaterThan(-1) + expect(idx('tick gen=1')).toBeGreaterThan(-1) + expect(idx('tick gen=1')).toBeLessThan(idx('swap')) + + // fresh instance for gen=2 (captured=reloaded proves a new evaluation) + expect(idx('apply gen=2 captured=reloaded')).toBeGreaterThan(idx('swap')) + // old plugin disposed only after the new one applied + expect(idx('disposed gen=1')).toBeGreaterThan(idx('apply gen=2')) + + // after gen=1's disposal, only gen=2 ticks + const disposed1 = idx('disposed gen=1') + expect(lines.slice(disposed1).some(l => l.includes('tick gen=1'))).toBe(false) + expect(lines.slice(disposed1).some(l => l.includes('tick gen=2'))).toBe(true) + + // root-owned effects survive the swap unduplicated, disposed once at shutdown + const swapAt = idx('swap') + const shutdownAt = idx('shutdown') + expect(shutdownAt).toBeGreaterThan(-1) + expect(lines.slice(swapAt, shutdownAt).some(l => l.includes('[resident] disposed'))).toBe(false) + expect(lines.slice(swapAt, shutdownAt).filter(l => l.includes('[resident] tick')).length).toBeGreaterThan(0) + expect(lines.filter(l => l.includes('[resident] disposed')).length).toBe(1) + expect(idx('[resident] disposed')).toBeGreaterThan(shutdownAt) + expect(idx('disposed gen=2')).toBeGreaterThan(shutdownAt) + + expect(code).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 60000) +}) From 5d2268b99a2fffb9a31c6d54af9cba2648bc3442 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 09:59:34 -0700 Subject: [PATCH 20/33] docs(bun): record downstream-usage comment on oven-sh/bun#39426 and PR #2 stack --- docs/BUN_PORT_STATUS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 7ba1280c..1747c805 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -147,6 +147,17 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): approve CI to run. No test failures anywhere; GitHub statuses remain "pending" until the gate clears. Monitoring signal: the PR's buildkite/bun badge, or `builds/100030.json` (public, unauthenticated). +- Downstream-usage comment posted on the PR (owner-approved, 2026-08-17): + — + the selective-plugin-reload use case the fix unlocks (Cordis PR #2's + spec linked), the two-public-call primitive, and full-suite green on + the debug build. +- Cordis PR #2 opened stacking on PR #1: + `feat/bun-selective-reload` → `feat/bun-compat`, 1 commit, +311/−9 — + the Phase C selective-reload spec + driver + repro + docs. CI green + (65 pass + 4 skip on stock Bun 1.3.14; the new test is + capability-gated and skips there, activating when a shipped Bun + includes #39426). ### Full-suite phase boundary results From 199f1f21c0dd5e1774b6bce47b92bd956c608730 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 10:06:20 -0700 Subject: [PATCH 21/33] docs(bun): record CodeRabbit resolution on oven-sh/bun#39426 --- docs/BUN_PORT_STATUS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 1747c805..b037a543 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -158,6 +158,16 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): (65 pass + 4 skip on stock Bun 1.3.14; the new test is capability-gated and skips there, activating when a shipped Bun includes #39426). +- CodeRabbit review loop closed (2026-08-17): its single actionable + finding (add `import.meta.resolve` coverage) was addressed by + `c16333e9`; a threaded reply on the finding + (`discussion_r3797402606`) plus an `@coderabbitai review` trigger got + the bot's acknowledgment — "the added coverage is sufficient — ✅ + Review thread resolved" — and it recorded the URL-join vs + `Bun__resolveSync` routing as a repo learning. **Zero open bot + findings**; `claude[bot]` review stays disabled for fork PRs until a + maintainer invokes it. Remaining gate: Buildkite approval (build + 100030 still `blocked`). ### Full-suite phase boundary results From 71aecfc7779469be4e5e93cfd03b76ce8d0541ff Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 10:21:36 -0700 Subject: [PATCH 22/33] =?UTF-8?q?docs(bun):=20correct=20CodeRabbit=20recor?= =?UTF-8?q?d=20=E2=80=94=20auto-update=20runs,=20merge=20risk=20now=20Mini?= =?UTF-8?q?mal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUN_PORT_STATUS.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index b037a543..a88b8dd3 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -161,13 +161,17 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): - CodeRabbit review loop closed (2026-08-17): its single actionable finding (add `import.meta.resolve` coverage) was addressed by `c16333e9`; a threaded reply on the finding - (`discussion_r3797402606`) plus an `@coderabbitai review` trigger got - the bot's acknowledgment — "the added coverage is sufficient — ✅ - Review thread resolved" — and it recorded the URL-join vs - `Bun__resolveSync` routing as a repo learning. **Zero open bot - findings**; `claude[bot]` review stays disabled for fork PRs until a - maintainer invokes it. Remaining gate: Buildkite approval (build - 100030 still `blocked`). + (`discussion_r3797402606`) got the bot's acknowledgment ("the added + coverage is sufficient — ✅ Review thread resolved", plus a recorded + repo learning on URL-join vs `Bun__resolveSync` routing). Note: + CodeRabbit **auto-updates on every push** by editing its walkthrough + comment in place (not via new review records) — its 17:06 auto-update + delta-reviewed `4fde4e56 → c16333e9` with **zero actionable + comments** and raised Merge Risk 🔵 Low → **⚪ Minimal**; the manual + `@coderabbitai review` trigger was redundant (that command is only + for paused auto-reviews). **Zero open bot findings**; `claude[bot]` + review stays disabled for fork PRs until a maintainer invokes it. + Remaining gate: Buildkite approval (build 100030 still `blocked`). ### Full-suite phase boundary results From b6a8bdba32502cf6e7ea40e08a6a30334597a145 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 10:56:49 -0700 Subject: [PATCH 23/33] docs(bun): record prior-art sweep finding oven-sh/bun#35601 duplicate + 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 prior-art search before opening a fix. --- docs/BUN_PORT_STATUS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index a88b8dd3..f02cffe0 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -158,6 +158,22 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): (65 pass + 4 skip on stock Bun 1.3.14; the new test is capability-gated and skips there, activating when a shipped Bun includes #39426). +- Prior-art sweep (owner-requested, 2026-08-17 ~19:30Z) found **#35601** — + robobun's earlier PR (Jul 25) fixing the SAME bug: identical root-cause + diagnosis, same three sites, PLUS BunPlugin.cpp / mock.module via a + shared `fileSystemPathWithQuery` helper, and it also fixes #13391 + (`bunx --bun astro dev` stale config). It is stalled/dirty (untouched + since Jul 26, merge conflicts vs main, mixed CI). **Our #39426 is a + partial duplicate, opened unknowingly** — the original recon searched + issues (found #21346) but never `is:pr 21346` for prior PRs. Lesson + recorded: run the 5-second prior-PR search before opening any fix. + Disclosed on both (owner-approved): [#39426 + comment](https://github.com/oven-sh/bun/pull/39426#issuecomment-5318372531) + (offering to close as superseded if they revive theirs) and [#35601 + comment](https://github.com/oven-sh/bun/pull/35601#issuecomment-5318379869) + (downstream validation + test-matrix offer, to help un-stall the better + PR). Cordis PR #2 is unaffected: its gating is capability-based and + activates on whichever PR lands. - CodeRabbit review loop closed (2026-08-17): its single actionable finding (add `import.meta.resolve` coverage) was addressed by `c16333e9`; a threaded reply on the finding From ffdd54f4b280d4745fd5c46178c343470e830430 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 11:01:25 -0700 Subject: [PATCH 24/33] docs(bun): record extended neighborhood sweep (fragment, plugin-prefilter, history, adjacent issues) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/BUN_PORT_STATUS.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index f02cffe0..99b2df72 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -158,7 +158,31 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): (65 pass + 4 skip on stock Bun 1.3.14; the new test is capability-gated and skips there, activating when a shipped Bun includes #39426). -- Prior-art sweep (owner-requested, 2026-08-17 ~19:30Z) found **#35601** — +- Extended prior-art sweep (owner-requested, same session) — full + neighborhood map of query/fragment specifier handling in oven-sh/bun: + - **#35601** (robobun, open, stalled/dirty) — file://+query fix; ours + duplicates partially (disclosed both sides) + - **#35703** (open) — resolver: split #fragment off ESM specifiers for + the module cache key — the fragment gap #35601's body flagged is + already claimed + - **#37702** (robobun, open, Aug 12) — plugin onResolve/onLoad prefilter + breaks when the QUERY contains a dot (`?mtime=...` fractional) — + adjacent, non-conflicting code sites (transpiler prefilter vs module + keys) + - **#16456** (Jarred-Sumner, MERGED Jan 2025) — introduced relative + +query support (fixes #15517); **#16480** was its superseded sibling + - **#13391** (astro stale config) — covered by #35601 + - **#35345** (open) — lcov coverage keeps only the LAST instance of a + query-reimported module; test-runner bug, but becomes REACHABLE via + file:// query-busting once either fix lands — cross-reference noted + for maintainer discussion, not posted publicly (noise discipline) + - **#7823** — mock.restore issues; mock.module sites touched by #35601 + - **Sweep verdict**: the neighborhood is fully occupied by the Bun + team's own in-flight work; no uncontested contribution target + remains here. Our value-adds stand as: fresh-on-main implementation + (#39426), downstream validation + capability-gated matrix (posted on + both PRs), and Cordis PR #2 which activates on whichever lands. +- #35601 detail (original finding, owner-requested, 2026-08-17 ~19:30Z): robobun's earlier PR (Jul 25) fixing the SAME bug: identical root-cause diagnosis, same three sites, PLUS BunPlugin.cpp / mock.module via a shared `fileSystemPathWithQuery` helper, and it also fixes #13391 From 6dfa978aed415d3c026b42d692881173166ce67d Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 11:01:34 -0700 Subject: [PATCH 25/33] test(bun): prefer release bun build in selective-reload capability probe build/release/bun (the ~60-90MB usable binary) checked before the debug build; env override unchanged. --- tests/bun/selective-reload.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/bun/selective-reload.spec.ts b/tests/bun/selective-reload.spec.ts index 62d379c9..48ad04a6 100644 --- a/tests/bun/selective-reload.spec.ts +++ b/tests/bun/selective-reload.spec.ts @@ -26,7 +26,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url' * are disposed exactly once, at shutdown. * * Runner selection is by capability probe, not version: BUN_QUERY_BUSTING_BIN - * env var, else a local `.upstream/bun/build/debug/bun-debug` checkout. + * env var, else a local Bun checkout's build under `.upstream/bun` (release + * `build/release/bun` preferred, then debug `build/debug/bun-debug`). * Skips cleanly when no capable binary exists (stock Bun, CI). */ @@ -35,8 +36,11 @@ const DRIVER = fileURLToPath(new URL('./fixtures/selective-reload-driver.ts', im function candidateBin(): string | undefined { if (process.env.BUN_QUERY_BUSTING_BIN) return process.env.BUN_QUERY_BUSTING_BIN - const local = join(REPO_ROOT, '.upstream/bun/build/debug/bun-debug') - return existsSync(local) ? local : undefined + for (const rel of ['build/release/bun', 'build/debug/bun-debug']) { + const local = join(REPO_ROOT, '.upstream/bun', rel) + if (existsSync(local)) return local + } + return undefined } /** capability probe: does import(url+query) bypass the module cache? */ From 9335557c6cb253010bb79f78065810fb2ca5dab5 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 11:05:05 -0700 Subject: [PATCH 26/33] docs(bun): record neighborhood-map comments posted on #39426 and #35601 --- docs/BUN_PORT_STATUS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 99b2df72..83f8839b 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -182,6 +182,14 @@ Delivered (in `.upstream/bun`, locally excluded; fork `ebowwa/bun`): remains here. Our value-adds stand as: fresh-on-main implementation (#39426), downstream validation + capability-gated matrix (posted on both PRs), and Cordis PR #2 which activates on whichever lands. + - Neighborhood map posted publicly (owner-approved): full version on + [#39426](https://github.com/oven-sh/bun/pull/39426#issuecomment-5318438990) + (four PRs' relationships + history + the #35345 lcov prediction), + trimmed version on + [#35601](https://github.com/oven-sh/bun/pull/35601#issuecomment-5318445162) + framed as downstream-shared context. The #35345 note is the + forward-looking piece: neither query PR mentions that landing either + makes the lcov re-import bug reachable via file:// cache-busting. - #35601 detail (original finding, owner-requested, 2026-08-17 ~19:30Z): robobun's earlier PR (Jul 25) fixing the SAME bug: identical root-cause diagnosis, same three sites, PLUS BunPlugin.cpp / mock.module via a From 660fe6ff3d1c07c18aa49461dbeb80f0ef6eb2c5 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 11:16:39 -0700 Subject: [PATCH 27/33] feat(bun): ship contributed-Bun release binary + harden spec path handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/BUN_COMPATIBILITY.md | 24 +++++++++++++++++++++++- docs/BUN_PORT_STATUS.md | 12 ++++++++++++ tests/bun/selective-reload.spec.ts | 4 +++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/BUN_COMPATIBILITY.md b/docs/BUN_COMPATIBILITY.md index 6715513f..c0f0f402 100644 --- a/docs/BUN_COMPATIBILITY.md +++ b/docs/BUN_COMPATIBILITY.md @@ -247,10 +247,32 @@ Proof: `tests/bun/selective-reload.spec.ts` (+ its driver fixture) asserts the full contract — fresh instance per generation, old fiber disposed only after the new one applies, root effects survive unduplicated, single disposal at shutdown. It is **capability-gated** (probes the runner binary; -skips on stock Bun) and passes against a local build of bun#39426 +skips on stock Bun) and passes against a release build of bun#39426 (`bun test tests/bun` = 69/69 with the build present, 68 + skip without). Manual narrative version: `tests/bun/repros/selective-reload.ts`. +### Running Cordis on the contributed Bun today + +A release binary built from the PR branch is kept at +`.upstream/bin/bun-39426` (59 MB, `1.4.0-canary.1+c16333e9e`, +git-excluded). It is a drop-in Bun: + +```bash +# production-style CLI entrypoint — verified end-to-end (boot, plugin +# load, SIGINT root disposal, exit 0): +.upstream/bin/bun-39426 packages/core/bin.bun.js + +# force the capability-gated tests to use it: +BUN_QUERY_BUSTING_BIN=.upstream/bin/bun-39426 bun test tests/bun +``` + +Without the env var, the spec auto-detects +`.upstream/bun/build/release/bun` → `.upstream/bun/build/debug/bun-debug` +→ skips. When a shipped Bun release includes the fix, the binary and the +gating can simply be deleted. (Prior-art note: the Bun team has its own +in-flight #35601 covering the same ground — see BUN_PORT_STATUS.md; this +binary is for local use regardless of which PR lands.) + What remains for a production `--hot`-class selective HMR service (still deferred): file watching mapped to per-module generations, config/state preservation across swaps, rollback on failed activation. The module-swap diff --git a/docs/BUN_PORT_STATUS.md b/docs/BUN_PORT_STATUS.md index 83f8839b..465612c3 100644 --- a/docs/BUN_PORT_STATUS.md +++ b/docs/BUN_PORT_STATUS.md @@ -328,6 +328,18 @@ Still deferred for a production selective-HMR service: fs watching → generations, config preservation, rollback. The module-swap primitive is done. +- **Release binary shipped locally (same session)**: `bun run + build:release` from the PR branch produced + `1.4.0-canary.1+c16333e9e` (62 MB at build; 59 MB preserved at + `.upstream/bin/bun-39426`, git-excluded; build dir deleted after — + full build ≈45 min, disk peak reclaimed to 15 GB free). Verified: + `--revision` ok; selective-reload spec passes against it (69/69 full + suite with it present); real-app smoke — `bin.bun.js` boots Cordis, + loads a plugin, SIGINT disposes the root, exit 0. Spec hardening from + this round: `BUN_QUERY_BUSTING_BIN` is `resolve()`d (the driver child + runs from a temp cwd — a relative override path broke it; found and + fixed during release-binary verification). + ## Current failures None. diff --git a/tests/bun/selective-reload.spec.ts b/tests/bun/selective-reload.spec.ts index 48ad04a6..3b26da86 100644 --- a/tests/bun/selective-reload.spec.ts +++ b/tests/bun/selective-reload.spec.ts @@ -35,7 +35,9 @@ const REPO_ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))) const DRIVER = fileURLToPath(new URL('./fixtures/selective-reload-driver.ts', import.meta.url)) function candidateBin(): string | undefined { - if (process.env.BUN_QUERY_BUSTING_BIN) return process.env.BUN_QUERY_BUSTING_BIN + // resolved to an absolute path: the driver child process runs with a + // temp cwd, so a relative binary path would not resolve from there + if (process.env.BUN_QUERY_BUSTING_BIN) return resolve(process.env.BUN_QUERY_BUSTING_BIN) for (const rel of ['build/release/bun', 'build/debug/bun-debug']) { const local = join(REPO_ROOT, '.upstream/bun', rel) if (existsSync(local)) return local From 6d1d9cb85166ecd7c000e414dd1d9bdeaa48fe11 Mon Sep 17 00:00:00 2001 From: ebowwa Date: Mon, 17 Aug 2026 11:51:43 -0700 Subject: [PATCH 28/33] bench(bun): four-configuration matrix + retract tainted ~80x eval claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/