BlogEngineering

Keeping Terminal Sessions Alive Across Electron App Restarts

How we built a process-isolated terminal host that survives app restarts, handles backpressure gracefully, and enables cold restore from disk.

Paradev keeps terminal sessions running when the desktop app restarts. Andreas Asprou led the work. This post explains how we separated the terminals from the app process.


Our original terminal processes ended when the app closed. That made updates expensive: restarting Paradev meant interrupting work across every open workspace. An app crash could do the same.

tmux showed that terminal sessions could outlive the interface attached to them.

The Problem: Electron Terminals Are Fragile

In a usual Electron + node-pty setup, your terminal lifecycle looks like this:

In this workflow, the PTY is spawned in the main Node process of the app. This process is killed when the app closes, killing its child process as well.

The Tempting Solution: Just Use tmux

We tried tmux first. It's battle-tested, handles persistence natively, and has a relatively easy interface.

Our workflow with tmux became:

We also used tmux with headless to make it feel native in xterm.

Why we moved away from tmux

  1. An extra dependency. Users also had to install tmux.
  2. Platform support. Native Windows support would need a different approach. We'd have to build another equivalent integration.
  3. Competing terminal controls. tmux manages scrolling, selection, and shortcuts, which made the integration with xterm feel awkward.

We built a separate terminal process instead.

Architecture: A detachable daemon

Our solution splits terminal management across three process layers:

With this architecture, the Electron app becomes just a client of the terminal daemon. It can:

  • Restart the app → Reconnect to running sessions
  • Open multiple windows → All attach to the same daemon
  • Crash and recover → Cold restore from disk history

Spawning the Daemon

The daemon uses Electron’s bundled Node.js runtime:

Setting ELECTRON_RUN_AS_NODE=1 tells Electron to act as a regular Node.js runtime, perfect for a background service that doesn't need Chromium.

The Protocol: NDJSON Over Unix Sockets

Communication between the main process and daemon uses newline-delimited JSON over Unix domain sockets:

Why Unix sockets? They're fast (no TCP overhead), secure (file permissions), and support backpressure natively through kernel buffers.

The Two-Socket Split

Our first protocol version used a single socket for everything. That caused head-of-line blocking.

When a terminal produces output faster than the socket can drain, the kernel buffer fills up. Data events and RPC responses shared the same output queue, so responses could be delayed behind terminal output. The result? A user opens a new terminal, but createOrAttach times out because the response is stuck behind megabytes of cat bigfile.log output.

Protocol v2 splits communication:

Now the stream socket can back up independently while RPC stays responsive.

Session Lifecycle: Create, Attach, Survive, Restore

A session goes through well-defined states:

What surviving looks like in practice:

An interrupted Claude Code session offering a one-click Resume after the client died: the daemon kept the session aliveAn interrupted Claude Code session offering a one-click Resume after the client died: the daemon kept the session alive

The attach request checks whether the session is still running:

Cold Restore: Recovering from Daemon crashes

When the daemon dies (machine reboot, crash, kill -9), sessions are lost. But we still have disk history:

On next app launch, we detect unclean shutdown (no endedAt in metadata) and offer cold restore:

Users can read the output from before the crash and start a new shell in the same directory. The old process is gone.

Backpressure: The Hidden Challenge

Terminals can produce output fast. A simple cat /dev/urandom | base64 will flood any buffer you throw at it. Without careful backpressure handling, you get:

  • Memory exhaustion (unbounded queues)
  • UI freezes (blocked event loops)
  • Lost data (dropped writes)

We implement multi-level backpressure from PTY to UI:

The PTY subprocess batches output aggressively:

Batching reduces the number of messages sent to the UI.

The Headless Emulator: State Without a Screen

Each daemon session runs a headless xterm.js emulator. This might seem redundant: why emulate if there's no screen?

The emulator gives us:

  1. Accurate snapshots: When a new client attaches, we serialize the current screen state, not just raw scrollback. The user sees exactly what was on screen, cursor position included.

  2. Terminal mode tracking: Application mode, bracketed paste, mouse tracking. All parsed and tracked so reconnecting clients get correct state.

  3. CWD detection: By parsing OSC escape sequences, we know the shell's current directory even when the session was created hours ago.

Lessons Learned

1. Protocol Versioning from Day One

When we introduced the two-socket split, existing daemons couldn't speak the new protocol. We handle this gracefully:

Always include version negotiation in your protocols.

2. React StrictMode Double-Mounts Are Real

React 18's StrictMode double-mounts components in development. Our terminal component would:

  1. Mount → createOrAttach() → receive cold restore
  2. Unmount (StrictMode cleanup)
  3. Mount again → createOrAttach() → ???

If we re-read from disk, the cold restore flag might be gone (we wrote endedAt). Solution: sticky cache:

3. Don't Kill on Disconnect

When the Electron app closes, we don't kill daemon sessions:

This is the whole point of the daemon architecture. The default should be persistence, not cleanup.

4. Concurrency Limits Prevent Spawn Storms

Opening a workspace with 10 terminal panes would previously spawn 10 sessions simultaneously, overwhelming the daemon. We added a semaphore with priority:

Users see their active terminal first, background tabs hydrate gradually.

The Future: Cloud Backends

The TerminalRuntime interface separates the renderer from the process that owns the terminal:

Today, LocalTerminalRuntime wraps our daemon. Tomorrow, CloudTerminalRuntime could wrap SSH connections or remote tmux sessions: same interface, different backend. The renderer doesn't need to know where the terminal lives.


Want to dive deeper? Check out the Paradev desktop source for the full implementation.