Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions docs/BUN_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -225,6 +227,62 @@ 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 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; SHA256
`57b1e6baa1ec0ea2fdfedf68b797abfd721cc93f8cc1e2700a31cbf10bdd099e`).
This state is pinned by git tag **`bun-39426`**. The binary is
reproducible from the public PR branch regardless:
`git fetch https://github.com/ebowwa/bun pull/39426/head && bun run
build:release`. 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
primitive itself is done.

## Upstream integration: oven-sh/bun#32856

[PR #32856](https://github.com/oven-sh/bun/pull/32856) ("Implement
Expand Down
56 changes: 56 additions & 0 deletions docs/BUN_PORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,62 @@ 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)

[oven-sh/bun#39426](https://github.com/oven-sh/bun/pull/39426) ("keep a
file:// URL's query in module keys", fixing
[#21346](https://github.com/oven-sh/bun/issues/21346)) is an upstream PR
authored from this port's upstream-contribution effort; it makes
`import(fileURL + "?v=N")` bypass the module cache — the public-API
primitive per-module reload needs on Bun.

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.

- **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).
- **Pinned & published (owner-approved)**: state tagged **`bun-39426`**
(annotated, on `f3ebb4e` = PR #2 head; message carries the SHA256,
verification evidence, rebuild path, upstream refs). Binary published
as a GitHub Release asset
(`releases/download/bun-39426/bun-39426-darwin-aarch64`, 59 MB) —
download round-trip verified: checksum matches
`57b1e6ba…bdd099e`, `--revision` reports our commit. Docs record the
checksum and rebuild command, so the artifact is verifiable AND
reproducible independent of this machine.
- Release referenced on both Bun PRs (owner-approved): full artifact
note (download, checksum, usage, caveats) on
[#39426](https://github.com/oven-sh/bun/pull/39426#issuecomment-5318617337);
short validation pointer on
[#35601](https://github.com/oven-sh/bun/pull/35601#issuecomment-5318617517)
(framed as "testable binary for this direction", not our PR).

## Current failures

None.
Expand Down
50 changes: 50 additions & 0 deletions tests/bun/fixtures/selective-reload-driver.ts
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions tests/bun/repros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
56 changes: 56 additions & 0 deletions tests/bun/repros/selective-reload.ts
Original file line number Diff line number Diff line change
@@ -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()
Loading