From 8b593d2a867ea2f4a843e4a5ec179f18f0b386f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hakan=20Ta=C5=9Fk=C3=B6pr=C3=BC?= Date: Sun, 19 Apr 2026 12:18:22 +0300 Subject: [PATCH 1/2] fix(desktop): keep terminal-host daemon alive across app quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting SIGHUP to a no-op (nohup semantics) so the detached daemon actually survives `app.exit(0)` on macOS. The marketing blog claims "the terminal survives app restart" via `detached: true` + `unref()`, but in practice the daemon was being killed on every Cmd+Q — log showed "Forced exit after SIGHUP shutdown timeout" within seconds of quit. Root cause: `setsid()` isolates the Unix SID but the daemon still shares the macOS Mach bootstrap / login Security Session with its parent. When Electron tears down that login session, SIGHUP propagates to the daemon and our handler called shutdownOnce(), killing every active PTY along with it. Fix: replace the SIGHUP shutdown handler with a logging no-op (signal-handlers.ts) and register a defensive no-op in main() before setupSignalHandlers() runs (index.ts) to close the race window during daemon boot. Refs #2501 --- apps/desktop/src/main/terminal-host/index.ts | 8 ++++++++ .../src/main/terminal-host/signal-handlers.ts | 17 +++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index ac5b1583bc2..e262e149d84 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -826,6 +826,14 @@ async function main() { log("info", `Environment: ${process.env.NODE_ENV || "production"}`); log("info", `Home directory: ${SUPERSET_HOME_DIR}`); + // Belt-and-suspenders SIGHUP guard against the race window where the + // macOS login session tears down between process boot and + // setupSignalHandlers() registration. Without a registered listener, + // Node's default action for SIGHUP is to terminate the process — which + // would kill the daemon during Electron's exit before we can install our + // no-op handler in setupSignalHandlers(). + process.on("SIGHUP", () => {}); + setupSignalHandlers(); try { diff --git a/apps/desktop/src/main/terminal-host/signal-handlers.ts b/apps/desktop/src/main/terminal-host/signal-handlers.ts index 1bb63f84db9..e491fe2fdc1 100644 --- a/apps/desktop/src/main/terminal-host/signal-handlers.ts +++ b/apps/desktop/src/main/terminal-host/signal-handlers.ts @@ -127,13 +127,18 @@ 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", - }); + log("info", "Received SIGHUP; ignoring (nohup semantics for daemon survival)"); }); process.on("uncaughtException", (error) => { From 920432c102a2a4b8a8c45f276254e20523efefa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hakan=20Ta=C5=9Fk=C3=B6pr=C3=BC?= Date: Sun, 19 Apr 2026 17:10:35 +0300 Subject: [PATCH 2/2] fix(daemon): harden SIGHUP handler and clean up early guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from coderabbitai and greptile on #3574: (1) Wrap the SIGHUP handler's log() call in try/catch. If the daemon was spawned with stdio:'pipe' and Electron exits first, writing to the now-closed pipe throws EPIPE; without the guard, that flows into uncaughtException → shutdownOnce() with exit 1, defeating the entire purpose of the nohup-semantics no-op. (2) Move the early SIGHUP guard to the very first statement of main() so it is installed before the log() calls that follow, closing the startup race window. Then remove the temporary guard after setupSignalHandlers() runs — process.on adds rather than replaces, so leaving both in place would permanently register two SIGHUP listeners. --- apps/desktop/src/main/terminal-host/index.ts | 21 ++++++++++++------- .../src/main/terminal-host/signal-handlers.ts | 14 ++++++++++++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index e262e149d84..3687b5238e1 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -822,19 +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}`); - // Belt-and-suspenders SIGHUP guard against the race window where the - // macOS login session tears down between process boot and - // setupSignalHandlers() registration. Without a registered listener, - // Node's default action for SIGHUP is to terminate the process — which - // would kill the daemon during Electron's exit before we can install our - // no-op handler in setupSignalHandlers(). - process.on("SIGHUP", () => {}); - 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 e491fe2fdc1..3c1f9c185e6 100644 --- a/apps/desktop/src/main/terminal-host/signal-handlers.ts +++ b/apps/desktop/src/main/terminal-host/signal-handlers.ts @@ -138,7 +138,19 @@ export function setupTerminalHostSignalHandlers({ // unless explicitly ignored. Registering a no-op listener prevents the // default terminate action without triggering our shutdownOnce path. process.on("SIGHUP", () => { - log("info", "Received SIGHUP; ignoring (nohup semantics for daemon survival)"); + // 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) => {