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
12 changes: 8 additions & 4 deletions .github/workflows/d1-stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ jobs:
steps:
- id: runs
env:
RUNS: ${{ inputs.runs || 10 }}
# `github.event.inputs` holds strings, so a typed 0 stays "0" (truthy) and is not replaced
# by the default; pull requests have no inputs and take the default.
RUNS: ${{ github.event.inputs.runs || '10' }}
run: |
echo "runs=$(jq -nc --argjson n "$RUNS" '[range(1; ([$n, 50] | min) + 1)]')" >> "$GITHUB_OUTPUT"
echo "runs=$(jq -nc --argjson n "$RUNS" '[range(1; ([[$n, 50] | min, 1] | max) + 1)]')" >> "$GITHUB_OUTPUT"

d1:
name: D1 halves (Node ${{ matrix.node }}, run ${{ matrix.run }})
Expand All @@ -47,6 +49,8 @@ jobs:
timeout-minutes: 30
strategy:
fail-fast: false
# Leave concurrent-job slots for other pull requests' CI.
max-parallel: 10
matrix:
node: [24, 26]
run: ${{ fromJSON(needs.plan.outputs.runs) }}
Expand All @@ -63,9 +67,9 @@ jobs:
# `-t` keeps only the suites labelled "(Miniflare)".
- name: Miniflare halves on an oversubscribed runner
env:
HOGS: ${{ inputs.hogs || 4 }}
HOGS: ${{ github.event.inputs.hogs || '4' }}
run: |
for i in $(seq "$HOGS"); do sh -c 'while :; do :; done' & done
for _ in $(seq "$HOGS"); do sh -c 'while :; do :; done' & done
trap 'kill $(jobs -p) 2>/dev/null' EXIT
pnpm turbo run test --concurrency=2 \
--filter=@cloudhal/storage --filter=@cloudhal/core-ts --filter=@cloudhal/api \
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pnpm i18n:export / pnpm i18n:import [--locale xx] # translations as nested ICU
- `packages/api` — Hono app (`createApi`), Better Auth (`src/auth.ts`), error mapping; shared suite `src/testing.ts` on sqlite + D1. Build chat is `build-turn.ts`, its model eval `build-eval.ts`; `design-pass.ts` is RFC-0021 21c (Build chat looks at its render, the eval's vision-judged score).
- `packages/ui` — `export * from "@cloudflare/kumo"` + blocks; `styles.css` is the only Tailwind entry for admin UIs. Heavy kits have lazy subpaths: `./charts` (ECharts), `./editor` (the Tiptap markdown body editor, RFC-0014).
- `packages/messages` — RFC-0032 / ADR-0032: the ICU MessageFormat 1 subset of Studio's catalogs (`plural`, `select`, `selectordinal`, `number`). `./compile` (build-time only: the Vite plugin `./vite`, the type generator, tests; `bundle-budget.ts` keeps it out of every chunk) and `./format` (Worker-safe runtime, cached `Intl.PluralRules`/`NumberFormat`), which is also Studio's `Intl` layer: `formatNumber`, `formatDate`, `formatRelative`, `formatList` and cached `numberFormat`/`dateTimeFormat`/`relativeTimeFormat`/`displayNames`/`collator` — screens and blocks never build `Intl` formatters themselves (lint `i18n-format`). Catalogs compile to data, never code.
- `patches/` — `pnpm patch` files (`patchedDependencies` in `pnpm-workspace.yaml`). `@[email protected]` is **generated** by `node scripts/kumo-patch.ts`: the hand-written changes in `patches/kumo/*.patch` (`tooltip-timestamp.patch` adds `TimeseriesChart`'s `tooltipTimestampFormat`, DESIGN.md §2v "Kumo locale") plus every physical utility in Kumo's `dist` made logical (ADR-0033 A1). Never edit it; on a Kumo upgrade rename it and the `patchedDependencies` entry, re-run the script, `pnpm install`, re-check the gaps in `docs/design/kumo-upstream-notes.md`. `packages/ui/test/kumo-rtl.test.tsx` fails when the installed Kumo is not patched. `[email protected]` backports Miniflare 5's sync binding-proxy fix (workers-sdk#15552) that the D1 harness needs (#190, ADR-0012); `packages/storage/test/miniflare-patch.test.ts` fails without it.
- `patches/` — `pnpm patch` files (`patchedDependencies` in `pnpm-workspace.yaml`). `@[email protected]` is **generated** by `node scripts/kumo-patch.ts`: the hand-written changes in `patches/kumo/*.patch` (`tooltip-timestamp.patch` adds `TimeseriesChart`'s `tooltipTimestampFormat`, DESIGN.md §2v "Kumo locale") plus every physical utility in Kumo's `dist` made logical (ADR-0033 A1). Never edit it; on a Kumo upgrade rename it and the `patchedDependencies` entry, re-run the script, `pnpm install`, re-check the gaps in `docs/design/kumo-upstream-notes.md`. `packages/ui/test/kumo-rtl.test.tsx` fails when the installed Kumo is not patched. `[email protected]` is **hand-written** against Miniflare's `dist/src/index.js`: it backports Miniflare 5's sync binding-proxy fix (workers-sdk#15552) that the D1 harness needs and closes that proxy pool's idle sockets after 1 s (`keepAliveTimeout`/`keepAliveMaxTimeout` 1000, workers-sdk#16445 A) (#190). On a Miniflare bump the first signal is `pnpm install --frozen-lockfile` failing on the now-unused patch; then follow the upgrade recipe in ADR-0012 (addendum 2026-11-10: drop the patch if the release carries #15552, else re-apply both changes with `pnpm patch`/`pnpm patch-commit` and rename the entry). `packages/storage/test/miniflare-patch.test.ts` fails when the installed Miniflare lacks the fix.
- `apps/studio` — SPA; routes in `src/router.tsx`, screens in `src/routes`, typed API client `src/lib/api.ts` (a barrel over `src/lib/api/<area>.ts`), i18n in `src/i18n` (messages are ICU MF1, compiled at build time, with parameter types generated into `src/i18n/generated/params.ts` by `pnpm gen` — never edit it; languages registered in `locales.ts`; add keys to `en.ts` and every `<locale>.ts`, or to a lazy namespace `src/i18n/lazy/<ns>.<locale>.ts` (the list and how to add one: `src/i18n/namespaces.ts`; DESIGN.md §1); only the active language of the core catalogs loads, `tr.ts` through `loadLocale`, and each other language's lazy-namespace loaders come with it from the generated `src/i18n/loaders/<locale>.ts`; Arabic and Persian format with Western digits through the registry's `intl` tag).
- `docs/adr/` decisions (with sources) · `docs/rfcs/` proposals · `docs/design/DESIGN.md` the binding admin design system (Kumo).
- `.agents/skills/` — skills for you: `contract-change`, `add-capability`, `write-rfc`, `studio-ui`, `translate`, and Cloudflare's official **`kumo-design`** (installed from `cloudflare/kumo`, tracked in `skills-lock.json`; update with `npx skills update`). Any admin-UI change loads `kumo-design` + `studio-ui` and follows `docs/design/DESIGN.md`.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0012-cloudflare-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The D1 suites failed now and then on CI, never on sqlite or Durable Object SQLit

- **Cause.** `SynchronousFetcher` blocks the caller on a shared 0/1 flag: the worker thread posts the reply, stores 1 and notifies. If the worker is preempted between its store and its notify while the caller has already read the reply (the flag was 1 when it reached `Atomics.wait`), the late notify wakes the *next* call before its reply exists. `receiveMessageOnPort` returns `undefined`, the assert throws, and every later call reads the previous call's reply, which is the cascade of 1–3 ms failures in `roles.test.ts`. A loaded runner makes the preemption likely: `turbo --concurrency=2` runs two packages of up to three Vitest workers each (two in `@cloudhal/api`), and every D1 or Durable Object file runs its own workerd process plus the proxy's worker thread, on four vCPUs. The race reproduces on the real Miniflare once both preemptions are forced (see the test below), and in a standalone model of the handshake (the shipped protocol desyncs at call 3, the fixed one runs 5,000 calls). It does not show up on demand without them: 20 unpatched runs of the D1 halves passed on CI's own load, and 20 more with one busy loop per vCPU. Upstream: cloudflare/workers-sdk#15552, merged 2026-09-16 into Miniflare 5 only.
- **Decision.** There is no newer Miniflare 4.x (4.20260730.0 is still `latest`; 5.x is beta), so the pin stays and `patches/[email protected]` backports #15552. It also closes the synchronous proxy's idle sockets after 1 s instead of undici's 4 s, well before workerd's 5 s idle timeout (#16445, part A). That pool lives on the proxy's own worker thread, which only waits for requests, so its timers fire even while a test blocks the main thread. The patch is applied through `patchedDependencies` in `pnpm-workspace.yaml`, so it covers monorepo installs only. Projects that use `@cloudhal/storage/testing` with their own Miniflare 4 keep the bug unless they apply the same patch. `packages/storage/test/miniflare-patch.test.ts` fails if the patch is missing: a `node -r` preload (Node loads it in worker threads too) stalls the proxy thread between its store and its notify and the caller before its wait, on every other call, and 300 `prepare`/`bind` calls must stay in step. Unpatched, the first stalled call fails the CI assertion; patched, all pass. No retries were added and D1 files still run in parallel.
- **On a Miniflare upgrade.** The patch is hand-written against `dist/src/index.js` (no generator, unlike Kumo's). If the new release carries #15552, delete the patch and its `patchedDependencies` entry. Otherwise `pnpm patch miniflare@<new>`, re-apply the two changes in the edit directory (in `SynchronousFetcher`'s worker script: store `(id + 1) | 0` instead of 1 and set `keepAliveTimeout`/`keepAliveMaxTimeout` to 1000 on its `Pool`; in `fetch()`: drop the reset to 0 and loop `Atomics.wait` until the generation is seen), `pnpm patch-commit`, rename the `patchedDependencies` entry, and run `packages/storage/test/miniflare-patch.test.ts`, which fails until the patch applies.
- **On a Miniflare upgrade.** The patch is hand-written against `dist/src/index.js` (no generator, unlike Kumo's). The first signal of a bump is `pnpm install --frozen-lockfile` failing because the `patchedDependencies` entry no longer matches an installed version. If the new release carries #15552, delete the patch and its `patchedDependencies` entry. Otherwise `pnpm patch miniflare@<new>`, re-apply the two changes in the edit directory (in `SynchronousFetcher`'s worker script: store `(id + 1) | 0` instead of 1 and set `keepAliveTimeout`/`keepAliveMaxTimeout` to 1000 on its `Pool`; in `fetch()`: drop the reset to 0 and loop `Atomics.wait` until the generation is seen), `pnpm patch-commit`, rename the `patchedDependencies` entry, and run `packages/storage/test/miniflare-patch.test.ts`, which fails until the patch applies.
- **Not taken: keep-alive `dispatchFetch`.** Every asynchronous proxy call (`first`, `all`, `run`, `batch`) goes through `dispatchFetch`, which sets undici's `reset`: one connection and one port in `TIME_WAIT` per query (2,000 queries left 2,002 `TIME_WAIT` sockets on macOS, where they last 30 s). On a Mac a single `@cloudhal/core-ts` run fills the whole ephemeral range (16,340 of 16,384 ports in `TIME_WAIT`, also at half the workers) and fails with `EADDRNOTAVAIL`; Linux CI has not shown it. cloudflare/workers-sdk#15781 keeps those connections alive in Miniflare 5, and it was backported here first: core-ts then passed locally, but one `roles.test.ts` run in 20 failed with `ECONNRESET` on two kept-alive sockets in a row, which cannot be retried safely because a write may have run. A main-thread stall past workerd's 5 s idle timeout reproduces that error (the client's idle timers cannot fire during it), but a normal run stalls at most about 0.5 s, so the cause is not settled. The backport was dropped from #190, which is about CI; the local port shortage is older than this change and is a follow-up.
- **Not patched:** #16445 part B (a `FREE` racing an in-flight `batch([prepare()])`). It needs a garbage collection during a workerd stall of over 100 ms, and it has not been seen in CI. When Miniflare 5 is stable, remove the patch together with the sharp and undici overrides.

Expand Down
65 changes: 58 additions & 7 deletions packages/storage/test/miniflare-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,74 @@
* apply: re-check the patch against the new release (drop it once the release carries #15552)
* and `pnpm install`.
*/
import { execFile } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";

const here = path.dirname(fileURLToPath(import.meta.url));
const fixture = (name: string) => path.join(here, "fixtures", name);

// Below storage's 30 s `testTimeout`, so the child is gone before Vitest gives up on the test.
const CHILD_TIMEOUT_MS = 20_000;

let child: ChildProcess | undefined;

/**
* Kills the child and its workerd. A hung synchronous proxy call blocks the child's main thread in
* `Atomics.wait`, where Miniflare's SIGTERM hook never runs, so the whole process group (the child
* is its leader) gets SIGKILL.
*/
function killChild(): void {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
}

afterEach(() => {
killChild();
child = undefined;
});

/** Runs `node -r preload script` and resolves with its stdout, or rejects after the timeout. */
function runNode(preload: string, script: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ["-r", preload, script], {
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
child = proc;
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk;
});
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk;
});
const timer = setTimeout(() => {
killChild();
reject(new Error(`child timed out after ${CHILD_TIMEOUT_MS} ms; stderr: ${stderr}`));
}, CHILD_TIMEOUT_MS);
proc.on("error", reject);
proc.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve(stdout);
else reject(new Error(`child exited with ${code}; stderr: ${stderr}`));
});
});
}

describe("Miniflare carries the #190 fix (ADR-0012)", () => {
it("synchronous D1 calls stay in step when the proxy thread is preempted (workers-sdk#15552)", async () => {
// A preload stalls both sides of the handshake on every other call, as a busy runner does
// now and then. Unpatched, the first stalled call fails `message?.id === id`.
const { stdout } = await promisify(execFile)(
process.execPath,
["-r", fixture("preempt-sync-proxy.cjs"), fixture("sync-proxy-calls.cjs")],
{ timeout: 60_000 },
const stdout = await runNode(
fixture("preempt-sync-proxy.cjs"),
fixture("sync-proxy-calls.cjs"),
);
expect(stdout.trim()).toBe("ok");
});
Expand Down
Loading