diff --git a/.changeset/wet-tables-pretend.md b/.changeset/wet-tables-pretend.md new file mode 100644 index 000000000000..dc861b654346 --- /dev/null +++ b/.changeset/wet-tables-pretend.md @@ -0,0 +1,10 @@ +--- +"wrangler": patch +--- + +fix: abort async operations in the `Remote` component to avoid unwanted side-effects +When the `Remote` component is unmounted, we now signal outstanding `fetch()` requests, and +`waitForPortToBeAvailable()` tasks to cancel them. This prevents unexpected requests from appearing +after the component has been unmounted, and also allows the process to exit cleanly without a delay. + +fixes #375 diff --git a/packages/wrangler/src/create-worker-preview.ts b/packages/wrangler/src/create-worker-preview.ts index 855f5811e0a2..677677b9ed0f 100644 --- a/packages/wrangler/src/create-worker-preview.ts +++ b/packages/wrangler/src/create-worker-preview.ts @@ -57,7 +57,8 @@ export interface CfPreviewToken { */ async function sessionToken( account: CfAccount, - ctx: CfWorkerContext + ctx: CfWorkerContext, + abortSignal: AbortSignal ): Promise { const { accountId } = account; const initUrl = ctx.zone @@ -66,7 +67,7 @@ async function sessionToken( const { exchange_url } = await fetchResult<{ exchange_url: string }>(initUrl); const { inspector_websocket, token } = (await ( - await fetch(exchange_url) + await fetch(exchange_url, { signal: abortSignal }) ).json()) as { inspector_websocket: string; token: string }; const { host } = new URL(inspector_websocket); const query = `cf_workers_preview_token=${token}`; @@ -96,11 +97,13 @@ function randomId(): string { async function createPreviewToken( account: CfAccount, worker: CfWorkerInit, - ctx: CfWorkerContext + ctx: CfWorkerContext, + abortSignal: AbortSignal ): Promise { const { value, host, inspectorUrl, prewarmUrl } = await sessionToken( account, - ctx + ctx, + abortSignal ); const { accountId } = account; @@ -155,10 +158,14 @@ async function createPreviewToken( export async function createWorkerPreview( init: CfWorkerInit, account: CfAccount, - ctx: CfWorkerContext + ctx: CfWorkerContext, + abortSignal: AbortSignal ): Promise { - const token = await createPreviewToken(account, init, ctx); - const response = await fetch(token.prewarmUrl.href, { method: "POST" }); + const token = await createPreviewToken(account, init, ctx, abortSignal); + const response = await fetch(token.prewarmUrl.href, { + method: "POST", + signal: abortSignal, + }); if (!response.ok) { // console.error("worker failed to prewarm: ", response.statusText); } diff --git a/packages/wrangler/src/dev/dev.tsx b/packages/wrangler/src/dev/dev.tsx index ea8579468df1..5370cb7243e0 100644 --- a/packages/wrangler/src/dev/dev.tsx +++ b/packages/wrangler/src/dev/dev.tsx @@ -348,6 +348,7 @@ function useHotkeys( ) { // UGH, we should put port in context instead const [toggles, setToggles] = useState(initial); + const { exit } = useApp(); useInput( async ( input, @@ -385,7 +386,7 @@ function useHotkeys( // shut down case "q": case "x": - process.exit(0); + exit(); break; default: // nothing? diff --git a/packages/wrangler/src/dev/local.tsx b/packages/wrangler/src/dev/local.tsx index c0f4a5cbb789..a9e61a8d1250 100644 --- a/packages/wrangler/src/dev/local.tsx +++ b/packages/wrangler/src/dev/local.tsx @@ -60,11 +60,16 @@ function useLocalWorker({ const removeSignalExitListener = useRef<() => void>(); const [inspectorUrl, setInspectorUrl] = useState(); useEffect(() => { + const abortController = new AbortController(); async function startLocalWorker() { if (!bundle || !format) return; // port for the worker - await waitForPortToBeAvailable(port, { retryPeriod: 200, timeout: 2000 }); + await waitForPortToBeAvailable(port, { + retryPeriod: 200, + timeout: 2000, + abortSignal: abortController.signal, + }); if (publicDirectory) { throw new Error( @@ -249,6 +254,7 @@ function useLocalWorker({ }); return () => { + abortController.abort(); if (local.current) { console.log("⎔ Shutting down local server."); local.current?.kill(); diff --git a/packages/wrangler/src/dev/remote.tsx b/packages/wrangler/src/dev/remote.tsx index 4bd3709a26d7..9fd41570a77b 100644 --- a/packages/wrangler/src/dev/remote.tsx +++ b/packages/wrangler/src/dev/remote.tsx @@ -106,6 +106,7 @@ export function useWorker(props: { const startedRef = useRef(false); useEffect(() => { + const abortController = new AbortController(); async function start() { setToken(undefined); // reset token in case we're re-running @@ -173,7 +174,8 @@ export function useWorker(props: { accountId, apiToken, }, - { env: props.env, legacyEnv: props.legacyEnv, zone: props.zone } + { env: props.env, legacyEnv: props.legacyEnv, zone: props.zone }, + abortController.signal ) ); } @@ -182,6 +184,10 @@ export function useWorker(props: { // since it could recover after the developer fixes whatever's wrong console.error("remote worker:", err); }); + + return () => { + abortController.abort(); + }; }, [ name, bundle, diff --git a/packages/wrangler/src/inspect.ts b/packages/wrangler/src/inspect.ts index f3af6b46a660..bec5edff598f 100644 --- a/packages/wrangler/src/inspect.ts +++ b/packages/wrangler/src/inspect.ts @@ -154,19 +154,26 @@ export default function useInspector(props: InspectorProps) { * of the component lifecycle. Convenient. */ useEffect(() => { + const abortController = new AbortController(); async function startInspectorProxy() { await waitForPortToBeAvailable(props.port, { retryPeriod: 200, timeout: 2000, + abortSignal: abortController.signal, }); server.listen(props.port); } - startInspectorProxy().catch((err) => console.error(err)); + startInspectorProxy().catch((err) => { + if ((err as { code: string }).code !== "ABORT_ERR") { + console.error(err); + } + }); return () => { server.close(); // Also disconnect any open websockets/devtools connections wsServer.clients.forEach((ws) => ws.close()); wsServer.close(); + abortController.abort(); }; }, [props.port, server, wsServer]); diff --git a/packages/wrangler/src/proxy.ts b/packages/wrangler/src/proxy.ts index c72633f46b56..7a2285cd9ae5 100644 --- a/packages/wrangler/src/proxy.ts +++ b/packages/wrangler/src/proxy.ts @@ -289,21 +289,29 @@ export function usePreviewServer({ // Start/stop the server whenever the // containing component is mounted/unmounted. useEffect(() => { + const abortController = new AbortController(); if (proxyServer === undefined) { return; } - waitForPortToBeAvailable(port, { retryPeriod: 200, timeout: 2000 }) + waitForPortToBeAvailable(port, { + retryPeriod: 200, + timeout: 2000, + abortSignal: abortController.signal, + }) .then(() => { proxyServer.listen(port, ip); console.log(`⬣ Listening at ${localProtocol}://${ip}:${port}`); }) .catch((err) => { - console.error(`⬣ Failed to start server: ${err}`); + if ((err as { code: string }).code !== "ABORT_ERR") { + console.error(`⬣ Failed to start server: ${err}`); + } }); return () => { proxyServer.close(); + abortController.abort(); }; }, [port, ip, proxyServer, localProtocol]); } @@ -398,9 +406,16 @@ function createStreamHandler( */ export async function waitForPortToBeAvailable( port: number, - options: { retryPeriod: number; timeout: number } + options: { retryPeriod: number; timeout: number; abortSignal: AbortSignal } ): Promise { return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options.abortSignal as any).addEventListener("abort", () => { + const abortError = new Error("waitForPortToBeAvailable() aborted"); + (abortError as Error & { code: string }).code = "ABORT_ERR"; + doReject(abortError); + }); + const timeout = setTimeout(() => { doReject(new Error(`Timed out waiting for port ${port}`)); }, options.timeout);