From 0f155c3c40def22bdac961c22cee5593941b6bae Mon Sep 17 00:00:00 2001 From: Deepu Mungamuri Date: Tue, 24 Feb 2026 12:29:34 +0530 Subject: [PATCH] fix: improve dev stop Ctrl+C output with concise shutdown messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @W-21322039@ - Add user-visible shutdown messages (stopping proxy, dev server) - Use checkmark style (✅ Stopped dev & proxy servers.) - Clear force-kill timeout in DevServerManager to prevent process hang - Fix explicit return type for handleSignal (lint) Co-authored-by: Cursor --- messages/webapp.dev.md | 12 +++++++ src/commands/webapp/dev.ts | 50 ++++++++++++++++++----------- src/config/webappDiscovery.ts | 4 +-- src/server/DevServerManager.ts | 19 +++++------ test/config/webappDiscovery.test.ts | 5 +-- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/messages/webapp.dev.md b/messages/webapp.dev.md index a27d832..4a9b9de 100644 --- a/messages/webapp.dev.md +++ b/messages/webapp.dev.md @@ -191,3 +191,15 @@ The proxy will use the actual dev server URL. # info.vite-proxy-detected Vite WebApp proxy detected at %s - using Vite's built-in proxy (standalone proxy skipped) + +# info.stopped-proxy-only + +✅ Stopped proxy server. + +# info.stopped-dev-only + +✅ Stopped dev server. + +# info.stopped-dev-and-proxy + +✅ Stopped dev & proxy servers. diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index c3b46d9..786831c 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -428,6 +428,12 @@ export default class WebappDev extends SfCommand { // Keep the command running until interrupted or dev server exits await new Promise((resolve) => { + const handleSignal = (signal: string): void => { + this.logger?.debug(`Received ${signal} signal, initiating graceful shutdown`); + process.exitCode = 130; // Standard exit code for SIGINT/SIGTERM + resolve(); + }; + // Exit if dev server exits with SIGINT (user pressed Ctrl+C) if (this.devServerManager) { this.devServerManager.on('exit', (code: number | null, signal: string | null) => { @@ -438,19 +444,15 @@ export default class WebappDev extends SfCommand { }); } - // CRITICAL: Use prependOnceListener to add our handlers BEFORE sfCommand's handlers + // CRITICAL: Remove sfCommand's signal handlers before adding our own. // sfCommand adds process.on('SIGINT', () => this.exit(130)) which throws ExitError - // By using prependOnceListener, our resolve() runs FIRST, allowing clean shutdown - // This is especially important when there's no dev server (explicit URL mode) - process.prependOnceListener('SIGINT', () => { - this.logger?.debug('Received SIGINT signal, initiating graceful shutdown'); - resolve(); - }); - - process.prependOnceListener('SIGTERM', () => { - this.logger?.debug('Received SIGTERM signal, initiating graceful shutdown'); - resolve(); - }); + // and prints an ugly stack trace. By removing those handlers and handling signals + // ourselves, we exit cleanly: resolve() -> run() returns -> finally() cleans up. + const signalsToHandle = ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP'] as const; + for (const signal of signalsToHandle) { + process.removeAllListeners(signal); + process.once(signal, () => handleSignal(signal)); + } }); // Return result (never reached, but required for type safety) @@ -482,8 +484,6 @@ export default class WebappDev extends SfCommand { * This is the proper way to handle cleanup in oclif commands */ protected async finally(): Promise { - // Cleanup all resources silently - // Don't show messages here as this runs on ALL exits (errors, Ctrl+C, etc) await this.cleanup(); } @@ -514,11 +514,18 @@ export default class WebappDev extends SfCommand { * Cleanup all resources (proxy, dev server, file watcher) */ private async cleanup(): Promise { - // Stop proxy server + const hasProxy = !!this.proxyServer; + const hasDevServer = !!this.devServerManager; + const showShutdownLog = hasProxy || hasDevServer; + + if (showShutdownLog) { + this.log(''); + } + + // Stop proxy server first (closes connections, stops accepting new requests) if (this.proxyServer) { try { await this.proxyServer.stop(); - this.logger?.debug('Proxy server stopped'); } catch (error) { this.logger?.debug(`Failed to stop proxy server: ${(error as Error).message}`); } @@ -529,7 +536,6 @@ export default class WebappDev extends SfCommand { if (this.devServerManager) { try { await this.devServerManager.stop(); - this.logger?.debug('Dev server stopped'); } catch (error) { this.logger?.debug(`Failed to stop dev server: ${(error as Error).message}`); } @@ -540,13 +546,21 @@ export default class WebappDev extends SfCommand { if (this.manifestWatcher) { try { await this.manifestWatcher.stop(); - this.logger?.debug('Manifest watcher stopped'); } catch (error) { this.logger?.debug(`Failed to stop manifest watcher: ${(error as Error).message}`); } this.manifestWatcher = null; } + if (showShutdownLog) { + if (hasProxy && hasDevServer) { + this.log(messages.getMessage('info.stopped-dev-and-proxy')); + } else if (hasProxy) { + this.log(messages.getMessage('info.stopped-proxy-only')); + } else { + this.log(messages.getMessage('info.stopped-dev-only')); + } + } this.logger?.debug('Cleanup complete'); } } diff --git a/src/config/webappDiscovery.ts b/src/config/webappDiscovery.ts index fa2df5c..88d3ef9 100644 --- a/src/config/webappDiscovery.ts +++ b/src/config/webappDiscovery.ts @@ -365,9 +365,7 @@ async function findAllWebapps(cwd: string = process.cwd()): Promise 0) { // Discover webapps from all package directories and combine - const webappArrays = await Promise.all( - webappsPaths.map((path) => discoverWebappsInFolder(path, cwd)) - ); + const webappArrays = await Promise.all(webappsPaths.map((path) => discoverWebappsInFolder(path, cwd))); const allWebapps = webappArrays.flat(); return { diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index 6475d0d..d10eb06 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -277,8 +277,17 @@ export class DevServerManager extends EventEmitter { const processToKill = this.process; - // Setup exit handler + // Force kill after 3 seconds if still running + const forceKillTimeout = setTimeout(() => { + if (this.process && !this.process.killed) { + this.logger.warn('Dev server did not exit gracefully, forcing kill...'); + this.process.kill('SIGKILL'); + } + }, 3000); + + // Setup exit handler - must clear timeout so process can exit immediately const onExit = (): void => { + clearTimeout(forceKillTimeout); this.logger.debug('Dev server process stopped'); this.process = null; resolve(); @@ -288,14 +297,6 @@ export class DevServerManager extends EventEmitter { // Try graceful shutdown first processToKill.kill('SIGTERM'); - - // Force kill after 3 seconds if still running - setTimeout(() => { - if (this.process && !this.process.killed) { - this.logger.warn('Dev server did not exit gracefully, forcing kill...'); - this.process.kill('SIGKILL'); - } - }, 3000); }); } diff --git a/test/config/webappDiscovery.test.ts b/test/config/webappDiscovery.test.ts index 4d5fc87..bc9d0a0 100644 --- a/test/config/webappDiscovery.test.ts +++ b/test/config/webappDiscovery.test.ts @@ -52,10 +52,7 @@ describe('webappDiscovery', () => { ): void { // Create SFDX project structure mkdirSync(sfdxWebappsPath, { recursive: true }); - writeFileSync( - join(testDir, 'sfdx-project.json'), - JSON.stringify({ packageDirectories: packageDirs }) - ); + writeFileSync(join(testDir, 'sfdx-project.json'), JSON.stringify({ packageDirectories: packageDirs })); // Mock SfProject.resolveProjectPath to return testDir SfProject.resolveProjectPath = async () => testDir; }