Skip to content

Commit 195e85a

Browse files
committed
fix: Implement production-ready shutdown timeout system
Fixes indefinite hangs during graceful shutdown (Ctrl+C) by implementing a three-tier timeout strategy that ensures the application always exits within 10 seconds, even when printers are unresponsive or HTTP connections are stuck. **Changes:** - **New timeout utility** (src/utils/ShutdownTimeout.ts): - TimeoutError class for timeout failures - withTimeout() wrapper for promises with timeout enforcement - createHardDeadline() for absolute maximum shutdown time - **ConnectionFlowManager.disconnectContext()**: - Added 5s timeout wrapper (configurable) - On timeout: forces cleanup (removes context, marks disconnected) - Backward compatible with existing callers - **WebUIManager.stop()**: - Added 3s timeout wrapper (configurable) - On timeout: calls closeAllConnections() to force-close stuck HTTP connections - Added isStopping guard flag to prevent concurrent stop calls - Added timing information logging - **Main shutdown logic** (src/index.ts): - Replaced sequential disconnect loop with parallel Promise.allSettled() - All printers disconnect concurrently instead of one-by-one - Added 10s hard deadline that calls process.exit(1) if exceeded - Added step-by-step logging with clear progress indicators - Preserved second Ctrl+C force-exit as safety net **Benefits:** - Single printer: < 5s shutdown - Multiple printers: < 10s (parallelized) - Hard deadline: Always exits within 10s maximum - Second Ctrl+C: Immediate force exit - Clear logging: Step-by-step progress visible **Timeout hierarchy:** ``` HARD DEADLINE (10s) ─────────────────────────────────┐ ├── Stop polling (immediate) │ ├── Parallel disconnects (5s each, concurrent) ─┤ └── WebUI server stop (3s, force close if timeout)─┘ ``` All changes are backward compatible - optional timeout parameters have sensible defaults and existing callers work without modification.
1 parent 8a31cbc commit 195e85a

4 files changed

Lines changed: 278 additions & 64 deletions

File tree

src/index.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getRtspStreamService } from './services/RtspStreamService';
2828
import { initializeSpoolmanIntegrationService } from './services/SpoolmanIntegrationService';
2929
import { getSavedPrinterService } from './services/SavedPrinterService';
3030
import { parseHeadlessArguments, validateHeadlessConfig } from './utils/HeadlessArguments';
31+
import { withTimeout, createHardDeadline } from './utils/ShutdownTimeout';
3132
import type { HeadlessConfig, PrinterSpec } from './utils/HeadlessArguments';
3233
import type { PrinterDetails, PrinterClientType } from './types/printer';
3334
import { initializeDataDirectory } from './utils/setup';
@@ -46,6 +47,25 @@ const _cameraProxyService = getCameraProxyService();
4647

4748
let connectedContexts: string[] = [];
4849
let isInitialized = false;
50+
let isShuttingDown = false;
51+
52+
/**
53+
* Shutdown timeout configuration
54+
*
55+
* Layered timeout strategy:
56+
* 1. Per-operation timeouts (disconnect: 5s, webui: 3s)
57+
* 2. Hard deadline (10s absolute maximum)
58+
*
59+
* This prevents hangs from unresponsive printers or stuck HTTP connections
60+
*/
61+
const SHUTDOWN_CONFIG = {
62+
/** Hard deadline - forces process.exit(1) if exceeded */
63+
HARD_DEADLINE_MS: 10000,
64+
/** Per-printer disconnect timeout (parallelized, so 3 printers = ~5s total) */
65+
DISCONNECT_TIMEOUT_MS: 5000,
66+
/** WebUI server graceful close timeout */
67+
WEBUI_STOP_TIMEOUT_MS: 3000
68+
} as const;
4969

