Skip to content

fix: restore terminal state after remote→local mode switch#570

Open
tomstetson wants to merge 2 commits intoslopus:mainfrom
tomstetson:fix/stdin-corruption-mode-switch
Open

fix: restore terminal state after remote→local mode switch#570
tomstetson wants to merge 2 commits intoslopus:mainfrom
tomstetson:fix/stdin-corruption-mode-switch

Conversation

@tomstetson
Copy link

Summary

Fixes the most-reported bug in Happy CLI: terminal input corruption after switching from remote mode (mobile) back to local mode via double-space. Users experience dropped characters, phantom mode switches, and an unresponsive Claude session.

Root cause analysis

Five independent bugs compound to corrupt stdin during the remote→local transition:

# Bug Effect
1 Wrong cleanup order — raw mode disabled before Ink unmounts Ink expects raw mode during teardown; disabling it first causes Ink to leave the terminal in a half-raw state
2 setEncoding("utf8") never reset This is a sticky, one-way call on the Node.js public API. Once set, stdin delivers strings instead of Buffers to all subsequent consumers, breaking Claude's input handling
3 process.exit() bypasses finally blocks Signal handlers (SIGINT/SIGTERM) call process.exit() which skips the finally block that would restore the terminal — leaves it in raw mode
4 No signal forwarding to binary children When spawning Claude as a binary (e.g. Homebrew install), the parent process doesn't forward signals — child becomes orphan competing for stdin
5 stdin left in flowing mode No pause() after remote cleanup, so stdin data events keep firing into the void

Approach: why this fix over alternatives

Compared to PR #458 (pkill-based process cleanup)

PR #458 addresses the orphan process symptom by adding HAPPY_SESSION_ID environment tracking and pkill-based cleanup with escalating SIGTERM→SIGKILL. While it correctly identifies the orphan problem, it:

  • Doesn't fix the core stdin corruption (bugs 1-3 above) — the wrong cleanup order, sticky encoding, and signal handler gaps
  • Adds pkill -f "HAPPY_SESSION_ID=..." with hardcoded timeouts (100ms → 500ms) which is platform-dependent and fragile
  • Registers signal handlers 4+ times ("multiple times to ensure they stick") which is a code smell
  • Adds a raw mode toggle hack in RemoteModeDisplay.tsx with setTimeout(100ms) that papers over the real ordering bug

Compared to PR #553 (node-pty proxy)

