feat(wrangler): createServer API#13992
Conversation
|
68ec1c2 to
4aee674
Compare
There was a problem hiding this comment.
I'm Bonk, and I've done a quick review of your PR.
This PR introduces a createServer API that wraps the existing DevEnv/controller infrastructure with a cleaner, migration-focused interface. It migrates existing fixture tests from unstable_startWorker and adds comprehensive e2e tests.
Issues (highest severity first)
-
update()may resolve too early in multiworker scenarios — In a multiworker setup,waitForReloadCompleteresolves on the firstreloadCompleteevent from the primary DevEnv. ButupdateConfigsets each worker's config sequentially. When the primary worker's config is set and triggers a bundle, theMultiworkerRuntimeControllermay still have cached options for the auxiliary worker from the previous round, so#canStartMiniflare()returns true and emitsreloadCompletewith stale auxiliary config. The subsequent auxiliary config update triggers a second reload, butwaitForReloadCompletehas already resolved. -
close()racing withlisten()can trigger assertion failure — Ifclose()is called whilelisten()is still awaitingstartPromise, both will resume after the promise settles. Ifclose()runs its microtask continuation first, it clearsserverSession, and thenlisten()hits theassert(serverSession)on line 372. A simple guard (e.g., aclosedflag checked inlisten()) would make this safe. -
Minor:
BaseController.mfis now a public mutable field — The#mfprivate field in bothLocalRuntimeControllerandMultiworkerRuntimeControllerwas refactored to a publicmfproperty on the abstractRuntimeControllerbase class. This is fine for theserver.tsuse case, but it widens the API surface ofRuntimeController. Consider whether a read-only accessor would be more appropriate to prevent accidental external mutation.
| if (serverSession) { | ||
| const nextInputs = resolveWorkerInputs(currentOptions, serverAuthHook); | ||
|
|
||
| if (nextInputs.length !== serverSession.devEnvs.length) { | ||
| throw new Error( | ||
| `Updating the number of workers running in the server is not supported.` | ||
| ); | ||
| } | ||
|
|
||
| await Promise.all([ | ||
| waitForReloadComplete(serverSession), | ||
| updateConfig(serverSession, nextInputs), | ||
| ]); |
There was a problem hiding this comment.
In a multiworker setup, updateConfig sets configs sequentially (primary first, then auxiliary). After the primary worker's config is set and its bundle pipeline runs, the MultiworkerRuntimeController may already have cached options for the auxiliary worker from the previous round. #canStartMiniflare() returns true, miniflare is updated with the old auxiliary config, and reloadComplete fires. waitForReloadComplete resolves here — but the auxiliary worker's config hasn't been updated yet.
When the auxiliary config is subsequently set, it triggers another bundle → reload cycle, but update() has already returned to the caller, who may now issue requests hitting stale auxiliary worker code.
This only affects multiworker update() calls. The initial listen() path doesn't have this issue because waitForPrimaryReady awaits the proxy ready promise (which is recreated on each reload).
One approach: for multiworker, wait for N reloadComplete events (or a final "all settled" signal) instead of resolving on the first one.
| async listen() { | ||
| if (!serverSession) { | ||
| if (!startPromise) { | ||
| startPromise = startServerSession().finally(() => { | ||
| startPromise = undefined; | ||
| }); | ||
| } | ||
|
|
||
| await startPromise; | ||
| } | ||
|
|
||
| assert(serverSession, "Worker server has no active session."); | ||
| const ready = await serverSession.primaryDevEnv.proxy.ready.promise; | ||
|
|
||
| return { | ||
| url: ready.url, | ||
| inspectorUrl: ready.inspectorUrl, | ||
| }; |
There was a problem hiding this comment.
If close() is called concurrently while listen() is awaiting startPromise, both awaiters resume after the promise resolves. If close()'s continuation runs first (clearing serverSession), the assert on line 372 will throw in listen().
This is an edge case (calling close() while listen() is in-flight is unusual), but since close() explicitly handles the pending-start case by awaiting startPromise, it seems like a scenario the code intends to support. A simple fix:
| async listen() { | |
| if (!serverSession) { | |
| if (!startPromise) { | |
| startPromise = startServerSession().finally(() => { | |
| startPromise = undefined; | |
| }); | |
| } | |
| await startPromise; | |
| } | |
| assert(serverSession, "Worker server has no active session."); | |
| const ready = await serverSession.primaryDevEnv.proxy.ready.promise; | |
| return { | |
| url: ready.url, | |
| inspectorUrl: ready.inspectorUrl, | |
| }; | |
| async listen() { | |
| if (!serverSession) { | |
| if (!startPromise) { | |
| startPromise = startServerSession().finally(() => { | |
| startPromise = undefined; | |
| }); | |
| } | |
| await startPromise; | |
| } | |
| if (!serverSession) { | |
| throw new Error("Worker server was closed before it finished starting."); | |
| } | |
| const ready = await serverSession.primaryDevEnv.proxy.ready.promise; | |
| return { | |
| url: ready.url, | |
| inspectorUrl: ready.inspectorUrl, | |
| }; | |
| }, |
|
Review posted on PR #13992 with two inline comments and a summary. The main findings are:
|
4aee674 to
3d28052
Compare
create-cloudflare
@cloudflare/kv-asset-handler
miniflare
@cloudflare/pages-shared
@cloudflare/unenv-preset
@cloudflare/vite-plugin
@cloudflare/vitest-pool-workers
@cloudflare/workers-editor-shared
@cloudflare/workers-utils
wrangler
commit: |
5bf2528 to
245747f
Compare
245747f to
1496d4f
Compare
1496d4f to
d1932dd
Compare
Fixes #[insert GH or internal issue link(s)].
Describe your change...
A picture of a cute animal (not mandatory, but encouraged)