Skip to content

[miniflare] Reconcile dev registry entries instead of deleting and recreating them - #15037

Merged
petebacondarwin merged 4 commits into
mainfrom
fix/dev-registry-diff-unregister
Aug 5, 2026
Merged

[miniflare] Reconcile dev registry entries instead of deleting and recreating them#15037
petebacondarwin merged 4 commits into
mainfrom
fix/dev-registry-diff-unregister

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Applying options to a Miniflare instance removed every dev registry entry it had registered and then wrote them all straight back. Other dev sessions find Workers by watching that directory, so each config update published a window in which this session looked like it had no Workers at all.

Why that matters

A peer watching the registry sees the deletion, refreshes, and pushes the reduced set into its own running workerd. If it had resolved one of the Workers that just vanished, it acts on that — and if the binding is a tail_consumers edge, on Windows the peer's runtime aborts outright with *** std::terminate() called with no exception, taking down a Worker that never stopped running. That abort is reported upstream as cloudflare/workerd#6913.

The delete was unconditional: updateRegistryPath() called unregisterWorkers() before checking whether the path had even changed, and #setOptions() calls it on the way through before re-registering.

The change

  • updateRegistryPath() only clears entries when the registry path actually changes, since in that case they live in the directory being left behind.
  • register() reconciles instead: Workers still present are overwritten in place, and only the ones genuinely gone are unlinked.
  • If the debug port isn't available we now explicitly withdraw our entries, which the blanket delete used to do for us — otherwise peers would be left pointed at an address we can't serve.

Effect

Two vite dev sessions sharing a registry, counting deletions of entries belonging to a live session during normal startup:

deletions
before 9, including exported-handler and worker-entrypoint-with-assets
after 0

Both of those are Workers that were up and serving at the time, and each is one end of a mutual tail_consumers pair — the shape that triggers the abort above.

The new test asserts what a peer actually observes: it watches the registry directory with chokidar, the way the dev registry itself does, and fails if a config update produces an unlink for a Worker that still exists. On main it reports unlink:stable-worker. A second test covers the other direction, so removing a Worker from the config still withdraws its entry.

Scope

This removes one source of spurious registry churn. I'm not claiming it fixes the fixtures/dev-registry Windows flake on its own — that suite is skipped on Windows by #15018 and there is a second contributor in #15035, where the Vite plugin's internal Workers share hard-coded names across sessions and so overwrite and delete each other's entries. I'll validate against the un-skipped suite separately before proposing lifting that skip.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: this is a bug fix with no API or configuration change.

A picture of a cute animal (not mandatory, but encouraged)

a heron minding its own business

Note

This is a contribution from an AI agent: OpenCode, claude-opus-5.


Open in Devin Review

…creating them

Applying options called `unregisterWorkers()` unconditionally, unlinking every
entry this instance had registered, before `register()` wrote them all back.
Peers discover Workers by watching that directory, so every config update
published a window in which this session appeared to have no Workers at all.

`updateRegistryPath()` now only clears entries when the registry path actually
changes, since those entries live in the directory being left behind, and
`register()` reconciles the set: retained Workers are overwritten in place and
only genuinely removed ones are unlinked.
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 13b5273

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
miniflare Patch
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
wrangler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Aug 5, 2026
@workers-devprod
workers-devprod requested review from a team and dario-piotrowicz and removed request for a team August 5, 2026 14:14
@workers-devprod

workers-devprod commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk

ask-bonk Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The implementation is sound. Let me do a final analysis of edge cases and check for any real logic bug.

One thing worth scrutinizing: the PR claims register({}) (debug port unavailable path) withdraws entries. But consider — when the debug port becomes unavailable, register({}) unregisters ALL previously registered workers. However this path is inside #registerWorkers, and register({}) also runs mkdirSync(this.registryPath). If registryPath is defined (isEnabled true) this is fine. Correct.

Let me verify there's no issue where the reconcile loop deletes a worker that should be kept because workers object uses a prototype-polluting key check name in workers. Using in on a plain object from Object.fromEntries is fine but in also checks prototype chain. A worker named "toString" or "constructor" would falsely match in. But worker names come from config; this is an extreme edge case and pre-existing patterns use similar checks. Not worth flagging as actionable.

The code is correct, well-commented, tested, and has a proper changeset. Let me do a final confirmation there are no missed concerns with the previousJSON refresh diffing given entries are no longer deleted/recreated.

The refresh() compares JSON of the registry; overwriting in place changes mtime but getWorkerRegistry reads file content (definition), so if a worker's definition changed (e.g. debugPortAddress), refresh correctly detects it. If unchanged, no spurious onUpdate. This is actually an improvement. No issues.

The change is correct, minimal, well-tested, and addresses a real bug (spurious registry churn causing peer crashes). The reconcile logic properly handles: workers removed (unlinked), workers kept (overwritten in place), path changes (cleared from old dir), and debug-port-unavailable (explicit withdrawal). No logic bugs, security issues, or backward-compat violations found.