5070
/**
5171
* Apply configuration overrides from CLI arguments
@@ -247,7 +267,12 @@ async function initializeCameraProxies(): Promise<void> {
247267
function setupSignalHandlers(): void {
248268
// Handle Ctrl+C (works on all platforms including Windows)
249269
process.on('SIGINT', () => {
270+
if (isShuttingDown) {
271+
console.log('\n[Shutdown] Force exit (second Ctrl+C)');
272+
process.exit(1);
273+
}
250274
console.log('\n[Shutdown] Received SIGINT signal (Ctrl+C)');
275+
isShuttingDown = true;
251276
void shutdown().then(() => {
252277
process.exit(0);
253278
}).catch((error) => {
@@ -283,36 +308,75 @@ function setupSignalHandlers(): void {
283308

284309
/**
285310
* Gracefully shutdown the application
311+
*
312+
* Implements a three-tier timeout strategy:
313+
* 1. Hard deadline (10s) - ultimate fallback with process.exit(1)
314+
* 2. Parallel disconnects (5s each, concurrent) - one hung printer doesn't block others
315+
* 3. WebUI stop (3s) - force-close connections if timeout
316+
*
317+
* This ensures the application always exits within 10 seconds, even if
318+
* printers are unresponsive or HTTP connections are stuck.
286319
*/
287320
async function shutdown(): Promise<void> {
288321
if (!isInitialized) {
289322
return;
290323
}
291324

292-
console.log('[Shutdown] Stopping services...');
325+
const startTime = Date.now();
326+
console.log('[Shutdown] Starting graceful shutdown...');
327+
328+
// Set hard deadline - ultimate fallback to prevent indefinite hangs
329+
const hardDeadline = createHardDeadline(SHUTDOWN_CONFIG.HARD_DEADLINE_MS);
293330

294331
try {
295-
// Stop all polling
332+
// Step 1: Stop polling (immediate)
333+
console.log('[Shutdown] Step 1/4: Stopping polling...');
296334
pollingCoordinator.stopAllPolling();
297-
console.log('[Shutdown] Polling stopped');
335+
console.log('[Shutdown] Polling stopped');
298336

299-
// Disconnect all printers
300-
for (const contextId of connectedContexts) {
301-
try {
302-
await connectionManager.disconnectContext(contextId);
303-
console.log(`[Shutdown] Disconnected context: ${contextId}`);
304-
} catch (error) {
305-
console.error(`[Shutdown] Error disconnecting context ${contextId}:`, error);
306-
}
337+
// Step 2: Parallel disconnects (all printers disconnect concurrently)
338+
console.log(`[Shutdown] Step 2/4: Disconnecting ${connectedContexts.length} context(s)...`);
339+
if (connectedContexts.length > 0) {
340+
const results = await Promise.allSettled(
341+
connectedContexts.map(contextId =>
342+
withTimeout(
343+
connectionManager.disconnectContext(contextId),
344+
{
345+
timeoutMs: SHUTDOWN_CONFIG.DISCONNECT_TIMEOUT_MS,
346+
operation: `disconnectContext(${contextId})`
347+
}
348+
)
349+
)
350+
);
351+
352+
const succeeded = results.filter(r => r.status === 'fulfilled').length;
353+
const failed = results.filter(r => r.status === 'rejected').length;
354+
355+
console.log(`[Shutdown] ✓ Disconnect: ${succeeded} succeeded, ${failed} failed`);
356+
357+
// Log individual failures for debugging
358+
results.forEach((result, index) => {
359+
if (result.status === 'rejected') {
360+
console.warn(`[Shutdown] Context ${connectedContexts[index]} failed:`, result.reason);
361+
}
362+
});
363+
} else {
364+
console.log('[Shutdown] ✓ No contexts to disconnect');
307365
}
308366

309-
// Stop WebUI
310-
await webUIManager.stop();
311-
console.log('[Shutdown] WebUI server stopped');
367+
// Step 3: Stop WebUI (with timeout and force-close fallback)
368+
console.log('[Shutdown] Step 3/4: Stopping WebUI...');
369+
await webUIManager.stop(SHUTDOWN_CONFIG.WEBUI_STOP_TIMEOUT_MS);
370+
console.log('[Shutdown] ✓ WebUI stopped');
371+
372+
// Step 4: Complete (cancel hard deadline)
373+
clearTimeout(hardDeadline);
374+
const duration = Date.now() - startTime;
375+
console.log(`[Shutdown] ✓ Complete (${duration}ms)`);
312376

313-
console.log('[Shutdown] Graceful shutdown complete');
314377
} catch (error) {
315-
console.error('[Shutdown] Error during shutdown:', error);
378+
console.error('[Shutdown] Error:', error);
379+
// Hard deadline will still fire if we exceed max time
316380
}
317381
}
318382

src/managers/ConnectionFlowManager.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { getLoadingManager } from './LoadingManager';
3333
import { getPrinterBackendManager } from './PrinterBackendManager';
3434
import { getPrinterContextManager } from './PrinterContextManager';
3535
import { getPrinterDiscoveryService } from '../services/PrinterDiscoveryService';
36+
import { withTimeout, TimeoutError } from '../utils/ShutdownTimeout';
3637
import { getThumbnailRequestQueue } from '../services/ThumbnailRequestQueue';
3738
import { getSavedPrinterService } from '../services/SavedPrinterService';
3839
import { getAutoConnectService } from '../services/AutoConnectService';
@@ -451,7 +452,7 @@ export class ConnectionFlowManager extends EventEmitter {
451452
}
452453

453454
/** Disconnect a specific printer context with proper cleanup */
454-
public async disconnectContext(contextId: string): Promise<void> {
455+
public async disconnectContext(contextId: string, timeoutMs = 5000): Promise<void> {
455456
const context = this.contextManager.getContext(contextId);
456457
if (!context) {
457458
console.warn(`Cannot disconnect - context ${contextId} not found`);
@@ -461,37 +462,48 @@ export class ConnectionFlowManager extends EventEmitter {
461462
const currentDetails = context.printerDetails;
462463

463464
try {
464-
console.log(`Starting disconnect sequence for context ${contextId}...`);
465-
466-
// Stop polling first
467-
this.emit('pre-disconnect', contextId);
468-
await new Promise(resolve => setTimeout(resolve, 100));
465+
await withTimeout(
466+
(async () => {
467+
console.log(`Starting disconnect sequence for context ${contextId}...`);
468+
469+
// Stop polling first
470+
this.emit('pre-disconnect', contextId);
471+
await new Promise(resolve => setTimeout(resolve, 100));
472+
473+
// Get clients for disposal from connection state
474+
const primaryClient = this.connectionStateManager.getPrimaryClient(contextId);
475+
const secondaryClient = this.connectionStateManager.getSecondaryClient(contextId);
476+
477+
// Dispose backend for this context
478+
await this.backendManager.disposeContext(contextId);
479+
480+
// Dispose clients through connection service (handles logout)
481+
await this.connectionService.disposeClients(
482+
primaryClient,
483+
secondaryClient,
484+
currentDetails?.ClientType
485+
);
469486

470-
// Get clients for disposal from connection state
471-
const primaryClient = this.connectionStateManager.getPrimaryClient(contextId);
472-
const secondaryClient = this.connectionStateManager.getSecondaryClient(contextId);
487+
// Update connection state
488+
this.connectionStateManager.setDisconnected(contextId);
473489

474-
// Dispose backend for this context
475-
await this.backendManager.disposeContext(contextId);
490+
// Remove context from manager
491+
this.contextManager.removeContext(contextId);
476492

477-
// Dispose clients through connection service (handles logout)
478-
await this.connectionService.disposeClients(
479-
primaryClient,
480-
secondaryClient,
481-
currentDetails?.ClientType
493+
// Emit disconnected event
494+
this.emit('disconnected', currentDetails?.Name);
495+
})(),
496+
{ timeoutMs, operation: `disconnectContext(${contextId})` }
482497
);
483-
484-
// Update connection state
485-
this.connectionStateManager.setDisconnected(contextId);
486-
487-
// Remove context from manager
488-
this.contextManager.removeContext(contextId);
489-
490-
// Emit disconnected event
491-
this.emit('disconnected', currentDetails?.Name);
492-
493498
} catch (error) {
494-
console.error(`Error during disconnect for context ${contextId}:`, error);
499+
if (error instanceof TimeoutError) {
500+
console.error(`[Shutdown] Context ${contextId} timed out, forcing cleanup`);
501+
// Force cleanup on timeout
502+
this.contextManager.removeContext(contextId);
503+
this.connectionStateManager.setDisconnected(contextId);
504+
} else {
505+
console.error(`Error during disconnect for context ${contextId}:`, error);
506+
}
495507
}
496508
}
497509

src/utils/ShutdownTimeout.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @fileoverview Timeout utilities for graceful shutdown operations.
3+
*
4+
* Provides timeout wrappers and deadline enforcement for async operations
5+
* during application shutdown. Prevents indefinite hangs from unresponsive
6+
* printers or stuck HTTP connections.
7+
*
8+
* Key exports:
9+
* - TimeoutError: Custom error class for timeout failures
10+
* - withTimeout(): Promise wrapper with timeout enforcement
11+
* - createHardDeadline(): Sets absolute maximum shutdown time with process.exit(1)
12+
*/
13+
14+
/**
15+
* Custom error thrown when an operation exceeds its timeout
16+
*/
17+
export class TimeoutError extends Error {
18+
constructor(operation: string, timeoutMs: number) {
19+
super(`Timeout: ${operation} exceeded ${timeoutMs}ms`);
20+
this.name = 'TimeoutError';
21+
}
22+
}
23+
24+
/**
25+
* Wrap a promise with timeout enforcement
26+
*
27+
* Races the provided promise against a timeout. If the timeout fires first,
28+
* the promise is rejected with TimeoutError. Properly cleans up timeout
29+
* handle to prevent memory leaks.
30+
*
31+
* @template T - Promise result type
32+
* @param promise - The promise to wrap with timeout
33+
* @param options - Timeout configuration
34+
* @returns Promise that rejects on timeout
35+
*
36+
* @example
37+
* ```typescript
38+
* await withTimeout(
39+
* disconnectContext(contextId),
40+
* { timeoutMs: 5000, operation: 'disconnectContext' }
41+
* );
42+
* ```
43+
*/
44+
export async function withTimeout<T>(
45+
promise: Promise<T>,
46+
options: { timeoutMs: number; operation: string; silent?: boolean }
47+
): Promise<T> {
48+
const { timeoutMs, operation, silent = false } = options;
49+
50+
let timeoutHandle: NodeJS.Timeout | undefined;
51+
52+
const timeoutPromise = new Promise<T>((_, reject) => {
53+
timeoutHandle = setTimeout(() => {
54+
if (!silent) {
55+
console.warn(`[Shutdown] Timeout: ${operation} (${timeoutMs}ms)`);
56+
}
57+
reject(new TimeoutError(operation, timeoutMs));
58+
}, timeoutMs);
59+
});
60+
61+
try {
62+
const result = await Promise.race([promise, timeoutPromise]);
63+
64+
// Clear timeout if promise won the race
65+
if (timeoutHandle) {
66+
clearTimeout(timeoutHandle);
67+
}
68+
69+
return result;
70+
} catch (error) {
71+
// Ensure timeout is cleared even on error
72+
if (timeoutHandle) {
73+
clearTimeout(timeoutHandle);
74+
}
75+
throw error;
76+
}
77+
}
78+
79+
/**
80+
* Create a hard deadline that forces process termination
81+
*
82+
* Sets a timeout that calls process.exit(1) when elapsed. This is the
83+
* ultimate fallback to prevent the application from hanging indefinitely
84+
* during shutdown. Returns the timeout handle so the deadline can be
85+
* cleared if shutdown completes successfully.
86+
*
87+
* @param timeoutMs - Deadline duration in milliseconds
88+
* @returns NodeJS.Timeout handle for deadline cancellation
89+
*
90+
* @example
91+
* ```typescript
92+
* const deadline = createHardDeadline(10000);
93+
* try {
94+
* await shutdown();
95+
* clearTimeout(deadline); // Shutdown succeeded, cancel deadline
96+
* } catch (error) {
97+
* // Error logged, deadline will fire if exceeded
98+
* }
99+
* ```
100+
*/
101+
export function createHardDeadline(timeoutMs: number): NodeJS.Timeout {
102+
return setTimeout(() => {
103+
console.error(`[Shutdown] HARD DEADLINE (${timeoutMs}ms) exceeded - forcing exit`);
104+
process.exit(1);
105+
}, timeoutMs);
106+
}

0 commit comments

Comments
 (0)