Skip to content
Merged
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
12 changes: 12 additions & 0 deletions messages/webapp.dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 32 additions & 18 deletions src/commands/webapp/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {

// Keep the command running until interrupted or dev server exits
await new Promise<void>((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) => {
Expand All @@ -438,19 +444,15 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
});
}

// 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)
Expand Down Expand Up @@ -482,8 +484,6 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
* This is the proper way to handle cleanup in oclif commands
*/
protected async finally(): Promise<void> {
// Cleanup all resources silently
// Don't show messages here as this runs on ALL exits (errors, Ctrl+C, etc)
await this.cleanup();
}

Expand Down Expand Up @@ -514,11 +514,18 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
* Cleanup all resources (proxy, dev server, file watcher)
*/
private async cleanup(): Promise<void> {
// 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}`);
}
Expand All @@ -529,7 +536,6 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
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}`);
}
Expand All @@ -540,13 +546,21 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
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');
}
}
4 changes: 1 addition & 3 deletions src/config/webappDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,7 @@ async function findAllWebapps(cwd: string = process.cwd()): Promise<FindAllWebap

if (webappsPaths.length > 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 {
Expand Down
19 changes: 10 additions & 9 deletions src/server/DevServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
});
}

Expand Down
5 changes: 1 addition & 4 deletions test/config/webappDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down