LGTM

github run

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15037

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15037

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15037

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15037

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15037

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15037

miniflare

npm i https://pkg.pr.new/miniflare@15037

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15037

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15037

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15037

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15037

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@15037

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15037

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15037

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15037

wrangler

npm i https://pkg.pr.new/wrangler@15037

commit: 13b5273

Comment thread packages/miniflare/src/index.ts Outdated

@workers-devprod workers-devprod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk Aug 5, 2026
@petebacondarwin

Copy link
Copy Markdown
Contributor Author

Validation result: this does not fix the Windows flake, so it should be judged only as the churn fix it is.

I lifted #15018's Windows skip on a throwaway branch (#15038) and ran the vite dev <-> vite dev suite four times. Still 23 std::terminate aborts, and supports exported handler fetch over service binding timed out in 2 of the 4 rounds.

What the change does deliver, measured locally with a watcher on the registry directory during startup of three vite dev sessions:

deletions of live entries
main 9
this branch 0

So deletions are gone, and the aborts continue. That rules out deletion as the sole trigger, which is worth knowing: the earlier evidence that aborts followed a shrinking registry payload was real but incomplete. Every remaining event is a change, and a change still causes each peer to push a fresh registry into its running workerd — including a new debugPortAddress for a tail-consumer target whenever a peer restarts its runtime. That looks like the more likely trigger, and it is not something this PR touches.

Worth noting how noisy those changes are: because the Vite plugin's internal Workers share hard-coded names (#15035), every session rewrites __asset-worker__, __router-worker__ and __vite_proxy_worker__, so each session's registration pushes an update into every other session. Fixing #15035 would cut most of that traffic and is probably the better next lever.

I'd still land this on its own merits — a config update shouldn't tell every peer that all your Workers vanished — but the Windows skip needs to stay.

`name in workers` walks the prototype chain, so a Worker legitimately named
after an inherited property (`constructor`, `toString`) was reported as still
configured and its entry was never withdrawn. Use `Object.hasOwn`, with a test
covering it.

Also call `devRegistry.unregisterWorkers()` directly on the path where the debug
port is unavailable, rather than expressing "withdraw everything" as
`register({})`.
devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin
petebacondarwin enabled auto-merge (squash) August 5, 2026 16:35
Dropping the unconditional unregister also dropped the safety net it provided
when a reload never completes. `#runtime.updateConfig()` stops the existing
workerd before starting its replacement and throws if the replacement fails, so
`#registerWorkers()` is never reached and the entries written for the previous
run stay on disk advertising a debug port nothing is listening on. Their 30s
heartbeats keep touching the files, so the 5 minute stale sweep never reclaims
them either.

Withdraw this instance's entries when a config update or a crash restart fails
to produce a running runtime, mirroring the missing-debug-port case.
@petebacondarwin

Copy link
Copy Markdown
Contributor Author

Good catch — you're right, and it reproduces. Fixed in 13b5273.

I confirmed the sequence you described: updateConfig() does await this.dispose() to stop the existing workerd before spawning the replacement (runtime/index.ts), throws ERR_RUNTIME_FAILURE if the replacement doesn't come up, and #registerWorkers() sits after that call at index.ts:2591 so it never runs. Demonstrated directly by forcing a bad reload with a compatibility flag workerd rejects:

registered: [ 'probe-worker' ]
service core:user:probe-worker: No such compatibility flag: definitely_not_a_real_flag_xyz
setOptions threw: ERR_RUNTIME_FAILURE
after failure, registry: [ 'probe-worker' ]   <- stale, pointing at a dead debug port

And the heartbeat detail matters exactly as you say: register() sets a 30s utimesSync timer per entry and only unregister() clears it, so the file keeps looking fresh and the 5 minute sweep in getWorkerRegistry never reclaims it. It would have sat there for the life of the process.

Now withdrawing the entries on both paths you identified — the failed update in #setOptions() and the failed restart in #handleWorkerdCrash() — which also lines up with the missing-debug-port case. Same probe after the fix reports after failure, registry: [].

Added a test that forces a rejected compatibility flag and asserts the entry is gone; verified it fails without the guard (expected { …(3) } to be undefined) and passes with it. I didn't add one for the crash-restart path, since making a crash and its restart fail deterministically is racy — the change there is the same two lines and the same reasoning.

Full miniflare suite is green (1019). One unrelated flake in test/plugins/r2/s3.spec.ts under full-suite load, passes 71/71 in isolation.

@petebacondarwin
petebacondarwin enabled auto-merge (squash) August 5, 2026 16:58
@petebacondarwin
petebacondarwin merged commit b4f0c97 into main Aug 5, 2026
94 of 101 checks passed
@petebacondarwin
petebacondarwin deleted the fix/dev-registry-diff-unregister branch August 5, 2026 19:18
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants