Skip to content

fix(pty): defer initial PTY resize to recover from startup TIOCGWINSZ race (Linux/Wayland)#140

Draft
aeriondyseti wants to merge 14 commits into
StanMarek:masterfrom
aeriondyseti:fix/defer-initial-resize
Draft

fix(pty): defer initial PTY resize to recover from startup TIOCGWINSZ race (Linux/Wayland)#140
aeriondyseti wants to merge 14 commits into
StanMarek:masterfrom
aeriondyseti:fix/defer-initial-resize

Conversation

@aeriondyseti
Copy link
Copy Markdown

Summary

Adds a 50ms-deferred re-query + resize of the inner PTY at proxy startup, working around a TIOCGWINSZ race that strands the inner shell with stale terminal dimensions until the first real SIGWINCH arrives.

I'm running ghost-complete on Linux (Ghostty/Wayland), so this PR is firmly in "patch from a fork user who hit a platform-specific edge case" territory — flagging that up front since CONTRIBUTING.md declares the proxy as macOS-focused. Posting as draft so you can decide whether it's worth merging, ignoring, or pointing me at a better fix.

The bug

On a fresh terminal window, the first zsh prompt renders misaligned:

  • zsh's PROMPT_SP indicator (%) appears at column 0 because zsh's internal cursor-column tracking disagrees with the actual terminal width.
  • Right-aligned RPROMPT segments (in my case oh-my-posh's clock) land on a new line instead of at the right edge.
  • Resizing the window (zoom in/out) instantly fixes everything for the remainder of the session.

Every subsequent prompt is fine.

Root cause

gc_pty::spawn::spawn_shell calls get_terminal_size() (a crossterm::terminal::size() → TIOCGWINSZ ioctl) once and uses the result to open the inner PTY. On Linux/Wayland with Ghostty, the outer terminal's window-size handshake with the compositor isn't always complete at the moment that ioctl runs, so the ioctl returns stale/default dimensions. The inner PTY is created with those dimensions; the inner shell's $COLUMNS is wrong until the next SIGWINCH.

The existing SIGWINCH handler in proxy.rs already does the right thing (re-query, resize_pty, update parser dimensions) — the only problem is that no SIGWINCH arrives during normal first-prompt rendering. Zooming "fixes" the bug only because zoom is what finally triggers SIGWINCH.

Why macOS likely doesn't see this

On macOS, AppKit's window-creation/spawn flow is synchronous enough that TIOCGWINSZ at child-process start almost always returns correct dimensions. On Linux + Wayland the spawn-then-size sequence is multiple async round-trips across the compositor, and "process alive" precedes "process knows its window size." This is the same class of bug that's bitten tmux, screen, and other PTY proxies historically.

The fix

// In run_proxy(), shortly before signal registration:
let (initial_resize_tx, mut initial_resize_rx) = mpsc::channel::<()>(1);
tokio::spawn(async move {
    tokio::time::sleep(Duration::from_millis(50)).await;
    let _ = initial_resize_tx.send(()).await;
});

// New tokio::select! branch (peer of sigwinch.recv()):
_ = initial_resize_rx.recv() => {
    // Re-query and propagate fresh size to inner PTY + parser.
    // Same as SIGWINCH branch minus the popup-overlay cleanup
    // (no popup can be visible 50ms after startup).
}

Single file change in crates/gc-pty/src/proxy.rs (+51 lines).

Trade-offs / open questions

  • 50ms is a magic number. It's the cheapest thing that demonstrably resolves the symptom on my hardware. A principled v2 would poll terminal::size() until two consecutive reads agree (capped at, say, 200ms), or wire a one-shot timer that's cancelled by the first real SIGWINCH if one arrives early. Happy to rework if you'd prefer a less timing-coupled approach.
  • No regression test. This race is hard to simulate in a unit test — you'd need to mock TIOCGWINSZ returning stale values for ~30ms then catching up. A weaker test (assert a second resize_pty call happens within 100ms of spawn, regardless of value) would at least pin the behavior. Let me know if you'd like one and I'll add it.
  • If the initial size was already correct, the deferred resize is a no-op (resize_pty with same dims doesn't emit anything to the inner shell). So this is a free defensive layer for macOS users too — but I haven't tested on macOS.
  • The popup-cleanup path is omitted from the new branch because no popup can be visible at the 50ms mark. If you'd prefer symmetry with the SIGWINCH handler, happy to factor the common body into a helper.

Verification

Manually verified on Ghostty 1.x / Linux x86_64 / Wayland: with this patch, fresh windows render the first prompt correctly without any zoom workaround. Built with cargo install --path crates/ghost-complete --locked --force and exercised across ~20 fresh windows.

cargo fmt --check passes on the changed file. cargo clippy -p gc-pty --all-targets -- -D warnings passes on the changed file (there's a preexisting clippy warning in gc-overlay/src/render.rs:67 from clippy 1.93's stricter doc-list rules — unrelated to this PR, worth a separate cleanup).

Test plan

  • Open a fresh Ghostty window on Linux — first prompt aligned, no % indicator
  • Repeat 5+ times to confirm not flaky
  • Verify no regression on macOS (I cannot test — would appreciate a Mac-side sanity check)
  • Verify popup behavior unchanged (the new branch doesn't touch the overlay path)

…warning

The OnceLock import in specs.rs is only used by the mach_absolute_time
clock helpers, which are themselves cfg(target_os = "macos"). On Linux
builds the import becomes unused and trips the compiler warning.
Mirror the cfg gate on the import.
…nfig

Three new [popup] keys give users control over popup appearance:

- gutter_padding (u8, 0-8, default 1): trailing spaces between the kind
  icon and suggestion text. Total gutter width is 1 + 2 + padding cols.
- fixed_width (bool, default false): when true, popup always renders at
  max_width (clamped to screen) instead of fitting the longest item.
  Also honored in the feedback-only path so Loading/Empty/Error popups
  don't jump width when results arrive.
- truncation_indicator (String, default ""): appended to clipped text
  or description on overflow; e.g. "…" (1 col) or "..." (3 cols).
  Empty preserves the historical silent-truncation behavior.

All three are hot-reloadable, exposed in the TUI config editor with
clamp validation, and documented in the default config template.

The static GUTTER_COLS layout constant is replaced by a theme-driven
gutter_cols() that derives from gutter_padding, with ICON_BASE_COLS
and DEFAULT_GUTTER_COLS preserving the historical 4-col default for
the TUI preview path.

A shared truncation_budget() helper dedupes the overflow-decision
logic between write_highlighted_text and write_description, and
short-circuits the per-row width scan when the indicator is empty
(default config) so the hot path is unaffected.

build_popup_theme now takes &PopupConfig instead of seven positional
scalars, dropping the clippy::too_many_arguments allow.
When the user is mid-argument (Context::SpecArg) the popup previously
mixed spec-declared flags into the candidate set alongside subcommands
and arg values. Adds `suggest.flags_require_dash` (default true) so
flags only surface once the user types `-` and falls into the
FlagPrefix arm. Wired through the engine, handler, proxy builder, TUI
field editor, and the default config template.
Local dev loop: uninstall shell integration, drop the cargo binary,
rebuild from the current checkout, reinstall the integration block,
and open the TUI config editor. Each cleanup step is best-effort
(fresh checkouts have nothing to remove); the cargo install step is
fatal so a failed rebuild does not strand the user without a binary.
cargo install refuses to overwrite a binary it does not have in its
local manifest (e.g. one placed by a manual cp into $CARGO_HOME/bin).
That orphan blocks the rebuild even after `cargo uninstall` cleanly
no-ops. --force tells cargo to overwrite regardless.
The reinstalled binary only takes effect for *new* shells: the proxy
running in the current terminal was spawned at shell startup and stays
on the old code. Print a heads-up after the config editor exits with
the two options (new terminal, or pkill + exec zsh).
The frecency ranker starts empty and only learns from accepted
suggestions, so commonly-typed commands don't outrank the long tail
until you've used the proxy for a few days. import-history walks
~/.zsh_history (or $HISTFILE / --from PATH), splits each line into
pipeline segments, and records:

  - cmd:<head>            for the head token of each segment
  - <head>:sub:<next>     for the next non-flag, non-path-looking token

That covers `git status`, `cargo build`, `docker ps`, etc. without
trying to mine remote/branch/file tokens that vary too much to be
reliable. Flags include --dry-run and --max N for previewing scope.

Internally exposes HistoryProvider::read_history_from so the importer
can reuse the existing zsh-extended-format parser and the multi-line
merger, instead of re-implementing them.
- FrecencyDb::record_many: batch path for bulk imports — single lock
  acquisition, single flush. Avoids the SAVE_EVERY=3 disk thrashing
  that record() does for the runtime per-keystroke path. ~100x fewer
  fsyncs on a typical 300-record import.
- parse_baseline_flag now delegates to the generic parse_value_flag
  instead of duplicating its loop.
- DEFAULT_MAX_HISTORY_ENTRIES promoted to pub in gc-suggest::history;
  the importer's local copy is gone.
- PIPELINE_SEPARATORS reordered so '||' precedes '|' (was dead in the
  array because '|' split first turned '||' into two empty segments).
- Drop the Display impl on Recording (its `_ =>` arm was an admission
  of incoherent invariants); inline the dry-run formatting instead.
- Bind `cmd.to_string()` once per segment instead of cloning twice;
  drop the redundant `tokens.find(|t| !t.is_empty())` since
  split_whitespace never yields empty tokens.
… race

`spawn_shell` queries the outer terminal's dimensions exactly once via
`crossterm::terminal::size()` (TIOCGWINSZ ioctl) and uses the result to
open the inner PTY. On some platforms — observed on Linux/Wayland with
Ghostty — the outer terminal hasn't completed its window-size handshake
with the compositor at the moment the ioctl runs, so the inner PTY is
created with stale/default dimensions and the inner shell renders
against the wrong $COLUMNS until the first real SIGWINCH arrives.

Symptom: first prompt only, right-aligned RPROMPT segments land on a
new line, zsh's `PROMPT_SP` fires with `%` because its internal cursor
tracking disagrees with the actual terminal width. Resizing the window
(zoom in/out) "fixes" it by firing SIGWINCH, which routes through the
existing resize handler — but that's exactly the signal that's missing
on first prompt.

Fix: 50ms after startup, fire a one-shot mpsc message that triggers a
fresh `get_terminal_size()` + `resize_pty()` + parser dimension update.
Reuses the same primitives as the SIGWINCH path; if the initial size
was already correct, the second `resize_pty` with the same dimensions
is a no-op.

The popup-overlay cleanup branch of the SIGWINCH path is intentionally
omitted: at the 50ms mark no popup can yet be visible (the user hasn't
typed), so the narrower body suffices.
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.

1 participant