Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .changeset/2026-08-29-guard-symbol-asyncdispose-installs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@dexpace/core": minor
"@dexpace/transport-fetch": minor
"@dexpace/transport-undici": minor
---

Guard every `[Symbol.asyncDispose]` install behind a runtime check, so disposal is never promised on a Node version that does not have the symbol.

`Page`, `FetchTransport`, and `UndiciTransport` each declared `[Symbol.asyncDispose]` as a plain computed class member. `Symbol.asyncDispose` arrived in Node **20.4**, but every package here declares `engines.node: ">=20.3"`. On the declared floor the computed key evaluates to `undefined`, so the method was bound to the string key `"undefined"` — leaving a junk prototype entry and **no working disposal**, while the emitted `.d.ts` promised `AsyncDisposable` unconditionally. `SseStream` was already guarded, and `Response` carries a regression test asserting the absence of exactly this junk key (`http/response.test.ts`); these three sites had reintroduced it.

The installs now match `SseStream`: `Object.defineProperty` behind `typeof Symbol.asyncDispose === 'symbol'`. Disposal works unchanged on Node 20.4+.

Breaking, in the type system only:

- `Page` no longer declares `implements AsyncDisposable`, and its `.d.ts` no longer declares `[Symbol.asyncDispose]`.
- `fetchTransport()` returns `Transport` rather than `Transport & AsyncDisposable`.
- `undiciTransport()` returns `Transport` rather than `Transport & AsyncDisposable`.

`await using page = ...` / `await using transport = ...` therefore no longer type-checks. This is deliberate: the declaration was only ever true on Node 20.4+, and on the floor it type-checked a call that silently did nothing — for `undiciTransport` that meant leaking every pooled connection. Call `close()` instead, which has always been the real teardown path and is unchanged. Consumers pinned to Node 20.4+ who want `await using` back can reach the installed symbol through a cast.

The floor will not be raised to `>=20.4` to restore the declaration. `NFR-10` requires a capability that needs a newer runtime to be isolated into its own unit declaring that higher floor, never to raise the floor of the general-purpose core; it also requires the emitted-artifact target and the visible-API level to agree, which is the clause the unguarded member violated. `>=20.3` is in any case derived rather than chosen — it is the lowest Node that runs what these packages emit, set by `globalThis.crypto` (absent from ESM on every Node 18 release) and `AbortSignal.any()` (20.3.0). The guarded install is the permanent shape.

`Paginator.pages()`'s published TSDoc is corrected to match: it had discharged `PAGE-12`'s "consumers MUST be told to wrap the view in a scoped/auto-close construct" clause by naming `await using` alongside `for await`, which no longer type-checks. It now names the two constructs that do give the guarantee — a `for await` loop, or `.return()` from a `finally` when you drive the iterator by hand — and says why `await using` is not a third.

Kept as **minor** rather than major because these packages are pre-1.0 (`0.0.0`), per the same semver initial-development carve-out the earlier `Body` narrowing used.
20 changes: 20 additions & 0 deletions .changeset/2026-08-30-undici-teardown-and-producer-ordering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@dexpace/transport-undici": patch
---

Fix two teardown defects in `undiciTransport()`.

`close()` no longer strands owned dispatchers when one fails to release. It previously walked the
owned set with a bare `for … await` loop, so the first rejecting `destroy()` aborted the walk — and
because the set is walked in reverse, a configured proxy meant the `ProxyAgent` actually holding the
pooled connections was the one left leaked. Every owned dispatcher is now destroyed before any
failure is reported, and the failure surfaces as a `TransportFailureError` carrying the underlying
cause (an `AggregateError` when more than one dispatcher failed) rather than a raw `undici` error
escaping a public method untyped.

`send()` now maps request headers before preparing the request body. `prepareBody()` starts a
streaming producer eagerly while header mapping reads `request.body.mediaType` — a getter on a
caller-supplied `Body` that may throw. In the old order such a throw left a live producer that
nothing could abandon, whose own later rejection reached Node's default `unhandledRejection` policy
(TRANSPORT-19, SEAM-30). `@dexpace/transport-fetch` already evaluated the two in this order and is
unaffected; both transports now carry a regression test pinning it.
23 changes: 16 additions & 7 deletions .claude/skills/ci-preflight/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: Use before pushing a branch, opening or updating a PR, or whenever

