Skip to content

Commit b039309

Browse files
committed
fix: guard autoRemove interval cleanup and timer shutdown (plan#runtime)
1 parent c91d001 commit b039309

3 files changed

Lines changed: 91 additions & 6 deletions

File tree

docs/PLANS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
- close() always shuts down the underlying MongoClient, even if the user supplied their own client/promise, which can tear down the rest of the app's DB connections (src/lib/MongoStore.ts:188-210, 541-543). Track whether the store created the client and only close in that case; otherwise just clear timers.
2727
- Interval-based cleanup leaks and relies on deprecated write concern: the timer created in setAutoRemove() is never cleared on shutdown and writes with w:0/j:false, which newer clusters reject (src/lib/MongoStore.ts:217-247). Store the handle, clearInterval it in close(), and use the configured write concern or
2828
default majority-safe options.
29+
- [done 2025-11-19] Guard interval cleanup errors and clear timers in close(); added regression test to prove timer cleanup (agent: Codex)
2930
- Type safety is paper-thin: option hooks (serialize, transformId, crypto) stay typed as any and defaultSerializeFunction is littered with @ts-ignore (src/lib/MongoStore.ts:61-124). Once you enable the stricter compiler flags below, refactor this class into generics (MongoStore<T extends SessionData>) so public
3031
types match reality.
3132
- [done 2025-11-16] Improve type safety with generics/typed hooks (agent: Codex)

src/lib/MongoStore.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,76 @@ test.serial('decrypt failure only calls callback once', async (t) => {
238238
})
239239
})
240240

241+
test.serial(
242+
'interval autoRemove suppresses rejections and clears timer on close',
243+
async (t) => {
244+
const originalSetInterval = global.setInterval
245+
const originalClearInterval = global.clearInterval
246+
const callbacks: (() => void)[] = []
247+
const fakeTimer = {
248+
ref() {
249+
return this
250+
},
251+
unref() {
252+
return this
253+
},
254+
} as unknown as NodeJS.Timeout
255+
let cleared = false
256+
;(global as typeof globalThis).setInterval = ((fn: () => void) => {
257+
callbacks.push(fn)
258+
return fakeTimer
259+
}) as typeof setInterval
260+
;(global as typeof globalThis).clearInterval = ((
261+
handle: NodeJS.Timeout
262+
) => {
263+
if (handle === fakeTimer) {
264+
cleared = true
265+
}
266+
}) as typeof clearInterval
267+
268+
const fakeCollection = {
269+
deleteMany: () => Promise.reject(new Error('interval failure')),
270+
}
271+
const fakeClient = {
272+
db: () => ({
273+
collection: () => fakeCollection,
274+
}),
275+
close: () => Promise.resolve(),
276+
}
277+
const unhandled: unknown[] = []
278+
const onUnhandled = (reason: unknown) => {
279+
unhandled.push(reason)
280+
}
281+
process.on('unhandledRejection', onUnhandled)
282+
283+
let intervalStore: MongoStore | undefined
284+
try {
285+
intervalStore = MongoStore.create({
286+
clientPromise: Promise.resolve(fakeClient as unknown as MongoClient),
287+
autoRemove: 'interval',
288+
autoRemoveInterval: 1,
289+
collectionName: 'interval-test',
290+
dbName: 'interval-db',
291+
})
292+
await intervalStore.collectionP
293+
t.is(callbacks.length, 1)
294+
callbacks[0]?.()
295+
await new Promise((resolve) => setImmediate(resolve))
296+
t.is(unhandled.length, 0)
297+
await intervalStore.close()
298+
t.true(cleared)
299+
t.is(
300+
(intervalStore as unknown as { timer?: NodeJS.Timeout }).timer,
301+
undefined
302+
)
303+
} finally {
304+
process.off('unhandledRejection', onUnhandled)
305+
global.setInterval = originalSetInterval
306+
global.clearInterval = originalClearInterval
307+
}
308+
}
309+
)
310+
241311
test.serial('test destory event', async (t) => {
242312
;({ store, storePromise } = createStoreHelper())
243313
const orgSession = makeData()

src/lib/MongoStore.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,20 +248,30 @@ export default class MongoStore<
248248
expireAfterSeconds: 0,
249249
}
250250
)
251-
case 'interval':
251+
case 'interval': {
252252
debug('create Timer to remove expired sessions')
253-
this.timer = setInterval(
254-
() =>
255-
collection.deleteMany(removeQuery(), {
253+
const runIntervalRemove = () =>
254+
collection
255+
.deleteMany(removeQuery(), {
256256
writeConcern: {
257257
w: 0,
258-
j: false,
259258
},
260-
}),
259+
})
260+
.catch((err) => {
261+
debug(
262+
'autoRemove interval cleanup failed: %s',
263+
(err as Error)?.message ?? err
264+
)
265+
})
266+
this.timer = setInterval(
267+
() => {
268+
void runIntervalRemove()
269+
},
261270
this.options.autoRemoveInterval * 1000 * 60
262271
)
263272
this.timer.unref()
264273
return Promise.resolve()
274+
}
265275
case 'disabled':
266276
default:
267277
return Promise.resolve()
@@ -559,6 +569,10 @@ export default class MongoStore<
559569
*/
560570
close(): Promise<void> {
561571
debug('MongoStore#close()')
572+
if (this.timer) {
573+
clearInterval(this.timer)
574+
this.timer = undefined
575+
}
562576
return this.clientP.then((c) => c.close())
563577
}
564578
}

0 commit comments

Comments
 (0)