Skip to content

fix(desktop): keep terminal-host daemon alive across app quit#3574

Open
Haknt wants to merge 2 commits intosuperset-sh:mainfrom
Haknt:fix/daemon-survives-app-quit
Open

fix(desktop): keep terminal-host daemon alive across app quit#3574
Haknt wants to merge 2 commits intosuperset-sh:mainfrom
Haknt:fix/daemon-survives-app-quit

Conversation

@Haknt
Copy link
Copy Markdown

@Haknt Haknt commented Apr 19, 2026

Summary

The terminal-host daemon is spawned detached: true + unref() so it can outlive the Electron main process, which is the mechanism behind the "Terminal That (Almost) Never Dies" promise in the terminal-daemon-deep-dive blog post. In practice, on macOS, Cmd+Q still tore the daemon down along with every active terminal session.

Root cause is a macOS-specific signal propagation path. setsid() (implicit from Node's detached: true) isolates the daemon's Unix SID, but the daemon still shares the Mach bootstrap / login Security Session with its parent. When Electron's app.exit(0) tears down that login session, macOS propagates SIGHUP to the daemon — and the existing signal handler called shutdownOnce(), killing every PTY subprocess with it.

Fix: register a SIGHUP listener that is a logging no-op (nohup semantics). The daemon now survives a Cmd+Q quit. Intentional daemon shutdown still goes through killDaemonFromPidFile() (SIGKILL, which this handler cannot intercept) and the explicit shutdown RPC path.

Fixes #2501.

Test plan

  • bun run typecheck clean
  • Manual verification on macOS: open Superset with terminals open, Cmd+Q, verify pgrep -f terminal-host.js still shows the same daemon PID, relaunch, verify sessions hot-reattach with Session ... attached log line (not created).
  • Confirmed on 2026-04-19: same daemon PID survived 5+ quit-relaunch cycles; PTY subprocess PID remained unchanged across the cycle.

Notes

  • Tested in combination with #<PR4_NUM> (fresh-spawn PTY spawn in daemon). That PR ensures PTY subprocesses are spawned as daemon's children rather than Electron main's, so daemon survival translates to session survival end-to-end.
  • A defensive SIGTERM no-op and a process-group-aware rewrite are possible follow-ups if observed SIGHUP handling proves insufficient on other macOS versions, but the SIGHUP-only patch was sufficient in all tested configurations.

Summary by cubic

Keeps the terminal-host daemon alive after Cmd+Q on macOS by ignoring SIGHUP, so terminal sessions persist across app restarts. Fixes #2501.

  • Bug Fixes
    • Treat SIGHUP as a no-op (nohup semantics) instead of calling shutdownOnce; wrap the log in try/catch to avoid EPIPE if stdio is closed.
    • Add a temporary SIGHUP guard at the first line of daemon startup (before any logs), then remove it after setupSignalHandlers() to close the race and avoid duplicate listeners; intentional shutdown paths remain unchanged.

Written for commit 920432c. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented unexpected termination from SIGHUP during early startup so the app can continue running.
    • Treat SIGHUP as ignored at runtime and record an informational message; logging is made resilient so logging failures won't trigger shutdown.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1884f699-0081-4ec8-98b6-1414af7fc137

📥 Commits

Reviewing files that changed from the base of the PR and between 903c94b and 920432c.

📒 Files selected for processing (2)
  • apps/desktop/src/main/terminal-host/index.ts
  • apps/desktop/src/main/terminal-host/signal-handlers.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/desktop/src/main/terminal-host/index.ts

📝 Walkthrough

Walkthrough

Adds an early no-op SIGHUP listener during startup and changes the SIGHUP shutdown handler to ignore the signal with a guarded informational log; other signal and error shutdown flows remain unchanged.

Changes

Cohort / File(s) Summary
Startup SIGHUP guard
apps/desktop/src/main/terminal-host/index.ts
Registers an early process.on('SIGHUP', ...) no-op listener before initial logging to prevent Node's default SIGHUP termination during startup, then removes it after setupSignalHandlers() runs.
SIGHUP handler update
apps/desktop/src/main/terminal-host/signal-handlers.ts
Replaces previous SIGHUP-triggered shutdown with an ignore/no-op handler that attempts a single informational log wrapped in try/catch to avoid logging errors (e.g., broken pipes) throwing; other handlers unchanged.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I felt a HUP upon the lea,
I twitched, then shrugged, "Not today, not me."
A quiet guard, a gentle sigh,
Terminals hum while moonbeams ply.
Hop on — the daemon keeps the key. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: keeping the terminal-host daemon alive when the app quits, which is the primary objective of the PR.
Description check ✅ Passed The description comprehensively covers the root cause, fix, test plan results, and notes. It includes linked issue reference (#2501) and explains the macOS-specific signal propagation problem and solution.
Linked Issues check ✅ Passed The code changes address the linked issue #2501 by implementing SIGHUP handling that allows the daemon to survive app quit, preventing unexpected session terminal loss on macOS.
Out of Scope Changes check ✅ Passed All changes are scoped to the terminal-host daemon signal handling and startup to address the daemon survival problem; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Haknt
Copy link
Copy Markdown
Author

Haknt commented Apr 19, 2026

Companion PR for end-to-end session survival on macOS: #3575 makes PTY subprocesses daemon-owned so the daemon-alive guarantee here translates into live terminal sessions after Cmd+Q. UX fallback banner: #3573.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 19, 2026

Greptile Summary

This PR fixes a macOS-specific bug where Cmd+Q was tearing down the terminal-host daemon despite it being spawned with detached: true + unref(). The root cause is that setsid() (implicit from Node's detached flag) isolates the Unix session but not the Mach bootstrap/login Security Session, so macOS propagates SIGHUP to the daemon when Electron's login session tears down. The fix implements nohup semantics: a no-op SIGHUP listener in setupTerminalHostSignalHandlers() replaces the previous behavior where SIGHUP had no handler at all (causing Node's default terminate action). An additional early guard is registered at the very top of main() to cover the race window before setupSignalHandlers() completes.

Key changes:

  • signal-handlers.ts: New process.on(\"SIGHUP\", () => { log(...) }) handler — intentionally ignores the signal so the daemon survives Electron's exit.
  • index.ts: Early silent process.on(\"SIGHUP\", () => {}) registered before setupSignalHandlers() to eliminate the race window at boot.
  • The SIGINT/SIGTERM paths are unchanged; intentional shutdown still goes through SIGKILL (killDaemonFromPidFile) or the authenticated shutdown RPC.

Confidence Score: 4/5

Safe to merge — the core fix is correct and well-targeted; two minor issues (unprotected log call and redundant listener) are worth addressing before shipping.

The SIGHUP no-op approach is the standard nohup mechanism and is correctly applied. The belt-and-suspenders early handler in main() closes the startup race window. One P1 concern: the log() call inside the SIGHUP handler is unprotected — if the daemon's stdout is a closed pipe after Electron exits, an EPIPE could be thrown as an uncaught exception, triggering shutdownOnce() and defeating the fix. Whether this manifests depends on how stdio is configured at spawn time. One P2 concern: the early silent handler is never removed, leaving two permanent SIGHUP listeners (harmless but redundant).

signal-handlers.ts — the SIGHUP handler body needs a try/catch around the log call to be unconditionally safe.

Important Files Changed

Filename Overview
apps/desktop/src/main/terminal-host/signal-handlers.ts Adds a SIGHUP no-op listener (nohup semantics) to prevent macOS login-session teardown from killing the daemon; the log() call inside the handler lacks a try/catch that could trigger an unintended shutdown if stdout is a closed pipe.
apps/desktop/src/main/terminal-host/index.ts Adds an early silent SIGHUP guard in main() to cover the race window before setupSignalHandlers() runs; the temporary listener is never removed, leaving two permanent SIGHUP listeners (harmless but redundant).

Sequence Diagram

sequenceDiagram
    participant Electron as Electron (main process)
    participant OS as macOS Login Session
    participant Daemon as terminal-host daemon

    Note over Daemon: main() registers early SIGHUP → () => {}
    Note over Daemon: setupSignalHandlers() registers SIGHUP → log + no-op

    Electron->>OS: app.exit(0) tears down login session
    OS-->>Daemon: SIGHUP (login session teardown)
    Daemon->>Daemon: Early handler: silent no-op (fires first)
    Daemon->>Daemon: Signal handler: log "ignoring SIGHUP"
    Note over Daemon: Daemon survives — PTY sessions intact

    Note over Electron: User relaunches app
    Electron->>Daemon: connect via Unix socket
    Daemon-->>Electron: Session hot-reattach ("attached" not "created")
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/terminal-host/index.ts
Line: 835-837

Comment:
**Redundant listener left after `setupSignalHandlers()`**

The early silent `() => {}` registered on line 835 is never removed before `setupSignalHandlers()` (line 837) installs its own SIGHUP listener. `process.on` always _adds_ a listener rather than replacing one, so after startup two SIGHUP listeners are permanently active: the silent one and the logging one from `signal-handlers.ts`.

This is harmless in practice — both handlers are no-ops that prevent the default terminate action, and two listeners is well below Node's `MaxListenersExceededWarning` threshold (10). However, it slightly pollutes the listener list and could confuse future readers who expect exactly one handler.

A cleaner approach is to store and remove the temporary listener once the definitive one is installed:

```ts
const earlyHupGuard = () => {};
process.on("SIGHUP", earlyHupGuard);

setupSignalHandlers();           // installs the logging handler

process.removeListener("SIGHUP", earlyHupGuard);
```

Or, since the logging handler is equally inert during startup, simply skip the early guard entirely and let `setupSignalHandlers()` install the only listener — moving the SIGHUP registration to the top of that function achieves the same race-window protection.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/main/terminal-host/signal-handlers.ts
Line: 140-142

Comment:
**Unprotected `log()` call inside signal handler**

If the daemon's stdout/stderr is still connected to Electron's pipe (i.e., spawned with `stdio: 'pipe'` or `stdio: 'inherit'` rather than `stdio: 'ignore'`), and Electron exits first, writing to that closed pipe will throw `EPIPE`. Because the `log()` call here is not wrapped in a `try/catch`, an EPIPE will propagate as an uncaught exception into the `uncaughtException` handler. `EPIPE` is not in `TRANSIENT_ERROR_CODES`, so `shutdownOnce()` would be called with `exitCode: 1` — defeating the entire purpose of this no-op handler.

If stdio is already redirected to a file or `/dev/null` at spawn time, this is a non-issue. But it is worth adding a guard to make the handler unconditionally safe:

```ts
process.on("SIGHUP", () => {
    try {
        log("info", "Received SIGHUP; ignoring (nohup semantics for daemon survival)");
    } catch {
        // Suppress logging errors (e.g. EPIPE after parent exit) —
        // the whole point of this handler is to survive without side-effects.
    }
});
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(desktop): keep terminal-host daemon ..." | Re-trigger Greptile

Comment on lines 835 to 837
process.on("SIGHUP", () => {});

setupSignalHandlers();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant listener left after setupSignalHandlers()

The early silent () => {} registered on line 835 is never removed before setupSignalHandlers() (line 837) installs its own SIGHUP listener. process.on always adds a listener rather than replacing one, so after startup two SIGHUP listeners are permanently active: the silent one and the logging one from signal-handlers.ts.

This is harmless in practice — both handlers are no-ops that prevent the default terminate action, and two listeners is well below Node's MaxListenersExceededWarning threshold (10). However, it slightly pollutes the listener list and could confuse future readers who expect exactly one handler.

A cleaner approach is to store and remove the temporary listener once the definitive one is installed:

const earlyHupGuard = () => {};
process.on("SIGHUP", earlyHupGuard);

setupSignalHandlers();           // installs the logging handler

process.removeListener("SIGHUP", earlyHupGuard);

Or, since the logging handler is equally inert during startup, simply skip the early guard entirely and let setupSignalHandlers() install the only listener — moving the SIGHUP registration to the top of that function achieves the same race-window protection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/terminal-host/index.ts
Line: 835-837

Comment:
**Redundant listener left after `setupSignalHandlers()`**

The early silent `() => {}` registered on line 835 is never removed before `setupSignalHandlers()` (line 837) installs its own SIGHUP listener. `process.on` always _adds_ a listener rather than replacing one, so after startup two SIGHUP listeners are permanently active: the silent one and the logging one from `signal-handlers.ts`.

This is harmless in practice — both handlers are no-ops that prevent the default terminate action, and two listeners is well below Node's `MaxListenersExceededWarning` threshold (10). However, it slightly pollutes the listener list and could confuse future readers who expect exactly one handler.

A cleaner approach is to store and remove the temporary listener once the definitive one is installed:

```ts
const earlyHupGuard = () => {};
process.on("SIGHUP", earlyHupGuard);

setupSignalHandlers();           // installs the logging handler

process.removeListener("SIGHUP", earlyHupGuard);
```

Or, since the logging handler is equally inert during startup, simply skip the early guard entirely and let `setupSignalHandlers()` install the only listener — moving the SIGHUP registration to the top of that function achieves the same race-window protection.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 903c94b93. Moved the early SIGHUP guard to the very first statement of main() (before any log() call — closes the startup race window the same way your earlier suggestion did) and removed the temporary guard after setupSignalHandlers() installs the definitive listener. Listener set is now clean: exactly one SIGHUP handler for the daemon's lifetime.

Comment on lines 140 to 142
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)");
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unprotected log() call inside signal handler

If the daemon's stdout/stderr is still connected to Electron's pipe (i.e., spawned with stdio: 'pipe' or stdio: 'inherit' rather than stdio: 'ignore'), and Electron exits first, writing to that closed pipe will throw EPIPE. Because the log() call here is not wrapped in a try/catch, an EPIPE will propagate as an uncaught exception into the uncaughtException handler. EPIPE is not in TRANSIENT_ERROR_CODES, so shutdownOnce() would be called with exitCode: 1 — defeating the entire purpose of this no-op handler.

If stdio is already redirected to a file or /dev/null at spawn time, this is a non-issue. But it is worth adding a guard to make the handler unconditionally safe:

process.on("SIGHUP", () => {
    try {
        log("info", "Received SIGHUP; ignoring (nohup semantics for daemon survival)");
    } catch {
        // Suppress logging errors (e.g. EPIPE after parent exit) —
        // the whole point of this handler is to survive without side-effects.
    }
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/terminal-host/signal-handlers.ts
Line: 140-142

Comment:
**Unprotected `log()` call inside signal handler**

If the daemon's stdout/stderr is still connected to Electron's pipe (i.e., spawned with `stdio: 'pipe'` or `stdio: 'inherit'` rather than `stdio: 'ignore'`), and Electron exits first, writing to that closed pipe will throw `EPIPE`. Because the `log()` call here is not wrapped in a `try/catch`, an EPIPE will propagate as an uncaught exception into the `uncaughtException` handler. `EPIPE` is not in `TRANSIENT_ERROR_CODES`, so `shutdownOnce()` would be called with `exitCode: 1` — defeating the entire purpose of this no-op handler.

If stdio is already redirected to a file or `/dev/null` at spawn time, this is a non-issue. But it is worth adding a guard to make the handler unconditionally safe:

```ts
process.on("SIGHUP", () => {
    try {
        log("info", "Received SIGHUP; ignoring (nohup semantics for daemon survival)");
    } catch {
        // Suppress logging errors (e.g. EPIPE after parent exit) —
        // the whole point of this handler is to survive without side-effects.
    }
});
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 903c94b93. Wrapped the log() call in try/catch. If the pipe closes first (e.g. Electron exits and our stdio was inherited), EPIPE from the write no longer propagates to uncaughtExceptionshutdownOnce, preserving the nohup semantics unconditionally.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/main/terminal-host/index.ts (1)

824-835: ⚠️ Potential issue | 🟡 Minor

Move the SIGHUP guard before startup logging.

The daemon is vulnerable to SIGHUP termination during the three log() calls at lines 825–827, since the guard is installed only at line 835. Moving process.on("SIGHUP", () => {}) to the first statement in main() closes the race window completely.

Proposed fix
 async function main() {
+	// 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", () => {});
+
 	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();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/terminal-host/index.ts` around lines 824 - 835, In
main(), move the SIGHUP guard (process.on("SIGHUP", () => {})) to be the very
first statement inside the function, before any log("info", ...) calls so that
the no-op handler is installed immediately and eliminates the race window; keep
the handler as the same no-op and leave setupSignalHandlers() logic intact for
later signal handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/terminal-host/signal-handlers.ts`:
- Around line 140-142: The SIGHUP handler currently calls log(...) which could
throw and bubble to uncaughtException, so change the process.on("SIGHUP", ...)
handler to protect the ignore semantics by wrapping the log call in a try/catch
(and swallow errors or at most fallback to console.error) so that any logging
failure does not rethrow; ensure the handler always returns without throwing.
Target the process.on("SIGHUP", ...) registration and the log(...) invocation
when applying this fix.

---

Outside diff comments:
In `@apps/desktop/src/main/terminal-host/index.ts`:
- Around line 824-835: In main(), move the SIGHUP guard (process.on("SIGHUP", ()
=> {})) to be the very first statement inside the function, before any
log("info", ...) calls so that the no-op handler is installed immediately and
eliminates the race window; keep the handler as the same no-op and leave
setupSignalHandlers() logic intact for later signal handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b682cfd3-e133-42ee-8704-386bae771189

📥 Commits

Reviewing files that changed from the base of the PR and between 9c40d2d and 7337910.

📒 Files selected for processing (2)
  • apps/desktop/src/main/terminal-host/index.ts
  • apps/desktop/src/main/terminal-host/signal-handlers.ts

Comment thread apps/desktop/src/main/terminal-host/signal-handlers.ts
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/main/terminal-host/signal-handlers.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/signal-handlers.ts:141">
P1: Wrap the `log()` call in a try/catch. When SIGHUP fires because Electron quit, its stdio pipes are already closed — writing here throws EPIPE, which propagates to the `uncaughtException` handler and calls `shutdownOnce()`, defeating the purpose of this no-op handler.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread apps/desktop/src/main/terminal-host/signal-handlers.ts Outdated
Haknt added a commit to Haknt/superset that referenced this pull request Apr 19, 2026
Addresses review feedback from coderabbitai and greptile on superset-sh#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.
@Haknt
Copy link
Copy Markdown
Author

Haknt commented Apr 19, 2026

Addressed review feedback in the latest push:

  • greptile P1 / coderabbitai minor (unprotected log() in SIGHUP handler): Wrapped in try/catch — if EPIPE throws after Electron closes stdio, the ignore semantics are preserved.
  • greptile P2 / coderabbitai minor (early guard hygiene): Moved the early SIGHUP guard to the very first statement of main() (before any log() calls — closes the startup race window) and removed it after setupSignalHandlers() so only one permanent listener remains.

Typecheck passes, 37 terminal-host tests green.

@Haknt
Copy link
Copy Markdown
Author

Haknt commented Apr 24, 2026

Friendly ping @Kitenite — small single-file PR (~6 net lines) that prevents the terminal-host daemon from dying on Cmd+Q. All bot reviews (greptile, cubic-dev-ai, coderabbit) have been addressed; the only concrete change request — a try/catch guard around the SIGHUP handler's log call — is applied.

Verification on macOS 25.3.0 (2026-04-19):

  • 5+ Cmd+Q → relaunch cycles, daemon PID stable (pgrep -f terminal-host.js)
  • PTY subprocess PID also preserved across the cycle
  • Hot reattach works (Session ... attached log line, not created)

Targets the root cause behind #2501 ("Session terminals disappearing"). Risk is low: swallowing SIGHUP (nohup semantics) is a standard daemon pattern; intentional shutdown still goes through killDaemonFromPidFile (SIGKILL) and the explicit shutdown RPC — neither of which this handler can intercept.

If you have a moment to take a look, much appreciated. PR #3573 and #3575 build on this one.

Haknt added 2 commits April 27, 2026 09:56
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 superset-sh#2501
Addresses review feedback from coderabbitai and greptile on superset-sh#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.
@Haknt Haknt force-pushed the fix/daemon-survives-app-quit branch from 903c94b to 920432c Compare April 27, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session terminals disappearing

1 participant