From b394a921957ba50a5fa7a6a3a68e53071dd3c24b Mon Sep 17 00:00:00 2001 From: Andrey Melikhov Date: Mon, 2 Mar 2026 23:44:32 +0300 Subject: [PATCH 1/2] fix: guard ctx.create() against ended context after client disconnect Add isEnded() checks in wrapMiddleware and wrapRouteHandler to prevent crashes when middleware or route handlers run after the client disconnects and the original context is already ended. --- src/router.ts | 6 +++ src/tests/context-lifecycle.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/router.ts b/src/router.ts index 846bbfe..b7fbbcf 100644 --- a/src/router.ts +++ b/src/router.ts @@ -23,6 +23,9 @@ function isAllowedMethod(method: string): method is Lowercase | 'mou function wrapMiddleware(fn: AppMiddleware, i?: number): AppMiddleware { const result: AppMiddleware = async (req, res, next) => { const reqCtx = req.ctx; + if (reqCtx.isEnded()) { + return next(); + } const ctx = reqCtx.create(`${fn.name || `noname-${i}`} middleware`); let ended = false; @@ -54,6 +57,9 @@ function wrapRouteHandler(fn: AppRouteHandler, handlerName?: string) { const handlerNameLocal = handlerName || fn.name || UNNAMED_CONTROLLER; const handler: AppMiddleware = async (req, res, next) => { + if (req.originalContext.isEnded()) { + return; + } req.ctx = req.originalContext.create(handlerNameLocal); if (req.routeInfo.handlerName !== handlerNameLocal) { if (req.routeInfo.handlerName === UNNAMED_CONTROLLER) { diff --git a/src/tests/context-lifecycle.test.ts b/src/tests/context-lifecycle.test.ts index 0e13e00..a2e23d2 100644 --- a/src/tests/context-lifecycle.test.ts +++ b/src/tests/context-lifecycle.test.ts @@ -326,4 +326,69 @@ describe('Context Lifecycle', () => { expect(middlewareOriginalCtx!.abortSignal.aborted).toBe(true); }); }); + + describe('Client Disconnect Handling', () => { + it('should not throw when middleware runs after client disconnect', async () => { + let slowMiddlewareCalled = false; + + const slowMiddleware = async (req: Request, _res: Response, next: NextFunction) => { + slowMiddlewareCalled = true; + // Simulate client disconnect by destroying the socket + req.socket.destroy(); + // Wait for the close event handler's setImmediate to end the context + await new Promise((resolve) => { + setImmediate(() => setImmediate(() => setImmediate(resolve))); + }); + next(); + }; + + const nodekit = new NodeKit(); + const app = new ExpressKit(nodekit, { + 'GET /test': { + beforeAuth: [slowMiddleware], + handler: (_req: Request, res: Response) => { + res.json({ok: true}); + }, + }, + }); + + await request + .agent(app.express) + .get('/test') + .catch(() => {}); + + expect(slowMiddlewareCalled).toBe(true); + }); + + it('should not throw when route handler runs after client disconnect', async () => { + const disconnectMiddleware = async ( + req: Request, + _res: Response, + next: NextFunction, + ) => { + // Simulate client disconnect by destroying the socket + req.socket.destroy(); + // Wait for the close event handler's setImmediate to end the context + await new Promise((resolve) => { + setImmediate(() => setImmediate(() => setImmediate(resolve))); + }); + next(); + }; + + const nodekit = new NodeKit(); + const app = new ExpressKit(nodekit, { + 'GET /test': { + beforeAuth: [disconnectMiddleware], + handler: (_req: Request, res: Response) => { + res.json({ok: true}); + }, + }, + }); + + await request + .agent(app.express) + .get('/test') + .catch(() => {}); + }); + }); }); From ac08d527a02aab28d799ba22d5033a23aed06f14 Mon Sep 17 00:00:00 2001 From: Andrey Melikhov Date: Tue, 3 Mar 2026 00:51:27 +0300 Subject: [PATCH 2/2] chore: bump @gravity-ui/nodekit devDependency to ^2.10.1 Required for isEnded() method on AppContext type. --- package-lock.json | 8 ++++---- package.json | 2 +- src/router.ts | 8 ++++++-- src/tests/context-lifecycle.test.ts | 11 +++++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4cdf36f..97023e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", "@gravity-ui/eslint-config": "^3.2.0", - "@gravity-ui/nodekit": "^2.7.0", + "@gravity-ui/nodekit": "^2.10.1", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", "@types/accept-language-parser": "^1.5.6", @@ -1050,9 +1050,9 @@ } }, "node_modules/@gravity-ui/nodekit": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-2.7.0.tgz", - "integrity": "sha512-+mAHg9OXbSkXE2FeEaEwl9FlXhvMP35J2w/PLa4MJWMa5A1VyA70QCNWZXU/vxCVzkir6soEZ1/IzpZyXPPpPg==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-2.10.1.tgz", + "integrity": "sha512-45ZB12pZcsIYFbMYpd0ftuWMf3/35JQOZFDg+K1uxtDaEzaEIsDMUkI8LaUQs8BA5OmAQ9lZTuWonfNz7Wxf0Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ddb9ae7..8c98294 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", "@gravity-ui/eslint-config": "^3.2.0", - "@gravity-ui/nodekit": "^2.7.0", + "@gravity-ui/nodekit": "^2.10.1", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", "@types/accept-language-parser": "^1.5.6", diff --git a/src/router.ts b/src/router.ts index b7fbbcf..dc0637d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -23,7 +23,9 @@ function isAllowedMethod(method: string): method is Lowercase | 'mou function wrapMiddleware(fn: AppMiddleware, i?: number): AppMiddleware { const result: AppMiddleware = async (req, res, next) => { const reqCtx = req.ctx; - if (reqCtx.isEnded()) { + // Skip creating child context if parent is already ended (e.g. client disconnected). + // Optional chaining for backward compatibility with nodekit < 2.5.0 (no abortSignal). + if (reqCtx.abortSignal?.aborted) { return next(); } const ctx = reqCtx.create(`${fn.name || `noname-${i}`} middleware`); @@ -57,7 +59,9 @@ function wrapRouteHandler(fn: AppRouteHandler, handlerName?: string) { const handlerNameLocal = handlerName || fn.name || UNNAMED_CONTROLLER; const handler: AppMiddleware = async (req, res, next) => { - if (req.originalContext.isEnded()) { + // Skip creating child context if parent is already ended (e.g. client disconnected). + // Optional chaining for backward compatibility with nodekit < 2.5.0 (no abortSignal). + if (req.originalContext.abortSignal?.aborted) { return; } req.ctx = req.originalContext.create(handlerNameLocal); diff --git a/src/tests/context-lifecycle.test.ts b/src/tests/context-lifecycle.test.ts index a2e23d2..b2d2b54 100644 --- a/src/tests/context-lifecycle.test.ts +++ b/src/tests/context-lifecycle.test.ts @@ -328,8 +328,9 @@ describe('Context Lifecycle', () => { }); describe('Client Disconnect Handling', () => { - it('should not throw when middleware runs after client disconnect', async () => { + it('should skip next middleware when context is already ended', async () => { let slowMiddlewareCalled = false; + let nextMiddlewareCalled = false; const slowMiddleware = async (req: Request, _res: Response, next: NextFunction) => { slowMiddlewareCalled = true; @@ -342,10 +343,15 @@ describe('Context Lifecycle', () => { next(); }; + const nextMiddleware = (_req: Request, _res: Response, next: NextFunction) => { + nextMiddlewareCalled = true; + next(); + }; + const nodekit = new NodeKit(); const app = new ExpressKit(nodekit, { 'GET /test': { - beforeAuth: [slowMiddleware], + beforeAuth: [slowMiddleware, nextMiddleware], handler: (_req: Request, res: Response) => { res.json({ok: true}); }, @@ -358,6 +364,7 @@ describe('Context Lifecycle', () => { .catch(() => {}); expect(slowMiddlewareCalled).toBe(true); + expect(nextMiddlewareCalled).toBe(false); }); it('should not throw when route handler runs after client disconnect', async () => {