fix: add AbortSignal-based server lifecycle cleanup#8217
fix: add AbortSignal-based server lifecycle cleanup#8217eventualbuddha wants to merge 6 commits into
Conversation
detectDevices adds attach/detach listeners to the global USB event emitter but never removes them, causing MaxListenersExceededWarning in tests that start the server multiple times. Return a cleanup function and wire it to the server close event. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the shared backend USB device detection helper to support listener cleanup, and wires that cleanup into each app server’s lifecycle to prevent MaxListenersExceededWarning when servers are started repeatedly in tests.
Changes:
- Change
detectDevicesto return a cleanup function that unregistersusbattach/detachlisteners. - Register that cleanup function on each backend server’s
closeevent (central-scan, scan, admin, mark-scan). - Adjust central-scan backend server unit tests to return an EventEmitter from the mocked
listen()soserver.on('close', …)can be registered.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/backend/src/detect_devices.ts | Make USB listener registration reversible via returned cleanup function. |
| apps/scan/backend/src/server.ts | Capture server from listen() and register cleanup on close. |
| apps/mark-scan/backend/src/server.ts | Register device-listener cleanup on server close. |
| apps/central-scan/backend/src/server.ts | Register device-listener cleanup on server close. |
| apps/central-scan/backend/src/server.test.ts | Update listen() mocks to return an EventEmitter-like server. |
| apps/admin/backend/src/server.ts | Register device-listener cleanup on server close. |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace server.on('close', stopDetectingDevices) with an optional
AbortSignal on StartOptions. The caller owns the signal and triggers
cleanup by aborting. For scan (which doesn't expose the server),
the abort handler also closes the server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The signal handler now calls both stopDetectingDevices() and server.close() in all four apps. Tests use abort() as the sole shutdown mechanism instead of calling server.close() separately. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The abort handler is exercised in central-scan via setup_app.ts but not in the other apps' server tests. Mark as istanbul ignore to avoid coverage threshold failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| detectDevices({ logger }); | ||
| testDetectDevices(logger, expect); |
There was a problem hiding this comment.
detectDevices now returns a cleanup function, but this test ignores it. That leaves USB listeners attached across tests and can reintroduce MaxListenersExceededWarning (and cross-test interference) within this suite. Capture the returned cleanup function and call it at the end of the test (or in an afterEach).
| detectDevices({ logger }); | |
| testDetectDevices(logger, expect); | |
| const cleanup = detectDevices({ logger }); | |
| testDetectDevices(logger, expect); | |
| cleanup(); |
| signal?.addEventListener( | ||
| 'abort', | ||
| /* istanbul ignore next - @preserve */ | ||
| () => { | ||
| stopDetectingDevices(); | ||
| server.close(); | ||
| }, | ||
| { once: true } |
There was a problem hiding this comment.
Cleanup only runs on AbortSignal abort, but this server is also closed directly in tests (e.g. server?.close() in apps/admin/backend/src/server.test.ts). Closing the server currently does not remove the USB listeners, so listeners will still accumulate across test cases. Consider registering stopDetectingDevices on server's 'close' event (once) and/or wrapping server.close so cleanup happens regardless of how shutdown is triggered. Also handle the case where signal is already aborted before the listener is attached (otherwise cleanup never runs).
| signal?.addEventListener( | ||
| 'abort', | ||
| /* istanbul ignore next - @preserve */ | ||
| () => { | ||
| stopDetectingDevices(); | ||
| server.close(); | ||
| }, | ||
| { once: true } | ||
| ); |
There was a problem hiding this comment.
Cleanup only runs on AbortSignal abort, but this server is closed directly in multiple tests via server.close(). That shutdown path currently does not remove the USB listeners, so they will still accumulate across tests. Register stopDetectingDevices on the server's 'close' event (once) and/or ensure server.close() triggers the same cleanup logic. Also consider handling an already-aborted signal so cleanup runs immediately.
| signal?.addEventListener( | ||
| 'abort', | ||
| () => { | ||
| stopDetectingDevices(); | ||
| server.close(); | ||
| }, | ||
| { once: true } |
There was a problem hiding this comment.
Cleanup only runs on AbortSignal abort, but this server is also closed directly in tests (e.g. server.close() in apps/central-scan/backend/src/auth.test.ts). Closing the server currently does not remove the USB listeners, so they will continue to accumulate across test cases. Register stopDetectingDevices on the server's 'close' event (once) and/or ensure server.close() triggers the same cleanup logic. Also handle an already-aborted signal so cleanup runs deterministically.
| app: Application; | ||
| logger: BaseLogger; | ||
| workspace: Workspace; | ||
| signal: AbortSignal; |
There was a problem hiding this comment.
StartOptions.signal is declared as required, but start accepts Partial<StartOptions> and uses signal?.addEventListener, which implies it is optional (and other apps’ StartOptions make signal optional). Making this field required is inconsistent and can mislead callers; consider making signal optional here for consistency.
| signal: AbortSignal; | |
| signal?: AbortSignal; |
Overview
Generated with Claude Code
detectDevicesaddsattach/detachlisteners to the globalusbsingleton but never removes them, causingMaxListenersExceededWarningin tests that callstart()multiple times.detectDevicesto return a cleanup function.signal: AbortSignalto each app'sStartOptions. Aborting the signal shuts down everythingstart()created: removes USB listeners and closes the HTTP server.Demo Video or Screenshot
Before:
pnpm test:coveragein central-scan backend emits:After: no warning. Tests use
AbortControllerfor clean shutdown:Testing Plan
cd apps/central-scan/backend && pnpm test:run— all 110 tests pass, no MaxListenersExceededWarningcd apps/scan/backend && pnpm test:run src/server.test.ts— 5 tests passcd libs/backend && pnpm test:run src/detect_devices.test.ts— 2 tests pass (includes cleanup verification)Checklist