Skip to content

Fix new Windows terminals freezing after the first frame - #652

Open
yutigi wants to merge 1 commit into
dcouple:mainfrom
yutigi:bugfix/windows-terminal-dont-refresh-rendering
Open

yutigi wants to merge 1 commit into
dcouple:mainfrom
yutigi:bugfix/windows-terminal-dont-refresh-rendering

Conversation

@yutigi

@yutigi yutigi commented Sep 16, 2026

Copy link
Copy Markdown

Description

On Windows, every newly spawned terminal painted its first frame and then went
silent. Typing reached the shell but never echoed, and the manual Refresh button
repainted once without restoring live updates.

The cause is not rendering. createWindow() handed the renderer its ptyHost
MessagePort from a did-finish-load listener registered after the awaited
page load:

await mainWindow.loadFile(indexPath);   // resolves ON did-finish-load
...  ~320 lines later ...
mainWindow.webContents.once('did-finish-load', () => {   // never fires
  ptyHostSupervisor.attachWindow(mainWindow.webContents);
});

loadURL/loadFile resolve on did-finish-load, so the listener was armed
after the event it waited for had already fired. attachWindow never ran and
electronAPI.ptyHost.onData had no source.

That is fatal because TerminalPanel.tsx permanently short-circuits the legacy
terminal:output IPC handler as soon as a ptyId arrives, making the port the
only byte source for a ptyHost-spawned panel. Main's headless emulator stayed
correct throughout, which is exactly why terminal:getState (Refresh) still
painted a single frame and the terminal looked frozen rather than broken.

Windows only because configManager.ts sets usePtyHost: process.platform === 'win32'.
macOS/Linux never fork the supervisor and stay on the working IPC path.

Changes

  • main/src/index.ts — register the port hand-off before the awaited load, and
    use on rather than once so every load re-attaches.
  • main/src/ptyHost/ptyHostSupervisor.tsattachWindow now replaces a stale
    port pair instead of returning early. webContents.id survives a reload but the
    preload closure holding the renderer end does not, so the old guard would leave a
    reloaded window permanently without a data source. The destroy-cleanup listener is
    registered only on first attach so re-attaches don't accumulate listeners.
  • main/src/preload.ts — warn once when a ptyHost frame is posted with no port.
    The previous silent no-op is what hid this bug.
  • attachWindow now takes a narrow structural PtyHostPortTarget instead of the
    full WebContents, so it is testable without standing up a real renderer.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the CONTRIBUTING.md guidelines
  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally

Critical Areas Modified

  • State management/IPC events

Additional Notes

Evidence the listener never ran

[ptyHost] attached window webContentsId= appears nowhere across two full days
of production logs, while [ptyHost] UtilityProcess exited (code=0) does — proving
the supervisor was running and only the attach was missing. After the fix,
[ptyHost] attached window webContentsId=1 appears on a live dev run.

End-to-end verification

Drove a real terminal through the RunPane CLI against a dev instance on an isolated
PANE_DIR and a throwaway repo: ~500KB streamed in 897ms with no flow-control
stall.
Acks are only emitted from inside xterm's post-render callback, so that
round trip proves main → port → preload → xterm → ack → main. With a dead port
ptyHost.ack is a silent no-op, so the PTY would have paused at the 100KB
HIGH_WATERMARK and crawled on 5s PAUSE_SAFETY_TIMEOUT cycles.

Note on the "tested the Electron app locally" checkbox: verification was done via a
PANE_DIR=~/.pane_test pnpm dev instance driven by the RunPane CLI rather than by
hand through pnpm electron-dev.

Tests

main/src/ptyHost/ptyHostSupervisor.attachWindow.test.ts adds 4 tests covering the
port hand-off and the re-attach contract. Two of them were confirmed to fail against
the previous early-return guard, so they are not vacuous. main/src/test/setup.ts
gains a MessageChannelMain / MessagePortMain stand-in.

Main suite: 22 files / 47 tests fail identically before and after this change —
the pre-existing better-sqlite3-multiple-ciphers ABI mismatch documented in
AGENTS.md, all SQLite-dependent. The 4 new passing tests are the only delta.

`loadURL`/`loadFile` resolve on `did-finish-load`, so the listener that
handed the renderer its ptyHost MessagePort was registered ~320 lines
after the event it waited for had already fired. `attachWindow` never
ran, and `electronAPI.ptyHost.onData` had no source.

That is fatal because TerminalPanel permanently short-circuits the
legacy `terminal:output` IPC handler as soon as a `ptyId` arrives, so
the port is the only byte source for a ptyHost-spawned panel. Every new
terminal painted its first frame and then went silent while main's
emulator stayed correct: typing reached the shell but never echoed, and
manual Refresh repainted once without restoring live updates. Windows
only, because `usePtyHost` defaults to on for win32.

- register the hand-off before the awaited load, and use `on` so every
  load re-attaches
- make `attachWindow` replace a stale port pair instead of returning
  early; `webContents.id` survives a reload but the preload closure
  holding the port does not
- warn once in preload when a port is missing instead of no-opping
  silently, which is what hid this
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.

2 participants