fix(terminals): a link click no longer leaks a spinning thread per click - #343
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/openthat 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 asecond 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.
logdandanalyticsdwere hot for the same reason.It also meant
TerminalURLPolicydecided nothing. helm drops any scheme outsideits allowlist, and ghostty opened it anyway — so the allowlist
TerminalSessioncalls "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_urlalone, and only on themain 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.shdied withunable to read treein every new worktree. Existingvendor/trees kept verifying fine, whichis 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.shfrom a wipedvendor/(proving the patch applies to aclean clone) ·
swift build·make lint·xcodegen generate— all green.swift test: 1504 tests, 11 failures, none from this change:TerminalKeyboardTests(9) andWorkbenchFocusRoutingTests(1), withthe log carrying the pair
AGENTS.mdnames a millisecond apart —CVDisplayLinkCreateWithCGDisplays error -6661 due to invalid display count (0)then
embedded_window: error initializing surface. Displays asleep; these arethe two suites CI skips for this reason.
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. Neithershould appear.