Skip to content

fix(terminals): a link click no longer leaks a spinning thread per click - #343

Merged
Wirasm merged 1 commit into
developmentfrom
fix/open-url-fallback-leak
Sep 3, 2026
Merged

fix(terminals): a link click no longer leaks a spinning thread per click#343
Wirasm merged 1 commit into
developmentfrom
fix/open-url-fallback-leak

Conversation

@Wirasm

@Wirasm Wirasm commented Sep 3, 2026

Copy link
Copy Markdown
Owner

helm was sitting at 398% CPU after nine days up, and it was not going to come
down on its own. Seven threads were spinning inside libghostty, each pinned near
57% of a core, all logging the same empty line as fast as the OS would let them.

What was happening

helm reported every ghostty action as unhandled, and ghostty reads that as its
own to perform. So a ⌘-click on a link in a pane opened twice: once through
helm, and once through a /usr/bin/open that ghostty spawned itself.

That second open never stopped running. Ghostty reads the child's stderr on a
detached thread whose loop only breaks on end-of-stream, and after the child
exits the reader returns empty slices instead — so the thread never leaves the
loop, never reaps the child, and logs open stderr= at roughly 9k lines a
second forever. One leaked thread and one zombie child per click, permanently:
only a restart clears them.

Measured on the live helm: seven spinning threads with zero I/O syscalls,
seven unreaped children whose ages matched the threads' accumulated CPU
one-for-one, and 587 CPU-hours burnt — three full cores continuously since
25 August. logd and analyticsd were hot for the same reason.

It also meant TerminalURLPolicy decided nothing. helm drops any scheme outside
its allowlist, and ghostty opened it anyway — so the allowlist TerminalSession
calls "the whole security story" was advisory.

The fix

Report the action as handled when a delegate actually took the URL, so the
fallback never runs. Ownership is claimed for open_url alone, and only on the
main thread where the answer can be given synchronously. Every other action keeps
ghostty's own default: no other fallback was measured, and claiming one would
suppress it blind.

It lands in the vendored wrapper because that is where the callback is, and it is
a backport — upstream libghostty-spm fixed it identically in 1.5.2, so the
code is byte-identical to theirs and a repin retires the patch rather than
needing a rebase. Ghostty's own stderr loop is still unfixed upstream
(ghostty-org/ghostty#13480, closed unmerged), so not triggering it is the fix
available to us.

Also: the patch script could not clone

Upstream has moved past the branch that held the pinned commit, so a fresh clone
no longer contains it and scripts/patch-libghostty.sh died with unable to read tree in every new worktree. Existing vendor/ trees kept verifying fine, which
is why it went unnoticed. It now fetches the SHA explicitly.

Validation

Red/green on the new test: with the conformance removed 2 of 3 fail and the
tree still compiles
, which is exactly the silent regression it exists to catch.
Restored, green. The third passes either way — it is the C-constant check, not a
control.

patch-libghostty.sh from a wiped vendor/ (proving the patch applies to a
clean clone) · swift build · make lint · xcodegen generate — all green.
swift test: 1504 tests, 11 failures, none from this change:

  • 10 are TerminalKeyboardTests (9) and WorkbenchFocusRoutingTests (1), with
    the log carrying the pair AGENTS.md names a millisecond apart —
    CVDisplayLinkCreateWithCGDisplays error -6661 due to invalid display count (0)
    then embedded_window: error initializing surface. Displays asleep; these are
    the two suites CI skips for this reason.
  • 1 is WorktreeCLITests.testHungListTimesOutAndTerminatesItsGitChild,
    timeout-shaped under full-suite load. Passes 3/3 in isolation and nothing in
    the diff reaches it. No control build on base was run for this one.

Not proved by any gate: that a ⌘-click no longer leaks. That needs a display
and an Accessibility grant, so it is the operator's check — ⌘-click a link, then
look for a new ~57% thread in ps -M -p <pid> and a new zombie child. Neither
should appear.

helm reported every ghostty action as unhandled, and ghostty reads that as
its own to perform. So a ⌘-click on a link in a pane opened twice: once
through helm, and once through a `/usr/bin/open` ghostty spawned itself.

That second open never stopped running. Ghostty reads the child's stderr on
a detached thread whose loop only breaks on end-of-stream, and after the
child exits the reader returns empty slices instead — so the thread never
leaves the loop, never reaps the child, and logs `open stderr=` at about 9k
lines a second forever. Measured on a helm up nine days: seven such threads,
seven unreaped children, 398% CPU at the time and 587 CPU-hours burnt, which
averages three full cores continuously since 25 August. Only a restart
clears it, and every click adds another one.

It also meant TerminalURLPolicy decided nothing. helm drops any scheme
outside its allowlist, and ghostty opened it anyway.

The fix reports the action as handled when a delegate actually took the URL,
so the fallback never runs. Ownership is claimed for open_url alone, and
only on the main thread where the answer can be given synchronously — every
other action keeps ghostty's own default, since no other fallback was
measured and claiming one would suppress it blind.

It lands in the vendored wrapper because that is where the callback is, and
it is a backport: upstream fixed it identically in 1.5.2, so the code is
byte-identical to theirs and a repin retires the patch rather than needing a
rebase. Ghostty's own stderr loop is still unfixed upstream
(ghostty-org/ghostty#13480, closed unmerged), so not triggering it is the
fix available to us.

Also fixes the patch script, which could not clone at all: upstream has
moved past the branch that held the pinned commit, so a fresh clone no
longer contains it and the checkout died with "unable to read tree" in every
new worktree. It now fetches the SHA explicitly.
@Wirasm
Wirasm merged commit 28b2c7a into development Sep 3, 2026
5 checks passed
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