diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index ac5b1583bc2..3687b5238e1 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -822,11 +822,24 @@ function setupSignalHandlers() { // ============================================================================= async function main() { + // Belt-and-suspenders SIGHUP guard, installed before anything else (log + // calls included). Without a registered listener, Node's default action + // for SIGHUP is to terminate the process — so if Electron tears down the + // macOS login session during our startup logging, the daemon dies before + // setupSignalHandlers() can install the definitive listener. + // + // We remove this temporary guard after setupSignalHandlers() to keep the + // listener set clean — `process.on` adds rather than replaces, so leaving + // it in place would leave two SIGHUP listeners for the daemon's lifetime. + const earlyHupGuard = (): void => {}; + process.on("SIGHUP", earlyHupGuard); + log("info", "Terminal Host Daemon starting..."); log("info", `Environment: ${process.env.NODE_ENV || "production"}`); log("info", `Home directory: ${SUPERSET_HOME_DIR}`); setupSignalHandlers(); + process.removeListener("SIGHUP", earlyHupGuard); try { await startServer(); diff --git a/apps/desktop/src/main/terminal-host/signal-handlers.ts b/apps/desktop/src/main/terminal-host/signal-handlers.ts index 1bb63f84db9..3c1f9c185e6 100644 --- a/apps/desktop/src/main/terminal-host/signal-handlers.ts +++ b/apps/desktop/src/main/terminal-host/signal-handlers.ts @@ -127,13 +127,30 @@ export function setupTerminalHostSignalHandlers({ timeoutMessage: "Forced exit after SIGTERM shutdown timeout", }); }); + // SIGHUP: intentionally ignored (nohup semantics). + // + // The daemon is spawned with `detached: true` + `child.unref()` so it can + // outlive Electron's exit — see marketing blog terminal-daemon-deep-dive + // "Terminal survives app restart". On macOS, `setsid()` isolates the Unix + // SID but the daemon still shares the Mach bootstrap / login Security + // Session with its parent. When Electron calls `app.exit(0)` and tears + // down that login session, SIGHUP propagates to the daemon and kills it + // unless explicitly ignored. Registering a no-op listener prevents the + // default terminate action without triggering our shutdownOnce path. process.on("SIGHUP", () => { - shutdownOnce({ - exitCode: 0, - message: "Received SIGHUP, shutting down...", - stopServerErrorMessage: "Error during stopServer in SIGHUP shutdown", - timeoutMessage: "Forced exit after SIGHUP shutdown timeout", - }); + // Protect the nohup semantics even if logging fails. If the daemon's + // stdout/stderr pipe closes (e.g. Electron exits first and we were + // spawned with stdio:"pipe"/"inherit"), writing to it throws EPIPE + // — an uncaught exception would flow into shutdownOnce and defeat + // the entire purpose of this handler. + try { + log( + "info", + "Received SIGHUP; ignoring (nohup semantics for daemon survival)", + ); + } catch { + // Preserve ignore semantics unconditionally. + } }); process.on("uncaughtException", (error) => {