Skip to content

Commit 46a93f0

Browse files
fix: improve dev stop Ctrl+C output with concise shutdown messages (#29)
@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 <cursoragent@cursor.com>
1 parent e743427 commit 46a93f0

5 files changed

Lines changed: 56 additions & 34 deletions

File tree

messages/webapp.dev.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,15 @@ The proxy will use the actual dev server URL.
191191
# info.vite-proxy-detected
192192

193193
Vite WebApp proxy detected at %s - using Vite's built-in proxy (standalone proxy skipped)
194+
195+
# info.stopped-proxy-only
196+
197+
✅ Stopped proxy server.
198+
199+
# info.stopped-dev-only
200+
201+
✅ Stopped dev server.
202+
203+
# info.stopped-dev-and-proxy
204+
205+
✅ Stopped dev & proxy servers.

src/commands/webapp/dev.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,12 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
428428

429429
// Keep the command running until interrupted or dev server exits
430430
await new Promise<void>((resolve) => {
431+
const handleSignal = (signal: string): void => {
432+
this.logger?.debug(`Received ${signal} signal, initiating graceful shutdown`);
433+
process.exitCode = 130; // Standard exit code for SIGINT/SIGTERM
434+
resolve();
435+
};
436+
431437
// Exit if dev server exits with SIGINT (user pressed Ctrl+C)
432438
if (this.devServerManager) {
433439
this.devServerManager.on('exit', (code: number | null, signal: string | null) => {
@@ -438,19 +444,15 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
438444
});
439445
}
440446

441-
// CRITICAL: Use prependOnceListener to add our handlers BEFORE sfCommand's handlers
447+
// CRITICAL: Remove sfCommand's signal handlers before adding our own.
442448
// sfCommand adds process.on('SIGINT', () => this.exit(130)) which throws ExitError
443-
// By using prependOnceListener, our resolve() runs FIRST, allowing clean shutdown
444-
// This is especially important when there's no dev server (explicit URL mode)
445-
process.prependOnceListener('SIGINT', () => {
446-
this.logger?.debug('Received SIGINT signal, initiating graceful shutdown');
447-
resolve();
448-
});
449-
450-
process.prependOnceListener('SIGTERM', () => {
451-
this.logger?.debug('Received SIGTERM signal, initiating graceful shutdown');
452-
resolve();
453-
});
449+
// and prints an ugly stack trace. By removing those handlers and handling signals
450+
// ourselves, we exit cleanly: resolve() -> run() returns -> finally() cleans up.
451+
const signalsToHandle = ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP'] as const;
452+
for (const signal of signalsToHandle) {
453+
process.removeAllListeners(signal);
454+
process.once(signal, () => handleSignal(signal));
455+
}
454456
});
455457

456458
// Return result (never reached, but required for type safety)
@@ -482,8 +484,6 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
482484
* This is the proper way to handle cleanup in oclif commands
483485
*/
484486
protected async finally(): Promise<void> {
485-
// Cleanup all resources silently
486-
// Don't show messages here as this runs on ALL exits (errors, Ctrl+C, etc)
487487
await this.cleanup();
488488
}
489489

@@ -514,11 +514,18 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
514514
* Cleanup all resources (proxy, dev server, file watcher)
515515
*/
516516
private async cleanup(): Promise<void> {
517-
// Stop proxy server
517+
const hasProxy = !!this.proxyServer;
518+
const hasDevServer = !!this.devServerManager;
519+
const showShutdownLog = hasProxy || hasDevServer;
520+
521+
if (showShutdownLog) {
522+
this.log('');
523+
}
524+
525+
// Stop proxy server first (closes connections, stops accepting new requests)
518526
if (this.proxyServer) {
519527
try {
520528
await this.proxyServer.stop();
521-
this.logger?.debug('Proxy server stopped');
522529
} catch (error) {
523530
this.logger?.debug(`Failed to stop proxy server: ${(error as Error).message}`);
524531
}
@@ -529,7 +536,6 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
529536
if (this.devServerManager) {
530537
try {
531538
await this.devServerManager.stop();
532-
this.logger?.debug('Dev server stopped');
533539
} catch (error) {
534540
this.logger?.debug(`Failed to stop dev server: ${(error as Error).message}`);
535541
}
@@ -540,13 +546,21 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
540546
if (this.manifestWatcher) {
541547
try {
542548
await this.manifestWatcher.stop();
543-
this.logger?.debug('Manifest watcher stopped');
544549
} catch (error) {
545550
this.logger?.debug(`Failed to stop manifest watcher: ${(error as Error).message}`);
546551
}
547552
this.manifestWatcher = null;
548553
}
549554

555+
if (showShutdownLog) {
556+
if (hasProxy && hasDevServer) {
557+
this.log(messages.getMessage('info.stopped-dev-and-proxy'));
558+
} else if (hasProxy) {
559+
this.log(messages.getMessage('info.stopped-proxy-only'));
560+
} else {
561+
this.log(messages.getMessage('info.stopped-dev-only'));
562+
}
563+
}
550564
this.logger?.debug('Cleanup complete');
551565
}
552566
}

src/config/webappDiscovery.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,7 @@ async function findAllWebapps(cwd: string = process.cwd()): Promise<FindAllWebap
365365

366366
if (webappsPaths.length > 0) {
367367
// Discover webapps from all package directories and combine
368-
const webappArrays = await Promise.all(
369-
webappsPaths.map((path) => discoverWebappsInFolder(path, cwd))
370-
);
368+
const webappArrays = await Promise.all(webappsPaths.map((path) => discoverWebappsInFolder(path, cwd)));
371369
const allWebapps = webappArrays.flat();
372370

373371
return {

src/server/DevServerManager.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,17 @@ export class DevServerManager extends EventEmitter {
277277

278278
const processToKill = this.process;
279279

280-
// Setup exit handler
280+
// Force kill after 3 seconds if still running
281+
const forceKillTimeout = setTimeout(() => {
282+
if (this.process && !this.process.killed) {
283+
this.logger.warn('Dev server did not exit gracefully, forcing kill...');
284+
this.process.kill('SIGKILL');
285+
}
286+
}, 3000);
287+
288+
// Setup exit handler - must clear timeout so process can exit immediately
281289
const onExit = (): void => {
290+
clearTimeout(forceKillTimeout);
282291
this.logger.debug('Dev server process stopped');
283292
this.process = null;
284293
resolve();
@@ -288,14 +297,6 @@ export class DevServerManager extends EventEmitter {
288297

289298
// Try graceful shutdown first
290299
processToKill.kill('SIGTERM');
291-
292-
// Force kill after 3 seconds if still running
293-
setTimeout(() => {
294-
if (this.process && !this.process.killed) {
295-
this.logger.warn('Dev server did not exit gracefully, forcing kill...');
296-
this.process.kill('SIGKILL');
297-
}
298-
}, 3000);
299300
});
300301
}
301302

test/config/webappDiscovery.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,7 @@ describe('webappDiscovery', () => {
5252
): void {
5353
// Create SFDX project structure
5454
mkdirSync(sfdxWebappsPath, { recursive: true });
55-
writeFileSync(
56-
join(testDir, 'sfdx-project.json'),
57-
JSON.stringify({ packageDirectories: packageDirs })
58-
);
55+
writeFileSync(join(testDir, 'sfdx-project.json'), JSON.stringify({ packageDirectories: packageDirs }));
5956
// Mock SfProject.resolveProjectPath to return testDir
6057
SfProject.resolveProjectPath = async () => testDir;
6158
}

0 commit comments

Comments
 (0)