Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function makeConfig(): Promise<CliConfig> {
'ignore-errors': { type: 'boolean', default: false, desc: 'Ignores synthesis errors, which will likely produce an invalid output' },
'json': { type: 'boolean', alias: 'j', desc: 'Use JSON output instead of YAML when templates are printed to STDOUT', default: false },
'verbose': { type: 'boolean', alias: 'v', desc: 'Show debug logs (specify multiple times to increase verbosity)', default: false, count: true },
'debug': { type: 'boolean', desc: 'Debug the CDK app. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis)', default: false },
'debug': { type: 'boolean', desc: 'Debug the CDK app and CLI. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis). Also reports any Node async resources still keeping the CLI process alive at exit, with the source location where each was created.', default: false },
'profile': { type: 'string', desc: 'Use the indicated AWS profile as the default environment', requiresArg: true },
'region': { type: 'string', desc: 'Use the indicated AWS region as the default region', requiresArg: true },
'proxy': { type: 'string', desc: 'Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified', requiresArg: true },
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/cli-type-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
},
"debug": {
"type": "boolean",
"desc": "Debug the CDK app. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis)",
"desc": "Debug the CDK app and CLI. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis). Also reports any Node async resources still keeping the CLI process alive at exit, with the source location where each was created.",
"default": false
},
"profile": {
Expand Down
25 changes: 25 additions & 0 deletions packages/aws-cdk/lib/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { checkForPlatformWarnings } from './platform-warnings';
import { prettyPrintError } from './pretty-print-error';
import { ProxyAgentProvider } from './proxy-agent';
import { GLOBAL_PLUGIN_HOST } from './singleton-plugin-host';
import { enableHandleTracking, reportLeakedHandles } from './debug-handles';
import { cdkCliErrorName } from './telemetry/error';
import type { ErrorDetails } from './telemetry/schema';
import type { Command } from './user-configuration';
Expand Down Expand Up @@ -42,6 +43,15 @@ import { findUnknownOptions } from './util/check-unknown-options';
import { isCI } from './util/ci';
import { guessAgent } from './util/guess-agent';

/**
* Grace period before the --debug handle dump fires after CLI work completes.
* Long enough that the normal exit path (telemetry flush, notices display,
* lock release) finishes first; short enough that customers seeing a hang
* don't wait long for the diagnostic. The timer is .unref()'d, so it never
* fires when Node exits cleanly within this window.
*/
const HANDLE_DUMP_GRACE_MS = 1000;

export async function exec(args: string[], synthesizer?: Synthesizer): Promise<number | void> {
// This is the very first code that runs, but libraries have been loaded already and that also costs time.
// Measure that.
Expand All @@ -50,6 +60,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
const argv = await parseCommandLineArguments(args);
argv.language = getLanguageFromAlias(argv.language) ?? argv.language;

if (argv.debug) {
// Start tracking async resources before any other work happens, so we can
// identify the ones still alive at exit time.
enableHandleTracking();
}

// Handle color output settings
// Priority: --no-color > --color > TTY detection
if (argv.noColor) {
Expand Down Expand Up @@ -260,6 +276,15 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
} else if (shouldDisplayNotices && cmd !== 'version') {
await trapErrors(ioHelper, 'Could not display notices', () => notices.display());
}

if (argv.debug) {
// If the process is still alive after the grace period, something is
// keeping the event loop busy. Dump the leaked handles so the user can
// see why. .unref() so this timer itself doesn't keep us alive.
setTimeout(() => {
void reportLeakedHandles(ioHelper);
}, HANDLE_DUMP_GRACE_MS).unref();
}
}

async function main(command: string, args: any): Promise<number | void> {
Expand Down
178 changes: 178 additions & 0 deletions packages/aws-cdk/lib/cli/debug-handles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { createHook } from 'node:async_hooks';
import { readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { IoHelper } from '../../lib/api-private';

/**
* Async resource types that are too noisy to be useful in a hang report.
* Promises and timer wrappers in particular are created in vast quantities
* and rarely correspond to a leaked handle the user can act on.
*/
const IGNORED_TYPES = new Set([
'TIMERWRAP',
'PROMISE',
'PerformanceObserver',
'RANDOMBYTESREQUEST',
]);

/**
* Filename suffix matching this module itself, used to drop our own frames
* from reported stack traces (the init callback is always the second frame).
* Suffix-match because the file is `.ts` in source and `.js` after build.
*/
const SELF_FILE_SUFFIXES = ['/debug-handles.ts', '/debug-handles.js'];

/** A single frame in a captured stack trace. */
interface StackFrame {
readonly fileName: string | null;
readonly lineNumber: number | null;
}

/** A tracked async resource and where it was created. */
interface TrackedResource {
readonly type: string;
readonly resource: { hasRef?: () => boolean };
readonly creationStack: StackFrame[];
}

const trackedResources = new Map<number, TrackedResource>();

const asyncHook = createHook({
init(asyncId, type, _triggerAsyncId, resource) {
if (IGNORED_TYPES.has(type)) {
return;
}
// Skip the first frame (this init callback itself).
const creationStack = captureStackFrames().slice(1);
trackedResources.set(asyncId, {
type,
resource: resource as { hasRef?: () => boolean },
creationStack,
});
},
destroy(asyncId) {
trackedResources.delete(asyncId);
},
});

/**
* Start tracking async resources so we can later report which ones are still
* keeping the Node event loop alive. Must be called before any async resource
* we care about is created (so as early as possible during CLI startup).
*
* Only call this when the user has opted in via --debug; the hook adds a
* small per-resource cost.
*/
export function enableHandleTracking(): void {
asyncHook.enable();
}

/**
* Report any async resources still keeping the event loop alive, with the
* source location where each one was created.
*
* Should be called at the very end of CLI execution. By the time the CLI's
* work is logically done, only leaked handles should remain in the registry.
*/
export async function reportLeakedHandles(ioHelper: IoHelper): Promise<void> {
asyncHook.disable();

const stillAlive = Array.from(trackedResources.values()).filter(({ resource }) => {
// Resources with a hasRef() of false have been .unref()'d and are NOT
// keeping the loop alive. Resources without a hasRef() default to "yes".
return resource.hasRef?.() ?? true;
});

await ioHelper.defaults.warn(`[cdk debug] ${stillAlive.length} handle(s) keeping the process running.`);

for (const resource of stillAlive) {
await reportResource(resource, ioHelper);
}
}

async function reportResource(resource: TrackedResource, ioHelper: IoHelper): Promise<void> {
// Drop frames that are inside Node internals or this file itself; the user
// can't act on either.
const userFrames = resource.creationStack.filter((frame) => {
if (frame.fileName === null) return false;
if (frame.fileName.startsWith('node:')) return false;
const normalized = normalizeFilePath(frame.fileName);
if (SELF_FILE_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return false;
return true;
});

await ioHelper.defaults.warn('');
await ioHelper.defaults.warn(`# ${resource.type}`);

if (userFrames.length === 0) {
await ioHelper.defaults.warn('(no user-code frames found)');
return;
}

// Pad locations to a uniform width so the source-line annotations line up.
const locations = userFrames.map(formatLocation);
const maxLocationWidth = Math.max(...locations.map((s) => s.length));

for (let i = 0; i < userFrames.length; i++) {
const frame = userFrames[i];
const location = locations[i];
const padding = ' '.repeat(maxLocationWidth - location.length);

const sourceLine = readSourceLine(frame);
if (sourceLine !== undefined) {
await ioHelper.defaults.warn(`${location}${padding} - ${sourceLine}`);
} else {
await ioHelper.defaults.warn(location);
}
}
}

function formatLocation(frame: StackFrame): string {
const absolutePath = normalizeFilePath(frame.fileName ?? '');
const cwdRelative = relative(process.cwd(), absolutePath);
// If the file is outside cwd, relative() yields a leading '..' which is
// less readable than the absolute path.
const displayPath = cwdRelative.startsWith('..') ? absolutePath : cwdRelative;
return `${displayPath}:${frame.lineNumber}`;
}

function readSourceLine(frame: StackFrame): string | undefined {
if (frame.fileName === null || frame.lineNumber === null) {
return undefined;
}
try {
const lines = readFileSync(normalizeFilePath(frame.fileName), 'utf-8').split(/\r?\n/);
return lines[frame.lineNumber - 1]?.trim();
} catch {
return undefined;
}
}

function normalizeFilePath(filePath: string): string {
return filePath.startsWith('file://') ? fileURLToPath(filePath) : filePath;
}

/**
* Capture the current stack as a list of frames, using V8's structured stack
* trace API (Error.prepareStackTrace). The default formatter is restored
* before returning.
*/
function captureStackFrames(): StackFrame[] {
const target: { stack?: StackFrame[] } = {};
const original = Error.prepareStackTrace;

Error.prepareStackTrace = (_error, callSites) => {
return callSites.map((site) => ({
fileName: site.getFileName(),
lineNumber: site.getLineNumber(),
}));
};

// Walk the stack starting from the caller of captureStackFrames.
Error.captureStackTrace(target, captureStackFrames);
const frames = (target.stack as unknown as StackFrame[]) ?? [];

Error.prepareStackTrace = original;
return frames;
}
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/parse-command-line-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function parseCommandLineArguments(args: Array<string>): any {
.option('debug', {
default: false,
type: 'boolean',
desc: 'Debug the CDK app. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis)',
desc: 'Debug the CDK app and CLI. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis). Also reports any Node async resources still keeping the CLI process alive at exit, with the source location where each was created.',
})
.option('profile', {
default: undefined,
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/user-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export interface GlobalOptions {
readonly verbose?: number;

/**
* Debug the CDK app. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis)
* Debug the CDK app and CLI. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis). Also reports any Node async resources still keeping the CLI process alive at exit, with the source location where each was created.
*
* @default - false
*/
Expand Down
Loading