-
Notifications
You must be signed in to change notification settings - Fork 113
feat(cli): --debug-cli reports active handles that block process exit
#1651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f413ade
c18381e
bbb1648
795aa67
954a070
5793819
cdd319a
1013a6e
66dbfbb
7e6f794
fcd2d18
0e259af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { integTest, withDefaultFixture } from '../../../lib'; | ||
|
|
||
| integTest( | ||
| 'debug-cli prints no handle report on a clean exit', | ||
| withDefaultFixture(async (fixture) => { | ||
| // synth exits cleanly, so the handle tracker's grace timer (which is | ||
| // unref'd) must never fire. Exercises the cli.ts wiring end to end: the | ||
| // flag enables tracking at startup, and the report stays silent unless the | ||
| // process is genuinely still alive after the work is done. | ||
| const output = await fixture.cdk(['synth', fixture.fullStackName('test-1'), '--debug-cli'], { | ||
| captureStderr: true, | ||
| }); | ||
|
|
||
| expect(output).not.toContain('keeping the CLI process alive'); | ||
| }), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| import { createHook } from 'node:async_hooks'; | ||
| import { readFileSync } from 'node:fs'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import * as chalk from 'chalk'; | ||
| import type { IoHelper } from '../../lib/api-private'; | ||
|
|
||
| // WeakRef exists at runtime on all supported Node versions; reach it via | ||
| // globalThis since the package's ES2020 lib predates its type. | ||
| type WeakRefConstructor = new <T extends object>(value: T) => { deref(): T | undefined }; | ||
| const WeakRefImpl = (globalThis as unknown as { WeakRef: WeakRefConstructor }).WeakRef; | ||
|
|
||
| /** | ||
| * async_hooks resource types that are noise in a "why won't the process exit" | ||
| * report: created in large numbers and effectively never the handle a user can | ||
| * act on. | ||
| */ | ||
| const SKIP_TYPES: ReadonlySet<string> = new Set([ | ||
| 'PROMISE', | ||
| 'PerformanceObserver', | ||
| 'RANDOMBYTESREQUEST', | ||
| ]); | ||
|
|
||
| /** | ||
| * Plain-language descriptions for Node's async resource types (the fixed set in | ||
| * V8/libuv's async_wrap providers). The raw type is always shown; a description | ||
| * is appended when we have one, so any future/unknown type still reports | ||
| * truthfully. | ||
| */ | ||
| const TYPE_DESCRIPTIONS: Readonly<Record<string, string>> = { | ||
| TCPWRAP: 'open network connection', | ||
| TCPSERVERWRAP: 'listening TCP server', | ||
| TCPSOCKETWRAP: 'open network connection', | ||
| TCPCONNECTWRAP: 'pending outbound TCP connection', | ||
| PIPEWRAP: 'open pipe', | ||
| PIPESERVERWRAP: 'listening pipe server', | ||
| PIPECONNECTWRAP: 'pending pipe connection', | ||
| UDPWRAP: 'open UDP socket', | ||
| UDPSENDWRAP: 'pending UDP send', | ||
| TLSWRAP: 'open TLS connection', | ||
| TTYWRAP: 'open terminal stream', | ||
| TIMERWRAP: 'internal timer holding the loop open', | ||
| Timeout: 'timer from setTimeout or setInterval', | ||
| Immediate: 'pending setImmediate callback', | ||
| FSREQCALLBACK: 'pending file-system operation', | ||
| FSREQPROMISE: 'pending file-system operation', | ||
| FSEVENTWRAP: 'file-system watcher', | ||
| STATWATCHER: 'file-system stat watcher', | ||
| PROCESSWRAP: 'spawned child process still running', | ||
| ChildProcess: 'spawned child process still running', | ||
| GETADDRINFOREQWRAP: 'pending DNS lookup', | ||
| GETNAMEINFOREQWRAP: 'pending DNS reverse lookup', | ||
| QUERYWRAP: 'pending DNS query', | ||
| HTTPCLIENTREQUEST: 'in-flight HTTP request', | ||
| HTTPINCOMINGMESSAGE: 'incoming HTTP message', | ||
| HTTP2SESSION: 'open HTTP/2 connection', | ||
| HTTP2STREAM: 'open HTTP/2 request stream', | ||
| HTTP2PING: 'pending HTTP/2 ping', | ||
| HTTP2SETTINGS: 'pending HTTP/2 settings', | ||
| WRITEWRAP: 'pending stream write', | ||
| SHUTDOWNWRAP: 'pending stream shutdown', | ||
| SIGNALWRAP: 'OS signal handler still registered', | ||
| WORKER: 'worker thread still running', | ||
| MESSAGEPORT: 'open worker-thread message channel', | ||
| ZLIB: 'open (de)compression stream', | ||
| DNSCHANNEL: 'DNS resolver holding sockets open', | ||
| ELDHISTOGRAM: 'event-loop-delay monitor (perf_hooks)', | ||
| FILEHANDLE: 'open file handle', | ||
| FILEHANDLECLOSEREQ: 'pending file-handle close', | ||
| HEAPSNAPSHOT: 'heap snapshot being written', | ||
| JSSTREAM: 'custom JavaScript-backed stream', | ||
| JSUDPWRAP: 'custom JavaScript-backed UDP socket', | ||
| KEYPAIRGENREQUEST: 'pending key-pair generation', | ||
| KEYGENREQUEST: 'pending key generation', | ||
| KEYEXPORTREQUEST: 'pending key export', | ||
| CIPHERREQUEST: 'pending cipher operation', | ||
| DERIVEBITSREQUEST: 'pending key-derivation', | ||
| HASHREQUEST: 'pending hash operation', | ||
| SIGNREQUEST: 'pending sign operation', | ||
| VERIFYREQUEST: 'pending verify operation', | ||
| HTTPPARSER: 'HTTP parser for an open connection', | ||
| INSPECTORJSBINDING: 'debugger/inspector session attached', | ||
| SCRYPTREQUEST: 'pending scrypt operation', | ||
| PBKDF2REQUEST: 'pending PBKDF2 operation', | ||
| }; | ||
|
|
||
| /** | ||
| * A single stack frame: the function name (used for the report heading), plus | ||
| * the file and line, which we read to show the line of code that created the | ||
| * handle. | ||
| */ | ||
| interface SourceFrame { | ||
| readonly func: string; | ||
| readonly file: string; | ||
| readonly line: number; | ||
| } | ||
|
|
||
| /** | ||
| * A resource we are watching: a weak reference to the handle (so tracking does | ||
| * not itself keep it alive) and the stack of where it was created. | ||
| */ | ||
| interface WatchedResource { | ||
| readonly type: string; | ||
| readonly handleRef: { deref(): { hasRef?(): boolean } | undefined }; | ||
| readonly creationStack: SourceFrame[]; | ||
| } | ||
|
|
||
| /** | ||
| * Tracks async resources via async_hooks and, on demand, reports the ones still | ||
| * keeping the event loop alive together with where they were created. | ||
| * | ||
| * The cost lands only when opted in: tracking is off until `start()` is called. | ||
| */ | ||
| class LeakedHandleTracker { | ||
| private readonly watched = new Map<number, WatchedResource>(); | ||
|
|
||
| private readonly hook = createHook({ | ||
| init: (asyncId, type, _triggerAsyncId, resource) => { | ||
| if (SKIP_TYPES.has(type)) { | ||
| return; | ||
| } | ||
| this.watched.set(asyncId, { | ||
| type, | ||
| handleRef: new WeakRefImpl(resource as { hasRef?(): boolean }), | ||
| creationStack: captureCreationStack(), | ||
| }); | ||
| }, | ||
| destroy: (asyncId) => { | ||
| this.watched.delete(asyncId); | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Begin watching async resources. Must run before the resources we care about | ||
| * are created, and only when the user opted in — the hook adds a small | ||
| * per-resource cost. | ||
| */ | ||
| public start = (): void => { | ||
| this.hook.enable(); | ||
| }; | ||
|
|
||
| /** | ||
| * Stop watching and discard all tracked state. | ||
| * | ||
| * @internal exposed only so tests can isolate the shared singleton. | ||
| */ | ||
| public reset = (): void => { | ||
| this.hook.disable(); | ||
| this.watched.clear(); | ||
| }; | ||
|
|
||
| /** | ||
| * Report every resource still holding the event loop open, each with the | ||
| * source location where it was created. Call at the very end of execution, by | ||
| * which point only genuinely leaked handles should remain. | ||
| */ | ||
| public report = async (ioHelper: IoHelper): Promise<void> => { | ||
| this.hook.disable(); | ||
|
|
||
| const leaks = [...this.watched.values()].filter((r) => { | ||
| const handle = r.handleRef.deref(); | ||
| // Already garbage collected, so no longer keeping the loop alive. | ||
| if (handle === undefined) { | ||
| return false; | ||
| } | ||
| return handle.hasRef?.() ?? true; | ||
| }); | ||
| this.watched.clear(); | ||
|
|
||
| await ioHelper.defaults.info(`${leaks.length} ${leaks.length === 1 ? 'handle' : 'handles'} still keeping the CLI process alive:`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these really at |
||
| for (const leak of leaks) { | ||
| await this.describe(leak, ioHelper); | ||
| } | ||
| }; | ||
|
|
||
| private async describe(leak: WatchedResource, ioHelper: IoHelper): Promise<void> { | ||
| const frames = actionableFrames(leak.creationStack); | ||
|
|
||
| await ioHelper.defaults.info(''); | ||
| const description = TYPE_DESCRIPTIONS[leak.type]; | ||
| const heading = description ? `# ${leak.type} (${description})` : `# ${leak.type}`; | ||
| await ioHelper.defaults.info(chalk.bold(heading)); | ||
|
|
||
| if (frames.length === 0) { | ||
| await ioHelper.defaults.info(' (no application stack frames)'); | ||
| return; | ||
| } | ||
|
|
||
| // Headline the function only when it has a real name; sockets often open | ||
| // from anonymous internal callbacks where the name says nothing. | ||
| const [origin] = frames; | ||
| if (origin.func && origin.func !== '<anonymous>') { | ||
| await ioHelper.defaults.info(` created in ${origin.func}()`); | ||
| } | ||
|
|
||
| await ioHelper.defaults.info(' call stack:'); | ||
| for (const frame of frames) { | ||
| const source = sourceAt(frame); | ||
| if (source) { | ||
| await ioHelper.defaults.info(` ${chalk.dim(source)}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Capture the call site of the current async resource as structured frames. | ||
| * | ||
| * We install a structured `prepareStackTrace` formatter, capture (using this | ||
| * function as the cut-off so neither it nor the async hook appears), then | ||
| * restore the previous formatter so we don't disturb anyone else's stacks. | ||
| * | ||
| * `captureStackTrace` removes this function and everything above it, but the | ||
| * caller is the tracker's own `init` hook, which sits just below it and would | ||
| * otherwise show up as the top frame. We drop that one frame by position rather | ||
| * than by filename, since after bundling every frame shares the same file name. | ||
| */ | ||
| function captureCreationStack(): SourceFrame[] { | ||
| const carrier: { stack?: SourceFrame[] } = {}; | ||
|
|
||
| // `Error.prepareStackTrace` is a V8 formatting hook we save and restore, not a | ||
| // method we invoke, so the unbound-method concern does not apply here. | ||
| // eslint-disable-next-line @typescript-eslint/unbound-method | ||
| const previous = Error.prepareStackTrace; | ||
| Error.prepareStackTrace = (_error, callSites) => callSites.map((site) => { | ||
| const file = site.getFileName() ?? ''; | ||
| return { | ||
| func: site.getFunctionName() ?? '<anonymous>', | ||
| file: file.startsWith('file://') ? fileURLToPath(file) : file, | ||
| line: site.getLineNumber() ?? 0, | ||
| }; | ||
| }); | ||
| try { | ||
| Error.captureStackTrace(carrier, captureCreationStack); | ||
| // Drop the `init` hook frame (always the top one) so reports point at the | ||
| // application code that created the resource, not at this tracker. | ||
| return carrier.stack?.slice(1) ?? []; | ||
| } finally { | ||
| Error.prepareStackTrace = previous; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Keep only frames the user can act on by dropping Node internals. Our own | ||
| * `init` frame is already removed at capture time (see captureCreationStack). | ||
| */ | ||
| function actionableFrames(frames: SourceFrame[]): SourceFrame[] { | ||
| return frames.filter((frame) => frame.file && !frame.file.startsWith('node:')); | ||
| } | ||
|
|
||
| function sourceAt(frame: SourceFrame): string | undefined { | ||
| try { | ||
| const line = readFileSync(frame.file, 'utf-8').split(/\r?\n/)[frame.line - 1]?.trim() || undefined; | ||
| // Truncate so an unexpectedly long line (e.g. a generated or packed file) | ||
| // doesn't flood the report. | ||
| return line && line.length > 200 ? `${line.slice(0, 200)}…` : line; | ||
| } catch { | ||
| // The source file may not be readable (e.g. a bundled or eval'd frame). | ||
| // The location is still useful on its own, so reporting continues without it. | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| const tracker = new LeakedHandleTracker(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Importing this file now has a side effect that might not be clear or intentional. instead we can do this inside a |
||
|
|
||
| /** | ||
| * Start tracking async resources. See {@link LeakedHandleTracker.start}. | ||
| */ | ||
| export const enableHandleTracking = tracker.start; | ||
|
|
||
| /** | ||
| * Report handles still keeping the loop alive. See {@link LeakedHandleTracker.report}. | ||
| */ | ||
| export const reportLeakedHandles = tracker.report; | ||
|
|
||
| /** | ||
| * Stop tracking and discard all state. | ||
| * | ||
| * @internal exposed only so tests can isolate the shared singleton. | ||
| */ | ||
| export const resetHandleTracking = tracker.reset; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: It'd probably have this inside a helper function
detectLeakedHandles(ioHelper)so that thecli.tsfile is less busy. It sounds likereportLeakedHandlesreally should never be called without thissetTimeout.