| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,5 @@ | |||
| 1 | + --- | ||
| 2 | + "miniflare": patch | ||
| 3 | + --- | ||
| 4 | + | ||
| 5 | + Prevent `workerd` from remaining running during Miniflare shutdown when browser or proxy cleanup is slow or fails. | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -3123,70 +3123,106 @@ export class Miniflare { | |||
| 3123 | 3123 | // Note `dispose()`ing the `#proxyClient` implicitly poison's proxies, but | |
| 3124 | 3124 | // we'd like them to be poisoned synchronously here. | |
| 3125 | 3125 | this.#proxyClient?.poisonProxies(); | |
| 3126 | + // Preserve a readiness failure without allowing it to skip teardown. | ||
| 3127 | + let waitForReadyFailed = false; | ||
| 3128 | + let waitForReadyError: unknown; | ||
| 3126 | 3129 | try { | |
| 3127 | 3130 | await this.#waitForReady(/* disposing */ true); | |
| 3128 | - } finally { | ||
| 3131 | + } catch (error) { | ||
| 3132 | + waitForReadyFailed = true; | ||
| 3133 | + waitForReadyError = error; | ||
| 3134 | + } | ||
| 3135 | + | ||
| 3136 | + // Runtime.dispose() requests workerd termination synchronously before | ||
| 3137 | + // returning its child-exit promise. Start it before awaiting independent | ||
| 3138 | + // cleanup so those hooks cannot delay or skip the termination request. | ||
| 3139 | + let runtimeDisposePromise: Promise<void>; | ||
| 3140 | + try { | ||
| 3141 | + runtimeDisposePromise = Promise.resolve(this.#runtime?.dispose()); | ||
| 3142 | + } catch (error) { | ||
| 3143 | + runtimeDisposePromise = Promise.reject(error); | ||
| 3144 | + } | ||
| 3145 | + // Attach a rejection handler immediately so a fast runtime failure cannot | ||
| 3146 | + // become unhandled while independent cleanup is still pending. | ||
| 3147 | + const runtimeDisposeOutcome = runtimeDisposePromise.then( | ||
| 3148 | + () => ({ ok: true as const }), | ||
| 3149 | + (error: unknown) => ({ ok: false as const, error }) | ||
| 3150 | + ); | ||
| 3151 | + | ||
| 3152 | + // Preserve the existing first cleanup error, while still waiting for the | ||
| 3153 | + // already-started runtime exit before the outer disposal settles. | ||
| 3154 | + let independentCleanupFailed = false; | ||
| 3155 | + let independentCleanupError: unknown; | ||
| 3156 | + try { | ||
| 3157 | + // Cleanup as much as possible even if `#init()` threw. | ||
| 3129 | 3158 | await this.#closeBrowserProcesses(); | |
| 3130 | 3159 | ||
| 3131 | 3160 | // Remove exit hook, we're cleaning up what they would've cleaned up now | |
| 3132 | 3161 | this.#removeExitHook?.(); | |
| 3133 | 3162 | ||
| 3134 | - // Cleanup as much as possible even if `#init()` threw | ||
| 3135 | 3163 | await this.#proxyClient?.dispose(); | |
| 3136 | - await this.#runtime?.dispose(); | ||
| 3137 | - // Close the undici Pool used for dispatching fetch requests to the | ||
| 3138 | - // runtime. This must happen after the runtime is disposed, so that | ||
| 3139 | - // in-flight connections are broken and close immediately. Without this, | ||
| 3140 | - // lingering sockets in the Pool can keep the Node.js event loop alive. | ||
| 3141 | - // The Pool may already be destroyed (e.g., if workerd was SIGKILL'd and | ||
| 3142 | - // all connections broke), so ignore ClientDestroyedError. | ||
| 3143 | - try { | ||
| 3144 | - await this.#runtimeDispatcher?.close(); | ||
| 3145 | - } catch {} | ||
| 3146 | - // Also close the dev-registry dispatcher (same issue as above). | ||
| 3147 | - try { | ||
| 3148 | - await this.#devRegistryDispatcher?.close(); | ||
| 3149 | - } catch {} | ||
| 3164 | + } catch (error) { | ||
| 3165 | + independentCleanupFailed = true; | ||
| 3166 | + independentCleanupError = error; | ||
| 3167 | + } | ||
| 3150 | 3168 | ||
| 3151 | - await this.#stopLoopbackServer(); | ||
| 3152 | - // Close the WebSocket server so any connected clients are disconnected | ||
| 3153 | - // and their sockets don't keep the event loop alive. It uses | ||
| 3154 | - // `noServer: true` so it doesn't own an HTTP server, but connected | ||
| 3155 | - // WebSocket clients still hold open sockets. | ||
| 3156 | - this.#webSocketServer.close(); | ||
| 3157 | - // Best-effort cleanup: on Windows, workerd may not release file handles | ||
| 3158 | - // immediately after disposal, causing EBUSY errors. The temp directory | ||
| 3159 | - // lives in os.tmpdir() so the OS will clean it up eventually. | ||
| 3160 | - removeDir(this.#tmpPath, { fireAndForget: true }); | ||
| 3161 | - // Clean up email session directories in the project temp path. When no | ||
| 3162 | - // project temp path is supplied, these live inside `#tmpPath` and are | ||
| 3163 | - // already removed above. | ||
| 3164 | - const emailPaths = getEmailPathsToClean( | ||
| 3165 | - this.#sharedOpts.resourceTmpPath, | ||
| 3166 | - this.#tmpPath | ||
| 3167 | - ); | ||
| 3168 | - if (emailPaths) { | ||
| 3169 | - try { | ||
| 3170 | - await removeDir(emailPaths.sessionDir); | ||
| 3171 | - } catch (e) { | ||
| 3172 | - this.#log.debug( | ||
| 3173 | - `Unable to remove email session directory: ${String(e)}` | ||
| 3174 | - ); | ||
| 3175 | - } | ||
| 3169 | + const runtimeCleanupOutcome = await runtimeDisposeOutcome; | ||
| 3170 | + // Close the undici Pool used for dispatching fetch requests to the | ||
| 3171 | + // runtime. This must happen after the runtime is disposed, so that | ||
| 3172 | + // in-flight connections are broken and close immediately. Without this, | ||
| 3173 | + // lingering sockets in the Pool can keep the Node.js event loop alive. | ||
| 3174 | + // The Pool may already be destroyed (e.g., if workerd was SIGKILL'd and | ||
| 3175 | + // all connections broke), so ignore ClientDestroyedError. | ||
| 3176 | + try { | ||
| 3177 | + await this.#runtimeDispatcher?.close(); | ||
| 3178 | + } catch {} | ||
| 3179 | + // Also close the dev-registry dispatcher (same issue as above). | ||
| 3180 | + try { | ||
| 3181 | + await this.#devRegistryDispatcher?.close(); | ||
| 3182 | + } catch {} | ||
| 3183 | + | ||
| 3184 | + await this.#stopLoopbackServer(); | ||
| 3185 | + // Close the WebSocket server so any connected clients are disconnected | ||
| 3186 | + // and their sockets don't keep the Node.js event loop alive. It uses | ||
| 3187 | + // `noServer: true` so it doesn't own an HTTP server, but connected | ||
| 3188 | + // WebSocket clients still hold open sockets. | ||
| 3189 | + this.#webSocketServer.close(); | ||
| 3190 | + // Best-effort cleanup: on Windows, workerd may not release file handles | ||
| 3191 | + // immediately after disposal, causing EBUSY errors. The temp directory | ||
| 3192 | + // lives in os.tmpdir() so the OS will clean it up eventually. | ||
| 3193 | + removeDir(this.#tmpPath, { fireAndForget: true }); | ||
| 3194 | + // Clean up email session directories in the project temp path. When no | ||
| 3195 | + // project temp path is supplied, these live inside `#tmpPath` and are | ||
| 3196 | + // already removed above. | ||
| 3197 | + const emailPaths = getEmailPathsToClean( | ||
| 3198 | + this.#sharedOpts.resourceTmpPath, | ||
| 3199 | + this.#tmpPath | ||
| 3200 | + ); | ||
| 3201 | + if (emailPaths) { | ||
| 3202 | + try { | ||
| 3203 | + await removeDir(emailPaths.sessionDir); | ||
| 3204 | + } catch (e) { | ||
| 3205 | + this.#log.debug( | ||
| 3206 | + `Unable to remove email session directory: ${String(e)}` | ||
| 3207 | + ); | ||
| 3176 | 3208 | } | |
| 3209 | + } | ||
| 3177 | 3210 | ||
| 3178 | - // Close the inspector proxy server if there is one | ||
| 3179 | - await this.#maybeInspectorProxyController?.dispose(); | ||
| 3180 | - // Unregister workers from dev registry and stop the file watcher | ||
| 3181 | - await this.#devRegistry.dispose(); | ||
| 3211 | + // Close the inspector proxy server if there is one | ||
| 3212 | + await this.#maybeInspectorProxyController?.dispose(); | ||
| 3213 | + // Unregister workers from dev registry and stop the file watcher | ||
| 3214 | + await this.#devRegistry.dispose(); | ||
| 3182 | 3215 | ||
| 3183 | - // shutdown hyperdrive proxies if any exist | ||
| 3184 | - await this.#hyperdriveProxyController.dispose(); | ||
| 3216 | + // shutdown hyperdrive proxies if any exist | ||
| 3217 | + await this.#hyperdriveProxyController.dispose(); | ||
| 3185 | 3218 | ||
| 3186 | - // Remove from instance registry as last step in `finally`, to make sure | ||
| 3187 | - // all dispose steps complete | ||
| 3188 | - maybeInstanceRegistry?.delete(this); | ||
| 3189 | - } | ||
| 3219 | + // Remove from instance registry as last step in disposal, preserving the | ||
| 3220 | + // existing behavior when an earlier cleanup operation fails. | ||
| 3221 | + maybeInstanceRegistry?.delete(this); | ||
| 3222 | + | ||
| 3223 | + if (independentCleanupFailed) throw independentCleanupError; | ||
| 3224 | + if (!runtimeCleanupOutcome.ok) throw runtimeCleanupOutcome.error; | ||
| 3225 | + if (waitForReadyFailed) throw waitForReadyError; | ||
| 3190 | 3226 | } | |
| 3191 | 3227 | } | |
| 3192 | 3228 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,156 @@ | |||
| 1 | + import childProcess from "node:child_process"; | ||
| 2 | + import path from "node:path"; | ||
| 3 | + import { Miniflare, ProxyClient } from "miniflare"; | ||
| 4 | + import { afterEach, test, vi } from "vitest"; | ||
| 5 | + import { WebSocketServer } from "ws"; | ||
| 6 | + import { singleModuleManifest } from "./test-shared"; | ||
| 7 | + | ||
| 8 | + async function createReadyMiniflare(): Promise<Miniflare> { | ||
| 9 | + const mf = new Miniflare({ | ||
| 10 | + workers: [ | ||
| 11 | + { | ||
| 12 | + config: { | ||
| 13 | + type: "worker", | ||
| 14 | + name: "", | ||
| 15 | + compatibilityDate: "2025-05-01", | ||
| 16 | + manifest: singleModuleManifest(`export default { | ||
| 17 | + fetch() { | ||
| 18 | + return new Response("ok"); | ||
| 19 | + } | ||
| 20 | + }`), | ||
| 21 | + }, | ||
| 22 | + }, | ||
| 23 | + ], | ||
| 24 | + }); | ||
| 25 | + await mf.ready; | ||
| 26 | + return mf; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + function findKilledWorkerd( | ||
| 30 | + kill: ReturnType<typeof vi.spyOn> | ||
| 31 | + ): childProcess.ChildProcess | undefined { | ||
| 32 | + for (let index = 0; index < kill.mock.calls.length; index++) { | ||
| 33 | + const [signal] = kill.mock.calls[index]; | ||
| 34 | + const child = kill.mock.contexts[index]; | ||
| 35 | + if ( | ||
| 36 | + signal === "SIGKILL" && | ||
| 37 | + child instanceof childProcess.ChildProcess && | ||
| 38 | + path.basename(child.spawnfile).toLowerCase().startsWith("workerd") | ||
| 39 | + ) { | ||
| 40 | + return child; | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + afterEach(() => { | ||
| 46 | + vi.restoreAllMocks(); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + test("Miniflare: dispose requests workerd termination while proxy cleanup is pending", async ({ | ||
| 50 | + expect, | ||
| 51 | + }) => { | ||
| 52 | + const mf = await createReadyMiniflare(); | ||
| 53 | + let markProxyDisposeStarted!: () => void; | ||
| 54 | + let releaseProxyDispose!: () => void; | ||
| 55 | + const proxyDisposeStarted = new Promise<void>((resolve) => { | ||
| 56 | + markProxyDisposeStarted = resolve; | ||
| 57 | + }); | ||
| 58 | + const proxyDisposeBlocked = new Promise<void>((resolve) => { | ||
| 59 | + releaseProxyDispose = resolve; | ||
| 60 | + }); | ||
| 61 | + const proxyDispose = vi | ||
| 62 | + .spyOn(ProxyClient.prototype, "dispose") | ||
| 63 | + .mockImplementationOnce(() => { | ||
| 64 | + markProxyDisposeStarted(); | ||
| 65 | + return proxyDisposeBlocked; | ||
| 66 | + }); | ||
| 67 | + const kill = vi.spyOn(childProcess.ChildProcess.prototype, "kill"); | ||
| 68 | + | ||
| 69 | + const disposePromise = mf.dispose(); | ||
| 70 | + try { | ||
| 71 | + await proxyDisposeStarted; | ||
| 72 | + expect(findKilledWorkerd(kill)).toBeDefined(); | ||
| 73 | + } finally { | ||
| 74 | + releaseProxyDispose(); | ||
| 75 | + proxyDispose.mockRestore(); | ||
| 76 | + await disposePromise; | ||
| 77 | + } | ||
| 78 | + }); | ||
| 79 | + | ||
| 80 | + test("Miniflare: dispose waits for workerd exit and continues cleanup before returning proxy cleanup failure", async ({ | ||
| 81 | + expect, | ||
| 82 | + }) => { | ||
| 83 | + let markRuntimeExitObserved!: () => void; | ||
| 84 | + let releaseRuntimeExit!: () => void; | ||
| 85 | + const runtimeExitObserved = new Promise<void>((resolve) => { | ||
| 86 | + markRuntimeExitObserved = resolve; | ||
| 87 | + }); | ||
| 88 | + const runtimeExitBlocked = new Promise<void>((resolve) => { | ||
| 89 | + releaseRuntimeExit = resolve; | ||
| 90 | + }); | ||
| 91 | + const originalEmit = childProcess.ChildProcess.prototype.emit; | ||
| 92 | + let interceptedRuntimeExit = false; | ||
| 93 | + const emit = vi | ||
| 94 | + .spyOn(childProcess.ChildProcess.prototype, "emit") | ||
| 95 | + .mockImplementation(function ( | ||
| 96 | + this: childProcess.ChildProcess, | ||
| 97 | + event: string | symbol, | ||
| 98 | + ...args: unknown[] | ||
| 99 | + ) { | ||
| 100 | + if ( | ||
| 101 | + !interceptedRuntimeExit && | ||
| 102 | + event === "exit" && | ||
| 103 | + path.basename(this.spawnfile).toLowerCase().startsWith("workerd") | ||
| 104 | + ) { | ||
| 105 | + interceptedRuntimeExit = true; | ||
| 106 | + markRuntimeExitObserved(); | ||
| 107 | + void runtimeExitBlocked.then(() => { | ||
| 108 | + Reflect.apply(originalEmit, this, [event, ...args]); | ||
| 109 | + }); | ||
| 110 | + return true; | ||
| 111 | + } | ||
| 112 | + return Reflect.apply(originalEmit, this, [event, ...args]) as boolean; | ||
| 113 | + }); | ||
| 114 | + const mf = await createReadyMiniflare(); | ||
| 115 | + const proxyDispose = vi | ||
| 116 | + .spyOn(ProxyClient.prototype, "dispose") | ||
| 117 | + .mockRejectedValueOnce(new Error("injected proxy cleanup failure")); | ||
| 118 | + const webSocketClose = vi.spyOn(WebSocketServer.prototype, "close"); | ||
| 119 | + const kill = vi.spyOn(childProcess.ChildProcess.prototype, "kill"); | ||
| 120 | + | ||
| 121 | + let runtimeExitReleased = false; | ||
| 122 | + let firstDisposeSettled = false; | ||
| 123 | + const firstDisposeResult = mf.dispose().then( | ||
| 124 | + () => { | ||
| 125 | + firstDisposeSettled = true; | ||
| 126 | + return undefined; | ||
| 127 | + }, | ||
| 128 | + (error: unknown) => { | ||
| 129 | + firstDisposeSettled = true; | ||
| 130 | + return error; | ||
| 131 | + } | ||
| 132 | + ); | ||
| 133 | + | ||
| 134 | + try { | ||
| 135 | + await runtimeExitObserved; | ||
| 136 | + await new Promise<void>((resolve) => setImmediate(resolve)); | ||
| 137 | + expect(firstDisposeSettled).toBe(false); | ||
| 138 | + expect(findKilledWorkerd(kill)).toBeDefined(); | ||
| 139 | + | ||
| 140 | + releaseRuntimeExit(); | ||
| 141 | + runtimeExitReleased = true; | ||
| 142 | + const firstDisposeError = await firstDisposeResult; | ||
| 143 | + expect(webSocketClose).toHaveBeenCalled(); | ||
| 144 | + expect(firstDisposeError).toBeInstanceOf(Error); | ||
| 145 | + expect((firstDisposeError as Error).message).toContain( | ||
| 146 | + "injected proxy cleanup failure" | ||
| 147 | + ); | ||
| 148 | + } finally { | ||
| 149 | + if (!runtimeExitReleased) releaseRuntimeExit(); | ||
| 150 | + emit.mockRestore(); | ||
| 151 | + proxyDispose.mockRestore(); | ||
| 152 | + webSocketClose.mockRestore(); | ||
| 153 | + await firstDisposeResult; | ||
| 154 | + await mf.dispose().catch(() => {}); | ||
| 155 | + } | ||
| 156 | + }); | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments