From 7087de4f84f2f0fd43e7510e58717ca96b1d2ddc Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Fri, 21 Aug 2026 13:56:25 +0900 Subject: [PATCH 1/2] fix(start-server-core): return 499 when the client disconnects mid-request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client disconnects, the abort reason is re-thrown internally to unwind in-flight work and dispose abandoned results. Previously it then escaped the request boundary, so h3 flagged it as an unhandled error and logged a 500 — with no way for the app to opt out (executeMiddleware's terminal check discards any response produced after the abort). Catch the abort at the outermost request boundary and classify it as 499 Client Closed Request instead. The check uses identity comparison against request.signal.reason so unrelated AbortErrors thrown by app code are not swallowed. Fixes #7991 --- .../src/createStartHandler.ts | 9 ++ .../tests/createStartHandler.test.ts | 121 ++++++++++++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c7b427bfb1f..6759c2fa4c2 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -779,6 +779,15 @@ export function createStartHandler( request.signal.throwIfAborted() responseOwnsCleanup = response.serverSsrCleanup === 'stream' return response.response + } catch (err) { + // classify a client disconnect as 499 instead of an unhandled 500 + if (request.signal.aborted && err === request.signal.reason) { + return new Response(null, { + status: 499, + statusText: 'Client Closed Request', + }) + } + throw err } finally { if (router?.serverSsr && !responseOwnsCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index dbcc169cb59..51a7a704409 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -406,7 +406,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(routeSignal?.aborted).toBe(true) expect(routeSignal?.reason).toBe(cancellation) expect(render).not.toHaveBeenCalled() @@ -463,7 +463,7 @@ describe('createStartHandler request cancellation', () => { await renderStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(cleanupCalls).toBe(1) expect(router.serverSsr).toBeUndefined() @@ -503,7 +503,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveRender(new Response(new ReadableStream({ cancel }))) await vi.waitFor(() => { expect(cancel).toHaveBeenCalledTimes(1) @@ -542,7 +542,7 @@ describe('createStartHandler request cancellation', () => { const cancellation = new Error('request disconnected') requestController.abort(cancellation) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveMiddleware(new Response(new ReadableStream({ cancel }))) await vi.waitFor(() => { expect(cancel).toHaveBeenCalledTimes(1) @@ -594,7 +594,7 @@ describe('createStartHandler request cancellation', () => { await renderStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) resolveRender({ response: new Response('stream'), @@ -657,7 +657,7 @@ describe('createStartHandler request cancellation', () => { await middlewareStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) await vi.waitFor(() => { expect(consoleError).toHaveBeenCalledWith(cleanupError) }) @@ -731,7 +731,7 @@ describe('createStartHandler request cancellation', () => { await middlewareStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(render).not.toHaveBeenCalled() releaseMiddleware({ @@ -779,7 +779,7 @@ describe('createStartHandler request cancellation', () => { await innerStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(outerFinally).toHaveBeenCalledOnce() expect(render).not.toHaveBeenCalled() }) @@ -812,7 +812,7 @@ describe('createStartHandler request cancellation', () => { await innerStarted requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) + expect((await response).status).toBe(499) expect(render).not.toHaveBeenCalled() }) @@ -838,7 +838,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) expect(render).not.toHaveBeenCalled() }) @@ -882,7 +882,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) await vi.waitFor(() => expect(observedErrors).toEqual([reason])) await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() @@ -920,7 +920,7 @@ describe('createStartHandler request cancellation', () => { {}, ) - expect(response.status).toBe(500) + expect(response.status).toBe(499) await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledWith(reason) @@ -985,7 +985,7 @@ describe('createStartHandler request cancellation', () => { const dispose = vi.spyOn(ssrResponse as any, 'dispose') requestController.abort(reason) - expect((await result).status).toBe(500) + expect((await result).status).toBe(499) releaseMiddleware() await lateResultDelivered await vi.waitFor(() => { @@ -996,6 +996,101 @@ describe('createStartHandler request cancellation', () => { }) expect(router.serverSsr).toBeUndefined() }) + + it('returns 499 when the client disconnects during SSR rendering', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + const handler = createStartHandler(() => { + notifyRenderStarted() + return new Promise(() => {}) + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + }) + + it('returns 499 when the client disconnects during a server route handler', async () => { + const rootRoute = new BaseRootRoute({}) + let notifyHandlerStarted!: () => void + const handlerStarted = new Promise((resolve) => { + notifyHandlerStarted = resolve + }) + const apiRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/api/slow', + server: { + handlers: { + GET: () => { + notifyHandlerStarted() + return new Promise(() => {}) + }, + }, + }, + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/api/slow'] }), + routeTree: rootRoute.addChildren([apiRoute]), + }, + getStoreConfig, + ) + router.isServer = true + startMocks.router = router + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/api/slow', { + signal: requestController.signal, + }), + {}, + ) + + await handlerStarted + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + expect(render).not.toHaveBeenCalled() + }) + + it('returns 499 when the client disconnects during a server function call', async () => { + const router = makeRouter() + startMocks.router = router + startMocks.serverFnResult = new Promise(() => {}) + const requestController = new AbortController() + const handler = createStartHandler(() => new Response('unused')) + const response = handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + + await Promise.resolve() + requestController.abort(new Error('request disconnected')) + + const result = await response + expect(result.status).toBe(499) + expect(result.statusText).toBe('Client Closed Request') + }) }) describe('createStartHandler inlineCss option', () => { From d5b344a788f1b7168431a87b6b34ad82f18ef9d5 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Fri, 21 Aug 2026 13:56:25 +0900 Subject: [PATCH 2/2] chore: add changeset --- .changeset/client-disconnect-499.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/client-disconnect-499.md diff --git a/.changeset/client-disconnect-499.md b/.changeset/client-disconnect-499.md new file mode 100644 index 00000000000..6e652f45b79 --- /dev/null +++ b/.changeset/client-disconnect-499.md @@ -0,0 +1,5 @@ +--- +'@tanstack/start-server-core': patch +--- + +Return `499 Client Closed Request` instead of an unhandled 500 when the client disconnects mid-request. Applies to SSR, server routes, and server functions.