## Overview

`.github/workflows/ci.yml` is 14 blocking steps across two jobs. Every one of them can run
`.github/workflows/ci.yml` is 15 blocking steps across two jobs. Every one of them can run
locally, so a red CI run is always avoidable — `bun test` passing is not evidence, and it is
the single most common reason work gets handed over broken.

Expand All @@ -19,10 +19,10 @@ node .claude/skills/ci-preflight/run-ci.mjs

~2.5 minutes warm on a green tree. Full output per step goes to
`node_modules/.cache/ci-preflight/<step>.log`; only a summary and a tail of each failure
reach stdout, so a red run costs a few hundred tokens rather than the ~40k that thirteen
reach stdout, so a red run costs a few hundred tokens rather than the ~40k that fourteen
raw `bun run` calls would.

Do not hand-run the thirteen commands instead. Two things go wrong when you do:
Do not hand-run the fourteen commands instead. Two things go wrong when you do:

- **Order is load-bearing.** `test`, `api`, `lint:publish` and every `verify:*` gate resolve
`@dexpace/core` by package name, which lands in `packages/core/dist/`. Run any of them
Expand All @@ -35,7 +35,7 @@ Do not hand-run the thirteen commands instead. Two things go wrong when you do:
1. **Run it.** Add `--skip-install` only if you have not touched `package.json` since the
last install. **Before you push, add `--clean`** — a warm tree is blind to a whole class of
defect CI hits on its first step. (The pinned Bun needs no flag; it is the default.)
2. **All green** → say so plainly: CI is all good, naming the count (`all 14 steps passed`).
2. **All green** → say so plainly: CI is all good, naming the count (`all 15 steps passed`).
Nothing else to do.
3. **Anything red** → report the findings to the user *first*: which gates failed, what each
one means, and the fix you intend. One line per finding, not a transcript dump.
Expand Down Expand Up @@ -88,7 +88,7 @@ Both of these will make you report a passing gate that CI rejects.
This is not hypothetical. PR #52 failed exactly this way: Phase 8a made
`@dexpace/transport-shared` the second published package imported by name from another
package's `src/`, `typecheck` and `lint` still pre-built only core, and a warm preflight
passed all 14 steps on the commit CI rejected. Fixed by `build:deps` — see CLAUDE.md, and
passed every step on the commit CI rejected. Fixed by `build:deps` — see CLAUDE.md, and
keep that list current when a new package crosses the same line.
- **The coverage floor fails silently.** `bun test` enforces `bunfig.toml`'s
`coverageThreshold` (0.8) by **exit code alone**. It prints no threshold message, and the
Expand All @@ -105,7 +105,7 @@ Both of these will make you report a passing gate that CI rejects.
| `install` | `bun.lock` disagrees with a `package.json`. The tree CI installs is not yours, so nothing after it is measuring the right thing — the runner stops here. | `bun install`, then commit `bun.lock`. |
| `typecheck` | `tsc --noEmit` over all 9 projects. | `Cannot find module '@dexpace/…'` means a build prerequisite is missing from `build:deps`, not a bad import — check with `--clean`. Otherwise a real fix; usual suspects: a missing `.js` extension on a relative import (NodeNext), a type import without `import type` (`verbatimModuleSyntax`), an enum/namespace/parameter property (`erasableSyntaxOnly`). |
| `lint` | Formatting **and** type-aware rules; formatting is an error, not a warning. | `bun run fix` first — it clears every prettier finding. Hand-fix what survives: 70-line function cap, `max-depth` 3, `max-params` 3, explicit return types on exported members. Every `eslint-disable` needs a `-- reason`. |
| `build` | Emit failed. **Blocks the ten gates below it**, which the runner reports `SKIP`. | Fix this before reading anything else; the skipped gates are unknown, not passing. |
| `build` | Emit failed. **Blocks the eleven gates below it**, which the runner reports `SKIP`. | Fix this before reading anything else; the skipped gates are unknown, not passing. |
| `test` | A failing test, *or* the silent coverage floor (see above). | If the tail says `0 fail`, it is coverage — find the file that dropped below 0.8 in the printed table and test it. Otherwise fix the test or the code. |
| `api` | The committed `etc/<pkg>.api.md` no longer matches the built surface, or an export lacks TSDoc. | Intended export change: `cd packages/<pkg> && bun run api:local`, then commit the regenerated report. `(undocumented)` in the diff means the export needs a `@public` block, plus `@throws` naming each catchable error class. **Unintended** change: revert the export, don't bless the report. |
| `lint:publish` | `publint` + `attw` on every built package's `exports` map, `types`/`main` fields, and declaration resolution. | Fix the manifest. `cjs-resolves-to-esm` is already ignored by design (ESM-only); every other rule is real. |
Expand All @@ -114,9 +114,18 @@ Both of these will make you report a passing gate that CI rejects.
| `verify:seam-1` | A package gained a runtime dependency outside the allow-list, or dropped its committed empty `dependencies` object (an omitted field is a violation too). | Remove the dependency — SEAM-1 is the constraint, not the gate. `@dexpace/core` is a **peer** of the satellites, never a dependency. |
| `verify:sse-37` | Core's SSE code reached for serde or a codec package. | Remove the import; SSE-37/38 forbid the coupling. |
| `verify:runtime-floor` | `engines.node` and the `target`/`lib` a package compiles to have drifted apart. | Move both together, deliberately — never raise one to silence this. |
| `verify:reproducible-build` | Two clean builds of an identical source tree disagreed (NFR-12) — either in an emitted `dist/` file or in an `npm pack` tarball. | The assertion names what differed. A wall-clock or random value reaching a build-time codegen step is the usual cause; `packages/core/scripts/gen-version.mjs` is the only such step today, and injecting a `Date.now()` there is this gate's own negative test (it fails naming `packages/core/dist/generated/version.js` and `npm-pack:dexpace-core-0.0.0.tgz`). If the log instead ends in `tsc` errors, the **build inside the gate** failed and there is no difference to read — fix that first. The gate sweeps every `dist/` and rebuilds twice itself, so it is last in the job and leaves the tree freshly built. |
| `audit` | A high-severity advisory in production dependencies. | `bun audit --prod` for detail. Note the tree is tiny (zero runtime deps by design), so a hit here is usually a transitive dev-dep misclassification worth reading carefully. |
| `test:node` | Bun-vs-Node runtime divergence, almost always in `packages/core/src/io/` — Web Streams, `AbortSignal`, `Uint8Array` chunking. | Fix against Node's semantics. A phase touching a runtime-divergent surface should be *adding* cases here; see `test/node-conformance/README.md`. |

