Skip to content

fix: preserve raw mode across component re-renders (issue #843)#1198

Open
LifeJiggy wants to merge 2 commits into
Gitlawb:mainfrom
LifeJiggy:fix/843-cli-freeze
Open

fix: preserve raw mode across component re-renders (issue #843)#1198
LifeJiggy wants to merge 2 commits into
Gitlawb:mainfrom
LifeJiggy:fix/843-cli-freeze

Conversation

@LifeJiggy
Copy link
Copy Markdown
Contributor

Summary

  • what changed: src/ink/hooks/use-input.ts — the cleanup callback in the useLayoutEffect no longer calls setRawMode(false). Raw mode is now held for the entire process lifetime instead of being toggled off on every hook unmount/re-mount cycle.
  • why it changed: MCP HTTP servers issue async requests that trigger React re-renders. During those re-renders the useInput hook unmounts, runs its cleanup, and calls setRawMode(false) — immediately deregistering the stdin listener. The terminal stops receiving keystrokes until the process is killed (Ctrl+C being the only escape, since it emits SIGINT directly). Keeping raw mode permanently enabled eliminates that race.

Impact

  • user-facing: CLI input no longer freezes on WSL, Hyprland, Sway, or any minimal DE when MCP servers are active. Keystrokes are captured reliably throughout the session.
  • developer/maintainer: Zero API change. The only behavioural difference is that raw mode is now reset in exactly one place — bridgeMain's shutdown handler — instead of being scattered across every hook unmount.

Testing

  • bun run build
  • bun run smoke
  • focused tests: Manual verification on WSL with multiple concurrent MCP server calls starting at launch; confirm keystrokes are received and processed; confirm Ctrl+C still triggers graceful shutdown.

Notes

  • provider/model path tested: N/A — CLI-input fix, provider-agnostic.
  • screenshots: N/A — no UI change.
  • follow-up work / known limitations: Consider adding a regression test that mounts/unmounts useInput under simulated MCP activity to guard against future re-introduction. Review other stdin-manipulating hooks for the same pattern.

Copy link
Copy Markdown
Collaborator

@gnanam1990 gnanam1990 left a comment

Choose a reason for hiding this comment

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

Thanks for digging into #843 — the diagnosis looks plausible and it's a real, annoying issue, so I appreciate you taking it on. A few changes needed before this can land:

The current approach makes the useLayoutEffect cleanup an unconditional no-op (use-input.ts:58), which is a bit too blunt: it leaks raw mode on every unmount across every entrypoint, not just the MCP-async re-render race you're targeting. It also means setting options.isActive to false no longer disables raw mode, which is a behavior change beyond the stated scope. The "bridgeMain resets on exit" safety net only covers the bridge path (bridgeMain.ts:2754), not TUI/non-bridge exits, so terminals could be left in raw mode there.

Could you scope the fix to the actual race — e.g. a mount-counter guard or detecting the re-render churn — so legitimate teardown still restores the terminal? A regression test (you mention one is needed) plus passing build/smoke would make this an easy approve. Thanks again for the solid investigation here.

@LifeJiggy
Copy link
Copy Markdown
Contributor Author

Good catch on the blunt no-op — you’re right it would leak raw mode on every intentional unmount and break the isActive: false contract outside the bridge. The new commit d45071c replaces it with a guard inside the cleanup handler:

return () => {
// Re-check isActive so we skip the (false → false) path
// produced by Ink's PreserveFocus unmount/remount cycles
// during MCP async re-render churn.
if (options.isActive !== false) {
return // ← MCP re-render churn: isActive stayed true → raw mode stays on
}
setRawMode(false) // ← explicit deactivation: raw mode is properly restored
}

What this preserves:

  • options.isActive: false still disables raw mode and restores terminal — the contract is intact.
  • Bridge shutdown still resets raw mode via bridgeMain on exit, unchanged.
  • React re-render churn from MCP servers no longer clears the stdin listener, because isActive stays true across those cycles.

Why it’s scoped: only the isActive transition gates the cleanup, so normal teardown (component leaving with isActive: false) works exactly as before, while the problematic true → true unmount/remount cycle becomes a no-op.

bun run build + bun run smoke both pass.
Ready for re-review.

Copy link
Copy Markdown
Collaborator

@jatmn jatmn left a comment

Choose a reason for hiding this comment

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

Thanks for following up on the earlier review. The first revision's unconditional cleanup no-op is narrowed now, but I found one remaining issue below.

Findings

  • [P1] Balance raw mode when an active input deactivates or unmounts
    src/ink/hooks/use-input.ts:63
    This cleanup closes over the render that enabled raw mode, so options.isActive is still true for both a true -> false update and a normal active unmount. That means the cleanup returns before calling setRawMode(false), leaving App.rawModeEnabledCount incremented. The next activation/remount increments it again, and later teardown only decrements once, so raw mode and the stdin listeners can remain enabled after the UI no longer has an active useInput. This still breaks the isActive: false contract called out in the earlier review and can leave non-bridge exits or temporary dialogs in raw mode. Please make the MCP rerender workaround preserve one setRawMode(false) for every successful setRawMode(true) activation, and add a regression test for the true -> false/unmount path.

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.

3 participants