Skip to content

Commit 172c364

Browse files
Blue FemilyrohrboughAtofStryker
authored
fix: Time out unmatched prerequests in proxy to avoid leaking memory (#22462)
* fix: Time out unmatched prerequests in proxy to avoid leaking memory (and generally improve proxy performance) * Fix types, whoops * More type fixes? Plz? * One more TS attempt. * Revert GQL changes that break TS * Revert accidental config change * Rewrote debug messages for clarity * One more logging change * Add test around pre-request garbage collection * Add test around pre-request garbage collection * Typo * Typo again * Apply suggestions from code review Co-authored-by: Emily Rohrbough <[email protected]> * Apply suggestions from code review Co-authored-by: Bill Glesias <[email protected]> * Clean up interval in prerequest tests Co-authored-by: Emily Rohrbough <[email protected]> Co-authored-by: Bill Glesias <[email protected]>
1 parent 1200526 commit 172c364

File tree

2 files changed

+107
-76
lines changed

2 files changed

+107
-76
lines changed

packages/proxy/lib/http/util/prerequests.ts

+76-75
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type {
33
BrowserPreRequest,
44
} from '@packages/proxy'
55
import Debug from 'debug'
6-
import _ from 'lodash'
76

87
const debug = Debug('cypress:proxy:http:util:prerequests')
98
const debugVerbose = Debug('cypress-verbose:proxy:http:util:prerequests')
@@ -12,101 +11,103 @@ const metrics: any = {
1211
browserPreRequestsReceived: 0,
1312
proxyRequestsReceived: 0,
1413
immediatelyMatchedRequests: 0,
15-
eventuallyReceivedPreRequest: [],
16-
neverReceivedPreRequest: [],
14+
unmatchedRequests: 0,
15+
unmatchedPreRequests: 0,
1716
}
1817

1918
process.once('exit', () => {
2019
debug('metrics: %o', metrics)
2120
})
2221

23-
function removeOne<T> (a: Array<T>, predicate: (v: T) => boolean): T | void {
24-
for (let i = a.length - 1; i >= 0; i--) {
25-
const v = a[i]
26-
27-
if (predicate(v)) {
28-
a.splice(i, 1)
29-
30-
return v
31-
}
32-
}
33-
}
22+
export type GetPreRequestCb = (browserPreRequest?: BrowserPreRequest) => void
3423

35-
function matches (preRequest: BrowserPreRequest, req: Pick<CypressIncomingRequest, 'proxiedUrl' | 'method'>) {
36-
return preRequest.method === req.method && preRequest.url === req.proxiedUrl
24+
type PendingRequest = {
25+
ctxDebug
26+
callback: GetPreRequestCb
27+
timeout: ReturnType<typeof setTimeout>
3728
}
3829

39-
export type GetPreRequestCb = (browserPreRequest?: BrowserPreRequest) => void
30+
// This class' purpose is to match up incoming "requests" (requests from the browser received by the http proxy)
31+
// with "pre-requests" (events received by our browser extension indicating that the browser is about to make a request).
32+
// Because these come from different sources, they can be out of sync, arriving in either order.
4033

34+
// Basically, when requests come in, we want to provide additional data read from the pre-request. but if no pre-request
35+
// ever comes in, we don't want to block proxied requests indefinitely.
4136
export class PreRequests {
42-
pendingBrowserPreRequests: Array<BrowserPreRequest> = []
43-
requestsPendingPreRequestCbs: Array<{
44-
cb: (browserPreRequest: BrowserPreRequest) => void
45-
method: string
46-
proxiedUrl: string
47-
}> = []
48-
49-
get (req: CypressIncomingRequest, ctxDebug, cb: GetPreRequestCb) {
50-
metrics.proxyRequestsReceived++
51-
52-
const pendingBrowserPreRequest = removeOne(this.pendingBrowserPreRequests, (browserPreRequest) => {
53-
return matches(browserPreRequest, req)
54-
})
55-
56-
if (pendingBrowserPreRequest) {
57-
metrics.immediatelyMatchedRequests++
58-
59-
ctxDebug('matches pending pre-request %o', pendingBrowserPreRequest)
37+
requestTimeout: number
38+
pendingPreRequests: Record<string, BrowserPreRequest> = {}
39+
pendingRequests: Record<string, PendingRequest> = {}
40+
prerequestTimestamps: Record<string, number> = {}
41+
sweepInterval: ReturnType<typeof setInterval>
42+
43+
constructor (requestTimeout = 500) {
44+
// If a request comes in and we don't have a matching pre-request after this timeout,
45+
// we invoke the request callback to tell the server to proceed (we don't want to block
46+
// user requests indefinitely).
47+
this.requestTimeout = requestTimeout
48+
49+
// Discarding prerequests on the other hand is not urgent, so we do it on a regular interval
50+
// rather than with a separate timer for each one.
51+
// 2 times the requestTimeout is arbitrary, chosen to give plenty of time and
52+
// make sure we don't discard any pre-requests prematurely but that we don't leak memory over time
53+
// if a large number of pre-requests don't match up
54+
// fixes: https://github.com/cypress-io/cypress/issues/17853
55+
this.sweepInterval = setInterval(() => {
56+
const now = Date.now()
57+
58+
Object.entries(this.prerequestTimestamps).forEach(([key, timestamp]) => {
59+
if (timestamp + this.requestTimeout * 2 < now) {
60+
debugVerbose('timed out unmatched pre-request %s: %o', key, this.pendingPreRequests[key])
61+
metrics.unmatchedPreRequests++
62+
delete this.pendingPreRequests[key]
63+
delete this.prerequestTimestamps[key]
64+
}
65+
})
66+
}, this.requestTimeout * 2)
67+
}
6068

61-
return cb(pendingBrowserPreRequest)
62-
}
69+
addPending (browserPreRequest: BrowserPreRequest) {
70+
metrics.browserPreRequestsReceived++
71+
const key = `${browserPreRequest.method}-${browserPreRequest.url}`
6372

64-
const timeout = setTimeout(() => {
65-
metrics.neverReceivedPreRequest.push({ url: req.proxiedUrl })
66-
ctxDebug('500ms passed without a pre-request, continuing request with an empty pre-request field!')
67-
68-
remove()
69-
cb()
70-
}, 500)
71-
72-
const startedMs = Date.now()
73-
const remove = _.once(() => removeOne(this.requestsPendingPreRequestCbs, (v) => v === requestPendingPreRequestCb))
74-
75-
const requestPendingPreRequestCb = {
76-
cb: (browserPreRequest) => {
77-
const afterMs = Date.now() - startedMs
78-
79-
metrics.eventuallyReceivedPreRequest.push({ url: browserPreRequest.url, afterMs })
80-
ctxDebug('received pre-request after %dms %o', afterMs, browserPreRequest)
81-
clearTimeout(timeout)
82-
remove()
83-
cb(browserPreRequest)
84-
},
85-
proxiedUrl: req.proxiedUrl,
86-
method: req.method,
73+
if (this.pendingRequests[key]) {
74+
debugVerbose('Incoming pre-request %s matches pending request. %o', key, browserPreRequest)
75+
clearTimeout(this.pendingRequests[key].timeout)
76+
this.pendingRequests[key].callback(browserPreRequest)
77+
delete this.pendingRequests[key]
8778
}
8879

89-
this.requestsPendingPreRequestCbs.push(requestPendingPreRequestCb)
80+
debugVerbose('Caching pre-request %s to be matched later. %o', key, browserPreRequest)
81+
this.pendingPreRequests[key] = browserPreRequest
82+
this.prerequestTimestamps[key] = Date.now()
9083
}
9184

92-
addPending (browserPreRequest: BrowserPreRequest) {
93-
if (this.pendingBrowserPreRequests.indexOf(browserPreRequest) !== -1) {
94-
return
95-
}
96-
97-
metrics.browserPreRequestsReceived++
85+
get (req: CypressIncomingRequest, ctxDebug, callback: GetPreRequestCb) {
86+
metrics.proxyRequestsReceived++
87+
const key = `${req.method}-${req.proxiedUrl}`
9888

99-
const requestPendingPreRequestCb = removeOne(this.requestsPendingPreRequestCbs, (req) => {
100-
return matches(browserPreRequest, req)
101-
})
89+
if (this.pendingPreRequests[key]) {
90+
metrics.immediatelyMatchedRequests++
91+
ctxDebug('Incoming request %s matches known pre-request: %o', key, this.pendingPreRequests[key])
92+
callback(this.pendingPreRequests[key])
10293

103-
if (requestPendingPreRequestCb) {
104-
debugVerbose('immediately matched pre-request %o', browserPreRequest)
94+
delete this.pendingPreRequests[key]
95+
delete this.prerequestTimestamps[key]
10596

106-
return requestPendingPreRequestCb.cb(browserPreRequest)
97+
return
10798
}
10899

109-
debugVerbose('queuing pre-request to be matched later %o %o', browserPreRequest, this.pendingBrowserPreRequests)
110-
this.pendingBrowserPreRequests.push(browserPreRequest)
100+
const timeout = setTimeout(() => {
101+
callback()
102+
ctxDebug('Never received pre-request for request %s after waiting %sms. Continuing without one.', key, this.requestTimeout)
103+
metrics.unmatchedRequests++
104+
delete this.pendingRequests[key]
105+
}, this.requestTimeout)
106+
107+
this.pendingRequests[key] = {
108+
ctxDebug,
109+
callback,
110+
timeout,
111+
}
111112
}
112113
}

packages/proxy/test/unit/http/util/prerequests.spec.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ describe('http/util/prerequests', () => {
77
let preRequests: PreRequests
88

99
beforeEach(() => {
10-
preRequests = new PreRequests()
10+
preRequests = new PreRequests(10)
11+
})
12+
13+
afterEach(() => {
14+
clearInterval(preRequests.sweepInterval)
1115
})
1216

1317
it('synchronously matches a pre-request that existed at the time of the request', () => {
@@ -33,4 +37,30 @@ describe('http/util/prerequests', () => {
3337
preRequests.get({ proxiedUrl: 'foo', method: 'GET' } as CypressIncomingRequest, () => {}, cb)
3438
preRequests.addPending({ requestId: '1234', url: 'foo', method: 'GET' } as BrowserPreRequest)
3539
})
40+
41+
it('invokes a request callback after a timeout if no pre-request occurs', (done) => {
42+
const cb = (preRequest) => {
43+
expect(preRequest).to.be.undefined
44+
done()
45+
}
46+
47+
preRequests.get({ proxiedUrl: 'foo', method: 'GET' } as CypressIncomingRequest, () => {}, cb)
48+
})
49+
50+
// https://github.com/cypress-io/cypress/issues/17853
51+
it('eventually discards pre-requests that don\'t match requests', (done) => {
52+
preRequests.addPending({ requestId: '1234', url: 'foo', method: 'GET' } as BrowserPreRequest)
53+
54+
// preRequests garbage collects pre-requests that never matched up with an incoming request after around
55+
// 2 * requestTimeout. We verify that it's gone (and therefore not leaking memory) by sending in a request
56+
// and assuring that the pre-request wasn't there to be matched anymore.
57+
setTimeout(() => {
58+
const cb = (preRequest) => {
59+
expect(preRequest).to.be.undefined
60+
done()
61+
}
62+
63+
preRequests.get({ proxiedUrl: 'foo', method: 'GET' } as CypressIncomingRequest, () => {}, cb)
64+
}, 50)
65+
})
3666
})

0 commit comments

Comments
 (0)