**A `timeout` verdict is not the same finding as a red one.** Every step is capped at
`STEP_TIMEOUT_MS` (10 minutes) in `run-ci.mjs`, and a step that hits the cap is reported `timeout`
whether it hung or was merely slow. Nearly all of them finish in seconds;
`verify:reproducible-build` is the exception, performing **two full swept builds plus two `npm pack`
passes** — ~34s observed warm, but a cold or loaded machine multiplies that, and it is the one step
with any real chance of approaching the cap. If *it* comes back `timeout`, raise `STEP_TIMEOUT_MS`
and re-run before reading the verdict as a reproducibility defect.

## Local-vs-CI divergences worth stating

The runner reproduces CI's steps, not CI's machine. Two gaps survive, and both belong in
Expand Down Expand Up @@ -153,7 +162,7 @@ consumer-facing change still needs `bun run changeset`).
| `--skip-install` | Skip the frozen-lockfile install. Safe when `package.json` is untouched. |
| `--node-floor` | Also run `test:node` under Node 20.3.0 via mise/fnm/nvm. |
| `--tail N` | Lines of a failing log to print (default 30). Raise for a wall of tsc errors. |
| — | Each step is capped at 10 minutes and reported `timeout` if it hangs. A gate *can* hang rather than fail — a conformance test holding the event loop open on an unclosed server does exactly that. |
| — | Each step is capped at 10 minutes (`STEP_TIMEOUT_MS`) and reported `timeout` if it hangs. A gate *can* hang rather than fail — a conformance test holding the event loop open on an unclosed server does exactly that. It can also just be slow: `verify:reproducible-build` builds the workspace twice and packs it twice, so raise the cap rather than diagnosing a `timeout` there as a real failure. |
| `--list` | Step ids and the command each runs. |

