Skip to content

Commit cd90edf

Browse files
committed
Merge pull request #941
2 parents cae68b0 + adbb3b3 commit cd90edf

2 files changed

Lines changed: 249 additions & 1 deletion

File tree

src/middleware/inFlightRequests.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,35 @@ import type { RequestHandler } from 'express'
22

33
let inFlight = 0
44
let draining = false
5+
let drainResolvers: (() => void)[] = []
56

67
export const getInFlightCount = (): number => inFlight
78
export const setDraining = (value: boolean): void => {
89
draining = value
910
}
1011

12+
/**
13+
* Resets the in-flight request counter and clear all drain resolvers.
14+
* Used primarily for ensuring test isolation.
15+
*/
16+
export const resetInFlight = (): void => {
17+
inFlight = 0
18+
drainResolvers = []
19+
}
20+
21+
/**
22+
* Returns a promise that remains pending while there are active requests,
23+
* and resolves once the active request count reaches zero.
24+
*/
25+
export const waitForZeroActiveRequests = (): Promise<void> => {
26+
if (inFlight === 0) {
27+
return Promise.resolve()
28+
}
29+
return new Promise<void>((resolve) => {
30+
drainResolvers.push(resolve)
31+
})
32+
}
33+
1134
export const inFlightMiddleware: RequestHandler = (req, res, next) => {
1235
if (draining) {
1336
res.setHeader('Connection', 'close')
@@ -17,15 +40,29 @@ export const inFlightMiddleware: RequestHandler = (req, res, next) => {
1740
}
1841

1942
inFlight += 1
20-
// Ensure counter is decremented when the response finishes for any reason
43+
let decremented = false
44+
2145
const cleanup = () => {
46+
if (decremented) return
47+
decremented = true
2248
inFlight = Math.max(0, inFlight - 1)
49+
2350
res.removeListener('finish', cleanup)
2451
res.removeListener('close', cleanup)
52+
res.removeListener('error', cleanup)
53+
54+
if (inFlight === 0) {
55+
const resolvers = drainResolvers
56+
drainResolvers = []
57+
for (const resolve of resolvers) {
58+
resolve()
59+
}
60+
}
2561
}
2662

2763
res.on('finish', cleanup)
2864
res.on('close', cleanup)
65+
res.on('error', cleanup)
2966
next()
3067
}
3168

src/tests/inFlightRequests.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import {
2+
inFlightMiddleware,
3+
getInFlightCount,
4+
setDraining,
5+
resetInFlight,
6+
waitForZeroActiveRequests,
7+
} from '../middleware/inFlightRequests.js'
8+
import { EventEmitter } from 'events'
9+
10+
class MockResponse extends EventEmitter {
11+
headers = new Map<string, string>()
12+
statusCode = 200
13+
body: any = null
14+
15+
setHeader(name: string, value: string) {
16+
this.headers.set(name, value)
17+
return this
18+
}
19+
20+
status(code: number) {
21+
this.statusCode = code
22+
return this
23+
}
24+
25+
json(data: any) {
26+
this.body = data
27+
return this
28+
}
29+
}
30+
31+
describe('inFlightRequests middleware', () => {
32+
beforeEach(() => {
33+
resetInFlight()
34+
setDraining(false)
35+
})
36+
37+
test('1. active request counter increments immediately when request enters middleware', () => {
38+
const req = {} as any
39+
const res = new MockResponse() as any
40+
const next = jest.fn()
41+
42+
expect(getInFlightCount()).toBe(0)
43+
inFlightMiddleware(req, res, next)
44+
expect(getInFlightCount()).toBe(1)
45+
expect(next).toHaveBeenCalledTimes(1)
46+
})
47+
48+
test('2. counter decrements after normal successful response (finish event)', () => {
49+
const req = {} as any
50+
const res = new MockResponse() as any
51+
const next = jest.fn()
52+
53+
inFlightMiddleware(req, res, next)
54+
expect(getInFlightCount()).toBe(1)
55+
56+
res.emit('finish')
57+
expect(getInFlightCount()).toBe(0)
58+
})
59+
60+
test('3. counter decrements when request ends due to error', () => {
61+
const req = {} as any
62+
const res = new MockResponse() as any
63+
const next = jest.fn()
64+
65+
inFlightMiddleware(req, res, next)
66+
expect(getInFlightCount()).toBe(1)
67+
68+
res.emit('error', new Error('Test error'))
69+
expect(getInFlightCount()).toBe(0)
70+
})
71+
72+
test('4. counter decrements when client aborts connection before completion (close event)', () => {
73+
const req = {} as any
74+
const res = new MockResponse() as any
75+
const next = jest.fn()
76+
77+
inFlightMiddleware(req, res, next)
78+
expect(getInFlightCount()).toBe(1)
79+
80+
res.emit('close')
81+
expect(getInFlightCount()).toBe(0)
82+
})
83+
84+
test('5. multiple concurrent requests produce correct peak active count', () => {
85+
const next = jest.fn()
86+
const clients = Array.from({ length: 5 }, () => {
87+
return {
88+
req: {} as any,
89+
res: new MockResponse() as any,
90+
}
91+
})
92+
93+
expect(getInFlightCount()).toBe(0)
94+
95+
// Enter all 5 requests
96+
clients.forEach((c, idx) => {
97+
inFlightMiddleware(c.req, c.res, next)
98+
expect(getInFlightCount()).toBe(idx + 1)
99+
})
100+
101+
expect(getInFlightCount()).toBe(5)
102+
103+
// Finish them one by one
104+
clients.forEach((c, idx) => {
105+
c.res.emit('finish')
106+
expect(getInFlightCount()).toBe(5 - (idx + 1))
107+
})
108+
109+
expect(getInFlightCount()).toBe(0)
110+
})
111+
112+
test('6. middleware drain/wait mechanism remains pending while active requests exist', async () => {
113+
const req = {} as any
114+
const res = new MockResponse() as any
115+
const next = jest.fn()
116+
117+
inFlightMiddleware(req, res, next)
118+
expect(getInFlightCount()).toBe(1)
119+
120+
let resolved = false
121+
const drainPromise = waitForZeroActiveRequests().then(() => {
122+
resolved = true
123+
})
124+
125+
// Wait a brief tick to verify promise remains pending
126+
await new Promise((resolve) => process.nextTick(resolve))
127+
expect(resolved).toBe(false)
128+
129+
// Complete the request
130+
res.emit('finish')
131+
await drainPromise
132+
expect(resolved).toBe(true)
133+
expect(getInFlightCount()).toBe(0)
134+
})
135+
136+
test('7. drain promise resolves only after active request count reaches zero', async () => {
137+
const req1 = {} as any
138+
const res1 = new MockResponse() as any
139+
const req2 = {} as any
140+
const res2 = new MockResponse() as any
141+
const next = jest.fn()
142+
143+
inFlightMiddleware(req1, res1, next)
144+
inFlightMiddleware(req2, res2, next)
145+
expect(getInFlightCount()).toBe(2)
146+
147+
let resolved = false
148+
const drainPromise = waitForZeroActiveRequests().then(() => {
149+
resolved = true
150+
})
151+
152+
// Finish first request, active count drops to 1 but not 0
153+
res1.emit('finish')
154+
expect(getInFlightCount()).toBe(1)
155+
156+
await new Promise((resolve) => process.nextTick(resolve))
157+
expect(resolved).toBe(false)
158+
159+
// Finish second request, count reaches zero
160+
res2.emit('close')
161+
expect(getInFlightCount()).toBe(0)
162+
163+
await drainPromise
164+
expect(resolved).toBe(true)
165+
})
166+
167+
test('8. active request counter never becomes negative and prevents duplicate completion events from double decrementing', () => {
168+
const req = {} as any
169+
const res = new MockResponse() as any
170+
const next = jest.fn()
171+
172+
inFlightMiddleware(req, res, next)
173+
expect(getInFlightCount()).toBe(1)
174+
175+
// Emit multiple completion events for the same request
176+
res.emit('finish')
177+
expect(getInFlightCount()).toBe(0)
178+
179+
res.emit('close')
180+
expect(getInFlightCount()).toBe(0)
181+
182+
res.emit('error', new Error('Duplicate error'))
183+
expect(getInFlightCount()).toBe(0)
184+
})
185+
186+
test('9. drain/wait resolves immediately when active request count is zero', async () => {
187+
expect(getInFlightCount()).toBe(0)
188+
189+
let resolved = false
190+
await waitForZeroActiveRequests().then(() => {
191+
resolved = true
192+
})
193+
expect(resolved).toBe(true)
194+
})
195+
196+
test('10. response is rejected with 503 when draining is active', () => {
197+
setDraining(true)
198+
const req = {} as any
199+
const res = new MockResponse() as any
200+
const next = jest.fn()
201+
202+
inFlightMiddleware(req, res, next)
203+
204+
expect(getInFlightCount()).toBe(0)
205+
expect(next).not.toHaveBeenCalled()
206+
expect(res.statusCode).toBe(503)
207+
expect(res.headers.get('Connection')).toBe('close')
208+
expect(res.headers.get('Retry-After')).toBe('0')
209+
expect(res.body).toEqual({ error: 'server is draining' })
210+
})
211+
})

0 commit comments

Comments
 (0)