PR #553 takes a nuclear approach: replace stdio: 'inherit' with a full PTY proxy via node-pty, giving Ink a separate /dev/tty fd. This correctly isolates stdin but:

  • Adds a native dependency (node-pty) requiring compilation on every platform — the comment thread already found a PTY fd leak bug in node-pty v1.1.0 (fix: /dev/ptmx leak on macOS microsoft/node-pty#882)
  • Rewrites 200+ lines of claudeLocal.ts including IPC socket plumbing to replace fd 3
  • Much larger blast radius and review surface for what is fundamentally a cleanup ordering bug

This PR's approach

Minimal, targeted fixes at each root cause — 94 lines added, 8 removed, zero new dependencies:

  1. restoreStdin() utility (new file) — idempotent function that properly restores stdin: disable raw mode, pause, reset encoding via _readableState, remove orphaned listeners. Every operation wrapped in try-catch.

  2. Fix cleanup order in claudeRemoteLauncher.ts — unmount Ink first (while raw mode is still active), then call restoreStdin(). This is the core fix. Also removed dead code (process.stdin.off('data', abort) where abort was never a data listener).

  3. Defensive guard in claudeLocal.ts — call restoreStdin() before spawning child with inherited stdio. Catches edge cases where remote cleanup was incomplete.

  4. Signal handler protection in runClaude.ts — call restoreStdin() at the top of the cleanup function, before any async work or process.exit(). Prevents terminal being left in raw mode when signals bypass finally blocks.

  5. Signal forwarding in claude_version_utils.cjs — forward SIGINT/SIGTERM/SIGHUP to binary child processes. Clean up handlers on child exit. Prevents orphan Claude processes.

Files changed

File Lines Change
packages/happy-cli/src/utils/restoreStdin.ts +53 New — shared stdin restoration utility
packages/happy-cli/src/claude/claudeRemoteLauncher.ts +12/-6 Fix cleanup order, use restoreStdin()
packages/happy-cli/src/claude/claudeLocal.ts +5/-2 Defensive restoreStdin() before spawn
packages/happy-cli/src/claude/runClaude.ts +6 restoreStdin() in signal handler
packages/happy-cli/scripts/claude_version_utils.cjs +18 Signal forwarding to binary children

Test plan

  • TypeScript type check passes (tsc --noEmit)
  • Full build passes (yarn build)
  • All 273 existing tests pass (vitest run)
  • Manual: Start happy → send message from mobile (enter remote mode) → double-space to switch to local → type a sentence with spaces → verify all characters appear, no phantom mode switches
  • Manual: Ctrl-C twice in remote mode → restart → verify terminal is clean
  • Manual: Rapid switching: remote→local→remote→local
  • Manual: ps aux | grep claude after mode switches — verify no orphan processes
  • Manual: Test on both macOS and Linux

Generated with Claude Code
via Happy

Fix terminal input corruption (dropped characters, phantom mode switches,
unresponsive Claude) when switching from remote mode back to local mode
via double-space.

Root causes addressed:
- Wrong cleanup order: raw mode was disabled BEFORE Ink unmounted, but Ink
  expects raw mode during teardown. Now Ink unmounts first.
- setEncoding("utf8") was never reset: poisons stdin for subsequent
  Buffer-mode consumers. Now reset via _readableState.
- process.exit() in signal handlers bypasses finally blocks: terminal
  left in raw mode. Now restoreStdin() runs before exit.
- No signal forwarding to binary children: orphan Claude processes
  compete for stdin. Now SIGINT/SIGTERM/SIGHUP forwarded.
- stdin left in flowing mode: no pause() after remote cleanup. Now
  restoreStdin() pauses stdin.

Fixes slopus#301, slopus#422, slopus#423, slopus#424, slopus#430, slopus#443, slopus#527

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b9a99171b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +505 to +506
if (!child.killed) {
child.kill(signal);

Choose a reason for hiding this comment

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

P1 Badge Forward all repeated termination signals to child

child.killed becomes true as soon as the first kill() is sent, not when the child has actually exited, so this guard drops every later signal even if the child is still running. In binary-install flows, if Claude traps the first signal (for graceful interrupt) and keeps running, a subsequent SIGTERM from a mode switch or shutdown will never be forwarded, which can leave the launcher waiting indefinitely for exit while the child continues owning stdin.

Useful? React with 👍 / 👎.

tomstetson pushed a commit to tomstetson/happy that referenced this pull request Feb 11, 2026
…rwarding

child.killed becomes true immediately after kill() is called, not when
the child actually exits. This caused subsequent signals to be silently
dropped if the child trapped the initial signal and kept running,
potentially leaving the launcher hanging while the child holds stdin.

Replace the child.killed guard with an explicit childExited flag that
is only set on the child's 'exit' event, and add a try/catch around
kill() to handle the race between our check and the child exiting.

Addresses review feedback from codex bot on PR slopus#570.

https://claude.ai/code/session_01XFfhV1BPjmPfpEximDf6ev
child.killed becomes true after the first kill() call, not when the
child actually exits. This means if the child traps the first signal
(e.g., SIGINT for graceful shutdown), subsequent signals from mode
switches would be silently dropped, leaving the launcher waiting
indefinitely.

Track exit state with a boolean instead, and wrap kill() in try-catch
for the race where the child exits between our check and the kill call.

Addresses review feedback from Codex on PR slopus#570.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
HirokiKobayashi-R added a commit to HirokiKobayashi-R/happy that referenced this pull request Feb 11, 2026
Adds restoreStdin utility, forwards SIGHUP alongside SIGINT/SIGTERM,
and properly restores terminal raw mode after Claude binary exits.
Prevents orphan processes competing for stdin.

Cherry-picked from slopus/happy PR slopus#570 (commit 1/2).
Co-authored-by: blechschmidt <[email protected]>
HirokiKobayashi-R pushed a commit to HirokiKobayashi-R/happy that referenced this pull request Feb 11, 2026
child.killed becomes true after the first kill() call, not when the
child actually exits. This means if the child traps the first signal
(e.g., SIGINT for graceful shutdown), subsequent signals from mode
switches would be silently dropped, leaving the launcher waiting
indefinitely.

Track exit state with a boolean instead, and wrap kill() in try-catch
for the race where the child exits between our check and the kill call.

Addresses review feedback from Codex on PR slopus#570.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
HirokiKobayashi-R added a commit to HirokiKobayashi-R/happy that referenced this pull request Feb 11, 2026
Cherry-picked from upstream PRs:
- slopus#566: Path normalization alignment with Claude Code (Adam Murphy)
- slopus#561: YOLO mode race condition fix (Adam Murphy)
- slopus#570: Terminal state restoration after remote→local switch (blechschmidt/tomstetson)
- slopus#539: tmux detach stalling prevention (claudio)

All 4 PRs applied successfully. slopus#561 required conflict resolution
(merged effectiveMode race fix with AskUserQuestion guard).
leeroybrun added a commit to happier-dev/happier that referenced this pull request Feb 14, 2026
Haknt pushed a commit to Haknt/happy that referenced this pull request Feb 16, 2026
child.killed becomes true after the first kill() call, not when the
child actually exits. This means if the child traps the first signal
(e.g., SIGINT for graceful shutdown), subsequent signals from mode
switches would be silently dropped, leaving the launcher waiting
indefinitely.

Track exit state with a boolean instead, and wrap kill() in try-catch
for the race where the child exits between our check and the kill call.

Addresses review feedback from Codex on PR slopus#570.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
@happier-bot

This comment was marked as abuse.

aaronmorris-dev pushed a commit to aaronmorris-dev/happy that referenced this pull request Feb 21, 2026
child.killed becomes true after the first kill() call, not when the
child actually exits. This means if the child traps the first signal
(e.g., SIGINT for graceful shutdown), subsequent signals from mode
switches would be silently dropped, leaving the launcher waiting
indefinitely.

Track exit state with a boolean instead, and wrap kill() in try-catch
for the race where the child exits between our check and the kill call.

Addresses review feedback from Codex on PR slopus#570.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
@leave-a-message
Copy link

Hit this twice on macOS (M1). Had to kill the terminal both times to recover. Would love to see this land — thanks for the fix @tomstetson.

bbaldino added a commit to bbaldino/happy that referenced this pull request Feb 25, 2026
Cherry-picked from upstream PR slopus#647 (by Haknt, rebased from PR slopus#570 by
tomstetson).  Fixes stdin corruption when switching from remote back to
local mode by addressing five root causes: cleanup sequencing, encoding
persistence, signal handler bypass, missing signal forwarding, and
flowing mode not being paused.

Fixes: slopus#301, slopus#422, slopus#423, slopus#424, slopus#430, slopus#443, slopus#527, slopus#613

Co-Authored-By: Claude Opus 4.6 <[email protected]>
bbaldino added a commit to bbaldino/happy that referenced this pull request Feb 28, 2026
Cherry-picked from upstream PR slopus#647 (by Haknt, rebased from PR slopus#570 by
tomstetson).  Fixes stdin corruption when switching from remote back to
local mode by addressing five root causes: cleanup sequencing, encoding
persistence, signal handler bypass, missing signal forwarding, and
flowing mode not being paused.

Fixes: slopus#301, slopus#422, slopus#423, slopus#424, slopus#430, slopus#443, slopus#527, slopus#613

Co-Authored-By: Claude Opus 4.6 <[email protected]>
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.

Bug Report: Terminal input corruption after switching from remote to local mode

3 participants