Exit code is 0 only when every selected step ran and passed. `SKIP` is never a pass.
22 changes: 17 additions & 5 deletions .claude/skills/ci-preflight/run-ci.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
// Runs every blocking step of `.github/workflows/ci.yml` against the working tree, in CI's own
// order, and reports all failures at once rather than stopping at the first.
//
// Two things make this more than a shell alias for thirteen `bun run` calls:
// Two things make this more than a shell alias for fourteen `bun run` calls:
//
// * Ordering is load-bearing. `bun test`, `api`, `lint:publish` and every `verify:*` gate resolve
// `@dexpace/core` by package name, which lands in `packages/core/dist/`. Run them before
// `build` and they either fail with unresolved-module noise or, worse, pass green against
// yesterday's artifact. CI is safe because its Build step precedes its Test step; a human
// running gates ad hoc is not.
// * A failed `build` invalidates the ten gates downstream of it. Running them anyway produces ten
// spurious findings that all say "cannot resolve @dexpace/core". They are reported SKIP here,
// so the summary names the one real defect.
// * A failed `build` invalidates the eleven gates downstream of it. Running them anyway produces
// eleven spurious findings that all say "cannot resolve @dexpace/core". They are reported SKIP
// here, so the summary names the one real defect.
//
// Logs go to node_modules/.cache/ci-preflight/<step>.log — full output stays on disk, only the
// summary and a tail of each failure reach stdout.
Expand Down Expand Up @@ -105,6 +105,14 @@ const STEPS = [
cmd: 'bun run verify:runtime-floor',
tier: 'gate',
},
{
// Last among the gates, matching ci.yml: it sweeps every dist/ and rebuilds twice, so running it
// earlier would pull the tree out from under any step that resolves a workspace package by name.
id: 'verify:reproducible-build',
ci: 'Reproducible-build check (NFR-12)',
cmd: 'bun run verify:reproducible-build',
tier: 'gate',
},
{id: 'audit', ci: 'Dependency audit', cmd: 'bun run audit', tier: 'gate'},
{
id: 'test:node',
Expand All @@ -117,7 +125,11 @@ const STEPS = [
// engines.node across every publishable package, and the floor leg of ci.yml's node-conformance
// matrix. The other leg is `lts/*`, which resolves at run time and so cannot be pinned here.
const NODE_FLOOR = '20.3.0';
// Comfortably past the slowest gate (`api`, ~50s) without letting a hung one stall the run.
// Comfortably past the slowest gates (`api` ~50s, `verify:reproducible-build` ~34s warm) without
// letting a hung one stall the run. `verify:reproducible-build` is the one worth watching as this
// grows: it does two full swept builds and two `npm pack` passes, so a cold or loaded machine
// multiplies its wall time. If it ever reports `timeout`, raise this rather than reading the verdict
// as a reproducibility defect.
const STEP_TIMEOUT_MS = 10 * 60 * 1000;
// setup-bun resolves this file, so it is the Bun every CI step actually runs on.
const PINNED_BUN = readFileSync('.bun-version', 'utf8').trim();
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ jobs:
- name: Runtime-floor consistency check
run: bun run verify:runtime-floor

# NFR-12. Deliberately last in this job: it sweeps every dist/ and rebuilds
# the workspace twice, so it would otherwise pull the rug from under any
# step above that resolves a workspace package through its dist/.
- name: Reproducible-build check (NFR-12)
run: bun run verify:reproducible-build

- name: Dependency audit
run: bun run audit

Expand Down
Loading
Loading