From 5be876148fa50c17a522d076a3da8068d08e556f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 21:59:41 -0700 Subject: [PATCH 01/39] test: reproduce Claude close teardown leak --- ...SurfaceTeardownCallbackLifetimeTests.swift | 37 ++++++++ .../GhosttyRuntimeTestStubs.c | 87 +++++++++++++++++++ .../include/GhosttyRuntimeTestStubs.h | 5 ++ tests/test_claude_wrapper_hooks.py | 45 ++++++++-- 4 files changed, 169 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index a6bac1c55886..993a91e0061d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -2,6 +2,7 @@ import AppKit import CmuxTerminalCore import Foundation import GhosttyKit +import GhosttyRuntimeTestStubs import Testing @testable import CmuxTerminal @@ -27,6 +28,42 @@ import Testing #expect(await wait.value == false) } + @Test func teardownSurfaceKeepsMainActorResponsiveWhileNativeFreeIsBlocked() async { + let surface = makeSurface() + let runtimeSurface = fakeRuntimeSurface() + surface.installRuntimeSurfaceForTesting(runtimeSurface) + cmux_test_ghostty_surface_free_blocking_begin(runtimeSurface) + defer { + cmux_test_ghostty_surface_free_release() + cmux_test_ghostty_surface_free_blocking_reset() + } + + let probeResult = AsyncStream.makeStream() + DispatchQueue.global(qos: .userInitiated).async { + guard cmux_test_ghostty_surface_free_wait_until_started() else { + probeResult.continuation.yield(false) + probeResult.continuation.finish() + return + } + + Task { @MainActor in + let nativeFreeIsStillBlocked = + cmux_test_ghostty_surface_free_blocking_is_active() + cmux_test_ghostty_surface_free_release() + probeResult.continuation.yield(nativeFreeIsStillBlocked) + probeResult.continuation.finish() + } + } + + surface.teardownSurface() + + var probeResultIterator = probeResult.stream.makeAsyncIterator() + #expect( + await probeResultIterator.next() == true, + "explicit teardown did not start native free while keeping the main actor responsive" + ) + } + @Test func teardownSurfaceKeepsTeeLeaseUntilNativeFree() async { let recorder = TeardownOrderRecorder() let surface = makeSurface() diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index e2d309aff596..9a9bd5ce0444 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -1,7 +1,9 @@ #include "include/GhosttyRuntimeTestStubs.h" +#include #include #include #include +#include typedef struct { uint8_t r; @@ -35,6 +37,19 @@ static bool cmux_test_font_binding_succeeds = true; static void* cmux_test_font_callback_surface = NULL; static ghostty_font_size_action_cb cmux_test_font_callback = NULL; static void* cmux_test_font_callback_userdata = NULL; +static pthread_mutex_t cmux_test_surface_free_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t cmux_test_surface_free_condition = PTHREAD_COND_INITIALIZER; +static bool cmux_test_surface_free_should_block = false; +static bool cmux_test_surface_free_started = false; +static bool cmux_test_surface_free_released = false; +static void* cmux_test_surface_free_target = NULL; + +static struct timespec cmux_test_surface_free_timeout(void) { + return (struct timespec) { + .tv_sec = 5, + .tv_nsec = 0, + }; +} void cmux_test_ghostty_runtime_stubs_reset(void) { cmux_test_needs_confirm_quit = false; @@ -43,6 +58,59 @@ void cmux_test_ghostty_runtime_stubs_reset(void) { cmux_test_tty_name_call_count = 0; } +void cmux_test_ghostty_surface_free_blocking_begin(void *surface) { + pthread_mutex_lock(&cmux_test_surface_free_mutex); + cmux_test_surface_free_should_block = true; + cmux_test_surface_free_started = false; + cmux_test_surface_free_released = false; + cmux_test_surface_free_target = surface; + pthread_mutex_unlock(&cmux_test_surface_free_mutex); +} + +bool cmux_test_ghostty_surface_free_wait_until_started(void) { + const struct timespec timeout = cmux_test_surface_free_timeout(); + pthread_mutex_lock(&cmux_test_surface_free_mutex); + while (!cmux_test_surface_free_started) { + const int result = pthread_cond_timedwait_relative_np( + &cmux_test_surface_free_condition, + &cmux_test_surface_free_mutex, + &timeout + ); + if (result != 0) break; + } + const bool started = cmux_test_surface_free_started; + pthread_mutex_unlock(&cmux_test_surface_free_mutex); + return started; +} + +bool cmux_test_ghostty_surface_free_blocking_is_active(void) { + pthread_mutex_lock(&cmux_test_surface_free_mutex); + const bool active = + cmux_test_surface_free_should_block + && cmux_test_surface_free_started + && !cmux_test_surface_free_released + && cmux_test_surface_free_target != NULL; + pthread_mutex_unlock(&cmux_test_surface_free_mutex); + return active; +} + +void cmux_test_ghostty_surface_free_release(void) { + pthread_mutex_lock(&cmux_test_surface_free_mutex); + cmux_test_surface_free_released = true; + pthread_cond_broadcast(&cmux_test_surface_free_condition); + pthread_mutex_unlock(&cmux_test_surface_free_mutex); +} + +void cmux_test_ghostty_surface_free_blocking_reset(void) { + pthread_mutex_lock(&cmux_test_surface_free_mutex); + cmux_test_surface_free_should_block = false; + cmux_test_surface_free_started = false; + cmux_test_surface_free_released = true; + cmux_test_surface_free_target = NULL; + pthread_cond_broadcast(&cmux_test_surface_free_condition); + pthread_mutex_unlock(&cmux_test_surface_free_mutex); +} + void cmux_test_ghostty_renderer_realized_begin(void* surface) { cmux_test_renderer_realized_target = surface; cmux_test_renderer_realized_call_count = 0; @@ -231,6 +299,25 @@ bool ghostty_surface_set_font_size_action_callback( void ghostty_surface_config_new(void) {} void ghostty_surface_free(void *surface) { + const struct timespec timeout = cmux_test_surface_free_timeout(); + pthread_mutex_lock(&cmux_test_surface_free_mutex); + if (cmux_test_surface_free_should_block + && surface == cmux_test_surface_free_target) { + cmux_test_surface_free_started = true; + pthread_cond_broadcast(&cmux_test_surface_free_condition); + while (!cmux_test_surface_free_released) { + const int result = pthread_cond_timedwait_relative_np( + &cmux_test_surface_free_condition, + &cmux_test_surface_free_mutex, + &timeout + ); + if (result != 0) break; + } + cmux_test_surface_free_should_block = false; + cmux_test_surface_free_target = NULL; + } + pthread_mutex_unlock(&cmux_test_surface_free_mutex); + if (cmux_test_font_callback_surface == surface) { cmux_test_font_callback_surface = NULL; cmux_test_font_callback = NULL; diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 920eaff9c3fa..641e451b51f1 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -92,6 +92,11 @@ ghostty_string_s ghostty_surface_tty_name(void *surface); void cmux_test_ghostty_runtime_stubs_reset(void); void cmux_test_ghostty_runtime_stubs_set_close_state(bool needs_confirm, uint64_t foreground_pid, const char* tty_name); +void cmux_test_ghostty_surface_free_blocking_begin(void *surface); +bool cmux_test_ghostty_surface_free_wait_until_started(void); +bool cmux_test_ghostty_surface_free_blocking_is_active(void); +void cmux_test_ghostty_surface_free_release(void); +void cmux_test_ghostty_surface_free_blocking_reset(void); uint32_t cmux_test_ghostty_tty_name_call_count(void); void cmux_test_ghostty_renderer_realized_begin(void *surface); void cmux_test_ghostty_renderer_realized_reset(void); diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index d71be0a36be2..850f3b41bacc 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -237,6 +237,7 @@ def run_wrapper_terminal_env_probe( hooks_disabled: bool = False, socket_state: str = "live", restore_token: str | None = None, + inherited_env: dict[str, str] | None = None, ) -> tuple[int, dict[str, str], list[str], str, set[str]]: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-env-probe-") as td: tmp = Path(td) @@ -272,7 +273,8 @@ def run_wrapper_terminal_env_probe( fingerprint_env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" if restore_token is not None: fingerprint_env["CMUX_AGENT_RESTORE_LAUNCH"] = restore_token - probe_key_lines = "\n".join(f" {key}" for key in fingerprint_env) + probe_keys = [*fingerprint_env, "CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS"] + probe_key_lines = "\n".join(f" {key}" for key in probe_keys) make_executable( real_dir / "claude", @@ -318,8 +320,11 @@ def run_wrapper_terminal_env_probe( test_socket.bind(socket_path) env = os.environ.copy() + env.pop("CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS", None) env["PATH"] = f"{wrapper_dir}:{real_dir}:{env.get('PATH', '/usr/bin:/bin')}" env.update(fingerprint_env) + if inherited_env is not None: + env.update(inherited_env) env["FAKE_REAL_ENV_LOG"] = str(env_log) env["FAKE_REAL_ARGS_LOG"] = str(args_log) env["FAKE_CMUX_PING_OK"] = "1" if socket_state == "live" else "0" @@ -337,7 +342,7 @@ def run_wrapper_terminal_env_probe( test_socket.close() observed_env = dict(line.split("=", 1) for line in read_lines(env_log)) - return proc.returncode, observed_env, read_lines(args_log), proc.stderr.strip(), set(fingerprint_env) + return proc.returncode, observed_env, read_lines(args_log), proc.stderr.strip(), set(probe_keys) def expect(condition: bool, message: str, failures: list[str]) -> None: @@ -614,15 +619,44 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: f"SubagentStop hook should not call the visible stop hook, got {subagent_stop_hooks}", failures, ) - # SessionEnd should have a short timeout (session is exiting) + # Claude's SessionEnd lifecycle is part of orderly PTY teardown, so it + # needs the same bounded callback budget as the other lifecycle hooks. session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}]) expect( - any(h.get("timeout", 999) <= 2 for h in session_end_hooks), - f"SessionEnd hook should have short timeout, got {session_end_hooks}", + any(h.get("timeout") == 10 for h in session_end_hooks), + f"SessionEnd hook should have a 10-second timeout, got {session_end_hooks}", failures, ) +def test_live_socket_sets_effective_session_end_hook_budget(failures: list[str]) -> None: + cases = ( + (None, "10000", "unset"), + ("1000", "10000", "too small"), + ("30000", "30000", "larger user budget"), + ("invalid", "10000", "invalid"), + ) + for inherited_timeout, expected_timeout, label in cases: + inherited_env = ( + {} + if inherited_timeout is None + else {"CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS": inherited_timeout} + ) + code, observed_env, real_argv, stderr, _ = run_wrapper_terminal_env_probe( + ["hello"], + inherited_env=inherited_env, + ) + expect(code == 0, f"SessionEnd budget ({label}): wrapper exited {code}: {stderr}", failures) + expect("--settings" in real_argv, f"SessionEnd budget ({label}): hooks were not injected", failures) + expect( + observed_env.get("CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS") == expected_timeout, + "SessionEnd budget " + f"({label}): expected {expected_timeout}ms, got " + f"{observed_env.get('CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS')!r}", + failures, + ) + + def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> None: code, real_argv, _cmux_log, stderr, *_ = run_wrapper( socket_state="live", @@ -1943,6 +1977,7 @@ def main() -> int: return 0 failures: list[str] = [] test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures) + test_live_socket_sets_effective_session_end_hook_budget(failures) test_live_socket_merges_user_settings_into_hooks(failures) test_live_socket_merges_inline_settings_form(failures) test_live_socket_repeated_settings_user_value_wins_conflict(failures) From 85db65880436ba24bcc42ec9fd91846ccf4b5848 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 22:40:35 -0700 Subject: [PATCH 02/39] fix: unblock Claude teardown when tabs close --- .../TerminalSurface+RuntimeLifecycle.swift | 20 +++++++----- Resources/bin/cmux-claude-wrapper | 31 +++++++++++++++++-- tests/test_claude_wrapper_hooks.py | 10 ++++-- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index c3a1f3b6766c..ac95eb1508b7 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -287,14 +287,18 @@ extension TerminalSurface { } #endif - Task { @MainActor in - // Keep free behavior aligned with deinit: perform the runtime teardown on - // the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant. - ghostty_surface_free(surfaceToFree) - callbackContext?.release() - manualIOContext?.release() - teeLease?.release() - } + // Native free can wait for the child process's SessionEnd callback to + // call back into cmux. Keep that join off the main actor; the coordinator + // releases all callback userdata only after the free returns. + runtimeTeardown.enqueueRuntimeTeardown( + id: id, + workspaceId: tabId, + reason: "teardown", + surface: surfaceToFree, + callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease + ) } /// Frees the runtime surface while keeping the model alive for an diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index f9fc65462f7f..dfcdab4fd9a7 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -764,6 +764,31 @@ install_cmux_node_options() { fi } +# Claude gives SessionEnd a separate exit budget and caps each hook's configured +# timeout to that budget. An absent or zero override makes Claude derive the +# budget from the configured hooks, so only raise an inherited positive value +# that would cut cmux's 10-second callback short. +ensure_claude_session_end_hook_timeout_budget() { + local minimum_timeout_ms=10000 + [[ ${CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS+x} ]] || return + + local configured_timeout_ms="$CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS" + + if ! cmux_claude_wrapper_is_nonnegative_integer "$configured_timeout_ms"; then + unset CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS + return + fi + + # Compare digit counts instead of evaluating user input as shell arithmetic: + # environment values can exceed Bash's integer range. The minimum is a power + # of ten, so every positive value with fewer significant digits is smaller. + local significant_digits="${configured_timeout_ms#"${configured_timeout_ms%%[!0]*}"}" + if [[ -n "$significant_digits" \ + && ${#significant_digits} -lt ${#minimum_timeout_ms} ]]; then + export CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS="$minimum_timeout_ms" + fi +} + encode_launch_argv() { { printf '%s\0' "$REAL_CLAUDE" @@ -906,6 +931,7 @@ export CMUX_AGENT_LAUNCH_KIND="claude" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CLAUDE" export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" export CMUX_AGENT_LAUNCH_CWD="$PWD" +ensure_claude_session_end_hook_timeout_budget install_cmux_node_options # Build Claude settings JSON. @@ -913,7 +939,8 @@ install_cmux_node_options # - preferredNotifChannel: disables Claude Code's OSC/terminal notification # channel inside cmux so hooks are the only notification source. # - SessionStart/Stop/Notification: existing lifecycle hooks -# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire) +# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't +# fire). SYNC with a 10s timeout and matching dedicated Claude exit budget. # - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt # - PreToolUse: rejects unsupported durable cron requests synchronously, then # (async) clears "Needs input" / sets "Running" for ordinary tools, and flags @@ -940,7 +967,7 @@ install_cmux_node_options # timeout because it may run a summarization subprocess; it gates itself # on the workspaceAutoNaming setting via a socket probe, so it is a # no-op when the feature is disabled. -HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' +HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' # Fold any user-provided --settings into HOOKS_JSON before launching. # diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 850f3b41bacc..73855a8f3415 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -630,11 +630,17 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: def test_live_socket_sets_effective_session_end_hook_budget(failures: list[str]) -> None: + very_large_timeout = "9" * 100 cases = ( - (None, "10000", "unset"), + (None, "__UNSET__", "unset settings-derived budget"), + ("", "__UNSET__", "empty invalid budget"), + ("invalid", "__UNSET__", "invalid budget"), + ("0", "0", "zero settings-derived budget"), ("1000", "10000", "too small"), + ("00009999", "10000", "too small with leading zeroes"), + ("10000", "10000", "exact minimum"), ("30000", "30000", "larger user budget"), - ("invalid", "10000", "invalid"), + (very_large_timeout, very_large_timeout, "larger than Bash integer range"), ) for inherited_timeout, expected_timeout, label in cases: inherited_env = ( From 5d21acb78426c2bc3bdbc9699e3e7cddd487bec2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 22:57:45 -0700 Subject: [PATCH 03/39] test: reproduce stranded terminal close teardown --- ...rfaceRuntimeTeardownCoordinatorTests.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index 25973959cc17..f79357ae50d0 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -120,6 +120,65 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec #expect(await Set(recorder.freed) == Set(surfaces.map { UInt(bitPattern: $0) })) } + @Test func stuckCloseFreeDoesNotStrandLaterCloses() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let surfaces = (0..<3).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { for surface in surfaces { surface.deallocate() } } + let stuckFreeStarted = AsyncStream.makeStream() + let releaseStuckFree = DispatchSemaphore(value: 0) + let freedSurfaceBits = OSAllocatedUnfairLock(initialState: Set()) + defer { + releaseStuckFree.signal() + stuckFreeStarted.continuation.finish() + } + + let stuckTicket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.stuckClose", + surface: surfaces[0], + callbackContext: nil, + freeSurface: { _ in + stuckFreeStarted.continuation.yield() + _ = releaseStuckFree.wait(timeout: .distantFuture) + } + ) + var stuckFreeIterator = stuckFreeStarted.stream.makeAsyncIterator() + _ = await stuckFreeIterator.next() + + let laterTickets = surfaces.dropFirst().map { surface in + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.laterClose", + surface: surface, + callbackContext: nil, + freeSurface: { pointer in + freedSurfaceBits.withLock { + _ = $0.insert(UInt(bitPattern: pointer)) + } + } + ) + } + + for ticket in laterTickets { + try #require( + await ticket.wait(timeout: .seconds(5)), + "a stuck native free stranded a later close" + ) + } + #expect(await stuckTicket.wait(timeout: .zero) == false) + #expect( + freedSurfaceBits.withLock { $0 } == + Set(surfaces.dropFirst().map { UInt(bitPattern: $0) }) + ) + + releaseStuckFree.signal() + #expect(await stuckTicket.wait(timeout: .seconds(5))) + } + @Test func stuckHibernationFreeDoesNotStrandAnotherAdmissionOrClose() async throws { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let isolatedSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) From cebfba428109af9a8c604bd00fdf72e964255373 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 23:05:10 -0700 Subject: [PATCH 04/39] fix: isolate blocked terminal close teardowns --- ...minalSurfaceRuntimeScreenTailRequest.swift | 2 +- ...nalSurfaceRuntimeTeardownCoordinator.swift | 78 ++++++++++++------- ...lSurfaceRuntimeTeardownExecutionLane.swift | 4 +- ...alSurfaceRuntimeTeardownRequestQueue.swift | 17 ++++ ...TerminalSurfaceRuntimeTeardownTicket.swift | 2 +- .../TerminalSurfaceRuntimeDependencies.swift | 2 +- ...rfaceRuntimeTeardownCoordinatorTests.swift | 20 ++--- 7 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequestQueue.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index b962b7fd8a56..9e7b4d45138d 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -1,7 +1,7 @@ internal import Foundation internal import GhosttyKit -/// A bounded native screen-tail read serialized with native surface teardown. +/// A bounded native screen-tail read ordered before later teardown requests. /// /// The raw surface pointer remains owned by its ``TerminalSurface``. The runtime /// coordinator executes this request without suspension, so an enqueued native diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index af2ba25d4dbf..5abb8b8a786b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -8,13 +8,16 @@ internal import CMUXDebugLog /// Coordinates native `ghostty_surface_free` calls off the close/deinit paths. /// -/// Close/deinit frees run one at a time on a utility worker so re-entrant -/// teardown loops cannot form. Each admitted hibernation owns one independently -/// startable utility slot, so one stuck native join cannot strand another pane. -/// Deadline observers report, but never block on, stuck frees. The app constructs -/// exactly one instance and injects it through +/// Close/deinit frees run on a bounded set of utility slots so one stuck native +/// join cannot strand later closes. Each admitted hibernation owns a separate, +/// independently startable utility slot. Deadline observers report, but never +/// block on, stuck frees. The app constructs exactly one instance and injects it +/// through /// ``TerminalSurfaceRuntimeDependencies``. public actor TerminalSurfaceRuntimeTeardownCoordinator { + /// Maximum number of close/deinit native frees that can run concurrently. + public static let maximumConcurrentCloseTeardownCount = 2 + /// Largest batch that can own independently startable native-free slots. public static let maximumIsolatedHibernationTeardownCount = 2 @@ -27,14 +30,26 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { #else private var pendingReasonsById: [UUID: String] = [:] #endif - private var queuedRequests: [TerminalSurfaceRuntimeTeardownRequest] = [] - private var isWorkerRunning = false + private var queuedCloseRequests = TerminalSurfaceRuntimeTeardownRequestQueue() + private var availableCloseExecutionSlots: Set + private let closeTeardownQueues: [DispatchQueue] private let isolatedHibernationQueues: [DispatchQueue] private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() /// Creates the process's teardown coordinator. public init() { + availableCloseExecutionSlots = Set( + 0..?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, - executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .serializedClose, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .boundedClose, isolatedHibernationReservation: TerminalSurfaceRuntimeTeardownReservation? = nil, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in @@ -172,7 +187,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { func enqueue( _ request: TerminalSurfaceRuntimeTeardownRequest, - executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .serializedClose, + executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .boundedClose, isolatedHibernationReservation: TerminalSurfaceRuntimeTeardownReservation? = nil ) async { @@ -209,31 +224,40 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { isolatedHibernationReservation ) } - case .serializedClose: + case .boundedClose: break } - queuedRequests.append(request) - if !isWorkerRunning { - isWorkerRunning = true - Task.detached(priority: .utility) { - while let request = await self.nextRequestForWorker() { - Task { - await self.observeTimeout(id: request.id) - } - self.freeNativeSurface(request) - await self.finishFree(request) - await self.complete(id: request.id) + queuedCloseRequests.append(request) + startAvailableCloseTeardowns() + } + + private func startAvailableCloseTeardowns() { + while let executionSlot = availableCloseExecutionSlots.min(), + let request = queuedCloseRequests.popFirst() { + availableCloseExecutionSlots.remove(executionSlot) + Task { + await self.observeTimeout(id: request.id) + } + closeTeardownQueues[executionSlot].async { + self.freeNativeSurface(request) + Task { + await self.finishCloseTeardown( + request, + executionSlot: executionSlot + ) } } } } - private func nextRequestForWorker() -> TerminalSurfaceRuntimeTeardownRequest? { - guard !queuedRequests.isEmpty else { - isWorkerRunning = false - return nil - } - return queuedRequests.removeFirst() + private func finishCloseTeardown( + _ request: TerminalSurfaceRuntimeTeardownRequest, + executionSlot: Int + ) async { + await finishFree(request) + complete(id: request.id) + availableCloseExecutionSlots.insert(executionSlot) + startAvailableCloseTeardowns() } private nonisolated func freeNativeSurface( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift index 0a2a800691d5..35e4cb97fbd7 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownExecutionLane.swift @@ -1,7 +1,7 @@ /// Selects the ownership boundary for a native surface free. enum TerminalSurfaceRuntimeTeardownExecutionLane: Sendable { - /// Preserves ordering for close/deinit flows that can re-enter teardown. - case serializedClose + /// Uses the bounded close/deinit pool without blocking later frees. + case boundedClose /// Gives an explicitly owned hibernation join an independent bounded slot. case isolatedHibernation diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequestQueue.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequestQueue.swift new file mode 100644 index 000000000000..f020f1cd4eb0 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequestQueue.swift @@ -0,0 +1,17 @@ +/// A FIFO queue that releases consumed teardown requests in amortized constant time. +struct TerminalSurfaceRuntimeTeardownRequestQueue { + private var incoming: [TerminalSurfaceRuntimeTeardownRequest] = [] + private var outgoing: [TerminalSurfaceRuntimeTeardownRequest] = [] + + mutating func append(_ request: TerminalSurfaceRuntimeTeardownRequest) { + incoming.append(request) + } + + mutating func popFirst() -> TerminalSurfaceRuntimeTeardownRequest? { + if outgoing.isEmpty { + outgoing = Array(incoming.reversed()) + incoming.removeAll(keepingCapacity: true) + } + return outgoing.popLast() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownTicket.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownTicket.swift index 1ed5df6ac631..a3feec39c2b2 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownTicket.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownTicket.swift @@ -1,6 +1,6 @@ public import Foundation -/// An awaitable handle for one serialized native surface teardown. +/// An awaitable handle for one native surface teardown. public struct TerminalSurfaceRuntimeTeardownTicket: Sendable { /// Stable identity used by the surface to clear only its current teardown. public let id: UUID diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift index 4dd6d4bcf76b..a7507087135d 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalSurfaceRuntimeDependencies.swift @@ -30,7 +30,7 @@ public struct TerminalSurfaceRuntimeDependencies { /// The agent-hibernation input recorder. public let hibernationRecorder: any AgentHibernationRecording - /// The serialized native-surface free queue. + /// The bounded native-surface teardown coordinator. public let runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator /// The paced native-surface creation queue for restored terminal sessions. diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index f79357ae50d0..319a0f9c2fb7 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -186,16 +186,16 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec byteCount: 8, alignment: 8 ) - let serializedSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let closeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) defer { isolatedSurface.deallocate() queuedIsolatedSurface.deallocate() - serializedSurface.deallocate() + closeSurface.deallocate() } let isolatedFreeStarted = AsyncStream.makeStream() let releaseIsolatedFree = DispatchSemaphore(value: 0) let secondIsolatedFreeCount = OSAllocatedUnfairLock(initialState: 0) - let serializedFreeCount = OSAllocatedUnfairLock(initialState: 0) + let closeFreeCount = OSAllocatedUnfairLock(initialState: 0) defer { releaseIsolatedFree.signal() isolatedFreeStarted.continuation.finish() @@ -240,20 +240,20 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec secondIsolatedFreeCount.withLock { $0 += 1 } } ) - let serializedTicket = coordinator.enqueueRuntimeTeardown( + let closeTicket = coordinator.enqueueRuntimeTeardown( id: UUID(), workspaceId: UUID(), - reason: "test.serializedClose", - surface: serializedSurface, + reason: "test.close", + surface: closeSurface, callbackContext: nil, freeSurface: { _ in - serializedFreeCount.withLock { $0 += 1 } + closeFreeCount.withLock { $0 += 1 } } ) - #expect(await serializedTicket.wait(timeout: .seconds(1))) + #expect(await closeTicket.wait(timeout: .seconds(1))) #expect(await secondIsolatedTicket.wait(timeout: .seconds(1))) - #expect(serializedFreeCount.withLock { $0 } == 1) + #expect(closeFreeCount.withLock { $0 } == 1) #expect(await isolatedTicket.wait(timeout: .zero) == false) #expect(secondIsolatedFreeCount.withLock { $0 } == 1) @@ -270,7 +270,7 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec await coordinator.cancelIsolatedHibernationTeardown(nextReservation) } - @Test func staleIsolatedReservationFallsBackToSerializedFree() async throws { + @Test func staleIsolatedReservationFallsBackToBoundedClose() async throws { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) defer { surface.deallocate() } From ef9c634a32312a06f9c3069a30fcc6eefb24edf3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 23:33:59 -0700 Subject: [PATCH 05/39] fix: respect Claude SessionEnd budget overrides --- Resources/bin/cmux-claude-wrapper | 29 ++------------------ tests/test_claude_wrapper_hooks.py | 44 ++---------------------------- 2 files changed, 4 insertions(+), 69 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index dfcdab4fd9a7..fcc75124fc3d 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -764,31 +764,6 @@ install_cmux_node_options() { fi } -# Claude gives SessionEnd a separate exit budget and caps each hook's configured -# timeout to that budget. An absent or zero override makes Claude derive the -# budget from the configured hooks, so only raise an inherited positive value -# that would cut cmux's 10-second callback short. -ensure_claude_session_end_hook_timeout_budget() { - local minimum_timeout_ms=10000 - [[ ${CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS+x} ]] || return - - local configured_timeout_ms="$CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS" - - if ! cmux_claude_wrapper_is_nonnegative_integer "$configured_timeout_ms"; then - unset CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS - return - fi - - # Compare digit counts instead of evaluating user input as shell arithmetic: - # environment values can exceed Bash's integer range. The minimum is a power - # of ten, so every positive value with fewer significant digits is smaller. - local significant_digits="${configured_timeout_ms#"${configured_timeout_ms%%[!0]*}"}" - if [[ -n "$significant_digits" \ - && ${#significant_digits} -lt ${#minimum_timeout_ms} ]]; then - export CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS="$minimum_timeout_ms" - fi -} - encode_launch_argv() { { printf '%s\0' "$REAL_CLAUDE" @@ -931,7 +906,6 @@ export CMUX_AGENT_LAUNCH_KIND="claude" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CLAUDE" export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" export CMUX_AGENT_LAUNCH_CWD="$PWD" -ensure_claude_session_end_hook_timeout_budget install_cmux_node_options # Build Claude settings JSON. @@ -940,7 +914,8 @@ install_cmux_node_options # channel inside cmux so hooks are the only notification source. # - SessionStart/Stop/Notification: existing lifecycle hooks # - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't -# fire). SYNC with a 10s timeout and matching dedicated Claude exit budget. +# fire). SYNC with a 10s timeout; absent an explicit user override, Claude +# derives its dedicated SessionEnd exit budget from this configured hook. # - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt # - PreToolUse: rejects unsupported durable cron requests synchronously, then # (async) clears "Needs input" / sets "Running" for ordinary tools, and flags diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 73855a8f3415..59d44540e362 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -237,7 +237,6 @@ def run_wrapper_terminal_env_probe( hooks_disabled: bool = False, socket_state: str = "live", restore_token: str | None = None, - inherited_env: dict[str, str] | None = None, ) -> tuple[int, dict[str, str], list[str], str, set[str]]: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-env-probe-") as td: tmp = Path(td) @@ -273,8 +272,7 @@ def run_wrapper_terminal_env_probe( fingerprint_env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" if restore_token is not None: fingerprint_env["CMUX_AGENT_RESTORE_LAUNCH"] = restore_token - probe_keys = [*fingerprint_env, "CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS"] - probe_key_lines = "\n".join(f" {key}" for key in probe_keys) + probe_key_lines = "\n".join(f" {key}" for key in fingerprint_env) make_executable( real_dir / "claude", @@ -320,11 +318,8 @@ def run_wrapper_terminal_env_probe( test_socket.bind(socket_path) env = os.environ.copy() - env.pop("CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS", None) env["PATH"] = f"{wrapper_dir}:{real_dir}:{env.get('PATH', '/usr/bin:/bin')}" env.update(fingerprint_env) - if inherited_env is not None: - env.update(inherited_env) env["FAKE_REAL_ENV_LOG"] = str(env_log) env["FAKE_REAL_ARGS_LOG"] = str(args_log) env["FAKE_CMUX_PING_OK"] = "1" if socket_state == "live" else "0" @@ -342,7 +337,7 @@ def run_wrapper_terminal_env_probe( test_socket.close() observed_env = dict(line.split("=", 1) for line in read_lines(env_log)) - return proc.returncode, observed_env, read_lines(args_log), proc.stderr.strip(), set(probe_keys) + return proc.returncode, observed_env, read_lines(args_log), proc.stderr.strip(), set(fingerprint_env) def expect(condition: bool, message: str, failures: list[str]) -> None: @@ -629,40 +624,6 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: ) -def test_live_socket_sets_effective_session_end_hook_budget(failures: list[str]) -> None: - very_large_timeout = "9" * 100 - cases = ( - (None, "__UNSET__", "unset settings-derived budget"), - ("", "__UNSET__", "empty invalid budget"), - ("invalid", "__UNSET__", "invalid budget"), - ("0", "0", "zero settings-derived budget"), - ("1000", "10000", "too small"), - ("00009999", "10000", "too small with leading zeroes"), - ("10000", "10000", "exact minimum"), - ("30000", "30000", "larger user budget"), - (very_large_timeout, very_large_timeout, "larger than Bash integer range"), - ) - for inherited_timeout, expected_timeout, label in cases: - inherited_env = ( - {} - if inherited_timeout is None - else {"CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS": inherited_timeout} - ) - code, observed_env, real_argv, stderr, _ = run_wrapper_terminal_env_probe( - ["hello"], - inherited_env=inherited_env, - ) - expect(code == 0, f"SessionEnd budget ({label}): wrapper exited {code}: {stderr}", failures) - expect("--settings" in real_argv, f"SessionEnd budget ({label}): hooks were not injected", failures) - expect( - observed_env.get("CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS") == expected_timeout, - "SessionEnd budget " - f"({label}): expected {expected_timeout}ms, got " - f"{observed_env.get('CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS')!r}", - failures, - ) - - def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> None: code, real_argv, _cmux_log, stderr, *_ = run_wrapper( socket_state="live", @@ -1983,7 +1944,6 @@ def main() -> int: return 0 failures: list[str] = [] test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures) - test_live_socket_sets_effective_session_end_hook_budget(failures) test_live_socket_merges_user_settings_into_hooks(failures) test_live_socket_merges_inline_settings_form(failures) test_live_socket_repeated_settings_user_value_wins_conflict(failures) From aedc1f700cbf5c0c5121db0bb075534a389d6bb2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 00:39:46 -0700 Subject: [PATCH 06/39] fix: clear inherited Swift warning regressions --- Sources/NotificationsPage.swift | 6 +++--- Sources/Panels/FilePreviewPDFSharingPresenter.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 45f3a12dec13..12af0fb8608b 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -35,13 +35,13 @@ struct NotificationsPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .onAppear(perform: setInitialFocus) - .onChange(of: notificationStore.notifications.first?.id) { _ in + .onChange(of: notificationStore.notifications.first?.id) { setInitialFocus() } - .onChange(of: isFocused) { _ in + .onChange(of: isFocused) { setInitialFocus() } - .onChange(of: isVisibleInUI) { _ in + .onChange(of: isVisibleInUI) { setInitialFocus() } } diff --git a/Sources/Panels/FilePreviewPDFSharingPresenter.swift b/Sources/Panels/FilePreviewPDFSharingPresenter.swift index efd132b5797b..8befa5bdadc6 100644 --- a/Sources/Panels/FilePreviewPDFSharingPresenter.swift +++ b/Sources/Panels/FilePreviewPDFSharingPresenter.swift @@ -3,8 +3,8 @@ import AppKit /// Owns the sharing picker presented by one PDF preview container. @MainActor final class FilePreviewPDFSharingPresenter: NSObject { - typealias PickerFactory = ([Any]) -> NSSharingServicePicker - typealias MenuPresenter = (NSMenu, NSView) -> Void + typealias PickerFactory = @MainActor ([Any]) -> NSSharingServicePicker + typealias MenuPresenter = @MainActor (NSMenu, NSView) -> Void private let presentMenu: MenuPresenter private let makePicker: PickerFactory @@ -63,7 +63,7 @@ final class FilePreviewPDFSharingPresenter: NSObject { } } -extension FilePreviewPDFSharingPresenter: NSSharingServicePickerDelegate { +extension FilePreviewPDFSharingPresenter: @MainActor NSSharingServicePickerDelegate { func sharingServicePicker( _ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService? From 0e4bb1c17af450633c4e99195340906623314aff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 00:39:52 -0700 Subject: [PATCH 07/39] ci: canonicalize app-host config evidence paths --- scripts/ci/run-app-host-xcodebuild.sh | 62 +++++++++++++++++++++------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/scripts/ci/run-app-host-xcodebuild.sh b/scripts/ci/run-app-host-xcodebuild.sh index 6b6b5ce6e9d1..96af72275208 100755 --- a/scripts/ci/run-app-host-xcodebuild.sh +++ b/scripts/ci/run-app-host-xcodebuild.sh @@ -110,6 +110,25 @@ kill_stale_app_host() { "$CMUX_RESOLVED_SYSTEM_TEMP_ROOT" } +canonicalize_existing_app_host_config_path() { + local path="$1" + if [ ! -e "$path" ] || [ -L "$path" ]; then + return 1 + fi + + if [ -d "$path" ]; then + (cd "$path" 2>/dev/null && pwd -P) + return + fi + + local parent name resolved_parent + parent="${path%/*}" + name="${path##*/}" + [ -n "$parent" ] || parent=/ + resolved_parent="$(cd "$parent" 2>/dev/null && pwd -P)" || return 1 + printf '%s/%s\n' "${resolved_parent%/}" "$name" +} + validate_app_host_config_paths() { local log_path="$1" local require_evidence="$2" @@ -120,9 +139,16 @@ validate_app_host_config_paths() { return 1 fi - local expected_config_path + local expected_config_path canonical_expected_config_path expected_config_path="${app_host_home%/}/Library/Application Support/com.mitchellh.ghostty/config.ghostty" - local matches scan_status line reported_path + canonical_expected_config_path="$( + canonicalize_existing_app_host_config_path "$expected_config_path" + )" || { + echo "FAIL: isolated app-host configuration sentinel is unavailable" >&2 + return 1 + } + local matches scan_status line reported_path canonical_reported_path + local found_expected_config_evidence=0 if matches="$(grep -E 'cmux DEV.*\[(config|default)\].*path=.*(Library/Application Support/com\.mitchellh\.ghostty/|/\.config/ghostty/)' "$log_path")"; then scan_status=0 else @@ -139,8 +165,16 @@ validate_app_host_config_paths() { if [ -n "$matches" ]; then while IFS= read -r line; do + line="${line%$'\r'}" reported_path="${line#*path=}" - case "$reported_path" in + canonical_reported_path="$( + canonicalize_existing_app_host_config_path "$reported_path" + )" || { + echo "FAIL: Ghostty accessed configuration outside the isolated app-host home" >&2 + echo "$line" >&2 + return 1 + } + case "$canonical_reported_path" in "$app_host_home"|"${app_host_home%/}/"*) ;; *) echo "FAIL: Ghostty accessed configuration outside the isolated app-host home" >&2 @@ -148,19 +182,21 @@ validate_app_host_config_paths() { return 1 ;; esac + + case "$line" in + *"[default] reading configuration file path="*|*"[config] reading configuration file path="*) + if [ "$canonical_reported_path" = "$canonical_expected_config_path" ]; then + found_expected_config_evidence=1 + fi + ;; + esac done <<< "$matches" fi - if [ "$require_evidence" = "1" ]; then - if ! grep -Fq \ - "[default] reading configuration file path=$expected_config_path" \ - "$log_path" \ - && ! grep -Fq \ - "[config] reading configuration file path=$expected_config_path" \ - "$log_path"; then - echo "FAIL: app-host configuration evidence is missing" >&2 - return 1 - fi + if [ "$require_evidence" = "1" ] \ + && [ "$found_expected_config_evidence" != "1" ]; then + echo "FAIL: app-host configuration evidence is missing" >&2 + return 1 fi } From b1cd4d89e6786e5ca69fe922a26070c28e8a9e09 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:03:27 -0700 Subject: [PATCH 08/39] test: cover teardown beyond blocked close slots --- ...rfaceRuntimeTeardownCoordinatorTests.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index 319a0f9c2fb7..5489a9c174c5 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -179,6 +179,74 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec #expect(await stuckTicket.wait(timeout: .seconds(5))) } + @Test func allBlockedCloseSlotsStillBeginLaterProcessTeardown() async throws { + let begunSurfaceBits = OSAllocatedUnfairLock(initialState: Set()) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + beginSurfaceTeardown: { surface in + begunSurfaceBits.withLock { + _ = $0.insert(UInt(bitPattern: surface)) + } + } + ) + let surfaces = (0..<3).map { _ in + UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + } + defer { for surface in surfaces { surface.deallocate() } } + let blockedFreeStarted = AsyncStream.makeStream() + let releaseBlockedFrees = DispatchSemaphore(value: 0) + let laterFreeCount = OSAllocatedUnfairLock(initialState: 0) + defer { + releaseBlockedFrees.signal() + releaseBlockedFrees.signal() + blockedFreeStarted.continuation.finish() + } + + let blockedTickets = surfaces.prefix(2).map { surface in + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.blockedCloseSlot", + surface: surface, + callbackContext: nil, + freeSurface: { pointer in + blockedFreeStarted.continuation.yield( + UInt(bitPattern: pointer) + ) + _ = releaseBlockedFrees.wait(timeout: .distantFuture) + } + ) + } + var blockedFreeIterator = blockedFreeStarted.stream.makeAsyncIterator() + _ = await blockedFreeIterator.next() + _ = await blockedFreeIterator.next() + + let laterTicket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.closeBeyondBlockedSlots", + surface: surfaces[2], + callbackContext: nil, + freeSurface: { _ in + laterFreeCount.withLock { $0 += 1 } + } + ) + + #expect( + begunSurfaceBits.withLock { $0 } == + Set(surfaces.map { UInt(bitPattern: $0) }) + ) + #expect(laterFreeCount.withLock { $0 } == 0) + #expect(await laterTicket.wait(timeout: .zero) == false) + + releaseBlockedFrees.signal() + releaseBlockedFrees.signal() + for ticket in blockedTickets { + #expect(await ticket.wait(timeout: .seconds(5))) + } + #expect(await laterTicket.wait(timeout: .seconds(5))) + #expect(laterFreeCount.withLock { $0 } == 1) + } + @Test func stuckHibernationFreeDoesNotStrandAnotherAdmissionOrClose() async throws { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let isolatedSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) From aee1f97a75e68eff9cff0104290183fc7c6fbcf0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:32:08 -0700 Subject: [PATCH 09/39] fix: start process teardown before free admission --- ...nalSurfaceRuntimeTeardownCoordinator.swift | 31 +++++++++--- .../GhosttyRuntimeTestStubs.c | 3 ++ .../include/GhosttyRuntimeTestStubs.h | 1 + docs/ghostty-fork.md | 48 ++++++++++++++++--- ghostty | 2 +- 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 5abb8b8a786b..cf57fe17fa46 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -8,11 +8,15 @@ internal import CMUXDebugLog /// Coordinates native `ghostty_surface_free` calls off the close/deinit paths. /// -/// Close/deinit frees run on a bounded set of utility slots so one stuck native -/// join cannot strand later closes. Each admitted hibernation owns a separate, -/// independently startable utility slot. Deadline observers report, but never -/// block on, stuck frees. The app constructs exactly one instance and injects it -/// through +/// Every request first starts Ghostty-owned child-process teardown before it +/// can wait for a bounded native-free slot. This keeps process lifetime +/// independent from resource-destruction admission. +/// +/// Close/deinit frees run on a bounded set of utility slots so stuck native +/// joins cannot create unbounded worker threads. Each admitted hibernation owns +/// a separate, independently startable utility slot. Deadline observers report, +/// but never block on, stuck frees. The app constructs exactly one instance and +/// injects it through /// ``TerminalSurfaceRuntimeDependencies``. public actor TerminalSurfaceRuntimeTeardownCoordinator { /// Maximum number of close/deinit native frees that can run concurrently. @@ -34,11 +38,21 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private var availableCloseExecutionSlots: Set private let closeTeardownQueues: [DispatchQueue] private let isolatedHibernationQueues: [DispatchQueue] + private nonisolated let beginSurfaceTeardown: + @Sendable (ghostty_surface_t) -> Void private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() /// Creates the process's teardown coordinator. - public init() { + /// + /// - Parameter beginSurfaceTeardown: A non-blocking native request that + /// retires the surface from process routing and starts child shutdown. + public init( + beginSurfaceTeardown: @escaping @Sendable (ghostty_surface_t) -> Void = { + ghostty_surface_request_process_termination($0) + } + ) { + self.beginSurfaceTeardown = beginSurfaceTeardown availableCloseExecutionSlots = Set( 0.. TerminalSurfaceRuntimeTeardownTicket { + // This call is intentionally synchronous and precedes the Task hop: + // even when every native-free lane is occupied, the surface's own IO + // thread can terminate and reap its process tree independently. + beginSurfaceTeardown(surface) + let completion = TerminalSurfaceRuntimeTeardownCompletion() let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) let request = TerminalSurfaceRuntimeTeardownRequest( diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 9a9bd5ce0444..a44f9aeee9fc 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -324,6 +324,9 @@ void ghostty_surface_free(void *surface) { cmux_test_font_callback_userdata = NULL; } } +void ghostty_surface_request_process_termination(void *surface) { + (void)surface; +} void ghostty_surface_free_text(void) {} float ghostty_surface_font_size(void *surface) { return surface == cmux_test_font_surface diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 641e451b51f1..9d51bd64e6a3 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -50,6 +50,7 @@ bool ghostty_surface_set_font_size_action_callback( void *userdata); void ghostty_surface_config_new(void); void ghostty_surface_free(void *surface); +void ghostty_surface_request_process_termination(void *surface); void ghostty_surface_free_text(void); float ghostty_surface_font_size(void *surface); bool ghostty_surface_font_size_adjusted(void *surface); diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 38b5cc89897d..09db53e13f0e 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,12 +12,14 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `11aa609d7`, which exposes whether the -VT parser is at a ground-state stream boundary. cmux uses that contract to -retain incomplete escape-sequence bytes across distributed snapshot handoff. -It builds on `19d03fa4d`, which suppresses empty opener stderr diagnostics on -top of `f0f8273b7`, the iOS startup locale/crash-reporting order fix. That -commit follows `88357634c`, the fork-main +The submodule pinned by this branch is `5b20c6229`, which adds bounded, +two-phase embedded-surface process teardown. It includes `9513174f2`, the +current-fork reapplication of the VT stream-boundary API previously pinned at +`11aa609d7`. cmux uses that VT contract to retain incomplete escape-sequence +bytes across distributed snapshot handoff. The current pin builds on +`19d03fa4d`, which suppresses empty opener stderr diagnostics on top of +`f0f8273b7`, the iOS startup locale/crash-reporting order fix. That commit +follows `88357634c`, the fork-main merge of https://github.com/manaflow-ai/ghostty/pull/175. That previous merge combines the initial cmux theme-picker render fix at `5068b3a37` with terminal-owned semantic-prompt row lifecycle enforcement through `2d6e944e3` from @@ -33,7 +35,9 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). ### VT stream-boundary visibility -- Commit: `11aa609d7` (Expose safe VT stream snapshot boundary) +- Commits: + - Original pin: `11aa609d7` (Expose safe VT stream snapshot boundary) + - Reapplied on current fork main: `9513174f2` - Files: `include/ghostty/vt/terminal.h`, `src/terminal/c/terminal.zig`, `src/lib_vt.zig` - Summary: @@ -46,6 +50,36 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - SHA-256 `1a4acbcc9e0e5b20c0b4dad6660d0c08546a5d36192053834df960144fa8fdb9` is pinned in `scripts/ghosttykit-checksums.txt`. +### Bounded embedded-surface process teardown + +- Pull request: + - https://github.com/manaflow-ai/ghostty/pull/184 +- Commits: + - `9be0c8b93` (test: cover subprocesses that ignore SIGHUP) + - `5b20c6229` (fix: bound embedded surface process teardown) +- Files: + - `include/ghostty.h` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `src/termio/Exec.zig` +- Summary: + - Exposes `ghostty_surface_request_process_termination`, a non-blocking, + idempotent pre-free request that retires an embedded surface from Ghostty + app routing and wakes its IO owner without joining or freeing the surface. + - Lets the child process group handle SIGHUP for 12 seconds, preserving + cmux's 10-second Claude `SessionEnd` hook budget, then escalates to SIGKILL. + - Bounds the post-SIGKILL reap wait to three seconds, so a pathological child + cannot hold a native-surface teardown worker indefinitely. + - Keeps `ghostty_surface_free` as the final synchronization and ownership + boundary for renderer, IO, callback userdata, and native allocation release. +- Conflict note: + - Preserve the two-phase contract during future embedded-surface or termio + merges: the pre-free request must prevent new app-action retains, remain + idempotent, and start IO-owned process teardown without freeing native state. + Final free must still wait for existing action leases and release the surface + exactly once. POSIX process teardown must retain the 12-second SIGHUP grace, + SIGKILL escalation, and bounded three-second final reap window. + The renderer line was reviewed in https://github.com/manaflow-ai/ghostty/pull/168, following the merged https://github.com/manaflow-ai/ghostty/pull/153, diff --git a/ghostty b/ghostty index 11aa609d75de..5b20c62297ac 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 11aa609d75dec882ef2f83171e2cbe887aeddbc5 +Subproject commit 5b20c62297aca8289b400cb7f5583f65a5c759bc From b962644af33c4d95a9addfed91c26bf631fe46de Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:35:24 -0700 Subject: [PATCH 10/39] test: cover source-aware network command fixtures --- scripts/check-test-determinism.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/check-test-determinism.py b/scripts/check-test-determinism.py index 2b70beebabfe..fcba47c366c7 100755 --- a/scripts/check-test-determinism.py +++ b/scripts/check-test-determinism.py @@ -626,6 +626,16 @@ def _self_test() -> int: "await fetch('https://93.184.216.34/probe')\n", # public IP in a real URL {RULE_LIVE_NETWORK_HOST}, ), + ( + "tests/curl.sh", + "curl -fsSL 'https://api.openai.com/v1/items'\n", + {RULE_LIVE_NETWORK_HOST}, + ), + ( + "tests/backtick.sh", + "value=`curl -fsSL https://api.openai.com/v1/items`\n", + {RULE_LIVE_NETWORK_HOST}, + ), ( "tests/d.py", "sock.connect(('8.8.8.8', 53))\n", # bare IP -> only the fixed port is high-confidence @@ -754,6 +764,20 @@ def _self_test() -> int: "web/tests/n18.ts", 'const llms = buildLlmsText("https://cmux.com")\n', ), + # Rendered commands and multiline string fixtures are inert throughout + # their quoted spans. + ( + "web/tests/n22.ts", + 'expect(html).toContain("curl -fsSL https://cmux.com/install.sh | sh")\n', + ), + ( + "web/tests/n23.ts", + "const command = `\ncurl -fsSL 'https://api.openai.com/v1/items'\n`\n", + ), + ( + "tests/n24.sh", + "expected='\ncurl -fsSL https://api.openai.com/v1/items\n'\n", + ), # A quoted shell command embedded in a Swift terminal-parser fixture is a # STRING literal, not a real delay: "sleep 5" must not flag sleep-then-assert. ( From e50e33da576c587abe8a76fbd563627bb92f4c9f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:36:10 -0700 Subject: [PATCH 11/39] fix: make network fixture masking source-aware --- scripts/check-test-determinism.py | 63 ++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/scripts/check-test-determinism.py b/scripts/check-test-determinism.py index fcba47c366c7..df85d4305e6b 100755 --- a/scripts/check-test-determinism.py +++ b/scripts/check-test-determinism.py @@ -316,6 +316,52 @@ def _is_assertion_line(line: str) -> bool: return bool(_ASSERT_TOKEN.search(line) or _RAISE_IF.search(line)) +@dataclass +class _QuotedLiteralMaskState: + """Lexical state carried across lines while masking string fixtures.""" + + quote: Optional[str] = None + escaped: bool = False + + +def _quoted_literal_delimiters(path_suffix: str) -> tuple[str, ...]: + """Return string delimiters that are inert for the source language.""" + if path_suffix in (".ts", ".tsx", ".js", ".mjs"): + return ("'", '"', "`") + if path_suffix in (".py", ".sh"): + # Backticks execute command substitutions in shell and are not Python + # string delimiters, so they must remain visible to network detectors. + return ("'", '"') + if path_suffix == ".swift": + return ('"',) + return ("'", '"') + + +def _mask_quoted_literals( + line: str, + path_suffix: str, + state: _QuotedLiteralMaskState, +) -> str: + """Blank inert quoted contents while retaining executable token positions.""" + masked = list(line) + delimiters = _quoted_literal_delimiters(path_suffix) + for index, character in enumerate(line): + if state.quote is None: + if character in delimiters: + state.quote = character + masked[index] = " " + continue + + masked[index] = " " + if state.escaped: + state.escaped = False + elif character == "\\": + state.escaped = True + elif character == state.quote: + state.quote = None + return "".join(masked) + + def detect_assert_on_duration(line: str) -> bool: if not _is_assertion_line(line): return False @@ -340,14 +386,14 @@ def detect_assert_on_duration(line: str) -> bool: return has_threshold_compare or has_relational_assert -def detect_live_network_host(line: str) -> bool: +def detect_live_network_host(line: str, executable_line: str) -> bool: # High-precision signal only: an actual http(s):// URL with a public host that - # is ALSO handed to a network-driving verb on the same line (fetch/axios/ - # requests/urlopen/...). A URL used as a string fixture (markdown builder, - # canonical-URL assertion, toContain) opens no socket and is not flagged. + # is ALSO handed to an executable network-driving verb on the same line + # (fetch/axios/requests/urlopen/...). Verbs inside quoted string fixtures are + # masked, so rendered commands and canonical-URL assertions are not flagged. # Bare quoted IPs in data structures are likewise too ambiguous to flag. # Loopback/private/CGNAT/RFC2606 hosts are allowed. - if not _NETWORK_VERB.search(line): + if not _NETWORK_VERB.search(executable_line): return False for match in _URL.finditer(line): host = match.group(1) @@ -494,6 +540,11 @@ def scan_text(rel_posix: str, text: str) -> list[Finding]: suffix = pathlib.PurePosixPath(rel_posix).suffix raw_lines = text.splitlines() code_lines = [_strip_comment(l, suffix) for l in raw_lines] + quote_state = _QuotedLiteralMaskState() + executable_lines = [ + _mask_quoted_literals(line, suffix, quote_state) + for line in code_lines + ] findings: list[Finding] = [] for i, code in enumerate(code_lines): @@ -504,7 +555,7 @@ def scan_text(rel_posix: str, text: str) -> list[Finding]: if detect_assert_on_duration(code): findings.append(Finding(rel_posix, line_no, RULE_ASSERT_ON_DURATION, snippet)) - if detect_live_network_host(code): + if detect_live_network_host(code, executable_lines[i]): findings.append(Finding(rel_posix, line_no, RULE_LIVE_NETWORK_HOST, snippet)) if detect_fixed_port_bind(code): findings.append(Finding(rel_posix, line_no, RULE_FIXED_PORT_BIND, snippet)) From db465d9ca0f3db5234661a27760b324840a3d7d1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:42:05 -0700 Subject: [PATCH 12/39] build: pin GhosttyKit for bounded teardown --- docs/ghostty-fork.md | 4 ++++ scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 09db53e13f0e..9d6712616f7c 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -72,6 +72,10 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). cannot hold a native-surface teardown worker indefinitely. - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. +- Artifact: + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-5b20c62297aca8289b400cb7f5583f65a5c759bc-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2` + is pinned in `scripts/ghosttykit-checksums.txt`. - Conflict note: - Preserve the two-phase contract during future embedded-surface or termio merges: the pre-free request must prevent new app-action retains, remain diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 9a06b17a26f7..8fb8081663ac 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -114,3 +114,4 @@ da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 51bb73625dd8e53a98675fb75dc573931ab3b65 f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650ac87c00841508783933bff77c3 19d03fa4d0161e60e02de2e42601992be0c001c3 d2842bb7778a4e8d5a5a5f57ce6a85508630e3184ba46c1ca1ae5cbe1655472f 11aa609d75dec882ef2f83171e2cbe887aeddbc5 1a4acbcc9e0e5b20c0b4dad6660d0c08546a5d36192053834df960144fa8fdb9 +5b20c62297aca8289b400cb7f5583f65a5c759bc 88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2 From 16b499fe3e96e890ede264fe4cd0a022da7fc42d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:55:18 -0700 Subject: [PATCH 13/39] revert: drop incomplete determinism lexer change --- scripts/check-test-determinism.py | 87 +++---------------------------- 1 file changed, 6 insertions(+), 81 deletions(-) diff --git a/scripts/check-test-determinism.py b/scripts/check-test-determinism.py index df85d4305e6b..2b70beebabfe 100755 --- a/scripts/check-test-determinism.py +++ b/scripts/check-test-determinism.py @@ -316,52 +316,6 @@ def _is_assertion_line(line: str) -> bool: return bool(_ASSERT_TOKEN.search(line) or _RAISE_IF.search(line)) -@dataclass -class _QuotedLiteralMaskState: - """Lexical state carried across lines while masking string fixtures.""" - - quote: Optional[str] = None - escaped: bool = False - - -def _quoted_literal_delimiters(path_suffix: str) -> tuple[str, ...]: - """Return string delimiters that are inert for the source language.""" - if path_suffix in (".ts", ".tsx", ".js", ".mjs"): - return ("'", '"', "`") - if path_suffix in (".py", ".sh"): - # Backticks execute command substitutions in shell and are not Python - # string delimiters, so they must remain visible to network detectors. - return ("'", '"') - if path_suffix == ".swift": - return ('"',) - return ("'", '"') - - -def _mask_quoted_literals( - line: str, - path_suffix: str, - state: _QuotedLiteralMaskState, -) -> str: - """Blank inert quoted contents while retaining executable token positions.""" - masked = list(line) - delimiters = _quoted_literal_delimiters(path_suffix) - for index, character in enumerate(line): - if state.quote is None: - if character in delimiters: - state.quote = character - masked[index] = " " - continue - - masked[index] = " " - if state.escaped: - state.escaped = False - elif character == "\\": - state.escaped = True - elif character == state.quote: - state.quote = None - return "".join(masked) - - def detect_assert_on_duration(line: str) -> bool: if not _is_assertion_line(line): return False @@ -386,14 +340,14 @@ def detect_assert_on_duration(line: str) -> bool: return has_threshold_compare or has_relational_assert -def detect_live_network_host(line: str, executable_line: str) -> bool: +def detect_live_network_host(line: str) -> bool: # High-precision signal only: an actual http(s):// URL with a public host that - # is ALSO handed to an executable network-driving verb on the same line - # (fetch/axios/requests/urlopen/...). Verbs inside quoted string fixtures are - # masked, so rendered commands and canonical-URL assertions are not flagged. + # is ALSO handed to a network-driving verb on the same line (fetch/axios/ + # requests/urlopen/...). A URL used as a string fixture (markdown builder, + # canonical-URL assertion, toContain) opens no socket and is not flagged. # Bare quoted IPs in data structures are likewise too ambiguous to flag. # Loopback/private/CGNAT/RFC2606 hosts are allowed. - if not _NETWORK_VERB.search(executable_line): + if not _NETWORK_VERB.search(line): return False for match in _URL.finditer(line): host = match.group(1) @@ -540,11 +494,6 @@ def scan_text(rel_posix: str, text: str) -> list[Finding]: suffix = pathlib.PurePosixPath(rel_posix).suffix raw_lines = text.splitlines() code_lines = [_strip_comment(l, suffix) for l in raw_lines] - quote_state = _QuotedLiteralMaskState() - executable_lines = [ - _mask_quoted_literals(line, suffix, quote_state) - for line in code_lines - ] findings: list[Finding] = [] for i, code in enumerate(code_lines): @@ -555,7 +504,7 @@ def scan_text(rel_posix: str, text: str) -> list[Finding]: if detect_assert_on_duration(code): findings.append(Finding(rel_posix, line_no, RULE_ASSERT_ON_DURATION, snippet)) - if detect_live_network_host(code, executable_lines[i]): + if detect_live_network_host(code): findings.append(Finding(rel_posix, line_no, RULE_LIVE_NETWORK_HOST, snippet)) if detect_fixed_port_bind(code): findings.append(Finding(rel_posix, line_no, RULE_FIXED_PORT_BIND, snippet)) @@ -677,16 +626,6 @@ def _self_test() -> int: "await fetch('https://93.184.216.34/probe')\n", # public IP in a real URL {RULE_LIVE_NETWORK_HOST}, ), - ( - "tests/curl.sh", - "curl -fsSL 'https://api.openai.com/v1/items'\n", - {RULE_LIVE_NETWORK_HOST}, - ), - ( - "tests/backtick.sh", - "value=`curl -fsSL https://api.openai.com/v1/items`\n", - {RULE_LIVE_NETWORK_HOST}, - ), ( "tests/d.py", "sock.connect(('8.8.8.8', 53))\n", # bare IP -> only the fixed port is high-confidence @@ -815,20 +754,6 @@ def _self_test() -> int: "web/tests/n18.ts", 'const llms = buildLlmsText("https://cmux.com")\n', ), - # Rendered commands and multiline string fixtures are inert throughout - # their quoted spans. - ( - "web/tests/n22.ts", - 'expect(html).toContain("curl -fsSL https://cmux.com/install.sh | sh")\n', - ), - ( - "web/tests/n23.ts", - "const command = `\ncurl -fsSL 'https://api.openai.com/v1/items'\n`\n", - ), - ( - "tests/n24.sh", - "expected='\ncurl -fsSL https://api.openai.com/v1/items\n'\n", - ), # A quoted shell command embedded in a Swift terminal-parser fixture is a # STRING literal, not a real delay: "sleep 5" must not flag sleep-then-assert. ( From 586a39be0b99d72985bd2f7c76943f9f772d3f11 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 01:58:33 -0700 Subject: [PATCH 14/39] fix: reap process-group descendants after child exit --- docs/ghostty-fork.md | 43 ++++++++++++++++++++++++++++++++++--------- ghostty | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 9d6712616f7c..077363d0ded7 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,8 +12,10 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `5b20c6229`, which adds bounded, -two-phase embedded-surface process teardown. It includes `9513174f2`, the +The submodule pinned by this branch is `88c3325dc`, which completes bounded, +two-phase embedded-surface process teardown by tracking direct-child reaping +separately from surviving process-group descendants. It includes `d462c1d97`, +the Hangul NFC/NFD font-resolution integration, and `9513174f2`, the current-fork reapplication of the VT stream-boundary API previously pinned at `11aa609d7`. cmux uses that VT contract to retain incomplete escape-sequence bytes across distributed snapshot handoff. The current pin builds on @@ -52,11 +54,14 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). ### Bounded embedded-surface process teardown -- Pull request: +- Pull requests: - https://github.com/manaflow-ai/ghostty/pull/184 + - https://github.com/manaflow-ai/ghostty/pull/187 - Commits: - `9be0c8b93` (test: cover subprocesses that ignore SIGHUP) - `5b20c6229` (fix: bound embedded surface process teardown) + - `26d320bfe` (test: cover descendants surviving direct child exit) + - `88c3325dc` (fix: reap surviving process-group descendants) - Files: - `include/ghostty.h` - `src/Surface.zig` @@ -70,19 +75,39 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). cmux's 10-second Claude `SessionEnd` hook budget, then escalates to SIGKILL. - Bounds the post-SIGKILL reap wait to three seconds, so a pathological child cannot hold a native-surface teardown worker indefinitely. + - Keeps process-group liveness independent from direct-child wait status, so + an ignoring grandchild is still escalated after its parent exits. - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. -- Artifact: - - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-5b20c62297aca8289b400cb7f5583f65a5c759bc-crashsubdir-cmux-crash-sentry-off-v1 - - SHA-256 `88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2` - is pinned in `scripts/ghosttykit-checksums.txt`. - Conflict note: - Preserve the two-phase contract during future embedded-surface or termio merges: the pre-free request must prevent new app-action retains, remain idempotent, and start IO-owned process teardown without freeing native state. Final free must still wait for existing action leases and release the surface - exactly once. POSIX process teardown must retain the 12-second SIGHUP grace, - SIGKILL escalation, and bounded three-second final reap window. + exactly once. POSIX process teardown must retain separate direct-child and + process-group liveness state, the 12-second SIGHUP grace, SIGKILL escalation, + and the bounded three-second final reap window. + +### Canonical Hangul font resolution + +- Pull request: + - https://github.com/manaflow-ai/ghostty/pull/185 +- Commits: + - `0316a8de8` (test: NFC and NFD Hangul must resolve the same font face) + - `3fbdd078d` (font: resolve NFD Hangul clusters via canonical composition) +- Files: + - `src/font/hangul.zig` + - `src/font/main.zig` + - `src/font/shaper/coretext.zig` + - `src/font/shaper/run.zig` +- Summary: + - Composes modern Hangul jamo clusters algorithmically for font-resolver + lookup so canonically equivalent NFC and NFD text selects the same face. + - Preserves the original cell codepoints and shaper input, including NFD + copy/paste contents. +- Conflict note: + - Preserve canonical composition at font lookup only; do not rewrite stored + terminal cells or the text passed to the shaper. The renderer line was reviewed in https://github.com/manaflow-ai/ghostty/pull/168, following the merged diff --git a/ghostty b/ghostty index 5b20c62297ac..88c3325dc969 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 5b20c62297aca8289b400cb7f5583f65a5c759bc +Subproject commit 88c3325dc9698d887da7e07ee0f9b79c53020be2 From 46fa5c01443ff43edf34fe0cbba586bcb55fe787 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 02:07:35 -0700 Subject: [PATCH 15/39] build: pin GhosttyKit for descendant teardown --- docs/ghostty-fork.md | 5 +++++ scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 6 insertions(+) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 077363d0ded7..3e1710661cd7 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -79,6 +79,11 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). an ignoring grandchild is still escalated after its parent exits. - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. +- Artifact: + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-88c3325dc9698d887da7e07ee0f9b79c53020be2-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604` + is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive + passed `scripts/validate-xcframework-archive.py`. - Conflict note: - Preserve the two-phase contract during future embedded-surface or termio merges: the pre-free request must prevent new app-action retains, remain diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 8fb8081663ac..14473ef5e064 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -115,3 +115,4 @@ f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650a 19d03fa4d0161e60e02de2e42601992be0c001c3 d2842bb7778a4e8d5a5a5f57ce6a85508630e3184ba46c1ca1ae5cbe1655472f 11aa609d75dec882ef2f83171e2cbe887aeddbc5 1a4acbcc9e0e5b20c0b4dad6660d0c08546a5d36192053834df960144fa8fdb9 5b20c62297aca8289b400cb7f5583f65a5c759bc 88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2 +88c3325dc9698d887da7e07ee0f9b79c53020be2 56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604 From 35dbb8cc515f97c910b5c65f3d4d3c7f34fb2090 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 02:32:13 -0700 Subject: [PATCH 16/39] test: cover teardown racing native surface reads --- ...minalSurfaceRuntimeNativeAccessTests.swift | 72 ++++++++++++++ .../GhosttyRuntimeTestStubs.c | 95 ++++++++++++++++++- .../include/GhosttyRuntimeTestStubs.h | 22 ++++- 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift new file mode 100644 index 000000000000..7f7f2f4fa6e6 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -0,0 +1,72 @@ +import Dispatch +import Foundation +import GhosttyRuntimeTestStubs +import os +import Testing +@testable import CmuxTerminal + +@Suite(.serialized) struct TerminalSurfaceRuntimeNativeAccessTests { + @Test func teardownWaitsForAnActiveScreenTailBorrow() async throws { + let teardownBegun = OSAllocatedUnfairLock(initialState: 0) + let nativeFreeCount = OSAllocatedUnfairLock(initialState: 0) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + beginSurfaceTeardown: { _ in + teardownBegun.withLock { $0 += 1 } + } + ) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + let surfaceBits = UInt(bitPattern: surface) + cmux_test_ghostty_surface_read_blocking_begin(surface) + defer { + cmux_test_ghostty_surface_read_release() + cmux_test_ghostty_surface_read_blocking_reset() + } + + let readTask = Task { + let borrowedSurface = UnsafeMutableRawPointer(bitPattern: surfaceBits)! + await coordinator.readScreenTailVT( + TerminalSurfaceRuntimeScreenTailRequest( + surface: borrowedSurface, + maxRows: 1, + maxBytes: 1 + ) + ) + } + let readStarted = AsyncStream.makeStream() + DispatchQueue.global(qos: .userInitiated).async { + readStarted.continuation.yield( + cmux_test_ghostty_surface_read_wait_until_started() + ) + readStarted.continuation.finish() + } + var readStartedIterator = readStarted.stream.makeAsyncIterator() + try #require(await readStartedIterator.next() == true) + #expect(cmux_test_ghostty_surface_read_blocking_is_active()) + + let ticket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.activeNativeBorrow", + surface: surface, + callbackContext: nil, + freeSurface: { _ in + nativeFreeCount.withLock { $0 += 1 } + } + ) + + #expect( + teardownBegun.withLock { $0 } == 0, + "process termination began while a native surface read held a borrow" + ) + #expect(nativeFreeCount.withLock { $0 } == 0) + #expect(await ticket.wait(timeout: .zero) == false) + + cmux_test_ghostty_surface_read_release() + _ = await readTask.value + + #expect(await ticket.wait(timeout: .seconds(1))) + #expect(teardownBegun.withLock { $0 } == 1) + #expect(nativeFreeCount.withLock { $0 } == 1) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index a44f9aeee9fc..4081a9f55e17 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -43,6 +43,12 @@ static bool cmux_test_surface_free_should_block = false; static bool cmux_test_surface_free_started = false; static bool cmux_test_surface_free_released = false; static void* cmux_test_surface_free_target = NULL; +static pthread_mutex_t cmux_test_surface_read_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t cmux_test_surface_read_condition = PTHREAD_COND_INITIALIZER; +static bool cmux_test_surface_read_should_block = false; +static bool cmux_test_surface_read_started = false; +static bool cmux_test_surface_read_released = false; +static void* cmux_test_surface_read_target = NULL; static struct timespec cmux_test_surface_free_timeout(void) { return (struct timespec) { @@ -111,6 +117,59 @@ void cmux_test_ghostty_surface_free_blocking_reset(void) { pthread_mutex_unlock(&cmux_test_surface_free_mutex); } +void cmux_test_ghostty_surface_read_blocking_begin(void *surface) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_read_should_block = true; + cmux_test_surface_read_started = false; + cmux_test_surface_read_released = false; + cmux_test_surface_read_target = surface; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); +} + +bool cmux_test_ghostty_surface_read_wait_until_started(void) { + const struct timespec timeout = cmux_test_surface_free_timeout(); + pthread_mutex_lock(&cmux_test_surface_read_mutex); + while (!cmux_test_surface_read_started) { + const int result = pthread_cond_timedwait_relative_np( + &cmux_test_surface_read_condition, + &cmux_test_surface_read_mutex, + &timeout + ); + if (result != 0) break; + } + const bool started = cmux_test_surface_read_started; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return started; +} + +bool cmux_test_ghostty_surface_read_blocking_is_active(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + const bool active = + cmux_test_surface_read_should_block + && cmux_test_surface_read_started + && !cmux_test_surface_read_released + && cmux_test_surface_read_target != NULL; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return active; +} + +void cmux_test_ghostty_surface_read_release(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_read_released = true; + pthread_cond_broadcast(&cmux_test_surface_read_condition); + pthread_mutex_unlock(&cmux_test_surface_read_mutex); +} + +void cmux_test_ghostty_surface_read_blocking_reset(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_read_should_block = false; + cmux_test_surface_read_started = false; + cmux_test_surface_read_released = true; + cmux_test_surface_read_target = NULL; + pthread_cond_broadcast(&cmux_test_surface_read_condition); + pthread_mutex_unlock(&cmux_test_surface_read_mutex); +} + void cmux_test_ghostty_renderer_realized_begin(void* surface) { cmux_test_renderer_realized_target = surface; cmux_test_renderer_realized_call_count = 0; @@ -327,7 +386,10 @@ void ghostty_surface_free(void *surface) { void ghostty_surface_request_process_termination(void *surface) { (void)surface; } -void ghostty_surface_free_text(void) {} +void ghostty_surface_free_text(void *surface, ghostty_text_s *text) { + (void)surface; + (void)text; +} float ghostty_surface_font_size(void *surface) { return surface == cmux_test_font_surface ? cmux_test_font_runtime_points @@ -357,7 +419,36 @@ bool ghostty_surface_process_exited(void *surface) { } void ghostty_surface_process_output(void) {} void ghostty_surface_quicklook_font(void) {} -void ghostty_surface_read_screen_tail_vt(void) {} +bool ghostty_surface_read_screen_tail_vt( + void *surface, + uintptr_t max_rows, + uintptr_t max_bytes, + ghostty_text_s *text +) { + (void)max_rows; + (void)max_bytes; + if (text != NULL) memset(text, 0, sizeof(*text)); + + const struct timespec timeout = cmux_test_surface_free_timeout(); + pthread_mutex_lock(&cmux_test_surface_read_mutex); + if (cmux_test_surface_read_should_block + && surface == cmux_test_surface_read_target) { + cmux_test_surface_read_started = true; + pthread_cond_broadcast(&cmux_test_surface_read_condition); + while (!cmux_test_surface_read_released) { + const int result = pthread_cond_timedwait_relative_np( + &cmux_test_surface_read_condition, + &cmux_test_surface_read_mutex, + &timeout + ); + if (result != 0) break; + } + cmux_test_surface_read_should_block = false; + cmux_test_surface_read_target = NULL; + } + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return false; +} void ghostty_surface_read_text(void) {} void ghostty_surface_refresh(void) {} void ghostty_surface_render_grid_json(void) {} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 9d51bd64e6a3..7c592d0c8a64 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -15,6 +15,15 @@ typedef struct { bool sentinel; } ghostty_string_s; +typedef struct { + double tl_px_x; + double tl_px_y; + uint32_t offset_start; + uint32_t offset_len; + const char* text; + uintptr_t text_len; +} ghostty_text_s; + typedef void (*ghostty_font_size_action_cb)( void* userdata, int32_t action, @@ -51,7 +60,7 @@ bool ghostty_surface_set_font_size_action_callback( void ghostty_surface_config_new(void); void ghostty_surface_free(void *surface); void ghostty_surface_request_process_termination(void *surface); -void ghostty_surface_free_text(void); +void ghostty_surface_free_text(void *surface, ghostty_text_s *text); float ghostty_surface_font_size(void *surface); bool ghostty_surface_font_size_adjusted(void *surface); uint64_t ghostty_surface_foreground_pid(void *surface); @@ -65,7 +74,11 @@ void ghostty_surface_new(void); bool ghostty_surface_process_exited(void *surface); void ghostty_surface_process_output(void); void ghostty_surface_quicklook_font(void); -void ghostty_surface_read_screen_tail_vt(void); +bool ghostty_surface_read_screen_tail_vt( + void *surface, + uintptr_t max_rows, + uintptr_t max_bytes, + ghostty_text_s *text); void ghostty_surface_read_text(void); void ghostty_surface_refresh(void); void ghostty_surface_render_grid_json(void); @@ -98,6 +111,11 @@ bool cmux_test_ghostty_surface_free_wait_until_started(void); bool cmux_test_ghostty_surface_free_blocking_is_active(void); void cmux_test_ghostty_surface_free_release(void); void cmux_test_ghostty_surface_free_blocking_reset(void); +void cmux_test_ghostty_surface_read_blocking_begin(void *surface); +bool cmux_test_ghostty_surface_read_wait_until_started(void); +bool cmux_test_ghostty_surface_read_blocking_is_active(void); +void cmux_test_ghostty_surface_read_release(void); +void cmux_test_ghostty_surface_read_blocking_reset(void); uint32_t cmux_test_ghostty_tty_name_call_count(void); void cmux_test_ghostty_renderer_realized_begin(void *surface); void cmux_test_ghostty_renderer_realized_reset(void); From 3e05822294904eeafb2d59b16d606ac384d22151 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:05:29 -0700 Subject: [PATCH 17/39] test: unblock native access regression execution --- .../TerminalSurfaceRuntimeTeardownCoordinatorTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index 5489a9c174c5..9d3d10c06c79 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -156,8 +156,9 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec surface: surface, callbackContext: nil, freeSurface: { pointer in + let pointerBits = UInt(bitPattern: pointer) freedSurfaceBits.withLock { - _ = $0.insert(UInt(bitPattern: pointer)) + _ = $0.insert(pointerBits) } } ) @@ -183,8 +184,9 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec let begunSurfaceBits = OSAllocatedUnfairLock(initialState: Set()) let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( beginSurfaceTeardown: { surface in + let surfaceBits = UInt(bitPattern: surface) begunSurfaceBits.withLock { - _ = $0.insert(UInt(bitPattern: surface)) + _ = $0.insert(surfaceBits) } } ) From c9b543f733e0ed372a310df40d4a21cf17277363 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:13:07 -0700 Subject: [PATCH 18/39] fix: gate native reads before surface teardown --- ...rminalSurfaceRuntimeNativeAccessGate.swift | 125 ++++++++++++++++++ ...minalSurfaceRuntimeScreenTailRequest.swift | 9 +- ...nalSurfaceRuntimeTeardownCoordinator.swift | 57 +++++--- .../TerminalSurface+ScreenSnapshot.swift | 16 ++- ...minalSurfaceRuntimeNativeAccessTests.swift | 55 ++++++-- 5 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift new file mode 100644 index 000000000000..d65a03f66876 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift @@ -0,0 +1,125 @@ +internal import GhosttyKit +internal import os + +/// Admits native surface borrows before suspension and orders teardown after them. +/// +/// `@unchecked Sendable` is safe because both the per-surface entries and each +/// borrow's one-shot release state are protected by their dedicated locks. +/// User callbacks always run after the entry lock is released. +final class TerminalSurfaceRuntimeNativeAccessGate: @unchecked Sendable { + /// A one-shot borrow whose lifetime prevents teardown of its native surface. + /// + /// `@unchecked Sendable` is safe because its only mutable state is accessed + /// through its dedicated lock and the referenced gate is concurrency-safe. + final class Borrow: @unchecked Sendable { + private struct State { + var gate: TerminalSurfaceRuntimeNativeAccessGate? + } + + private let surfaceKey: UInt + // Synchronous cancellation/deinit must release exactly once; this is a + // bounded compare-and-set, not ongoing domain state. + private let state: OSAllocatedUnfairLock + + fileprivate init( + gate: TerminalSurfaceRuntimeNativeAccessGate, + surfaceKey: UInt + ) { + self.surfaceKey = surfaceKey + state = OSAllocatedUnfairLock(initialState: State(gate: gate)) + } + + func release() { + let gate = state.withLock { state in + defer { state.gate = nil } + return state.gate + } + gate?.releaseBorrow(surfaceKey: surfaceKey) + } + + deinit { + release() + } + } + + private struct Entry { + var borrowCount = 0 + var teardownStarted = false + var pendingTeardown: (@Sendable () -> Void)? + } + + // Borrow admission and synchronous deinit teardown cannot await an actor. + // The lock guards only bounded counters/flags; callbacks run after unlock. + private let entries = OSAllocatedUnfairLock(initialState: [UInt: Entry]()) + + func acquireBorrow(for surface: ghostty_surface_t) -> Borrow? { + let surfaceKey = UInt(bitPattern: surface) + let acquired = entries.withLock { entries in + var entry = entries[surfaceKey] ?? Entry() + guard !entry.teardownStarted, + entry.pendingTeardown == nil else { + return false + } + entry.borrowCount += 1 + entries[surfaceKey] = entry + return true + } + guard acquired else { return nil } + return Borrow(gate: self, surfaceKey: surfaceKey) + } + + func requestTeardown( + for surface: ghostty_surface_t, + start: @escaping @Sendable () -> Void + ) { + let surfaceKey = UInt(bitPattern: surface) + let ready = entries.withLock { entries -> (@Sendable () -> Void)? in + var entry = entries[surfaceKey] ?? Entry() + guard !entry.teardownStarted, + entry.pendingTeardown == nil else { + return nil + } + guard entry.borrowCount > 0 else { + entry.teardownStarted = true + entries[surfaceKey] = entry + return start + } + entry.pendingTeardown = start + entries[surfaceKey] = entry + return nil + } + ready?() + } + + func finishTeardown(for surface: ghostty_surface_t) { + let surfaceKey = UInt(bitPattern: surface) + entries.withLock { entries in + guard let entry = entries[surfaceKey], + entry.teardownStarted, + entry.borrowCount == 0 else { + return + } + entries.removeValue(forKey: surfaceKey) + } + } + + private func releaseBorrow(surfaceKey: UInt) { + let ready = entries.withLock { entries -> (@Sendable () -> Void)? in + guard var entry = entries[surfaceKey], + entry.borrowCount > 0 else { + return nil + } + entry.borrowCount -= 1 + guard entry.borrowCount == 0, + let pendingTeardown = entry.pendingTeardown else { + entries[surfaceKey] = entry + return nil + } + entry.pendingTeardown = nil + entry.teardownStarted = true + entries[surfaceKey] = entry + return pendingTeardown + } + ready?() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index 9e7b4d45138d..a789a0c28751 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -3,10 +3,11 @@ internal import GhosttyKit /// A bounded native screen-tail read ordered before later teardown requests. /// -/// The raw surface pointer remains owned by its ``TerminalSurface``. The runtime -/// coordinator executes this request without suspension, so an enqueued native -/// free cannot interleave after the read begins. `@unchecked Sendable` is -/// limited to transporting that borrowed pointer onto the coordinator actor. +/// The raw surface pointer remains owned by its ``TerminalSurface``. The caller +/// admits a native-access borrow before its first suspension, and the runtime +/// coordinator executes this request without suspension while that borrow +/// defers process termination and native free. `@unchecked Sendable` is limited +/// to transporting the borrowed pointer onto the coordinator actor. struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { let surface: ghostty_surface_t let maxRows: Int diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index cf57fe17fa46..0caf5c6e7691 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -8,9 +8,10 @@ internal import CMUXDebugLog /// Coordinates native `ghostty_surface_free` calls off the close/deinit paths. /// -/// Every request first starts Ghostty-owned child-process teardown before it -/// can wait for a bounded native-free slot. This keeps process lifetime -/// independent from resource-destruction admission. +/// Every request synchronously registers Ghostty-owned child-process teardown. +/// Teardown starts after any already-admitted native read and before waiting +/// for a bounded native-free slot, keeping process lifetime independent from +/// resource-destruction admission without invalidating a borrowed pointer. /// /// Close/deinit frees run on a bounded set of utility slots so stuck native /// joins cannot create unbounded worker threads. Each admitted hibernation owns @@ -40,6 +41,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private let isolatedHibernationQueues: [DispatchQueue] private nonisolated let beginSurfaceTeardown: @Sendable (ghostty_surface_t) -> Void + private nonisolated let nativeAccessGate = + TerminalSurfaceRuntimeNativeAccessGate() private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() @@ -87,14 +90,23 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { isolatedHibernationAdmission.release(reservation) } - /// Reads a bounded screen tail away from the main actor and before any - /// subsequently enqueued native free for the same surface. + /// Admits a bounded screen-tail read before its first suspension. /// - /// The request performs no suspension while it holds the borrowed pointer; - /// actor serialization therefore makes the read and a later free mutually - /// exclusive. - func readScreenTailVT(_ request: TerminalSurfaceRuntimeScreenTailRequest) -> String? { - request.read() + /// A close that wins admission first rejects the borrow. A borrow that wins + /// first defers both process termination and native free until release. + nonisolated func acquireScreenTailBorrow( + for request: TerminalSurfaceRuntimeScreenTailRequest + ) -> TerminalSurfaceRuntimeNativeAccessGate.Borrow? { + nativeAccessGate.acquireBorrow(for: request.surface) + } + + /// Reads a bounded screen tail away from the main actor under an admitted borrow. + func readScreenTailVT( + _ request: TerminalSurfaceRuntimeScreenTailRequest, + borrow: TerminalSurfaceRuntimeNativeAccessGate.Borrow + ) -> String? { + defer { borrow.release() } + return request.read() } /// Queues a native-surface free from any isolation (the surface model's @@ -176,11 +188,6 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ghostty_surface_free(surface) } ) -> TerminalSurfaceRuntimeTeardownTicket { - // This call is intentionally synchronous and precedes the Task hop: - // even when every native-free lane is occupied, the surface's own IO - // thread can terminate and reap its process tree independently. - beginSurfaceTeardown(surface) - let completion = TerminalSurfaceRuntimeTeardownCompletion() let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) let request = TerminalSurfaceRuntimeTeardownRequest( @@ -194,12 +201,19 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { freeSurface: freeSurface, completion: completion ) - Task { - await self.enqueue( - request, - executionLane: executionLane, - isolatedHibernationReservation: isolatedHibernationReservation - ) + nativeAccessGate.requestTeardown(for: surface) { + // When there is no admitted borrow this remains synchronous and + // precedes the Task hop. A pending borrow is the only reason to + // defer termination, because Ghostty forbids surface API calls + // after the termination request. + self.beginSurfaceTeardown(request.surface) + Task { + await self.enqueue( + request, + executionLane: executionLane, + isolatedHibernationReservation: isolatedHibernationReservation + ) + } } return ticket } @@ -289,6 +303,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) #endif request.freeSurface(request.surface) + nativeAccessGate.finishTeardown(for: request.surface) } private nonisolated func finishFree( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index c1c9f7aebb01..bab6e8cebc37 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -17,12 +17,16 @@ extension TerminalSurface { let surface = liveSurfaceForGhosttyAccess(reason: "boundedScreenTailVT") else { return nil } - return await runtimeTeardown.readScreenTailVT( - TerminalSurfaceRuntimeScreenTailRequest( - surface: surface, - maxRows: maxRows, - maxBytes: maxBytes - ) + let request = TerminalSurfaceRuntimeScreenTailRequest( + surface: surface, + maxRows: maxRows, + maxBytes: maxBytes ) + guard let borrow = runtimeTeardown.acquireScreenTailBorrow( + for: request + ) else { + return nil + } + return await runtimeTeardown.readScreenTailVT(request, borrow: borrow) } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 7f7f2f4fa6e6..347e55e2396a 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -6,6 +6,43 @@ import Testing @testable import CmuxTerminal @Suite(.serialized) struct TerminalSurfaceRuntimeNativeAccessTests { + @Test func screenTailBorrowIsRejectedAfterTeardownStarts() async { + let teardownBegun = OSAllocatedUnfairLock(initialState: 0) + let releaseNativeFree = DispatchSemaphore(value: 0) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( + beginSurfaceTeardown: { _ in + teardownBegun.withLock { $0 += 1 } + } + ) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { + releaseNativeFree.signal() + surface.deallocate() + } + + let ticket = coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.teardownBeforeNativeBorrow", + surface: surface, + callbackContext: nil, + freeSurface: { _ in + _ = releaseNativeFree.wait(timeout: .distantFuture) + } + ) + let request = TerminalSurfaceRuntimeScreenTailRequest( + surface: surface, + maxRows: 1, + maxBytes: 1 + ) + + #expect(coordinator.acquireScreenTailBorrow(for: request) == nil) + #expect(teardownBegun.withLock { $0 } == 1) + + releaseNativeFree.signal() + #expect(await ticket.wait(timeout: .seconds(1))) + } + @Test func teardownWaitsForAnActiveScreenTailBorrow() async throws { let teardownBegun = OSAllocatedUnfairLock(initialState: 0) let nativeFreeCount = OSAllocatedUnfairLock(initialState: 0) @@ -23,15 +60,17 @@ import Testing cmux_test_ghostty_surface_read_blocking_reset() } + let borrowedSurface = UnsafeMutableRawPointer(bitPattern: surfaceBits)! + let request = TerminalSurfaceRuntimeScreenTailRequest( + surface: borrowedSurface, + maxRows: 1, + maxBytes: 1 + ) + let borrow = try #require( + coordinator.acquireScreenTailBorrow(for: request) + ) let readTask = Task { - let borrowedSurface = UnsafeMutableRawPointer(bitPattern: surfaceBits)! - await coordinator.readScreenTailVT( - TerminalSurfaceRuntimeScreenTailRequest( - surface: borrowedSurface, - maxRows: 1, - maxBytes: 1 - ) - ) + await coordinator.readScreenTailVT(request, borrow: borrow) } let readStarted = AsyncStream.makeStream() DispatchQueue.global(qos: .userInitiated).async { From be48e858b578dc33d0e137f91a5ccf96f6caaa6c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:13:23 -0700 Subject: [PATCH 19/39] build: pin GhosttyKit for stable process groups --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 88c3325dc969..81b4de4f540e 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 88c3325dc9698d887da7e07ee0f9b79c53020be2 +Subproject commit 81b4de4f540eeea9be34574ffdd13ec51ee8a340 From 6677811853311c05024d3d7284fe530cc315a2f2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:17:11 -0700 Subject: [PATCH 20/39] refactor: tighten native access gate ownership --- ...rminalSurfaceRuntimeNativeAccessGate.swift | 99 ++++++++++--------- ...minalSurfaceRuntimeScreenTailRequest.swift | 1 + ...nalSurfaceRuntimeTeardownCoordinator.swift | 8 +- ...erminalSurfaceRuntimeTeardownRequest.swift | 3 + .../TerminalSurface+RuntimeLifecycle.swift | 4 + .../TerminalSurface+ScreenSnapshot.swift | 1 + .../Surface/TerminalSurface.swift | 2 + ...minalSurfaceRuntimeNativeAccessTests.swift | 8 +- 8 files changed, 74 insertions(+), 52 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift index d65a03f66876..99b9841d5c64 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift @@ -1,31 +1,27 @@ -internal import GhosttyKit +internal import Foundation internal import os /// Admits native surface borrows before suspension and orders teardown after them. /// -/// `@unchecked Sendable` is safe because both the per-surface entries and each -/// borrow's one-shot release state are protected by their dedicated locks. -/// User callbacks always run after the entry lock is released. -final class TerminalSurfaceRuntimeNativeAccessGate: @unchecked Sendable { +/// Entries use the terminal's unique runtime-lifecycle identity rather than a +/// raw pointer address, which an allocator may reuse immediately after free. +final class TerminalSurfaceRuntimeNativeAccessGate: Sendable { /// A one-shot borrow whose lifetime prevents teardown of its native surface. - /// - /// `@unchecked Sendable` is safe because its only mutable state is accessed - /// through its dedicated lock and the referenced gate is concurrency-safe. - final class Borrow: @unchecked Sendable { + final class Borrow: Sendable { private struct State { var gate: TerminalSurfaceRuntimeNativeAccessGate? } - private let surfaceKey: UInt + private let runtimeLifecycleId: UUID // Synchronous cancellation/deinit must release exactly once; this is a // bounded compare-and-set, not ongoing domain state. private let state: OSAllocatedUnfairLock fileprivate init( gate: TerminalSurfaceRuntimeNativeAccessGate, - surfaceKey: UInt + runtimeLifecycleId: UUID ) { - self.surfaceKey = surfaceKey + self.runtimeLifecycleId = runtimeLifecycleId state = OSAllocatedUnfairLock(initialState: State(gate: gate)) } @@ -34,7 +30,7 @@ final class TerminalSurfaceRuntimeNativeAccessGate: @unchecked Sendable { defer { state.gate = nil } return state.gate } - gate?.releaseBorrow(surfaceKey: surfaceKey) + gate?.releaseBorrow(runtimeLifecycleId: runtimeLifecycleId) } deinit { @@ -42,83 +38,92 @@ final class TerminalSurfaceRuntimeNativeAccessGate: @unchecked Sendable { } } + private enum Phase { + case acceptingBorrows + case teardownPending(@Sendable () -> Void) + case tearingDown + } + private struct Entry { var borrowCount = 0 - var teardownStarted = false - var pendingTeardown: (@Sendable () -> Void)? + var phase = Phase.acceptingBorrows } // Borrow admission and synchronous deinit teardown cannot await an actor. - // The lock guards only bounded counters/flags; callbacks run after unlock. - private let entries = OSAllocatedUnfairLock(initialState: [UInt: Entry]()) + // The lock guards only short, nonblocking entry transitions; callbacks run + // after unlock. Idle borrow entries leave on release, and teardown entries + // leave immediately after the corresponding native free returns. + private let entries = OSAllocatedUnfairLock(initialState: [UUID: Entry]()) - func acquireBorrow(for surface: ghostty_surface_t) -> Borrow? { - let surfaceKey = UInt(bitPattern: surface) + func acquireBorrow(for runtimeLifecycleId: UUID) -> Borrow? { let acquired = entries.withLock { entries in - var entry = entries[surfaceKey] ?? Entry() - guard !entry.teardownStarted, - entry.pendingTeardown == nil else { + var entry = entries[runtimeLifecycleId] ?? Entry() + guard case .acceptingBorrows = entry.phase else { return false } entry.borrowCount += 1 - entries[surfaceKey] = entry + entries[runtimeLifecycleId] = entry return true } guard acquired else { return nil } - return Borrow(gate: self, surfaceKey: surfaceKey) + return Borrow(gate: self, runtimeLifecycleId: runtimeLifecycleId) } func requestTeardown( - for surface: ghostty_surface_t, + for runtimeLifecycleId: UUID, start: @escaping @Sendable () -> Void ) { - let surfaceKey = UInt(bitPattern: surface) let ready = entries.withLock { entries -> (@Sendable () -> Void)? in - var entry = entries[surfaceKey] ?? Entry() - guard !entry.teardownStarted, - entry.pendingTeardown == nil else { + var entry = entries[runtimeLifecycleId] ?? Entry() + guard case .acceptingBorrows = entry.phase else { return nil } guard entry.borrowCount > 0 else { - entry.teardownStarted = true - entries[surfaceKey] = entry + entry.phase = .tearingDown + entries[runtimeLifecycleId] = entry return start } - entry.pendingTeardown = start - entries[surfaceKey] = entry + entry.phase = .teardownPending(start) + entries[runtimeLifecycleId] = entry return nil } ready?() } - func finishTeardown(for surface: ghostty_surface_t) { - let surfaceKey = UInt(bitPattern: surface) + func finishTeardown(for runtimeLifecycleId: UUID) { entries.withLock { entries in - guard let entry = entries[surfaceKey], - entry.teardownStarted, + guard let entry = entries[runtimeLifecycleId], + case .tearingDown = entry.phase, entry.borrowCount == 0 else { return } - entries.removeValue(forKey: surfaceKey) + entries.removeValue(forKey: runtimeLifecycleId) } } - private func releaseBorrow(surfaceKey: UInt) { + private func releaseBorrow(runtimeLifecycleId: UUID) { let ready = entries.withLock { entries -> (@Sendable () -> Void)? in - guard var entry = entries[surfaceKey], + guard var entry = entries[runtimeLifecycleId], entry.borrowCount > 0 else { return nil } entry.borrowCount -= 1 - guard entry.borrowCount == 0, - let pendingTeardown = entry.pendingTeardown else { - entries[surfaceKey] = entry + guard entry.borrowCount == 0 else { + entries[runtimeLifecycleId] = entry + return nil + } + switch entry.phase { + case .acceptingBorrows: + entries.removeValue(forKey: runtimeLifecycleId) + return nil + case .teardownPending(let pendingTeardown): + entry.phase = .tearingDown + entries[runtimeLifecycleId] = entry + return pendingTeardown + case .tearingDown: + entries[runtimeLifecycleId] = entry return nil } - entry.pendingTeardown = nil - entry.teardownStarted = true - entries[surfaceKey] = entry - return pendingTeardown } ready?() } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index a789a0c28751..9fbda2ecfcdd 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -9,6 +9,7 @@ internal import GhosttyKit /// defers process termination and native free. `@unchecked Sendable` is limited /// to transporting the borrowed pointer onto the coordinator actor. struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { + let runtimeLifecycleId: UUID let surface: ghostty_surface_t let maxRows: Int let maxBytes: Int diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 0caf5c6e7691..4c0344ab2a89 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -97,7 +97,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { nonisolated func acquireScreenTailBorrow( for request: TerminalSurfaceRuntimeScreenTailRequest ) -> TerminalSurfaceRuntimeNativeAccessGate.Borrow? { - nativeAccessGate.acquireBorrow(for: request.surface) + nativeAccessGate.acquireBorrow(for: request.runtimeLifecycleId) } /// Reads a bounded screen tail away from the main actor under an admitted borrow. @@ -175,6 +175,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { @discardableResult nonisolated func enqueueRuntimeTeardown( id: UUID, + runtimeLifecycleId: UUID? = nil, workspaceId: UUID, reason: String, surface: ghostty_surface_t, @@ -192,6 +193,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) let request = TerminalSurfaceRuntimeTeardownRequest( id: id, + runtimeLifecycleId: runtimeLifecycleId ?? id, workspaceId: workspaceId, reason: reason, surface: surface, @@ -201,7 +203,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { freeSurface: freeSurface, completion: completion ) - nativeAccessGate.requestTeardown(for: surface) { + nativeAccessGate.requestTeardown(for: request.runtimeLifecycleId) { // When there is no admitted borrow this remains synchronous and // precedes the Task hop. A pending borrow is the only reason to // defer termination, because Ghostty forbids surface API calls @@ -303,7 +305,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) #endif request.freeSurface(request.surface) - nativeAccessGate.finishTeardown(for: request.surface) + nativeAccessGate.finishTeardown(for: request.runtimeLifecycleId) } private nonisolated func finishFree( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 18d82e8f78b1..8278a0502038 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -18,6 +18,7 @@ public import CmuxTerminalCore /// so a release ordered after the free can never race an in-flight callback. struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let id: UUID + let runtimeLifecycleId: UUID let workspaceId: UUID let reason: String let surface: ghostty_surface_t @@ -33,6 +34,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { init( id: UUID, + runtimeLifecycleId: UUID, workspaceId: UUID, reason: String, surface: ghostty_surface_t, @@ -43,6 +45,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { completion: TerminalSurfaceRuntimeTeardownCompletion ) { self.id = id + self.runtimeLifecycleId = runtimeLifecycleId self.workspaceId = workspaceId self.reason = reason self.surface = surface diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index ac95eb1508b7..78dc6943de68 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -275,6 +275,7 @@ extension TerminalSurface { // native free, which is what joins ghostty's IO threads. runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, @@ -292,6 +293,7 @@ extension TerminalSurface { // releases all callback userdata only after the free returns. runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, @@ -367,6 +369,7 @@ extension TerminalSurface { // native free, which is what joins ghostty's IO threads. agentHibernationRuntimeTeardownTicket = runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: reason, surface: surfaceToFree, @@ -383,6 +386,7 @@ extension TerminalSurface { agentHibernationRuntimeTeardownTicket = runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: reason, surface: surfaceToFree, diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index bab6e8cebc37..ca934f1bd4b3 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -18,6 +18,7 @@ extension TerminalSurface { return nil } let request = TerminalSurfaceRuntimeScreenTailRequest( + runtimeLifecycleId: terminalLifecycleId, surface: surface, maxRows: maxRows, maxBytes: maxBytes diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 5456f35f8673..0f75b62c4329 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -700,6 +700,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, @@ -713,6 +714,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { #endif runtimeTeardown.enqueueRuntimeTeardown( id: id, + runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 347e55e2396a..29c802da8aa8 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -15,13 +15,14 @@ import Testing } ) let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let runtimeLifecycleId = UUID() defer { releaseNativeFree.signal() surface.deallocate() } let ticket = coordinator.enqueueRuntimeTeardown( - id: UUID(), + id: runtimeLifecycleId, workspaceId: UUID(), reason: "test.teardownBeforeNativeBorrow", surface: surface, @@ -31,6 +32,7 @@ import Testing } ) let request = TerminalSurfaceRuntimeScreenTailRequest( + runtimeLifecycleId: runtimeLifecycleId, surface: surface, maxRows: 1, maxBytes: 1 @@ -52,6 +54,7 @@ import Testing } ) let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let runtimeLifecycleId = UUID() defer { surface.deallocate() } let surfaceBits = UInt(bitPattern: surface) cmux_test_ghostty_surface_read_blocking_begin(surface) @@ -62,6 +65,7 @@ import Testing let borrowedSurface = UnsafeMutableRawPointer(bitPattern: surfaceBits)! let request = TerminalSurfaceRuntimeScreenTailRequest( + runtimeLifecycleId: runtimeLifecycleId, surface: borrowedSurface, maxRows: 1, maxBytes: 1 @@ -84,7 +88,7 @@ import Testing #expect(cmux_test_ghostty_surface_read_blocking_is_active()) let ticket = coordinator.enqueueRuntimeTeardown( - id: UUID(), + id: runtimeLifecycleId, workspaceId: UUID(), reason: "test.activeNativeBorrow", surface: surface, From f4e112f5dc59e16a1519d1f9fe37d21ddc1f6fcc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:41:25 -0700 Subject: [PATCH 21/39] refactor: make native access gates runtime-owned --- .../Concurrency/AtomicRawPointerValue.swift | 45 +++++ .../Concurrency/AtomicUInt64Value.swift | 17 ++ .../CmuxFoundationAtomicsC.c | 44 ++++ .../include/CmuxFoundationAtomicsC.h | 23 +++ .../AtomicRawPointerValueTests.swift | 22 ++ .../Concurrency/AtomicUInt64ValueTests.swift | 10 + ...inalSurfaceRuntimeNativeAccessBorrow.swift | 22 ++ ...rminalSurfaceRuntimeNativeAccessGate.swift | 191 ++++++++---------- ...minalSurfaceRuntimeScreenTailRequest.swift | 2 +- ...TerminalSurfaceRuntimeTeardownAction.swift | 12 ++ ...nalSurfaceRuntimeTeardownCoordinator.swift | 16 +- ...erminalSurfaceRuntimeTeardownRequest.swift | 6 +- .../TerminalSurface+RuntimeLifecycle.swift | 8 +- .../TerminalSurface+ScreenSnapshot.swift | 4 +- .../Surface/TerminalSurface.swift | 8 +- ...minalSurfaceRuntimeNativeAccessTests.swift | 12 +- 16 files changed, 314 insertions(+), 128 deletions(-) create mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicRawPointerValue.swift create mode 100644 Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicRawPointerValueTests.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessBorrow.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownAction.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicRawPointerValue.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicRawPointerValue.swift new file mode 100644 index 000000000000..aeed0d19ad20 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicRawPointerValue.swift @@ -0,0 +1,45 @@ +internal import CmuxFoundationAtomicsC + +/// A macOS 14-compatible atomic raw pointer that does not own its pointee. +/// +/// C11 owns every storage access, so the wrapper is safe to send between +/// isolation domains. Callers remain responsible for retaining any object +/// represented by the pointer until a successful exchange removes it. +public final class AtomicRawPointerValue: @unchecked Sendable { + // The storage address never changes, and all pointee access occurs through + // the C11 atomic API rather than overlapping Swift `inout` accesses. + nonisolated(unsafe) private let storage: + UnsafeMutablePointer + + /// Creates an atomic pointer value. + /// + /// - Parameter initialValue: The unowned pointer returned until replaced. + public init(_ initialValue: UnsafeRawPointer? = nil) { + storage = .allocate(capacity: 1) + CmuxAtomicRawPointerInitialize(storage, initialValue) + } + + deinit { + storage.deallocate() + } + + /// Returns the current unowned pointer with acquire ordering. + @inline(__always) + public func loadAcquire() -> UnsafeRawPointer? { + CmuxAtomicRawPointerLoadAcquire(storage) + } + + /// Atomically replaces `expected` with `desired` using acquire-release ordering. + /// + /// - Parameters: + /// - expected: The pointer that must still be stored for replacement to occur. + /// - desired: The unowned replacement pointer. + /// - Returns: `true` when the replacement occurred, otherwise `false`. + @inline(__always) + public func compareExchange( + expected: UnsafeRawPointer?, + desired: UnsafeRawPointer? + ) -> Bool { + CmuxAtomicRawPointerCompareExchange(storage, expected, desired) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicUInt64Value.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicUInt64Value.swift index 23d5eb7ecef6..859fb68d443c 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicUInt64Value.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicUInt64Value.swift @@ -28,6 +28,12 @@ public final class AtomicUInt64Value: @unchecked Sendable { CmuxAtomicUInt64LoadRelaxed(storage) } + /// Returns the current value with acquire memory ordering. + @inline(__always) + public func loadAcquire() -> UInt64 { + CmuxAtomicUInt64LoadAcquire(storage) + } + /// Replaces the current value with relaxed memory ordering. /// /// - Parameter value: The value subsequent loads should observe. @@ -36,6 +42,17 @@ public final class AtomicUInt64Value: @unchecked Sendable { CmuxAtomicUInt64StoreRelaxed(storage, value) } + /// Atomically replaces `expected` with `desired` using acquire-release ordering. + /// + /// - Parameters: + /// - expected: The value that must still be stored for the replacement to occur. + /// - desired: The replacement value. + /// - Returns: `true` when the replacement occurred, otherwise `false`. + @inline(__always) + public func compareExchange(expected: UInt64, desired: UInt64) -> Bool { + CmuxAtomicUInt64CompareExchange(storage, expected, desired) + } + /// Atomically increments the value with wrapping UInt64 arithmetic. /// /// - Returns: The value after the increment. diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c index 7ba04d3f7f4d..124c04f698f4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c @@ -37,10 +37,27 @@ uint64_t CmuxAtomicUInt64LoadRelaxed(const CmuxAtomicUInt64Storage *storage) { return atomic_load_explicit(&storage->value, memory_order_relaxed); } +uint64_t CmuxAtomicUInt64LoadAcquire(const CmuxAtomicUInt64Storage *storage) { + return atomic_load_explicit(&storage->value, memory_order_acquire); +} + void CmuxAtomicUInt64StoreRelaxed(CmuxAtomicUInt64Storage *storage, uint64_t value) { atomic_store_explicit(&storage->value, value, memory_order_relaxed); } +bool CmuxAtomicUInt64CompareExchange( + CmuxAtomicUInt64Storage *storage, + uint64_t expected, + uint64_t desired +) { + return atomic_compare_exchange_strong_explicit( + &storage->value, + &expected, + desired, + memory_order_acq_rel, + memory_order_acquire); +} + uint64_t CmuxAtomicUInt64IncrementRelaxed(CmuxAtomicUInt64Storage *storage) { return atomic_fetch_add_explicit(&storage->value, 1, memory_order_relaxed) + 1; } @@ -93,3 +110,30 @@ bool CmuxAtomicUInt64DecrementIfPositive(CmuxAtomicUInt64Storage *storage) { } return false; } + +void CmuxAtomicRawPointerInitialize( + CmuxAtomicRawPointerStorage *storage, + const void *initialValue +) { + atomic_init(&storage->value, (uintptr_t)initialValue); +} + +const void *CmuxAtomicRawPointerLoadAcquire( + const CmuxAtomicRawPointerStorage *storage +) { + return (const void *)atomic_load_explicit(&storage->value, memory_order_acquire); +} + +bool CmuxAtomicRawPointerCompareExchange( + CmuxAtomicRawPointerStorage *storage, + const void *expected, + const void *desired +) { + uintptr_t expectedBits = (uintptr_t)expected; + return atomic_compare_exchange_strong_explicit( + &storage->value, + &expectedBits, + (uintptr_t)desired, + memory_order_acq_rel, + memory_order_acquire); +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h index 97a750464950..98df47572b70 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h @@ -25,7 +25,13 @@ typedef struct { void CmuxAtomicUInt64Initialize(CmuxAtomicUInt64Storage *storage, uint64_t initialValue); uint64_t CmuxAtomicUInt64LoadRelaxed(const CmuxAtomicUInt64Storage *storage); +uint64_t CmuxAtomicUInt64LoadAcquire(const CmuxAtomicUInt64Storage *storage); void CmuxAtomicUInt64StoreRelaxed(CmuxAtomicUInt64Storage *storage, uint64_t value); +bool CmuxAtomicUInt64CompareExchange( + CmuxAtomicUInt64Storage *storage, + uint64_t expected, + uint64_t desired +); uint64_t CmuxAtomicUInt64IncrementRelaxed(CmuxAtomicUInt64Storage *storage); uint64_t CmuxAtomicUInt64AdvanceRelaxed(CmuxAtomicUInt64Storage *storage); bool CmuxAtomicUInt64IncrementIfBelow( @@ -34,4 +40,21 @@ bool CmuxAtomicUInt64IncrementIfBelow( ); bool CmuxAtomicUInt64DecrementIfPositive(CmuxAtomicUInt64Storage *storage); +typedef struct { + _Atomic(uintptr_t) value; +} CmuxAtomicRawPointerStorage; + +void CmuxAtomicRawPointerInitialize( + CmuxAtomicRawPointerStorage *storage, + const void *initialValue +); +const void *CmuxAtomicRawPointerLoadAcquire( + const CmuxAtomicRawPointerStorage *storage +); +bool CmuxAtomicRawPointerCompareExchange( + CmuxAtomicRawPointerStorage *storage, + const void *expected, + const void *desired +); + #endif diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicRawPointerValueTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicRawPointerValueTests.swift new file mode 100644 index 000000000000..a9fd621990dc --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicRawPointerValueTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import CmuxFoundation + +@Suite +struct AtomicRawPointerValueTests { + @Test func compareExchangeOnlyReplacesTheExpectedPointer() { + let first = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) + let second = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) + defer { + first.deallocate() + second.deallocate() + } + let value = AtomicRawPointerValue() + + #expect(value.loadAcquire() == nil) + #expect(value.compareExchange(expected: nil, desired: first)) + #expect(value.loadAcquire() == UnsafeRawPointer(first)) + #expect(!value.compareExchange(expected: nil, desired: second)) + #expect(value.compareExchange(expected: first, desired: second)) + #expect(value.loadAcquire() == UnsafeRawPointer(second)) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicUInt64ValueTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicUInt64ValueTests.swift index 7a171c355ba9..d78675610109 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicUInt64ValueTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicUInt64ValueTests.swift @@ -7,12 +7,22 @@ struct AtomicUInt64ValueTests { let value = AtomicUInt64Value(41) #expect(value.loadRelaxed() == 41) + #expect(value.loadAcquire() == 41) value.storeRelaxed(7) #expect(value.loadRelaxed() == 7) #expect(value.wrappingIncrementRelaxed() == 8) #expect(value.loadRelaxed() == 8) } + @Test func compareExchangeOnlyReplacesTheExpectedValue() { + let value = AtomicUInt64Value(7) + + #expect(!value.compareExchange(expected: 6, desired: 8)) + #expect(value.loadAcquire() == 7) + #expect(value.compareExchange(expected: 7, desired: 8)) + #expect(value.loadAcquire() == 8) + } + @Test func concurrentIncrementsAreNotLost() async { let value = AtomicUInt64Value() await withTaskGroup(of: Void.self) { group in diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessBorrow.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessBorrow.swift new file mode 100644 index 000000000000..dbb7051b2b87 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessBorrow.swift @@ -0,0 +1,22 @@ +internal import CmuxFoundation + +/// A one-shot borrow that keeps one native runtime generation accessible. +final class TerminalSurfaceRuntimeNativeAccessBorrow: Sendable { + private let gate: TerminalSurfaceRuntimeNativeAccessGate + private let isActive = AtomicBooleanGate(true) + + init(gate: TerminalSurfaceRuntimeNativeAccessGate) { + self.gate = gate + } + + func release() { + guard isActive.compareExchange(expected: true, desired: false) else { + return + } + gate.releaseBorrow() + } + + deinit { + release() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift index 99b9841d5c64..04efc11d664e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift @@ -1,130 +1,115 @@ -internal import Foundation -internal import os +internal import CmuxFoundation -/// Admits native surface borrows before suspension and orders teardown after them. +/// Orders native surface borrows before the teardown of one runtime generation. /// -/// Entries use the terminal's unique runtime-lifecycle identity rather than a -/// raw pointer address, which an allocator may reuse immediately after free. +/// The high state bit permanently closes borrow admission. Lower bits count +/// active borrows. A retained one-shot teardown action is published before the +/// close transition, so either the closer or the final borrower can claim and +/// run it synchronously without a task hop. final class TerminalSurfaceRuntimeNativeAccessGate: Sendable { - /// A one-shot borrow whose lifetime prevents teardown of its native surface. - final class Borrow: Sendable { - private struct State { - var gate: TerminalSurfaceRuntimeNativeAccessGate? - } - - private let runtimeLifecycleId: UUID - // Synchronous cancellation/deinit must release exactly once; this is a - // bounded compare-and-set, not ongoing domain state. - private let state: OSAllocatedUnfairLock + private static let teardownRequestedMask: UInt64 = 1 << 63 + private static let borrowCountMask = teardownRequestedMask - 1 - fileprivate init( - gate: TerminalSurfaceRuntimeNativeAccessGate, - runtimeLifecycleId: UUID - ) { - self.runtimeLifecycleId = runtimeLifecycleId - state = OSAllocatedUnfairLock(initialState: State(gate: gate)) - } + private let state = AtomicUInt64Value() + private let pendingTeardownAction = AtomicRawPointerValue() - func release() { - let gate = state.withLock { state in - defer { state.gate = nil } - return state.gate + /// Acquires a borrow unless teardown has already claimed this generation. + func acquireBorrow() -> TerminalSurfaceRuntimeNativeAccessBorrow? { + while true { + let current = state.loadAcquire() + guard current & Self.teardownRequestedMask == 0, + current != Self.borrowCountMask else { + return nil } - gate?.releaseBorrow(runtimeLifecycleId: runtimeLifecycleId) - } - - deinit { - release() + guard state.compareExchange( + expected: current, + desired: current + 1 + ) else { + continue + } + return TerminalSurfaceRuntimeNativeAccessBorrow(gate: self) } } - private enum Phase { - case acceptingBorrows - case teardownPending(@Sendable () -> Void) - case tearingDown - } - - private struct Entry { - var borrowCount = 0 - var phase = Phase.acceptingBorrows - } - - // Borrow admission and synchronous deinit teardown cannot await an actor. - // The lock guards only short, nonblocking entry transitions; callbacks run - // after unlock. Idle borrow entries leave on release, and teardown entries - // leave immediately after the corresponding native free returns. - private let entries = OSAllocatedUnfairLock(initialState: [UUID: Entry]()) + /// Closes admission and starts teardown after all admitted borrows finish. + func requestTeardown(start: @escaping @Sendable () -> Void) { + let retainedAction = Unmanaged.passRetained( + TerminalSurfaceRuntimeTeardownAction(start: start) + ) + let actionPointer = UnsafeRawPointer(retainedAction.toOpaque()) + guard pendingTeardownAction.compareExchange( + expected: nil, + desired: actionPointer + ) else { + retainedAction.release() + return + } - func acquireBorrow(for runtimeLifecycleId: UUID) -> Borrow? { - let acquired = entries.withLock { entries in - var entry = entries[runtimeLifecycleId] ?? Entry() - guard case .acceptingBorrows = entry.phase else { - return false + while true { + let current = state.loadAcquire() + guard current & Self.teardownRequestedMask == 0 else { + discardPendingAction(actionPointer: actionPointer) + return } - entry.borrowCount += 1 - entries[runtimeLifecycleId] = entry - return true + let closed = current | Self.teardownRequestedMask + guard state.compareExchange(expected: current, desired: closed) else { + continue + } + if closed & Self.borrowCountMask == 0 { + runPendingTeardown() + } + return } - guard acquired else { return nil } - return Borrow(gate: self, runtimeLifecycleId: runtimeLifecycleId) } - func requestTeardown( - for runtimeLifecycleId: UUID, - start: @escaping @Sendable () -> Void - ) { - let ready = entries.withLock { entries -> (@Sendable () -> Void)? in - var entry = entries[runtimeLifecycleId] ?? Entry() - guard case .acceptingBorrows = entry.phase else { - return nil + /// Releases one admitted borrow and starts any newly-unblocked teardown. + fileprivate func releaseBorrow() { + while true { + let current = state.loadAcquire() + let borrowCount = current & Self.borrowCountMask + guard borrowCount > 0 else { return } + let released = current - 1 + guard state.compareExchange(expected: current, desired: released) else { + continue } - guard entry.borrowCount > 0 else { - entry.phase = .tearingDown - entries[runtimeLifecycleId] = entry - return start + if released == Self.teardownRequestedMask { + runPendingTeardown() } - entry.phase = .teardownPending(start) - entries[runtimeLifecycleId] = entry - return nil + return } - ready?() } - func finishTeardown(for runtimeLifecycleId: UUID) { - entries.withLock { entries in - guard let entry = entries[runtimeLifecycleId], - case .tearingDown = entry.phase, - entry.borrowCount == 0 else { + private func runPendingTeardown() { + while true { + guard let actionPointer = pendingTeardownAction.loadAcquire() else { return } - entries.removeValue(forKey: runtimeLifecycleId) + guard pendingTeardownAction.compareExchange( + expected: actionPointer, + desired: nil + ) else { + continue + } + takeRetainedAction(actionPointer: actionPointer).run() + return } } - private func releaseBorrow(runtimeLifecycleId: UUID) { - let ready = entries.withLock { entries -> (@Sendable () -> Void)? in - guard var entry = entries[runtimeLifecycleId], - entry.borrowCount > 0 else { - return nil - } - entry.borrowCount -= 1 - guard entry.borrowCount == 0 else { - entries[runtimeLifecycleId] = entry - return nil - } - switch entry.phase { - case .acceptingBorrows: - entries.removeValue(forKey: runtimeLifecycleId) - return nil - case .teardownPending(let pendingTeardown): - entry.phase = .tearingDown - entries[runtimeLifecycleId] = entry - return pendingTeardown - case .tearingDown: - entries[runtimeLifecycleId] = entry - return nil - } + private func discardPendingAction(actionPointer: UnsafeRawPointer) { + guard pendingTeardownAction.compareExchange( + expected: actionPointer, + desired: nil + ) else { + return } - ready?() + _ = takeRetainedAction(actionPointer: actionPointer) + } + + private func takeRetainedAction( + actionPointer: UnsafeRawPointer + ) -> TerminalSurfaceRuntimeTeardownAction { + return Unmanaged + .fromOpaque(actionPointer) + .takeRetainedValue() } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index 9fbda2ecfcdd..c57d8ea9446f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -9,10 +9,10 @@ internal import GhosttyKit /// defers process termination and native free. `@unchecked Sendable` is limited /// to transporting the borrowed pointer onto the coordinator actor. struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { - let runtimeLifecycleId: UUID let surface: ghostty_surface_t let maxRows: Int let maxBytes: Int + let nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate func read() -> String? { var text = ghostty_text_s() diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownAction.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownAction.swift new file mode 100644 index 000000000000..ee77a726fe4c --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownAction.swift @@ -0,0 +1,12 @@ +/// A retained one-shot action published across the native-access atomic gate. +final class TerminalSurfaceRuntimeTeardownAction: Sendable { + private let start: @Sendable () -> Void + + init(start: @escaping @Sendable () -> Void) { + self.start = start + } + + func run() { + start() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 4c0344ab2a89..719d05b7f639 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -41,8 +41,6 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private let isolatedHibernationQueues: [DispatchQueue] private nonisolated let beginSurfaceTeardown: @Sendable (ghostty_surface_t) -> Void - private nonisolated let nativeAccessGate = - TerminalSurfaceRuntimeNativeAccessGate() private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() @@ -96,14 +94,14 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { /// first defers both process termination and native free until release. nonisolated func acquireScreenTailBorrow( for request: TerminalSurfaceRuntimeScreenTailRequest - ) -> TerminalSurfaceRuntimeNativeAccessGate.Borrow? { - nativeAccessGate.acquireBorrow(for: request.runtimeLifecycleId) + ) -> TerminalSurfaceRuntimeNativeAccessBorrow? { + request.nativeAccessGate.acquireBorrow() } /// Reads a bounded screen tail away from the main actor under an admitted borrow. func readScreenTailVT( _ request: TerminalSurfaceRuntimeScreenTailRequest, - borrow: TerminalSurfaceRuntimeNativeAccessGate.Borrow + borrow: TerminalSurfaceRuntimeNativeAccessBorrow ) -> String? { defer { borrow.release() } return request.read() @@ -175,10 +173,11 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { @discardableResult nonisolated func enqueueRuntimeTeardown( id: UUID, - runtimeLifecycleId: UUID? = nil, workspaceId: UUID, reason: String, surface: ghostty_surface_t, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate = + TerminalSurfaceRuntimeNativeAccessGate(), callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, @@ -193,17 +192,17 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) let request = TerminalSurfaceRuntimeTeardownRequest( id: id, - runtimeLifecycleId: runtimeLifecycleId ?? id, workspaceId: workspaceId, reason: reason, surface: surface, + nativeAccessGate: nativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: byteTeeLease, freeSurface: freeSurface, completion: completion ) - nativeAccessGate.requestTeardown(for: request.runtimeLifecycleId) { + request.nativeAccessGate.requestTeardown { // When there is no admitted borrow this remains synchronous and // precedes the Task hop. A pending borrow is the only reason to // defer termination, because Ghostty forbids surface API calls @@ -305,7 +304,6 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) #endif request.freeSurface(request.surface) - nativeAccessGate.finishTeardown(for: request.runtimeLifecycleId) } private nonisolated func finishFree( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 8278a0502038..d4d278176247 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -18,10 +18,10 @@ public import CmuxTerminalCore /// so a release ordered after the free can never race an in-flight callback. struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let id: UUID - let runtimeLifecycleId: UUID let workspaceId: UUID let reason: String let surface: ghostty_surface_t + let nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate let callbackContext: Unmanaged? let manualIOContext: Unmanaged? let byteTeeLease: (any TerminalByteTeeLease)? @@ -34,10 +34,10 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { init( id: UUID, - runtimeLifecycleId: UUID, workspaceId: UUID, reason: String, surface: ghostty_surface_t, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate, callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, @@ -45,10 +45,10 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { completion: TerminalSurfaceRuntimeTeardownCompletion ) { self.id = id - self.runtimeLifecycleId = runtimeLifecycleId self.workspaceId = workspaceId self.reason = reason self.surface = surface + self.nativeAccessGate = nativeAccessGate self.callbackContext = callbackContext self.manualIOContext = manualIOContext self.byteTeeLease = byteTeeLease diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 78dc6943de68..1ebfe06ba3f4 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -275,10 +275,10 @@ extension TerminalSurface { // native free, which is what joins ghostty's IO threads. runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, @@ -293,10 +293,10 @@ extension TerminalSurface { // releases all callback userdata only after the free returns. runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "teardown", surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease @@ -369,10 +369,10 @@ extension TerminalSurface { // native free, which is what joins ghostty's IO threads. agentHibernationRuntimeTeardownTicket = runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: reason, surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, @@ -386,10 +386,10 @@ extension TerminalSurface { agentHibernationRuntimeTeardownTicket = runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: reason, surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index ca934f1bd4b3..64b3c264e255 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -18,10 +18,10 @@ extension TerminalSurface { return nil } let request = TerminalSurfaceRuntimeScreenTailRequest( - runtimeLifecycleId: terminalLifecycleId, surface: surface, maxRows: maxRows, - maxBytes: maxBytes + maxBytes: maxBytes, + nativeAccessGate: runtimeNativeAccessGate ) guard let borrow = runtimeTeardown.acquireScreenTailBorrow( for: request diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 0f75b62c4329..2977159a17de 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -57,6 +57,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { public typealias CodexCommandShim = TerminalSurfaceCodexCommandShim public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment private var runtimeSurface: ghostty_surface_t? + var runtimeNativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() var runtimeControllingTTYName: String? var runtimeControllingTTYDeviceIdentifier: Int64? /// The live runtime surface pointer, or nil before creation/after teardown. @@ -64,6 +65,9 @@ public final class TerminalSurface: Identifiable, ObservableObject { get { runtimeSurface } set { guard runtimeSurface != newValue else { return } + if runtimeSurface == nil, newValue != nil { + runtimeNativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() + } runtimeSurface = newValue runtimeControllingTTYName = nil runtimeControllingTTYDeviceIdentifier = nil @@ -700,10 +704,10 @@ public final class TerminalSurface: Identifiable, ObservableObject { if let freeSurface = Self.runtimeSurfaceFreeOverrideForTesting { runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, @@ -714,10 +718,10 @@ public final class TerminalSurface: Identifiable, ObservableObject { #endif runtimeTeardown.enqueueRuntimeTeardown( id: id, - runtimeLifecycleId: terminalLifecycleId, workspaceId: tabId, reason: "deinit", surface: surfaceToFree, + nativeAccessGate: runtimeNativeAccessGate, callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 29c802da8aa8..bc66c200e02a 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -16,6 +16,7 @@ import Testing ) let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) let runtimeLifecycleId = UUID() + let nativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() defer { releaseNativeFree.signal() surface.deallocate() @@ -26,16 +27,17 @@ import Testing workspaceId: UUID(), reason: "test.teardownBeforeNativeBorrow", surface: surface, + nativeAccessGate: nativeAccessGate, callbackContext: nil, freeSurface: { _ in _ = releaseNativeFree.wait(timeout: .distantFuture) } ) let request = TerminalSurfaceRuntimeScreenTailRequest( - runtimeLifecycleId: runtimeLifecycleId, surface: surface, maxRows: 1, - maxBytes: 1 + maxBytes: 1, + nativeAccessGate: nativeAccessGate ) #expect(coordinator.acquireScreenTailBorrow(for: request) == nil) @@ -55,6 +57,7 @@ import Testing ) let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) let runtimeLifecycleId = UUID() + let nativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() defer { surface.deallocate() } let surfaceBits = UInt(bitPattern: surface) cmux_test_ghostty_surface_read_blocking_begin(surface) @@ -65,10 +68,10 @@ import Testing let borrowedSurface = UnsafeMutableRawPointer(bitPattern: surfaceBits)! let request = TerminalSurfaceRuntimeScreenTailRequest( - runtimeLifecycleId: runtimeLifecycleId, surface: borrowedSurface, maxRows: 1, - maxBytes: 1 + maxBytes: 1, + nativeAccessGate: nativeAccessGate ) let borrow = try #require( coordinator.acquireScreenTailBorrow(for: request) @@ -92,6 +95,7 @@ import Testing workspaceId: UUID(), reason: "test.activeNativeBorrow", surface: surface, + nativeAccessGate: nativeAccessGate, callbackContext: nil, freeSurface: { _ in nativeFreeCount.withLock { $0 += 1 } From 8fc0ece1b9b82d3aa4c61b2640369e0fb8709ef1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:52:06 -0700 Subject: [PATCH 22/39] test: cover one-shot native teardown admission --- ...rminalSurfaceRuntimeNativeAccessGate.swift | 5 +++- .../Surface/TerminalSurface.swift | 1 + ...minalSurfaceRuntimeNativeAccessTests.swift | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift index 04efc11d664e..456d0a349814 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift @@ -31,7 +31,10 @@ final class TerminalSurfaceRuntimeNativeAccessGate: Sendable { } } - /// Closes admission and starts teardown after all admitted borrows finish. + /// Closes admission and starts the first teardown after admitted borrows finish. + /// + /// Later teardown requests are ignored because one native runtime generation + /// has exactly one terminal teardown transition. func requestTeardown(start: @escaping @Sendable () -> Void) { let retainedAction = Unmanaged.passRetained( TerminalSurfaceRuntimeTeardownAction(start: start) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 2977159a17de..344da02ee5fd 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -57,6 +57,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { public typealias CodexCommandShim = TerminalSurfaceCodexCommandShim public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment private var runtimeSurface: ghostty_surface_t? + /// Native API admission state replaced with every installed runtime pointer. var runtimeNativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() var runtimeControllingTTYName: String? var runtimeControllingTTYDeviceIdentifier: Int64? diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index bc66c200e02a..3f0caf5f2fdc 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -6,6 +6,29 @@ import Testing @testable import CmuxTerminal @Suite(.serialized) struct TerminalSurfaceRuntimeNativeAccessTests { + @Test func finalBorrowStartsTheFirstTeardownExactlyOnce() throws { + let teardownBegun = OSAllocatedUnfairLock(initialState: 0) + let gate = TerminalSurfaceRuntimeNativeAccessGate() + let firstBorrow = try #require(gate.acquireBorrow()) + let secondBorrow = try #require(gate.acquireBorrow()) + + gate.requestTeardown { + teardownBegun.withLock { $0 += 1 } + } + #expect(gate.acquireBorrow() == nil) + #expect(teardownBegun.withLock { $0 } == 0) + + firstBorrow.release() + #expect(teardownBegun.withLock { $0 } == 0) + + secondBorrow.release() + secondBorrow.release() + gate.requestTeardown { + teardownBegun.withLock { $0 += 100 } + } + #expect(teardownBegun.withLock { $0 } == 1) + } + @Test func screenTailBorrowIsRejectedAfterTeardownStarts() async { let teardownBegun = OSAllocatedUnfairLock(initialState: 0) let releaseNativeFree = DispatchSemaphore(value: 0) From 230a8e81a15d1f3afb822087313bc8e63e530a0c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 03:52:07 -0700 Subject: [PATCH 23/39] build: pin stable process-group GhosttyKit --- docs/ghostty-fork.md | 38 +++++++++++++++++++------------- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 3e1710661cd7..f00349fe1a42 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,10 +12,12 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `88c3325dc`, which completes bounded, -two-phase embedded-surface process teardown by tracking direct-child reaping -separately from surviving process-group descendants. It includes `d462c1d97`, -the Hangul NFC/NFD font-resolution integration, and `9513174f2`, the +The submodule pinned by this branch is `81b4de4f5`, which preserves the stable +POSIX process-group identity after its direct-child leader exits and sends each +graceful/escalation signal exactly once. It builds on `88c3325dc`, the bounded, +two-phase embedded-surface teardown that tracks direct-child reaping separately +from surviving process-group descendants. It includes `d462c1d97`, the Hangul +NFC/NFD font-resolution integration, and `9513174f2`, the current-fork reapplication of the VT stream-boundary API previously pinned at `11aa609d7`. cmux uses that VT contract to retain incomplete escape-sequence bytes across distributed snapshot handoff. The current pin builds on @@ -57,11 +59,14 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Pull requests: - https://github.com/manaflow-ai/ghostty/pull/184 - https://github.com/manaflow-ai/ghostty/pull/187 + - https://github.com/manaflow-ai/ghostty/pull/188 - Commits: - `9be0c8b93` (test: cover subprocesses that ignore SIGHUP) - `5b20c6229` (fix: bound embedded surface process teardown) - `26d320bfe` (test: cover descendants surviving direct child exit) - `88c3325dc` (fix: reap surviving process-group descendants) + - `b8a643561` (test: cover process-group grace and leader exit) + - `81b4de4f5` (fix: preserve graceful process-group teardown) - Files: - `include/ghostty.h` - `src/Surface.zig` @@ -71,17 +76,19 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Exposes `ghostty_surface_request_process_termination`, a non-blocking, idempotent pre-free request that retires an embedded surface from Ghostty app routing and wakes its IO owner without joining or freeing the surface. - - Lets the child process group handle SIGHUP for 12 seconds, preserving - cmux's 10-second Claude `SessionEnd` hook budget, then escalates to SIGKILL. - - Bounds the post-SIGKILL reap wait to three seconds, so a pathological child - cannot hold a native-surface teardown worker indefinitely. - - Keeps process-group liveness independent from direct-child wait status, so - an ignoring grandchild is still escalated after its parent exits. + - Sends the process group one SIGHUP, then polls liveness with signal `0` for + 12 seconds, preserving cmux's 10-second Claude `SessionEnd` hook budget. + - Retains the process-group id independently from direct-child wait status, + so descendants remain addressable after the group leader has been reaped. + - Sends one SIGKILL at escalation and bounds the final reap wait to three + seconds, so a pathological child cannot hold a native teardown worker. + - Handles the pre-`exec` race by terminating the still-waitable direct child + if its intended process group has not been created yet. - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. - Artifact: - - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-88c3325dc9698d887da7e07ee0f9b79c53020be2-crashsubdir-cmux-crash-sentry-off-v1 - - SHA-256 `56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604` + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-81b4de4f540eeea9be34574ffdd13ec51ee8a340-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7` is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive passed `scripts/validate-xcframework-archive.py`. - Conflict note: @@ -89,9 +96,10 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). merges: the pre-free request must prevent new app-action retains, remain idempotent, and start IO-owned process teardown without freeing native state. Final free must still wait for existing action leases and release the surface - exactly once. POSIX process teardown must retain separate direct-child and - process-group liveness state, the 12-second SIGHUP grace, SIGKILL escalation, - and the bounded three-second final reap window. + exactly once. POSIX process teardown must retain the stable group id, + separate direct-child and process-group liveness state, a single SIGHUP, + signal-`0` grace polling, a single SIGKILL escalation, and the bounded + three-second final reap window. ### Canonical Hangul font resolution diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 14473ef5e064..82a3db3c94d5 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -116,3 +116,4 @@ f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650a 11aa609d75dec882ef2f83171e2cbe887aeddbc5 1a4acbcc9e0e5b20c0b4dad6660d0c08546a5d36192053834df960144fa8fdb9 5b20c62297aca8289b400cb7f5583f65a5c759bc 88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2 88c3325dc9698d887da7e07ee0f9b79c53020be2 56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604 +81b4de4f540eeea9be34574ffdd13ec51ee8a340 a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7 From db12524ce826287df08774f2a65c54b9cf8b560f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 04:06:51 -0700 Subject: [PATCH 24/39] fix: expose native borrow release within package --- .../Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift index 456d0a349814..477cf56d3042 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeAccessGate.swift @@ -66,7 +66,7 @@ final class TerminalSurfaceRuntimeNativeAccessGate: Sendable { } /// Releases one admitted borrow and starts any newly-unblocked teardown. - fileprivate func releaseBorrow() { + func releaseBorrow() { while true { let current = state.loadAcquire() let borrowCount = current & Self.borrowCountMask From 4db0fa39cf1b82a13617ed42b5a086f9a8650634 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 04:15:12 -0700 Subject: [PATCH 25/39] fix: gate sharing conformance for Xcode 16 --- Sources/Panels/FilePreviewPDFSharingPresenter.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/FilePreviewPDFSharingPresenter.swift b/Sources/Panels/FilePreviewPDFSharingPresenter.swift index 8befa5bdadc6..f663e211c2c3 100644 --- a/Sources/Panels/FilePreviewPDFSharingPresenter.swift +++ b/Sources/Panels/FilePreviewPDFSharingPresenter.swift @@ -61,9 +61,7 @@ final class FilePreviewPDFSharingPresenter: NSObject { activePicker.delegate = nil activePicker.close() } -} -extension FilePreviewPDFSharingPresenter: @MainActor NSSharingServicePickerDelegate { func sharingServicePicker( _ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService? @@ -73,3 +71,11 @@ extension FilePreviewPDFSharingPresenter: @MainActor NSSharingServicePickerDeleg activePicker = nil } } + +// Isolated conformances are Swift 6.2 syntax; retain the legacy spelling for the +// Xcode 16.2 compiler used by the macOS 14 compatibility lane. +#if compiler(>=6.2) +extension FilePreviewPDFSharingPresenter: @MainActor NSSharingServicePickerDelegate {} +#else +extension FilePreviewPDFSharingPresenter: NSSharingServicePickerDelegate {} +#endif From 4f4b389802ee9a9e4797d58d32a8a246ee927bc6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 7 Aug 2026 04:32:09 -0700 Subject: [PATCH 26/39] fix: keep native reads off teardown actor --- ...minalSurfaceRuntimeScreenTailRequest.swift | 2 +- ...nalSurfaceRuntimeTeardownCoordinator.swift | 9 +++++-- ...minalSurfaceRuntimeNativeAccessTests.swift | 4 +++ ...SurfaceTeardownCallbackLifetimeTests.swift | 26 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index c57d8ea9446f..f4bb23086456 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -7,7 +7,7 @@ internal import GhosttyKit /// admits a native-access borrow before its first suspension, and the runtime /// coordinator executes this request without suspension while that borrow /// defers process termination and native free. `@unchecked Sendable` is limited -/// to transporting the borrowed pointer onto the coordinator actor. +/// to transporting the borrowed pointer onto the concurrent read executor. struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { let surface: ghostty_surface_t let maxRows: Int diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 719d05b7f639..0ac7b800636a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -99,10 +99,15 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { } /// Reads a bounded screen tail away from the main actor under an admitted borrow. - func readScreenTailVT( +#if compiler(>=6.2) + @concurrent +#else + @Sendable +#endif + nonisolated func readScreenTailVT( _ request: TerminalSurfaceRuntimeScreenTailRequest, borrow: TerminalSurfaceRuntimeNativeAccessBorrow - ) -> String? { + ) async -> String? { defer { borrow.release() } return request.read() } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 3f0caf5f2fdc..3f85c55531f9 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -52,6 +52,8 @@ import Testing surface: surface, nativeAccessGate: nativeAccessGate, callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, freeSurface: { _ in _ = releaseNativeFree.wait(timeout: .distantFuture) } @@ -120,6 +122,8 @@ import Testing surface: surface, nativeAccessGate: nativeAccessGate, callbackContext: nil, + manualIOContext: nil, + byteTeeLease: nil, freeSurface: { _ in nativeFreeCount.withLock { $0 += 1 } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index 993a91e0061d..186b2de9dafd 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -88,6 +88,32 @@ import Testing #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) } + @Test func installingNewRuntimeSurfaceReplacesClosedNativeAccessGate() async throws { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + let runtimeSurface = fakeRuntimeSurface() + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + recorder.record(.nativeFree) + } + defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil } + + surface.installRuntimeSurfaceForTesting(runtimeSurface) + let retiredGate = surface.runtimeNativeAccessGate + surface.teardownSurface() + + #expect(retiredGate.acquireBorrow() == nil) + #expect(await recorder.waitForEventCount(1)) + + surface.installRuntimeSurfaceForTesting(runtimeSurface) + let replacementGate = surface.runtimeNativeAccessGate + #expect(replacementGate !== retiredGate) + let replacementBorrow = try #require(replacementGate.acquireBorrow()) + replacementBorrow.release() + + surface.teardownSurface() + #expect(await recorder.waitForEventCount(2)) + } + @Test func agentHibernationSuspendKeepsTeeLeaseUntilNativeFree() async { let recorder = TeardownOrderRecorder() let registry = TerminalSurfaceRegistry() From c54d9d52fdfd9322693ddc8d8366dd88e48fb6f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 15:44:35 -0700 Subject: [PATCH 27/39] build: pin foreground process-group GhosttyKit --- docs/ghostty-fork.md | 42 ++++++++++++++++++++------------ ghostty | 2 +- scripts/ghosttykit-checksums.txt | 1 + 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index f00349fe1a42..b89fba9202af 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,11 +12,12 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `81b4de4f5`, which preserves the stable -POSIX process-group identity after its direct-child leader exits and sends each -graceful/escalation signal exactly once. It builds on `88c3325dc`, the bounded, -two-phase embedded-surface teardown that tracks direct-child reaping separately -from surviving process-group descendants. It includes `d462c1d97`, the Hangul +The submodule pinned by this branch is `47e9bd4c9`, which treats the PTY's +foreground job-control group as a teardown target distinct from the original +session-leader group and drives both through one bounded shutdown state machine. +It builds on `81b4de4f5`, which preserves the stable POSIX process-group identity +after its direct-child leader exits and sends each graceful/escalation signal +exactly once. It includes `d462c1d97`, the Hangul NFC/NFD font-resolution integration, and `9513174f2`, the current-fork reapplication of the VT stream-boundary API previously pinned at `11aa609d7`. cmux uses that VT contract to retain incomplete escape-sequence @@ -60,6 +61,7 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - https://github.com/manaflow-ai/ghostty/pull/184 - https://github.com/manaflow-ai/ghostty/pull/187 - https://github.com/manaflow-ai/ghostty/pull/188 + - https://github.com/manaflow-ai/ghostty/pull/192 - Commits: - `9be0c8b93` (test: cover subprocesses that ignore SIGHUP) - `5b20c6229` (fix: bound embedded surface process teardown) @@ -67,6 +69,8 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - `88c3325dc` (fix: reap surviving process-group descendants) - `b8a643561` (test: cover process-group grace and leader exit) - `81b4de4f5` (fix: preserve graceful process-group teardown) + - `babe4266c` (test: cover distinct foreground process-group teardown) + - `47e9bd4c9` (fix: reap foreground job-control process groups) - Files: - `include/ghostty.h` - `src/Surface.zig` @@ -76,19 +80,26 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Exposes `ghostty_surface_request_process_termination`, a non-blocking, idempotent pre-free request that retires an embedded surface from Ghostty app routing and wakes its IO owner without joining or freeing the surface. - - Sends the process group one SIGHUP, then polls liveness with signal `0` for - 12 seconds, preserving cmux's 10-second Claude `SessionEnd` hook budget. + - Captures the PTY foreground process-group id before teardown, deduplicates it + against the stored session-leader group, and sends both one SIGHUP before + polling liveness with signal `0` for 12 seconds. This preserves cmux's + 10-second Claude `SessionEnd` hook budget even when interactive job control + puts Claude in a group separate from its shell. - Retains the process-group id independently from direct-child wait status, so descendants remain addressable after the group leader has been reaped. - - Sends one SIGKILL at escalation and bounds the final reap wait to three - seconds, so a pathological child cannot hold a native teardown worker. + - Applies one shared graceful/escalation deadline to both groups, sends one + SIGKILL to each surviving group, and bounds the final reap wait to three + seconds, so a pathological foreground job cannot hold a native teardown + worker or escape when the PTY closes. + - Stops targeting a captured foreground group after it disappears, preventing + a recycled process-group id from being signalled later in the grace window. - Handles the pre-`exec` race by terminating the still-waitable direct child if its intended process group has not been created yet. - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. - Artifact: - - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-81b4de4f540eeea9be34574ffdd13ec51ee8a340-crashsubdir-cmux-crash-sentry-off-v1 - - SHA-256 `a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7` + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-47e9bd4c90dec35b55b550095eea14498a37508f-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `f0b268f74bff5866fc8f28be701d57fdcd0cf143ad9629e397eb545a29aea99e` is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive passed `scripts/validate-xcframework-archive.py`. - Conflict note: @@ -96,10 +107,11 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). merges: the pre-free request must prevent new app-action retains, remain idempotent, and start IO-owned process teardown without freeing native state. Final free must still wait for existing action leases and release the surface - exactly once. POSIX process teardown must retain the stable group id, - separate direct-child and process-group liveness state, a single SIGHUP, - signal-`0` grace polling, a single SIGKILL escalation, and the bounded - three-second final reap window. + exactly once. POSIX process teardown must retain the stable session-leader + group id, capture and deduplicate the PTY foreground group before signalling, + keep separate direct-child and per-group liveness state, stop targeting a + foreground group after it disappears, and preserve the shared SIGHUP, + signal-`0` polling, SIGKILL, and bounded final-reap deadlines. ### Canonical Hangul font resolution diff --git a/ghostty b/ghostty index 81b4de4f540e..47e9bd4c90de 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 81b4de4f540eeea9be34574ffdd13ec51ee8a340 +Subproject commit 47e9bd4c90dec35b55b550095eea14498a37508f diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 82a3db3c94d5..4b31faeb3ed8 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -117,3 +117,4 @@ f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650a 5b20c62297aca8289b400cb7f5583f65a5c759bc 88a4e639d230f8532f44902ee563ed83527521c7cb2a09e10a3b06f40b8879b2 88c3325dc9698d887da7e07ee0f9b79c53020be2 56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604 81b4de4f540eeea9be34574ffdd13ec51ee8a340 a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7 +47e9bd4c90dec35b55b550095eea14498a37508f f0b268f74bff5866fc8f28be701d57fdcd0cf143ad9629e397eb545a29aea99e From 1d2334a86fcd992bdfe4a7a79f562616b6eccbbd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 17:25:57 -0700 Subject: [PATCH 28/39] Update Ghostty to main-integrated process reaping --- docs/ghostty-fork.md | 1 + ghostty | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index a75b8dc3240c..e4bea299f296 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -115,6 +115,7 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - `81b4de4f5` (fix: preserve graceful process-group teardown) - `babe4266c` (test: cover distinct foreground process-group teardown) - `47e9bd4c9` (fix: reap foreground job-control process groups) + - `f66cfbd6f` (merge: integrate descendant reaping into Ghostty `main`) - Files: - `include/ghostty.h` - `src/Surface.zig` diff --git a/ghostty b/ghostty index 47e9bd4c90de..f66cfbd6f2d4 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 47e9bd4c90dec35b55b550095eea14498a37508f +Subproject commit f66cfbd6f2d43ef903376b366476fa0af1d7d7d3 From bd266e22b3769fb5398f6685a3f3b3d6bf65f111 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 17:37:24 -0700 Subject: [PATCH 29/39] Pin merged GhosttyKit archive --- docs/ghostty-fork.md | 4 ++-- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index e4bea299f296..cee7de2a64b4 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -143,8 +143,8 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. - Artifact: - - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-47e9bd4c90dec35b55b550095eea14498a37508f-crashsubdir-cmux-crash-sentry-off-v1 - - SHA-256 `f0b268f74bff5866fc8f28be701d57fdcd0cf143ad9629e397eb545a29aea99e` + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-f66cfbd6f2d43ef903376b366476fa0af1d7d7d3-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `284d4af7516a50a893e1c03d6870c3b39ae8678aed16a1fad67475f1c32a1c83` is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive passed `scripts/validate-xcframework-archive.py`. - Conflict note: diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 55c8ab945785..870097f7481e 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -119,3 +119,4 @@ f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650a 88c3325dc9698d887da7e07ee0f9b79c53020be2 56869d9d8702d6710d5a1992a3d21c02e29b12d1130964ee546f80bff7353604 81b4de4f540eeea9be34574ffdd13ec51ee8a340 a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7 47e9bd4c90dec35b55b550095eea14498a37508f f0b268f74bff5866fc8f28be701d57fdcd0cf143ad9629e397eb545a29aea99e +f66cfbd6f2d43ef903376b366476fa0af1d7d7d3 284d4af7516a50a893e1c03d6870c3b39ae8678aed16a1fad67475f1c32a1c83 From 52b914b8d13ad1b0735f2578df571200db29fd63 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:31:58 -0700 Subject: [PATCH 30/39] test: reproduce concurrent screen-tail admission --- ...minalSurfaceRuntimeNativeAccessTests.swift | 141 ++++++++++++++++++ .../GhosttyRuntimeTestStubs.c | 55 +++++++ .../include/GhosttyRuntimeTestStubs.h | 5 + 3 files changed, 201 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 3f85c55531f9..25dc69a1126e 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -143,4 +143,145 @@ import Testing #expect(teardownBegun.withLock { $0 } == 1) #expect(nativeFreeCount.withLock { $0 } == 1) } + + @Test func screenTailReadsDoNotOverlapNativeFormatting() async throws { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let firstSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let secondSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { + cmux_test_ghostty_surface_read_release() + cmux_test_ghostty_surface_read_blocking_reset() + firstSurface.deallocate() + secondSurface.deallocate() + } + cmux_test_ghostty_surface_read_blocking_begin(firstSurface) + + let firstRequest = TerminalSurfaceRuntimeScreenTailRequest( + surface: firstSurface, + maxRows: 1, + maxBytes: 1, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate() + ) + let secondRequest = TerminalSurfaceRuntimeScreenTailRequest( + surface: secondSurface, + maxRows: 1, + maxBytes: 1, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate() + ) + let firstBorrow = try #require( + coordinator.acquireScreenTailBorrow(for: firstRequest) + ) + let secondBorrow = try #require( + coordinator.acquireScreenTailBorrow(for: secondRequest) + ) + + let firstRead = Task { + await coordinator.readScreenTailVT(firstRequest, borrow: firstBorrow) + } + let firstReadStarted = AsyncStream.makeStream() + DispatchQueue.global(qos: .userInitiated).async { + firstReadStarted.continuation.yield( + cmux_test_ghostty_surface_read_wait_until_started() + ) + firstReadStarted.continuation.finish() + } + var firstReadStartedIterator = firstReadStarted.stream.makeAsyncIterator() + try #require(await firstReadStartedIterator.next() == true) + + let secondRead = Task(priority: .high) { + await coordinator.readScreenTailVT(secondRequest, borrow: secondBorrow) + } + let overlapProbe = AsyncStream.makeStream() + DispatchQueue.global(qos: .userInitiated).async { + let overlapped = cmux_test_ghostty_surface_read_wait_until_call_count( + 2, + 1_000 + ) + cmux_test_ghostty_surface_read_release() + overlapProbe.continuation.yield(overlapped) + overlapProbe.continuation.finish() + } + var overlapProbeIterator = overlapProbe.stream.makeAsyncIterator() + let overlapResult = await overlapProbeIterator.next() + guard let overlapped = overlapResult else { + Issue.record("overlap probe ended without a result") + return + } + + _ = await firstRead.value + _ = await secondRead.value + + #expect(overlapped == false) + #expect(cmux_test_ghostty_surface_read_call_count() == 2) + #expect( + cmux_test_ghostty_surface_read_maximum_concurrent_call_count() == 1 + ) + } + + @Test func cancelledQueuedScreenTailReadReleasesBorrowWithoutNativeAccess() async throws { + let teardownBegun = OSAllocatedUnfairLock(initialState: 0) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let firstSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let cancelledSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { + cmux_test_ghostty_surface_read_release() + cmux_test_ghostty_surface_read_blocking_reset() + firstSurface.deallocate() + cancelledSurface.deallocate() + } + cmux_test_ghostty_surface_read_blocking_begin(firstSurface) + + let firstRequest = TerminalSurfaceRuntimeScreenTailRequest( + surface: firstSurface, + maxRows: 1, + maxBytes: 1, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate() + ) + let cancelledGate = TerminalSurfaceRuntimeNativeAccessGate() + let cancelledRequest = TerminalSurfaceRuntimeScreenTailRequest( + surface: cancelledSurface, + maxRows: 1, + maxBytes: 1, + nativeAccessGate: cancelledGate + ) + let firstBorrow = try #require( + coordinator.acquireScreenTailBorrow(for: firstRequest) + ) + let cancelledBorrow = try #require( + coordinator.acquireScreenTailBorrow(for: cancelledRequest) + ) + + let firstRead = Task { + await coordinator.readScreenTailVT(firstRequest, borrow: firstBorrow) + } + let firstReadStarted = AsyncStream.makeStream() + DispatchQueue.global(qos: .userInitiated).async { + firstReadStarted.continuation.yield( + cmux_test_ghostty_surface_read_wait_until_started() + ) + firstReadStarted.continuation.finish() + } + var firstReadStartedIterator = firstReadStarted.stream.makeAsyncIterator() + try #require(await firstReadStartedIterator.next() == true) + + let cancelledRead = Task { + withUnsafeCurrentTask { task in + task?.cancel() + } + return await coordinator.readScreenTailVT( + cancelledRequest, + borrow: cancelledBorrow + ) + } + cancelledGate.requestTeardown { + teardownBegun.withLock { $0 += 1 } + } + cmux_test_ghostty_surface_read_release() + + _ = await firstRead.value + _ = await cancelledRead.value + + #expect(cmux_test_ghostty_surface_read_call_count() == 1) + #expect(teardownBegun.withLock { $0 } == 1) + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 4081a9f55e17..e996256f9836 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -49,6 +49,9 @@ static bool cmux_test_surface_read_should_block = false; static bool cmux_test_surface_read_started = false; static bool cmux_test_surface_read_released = false; static void* cmux_test_surface_read_target = NULL; +static uint32_t cmux_test_surface_read_call_count = 0; +static uint32_t cmux_test_surface_read_active_call_count = 0; +static uint32_t cmux_test_surface_read_maximum_concurrent_call_count = 0; static struct timespec cmux_test_surface_free_timeout(void) { return (struct timespec) { @@ -123,6 +126,9 @@ void cmux_test_ghostty_surface_read_blocking_begin(void *surface) { cmux_test_surface_read_started = false; cmux_test_surface_read_released = false; cmux_test_surface_read_target = surface; + cmux_test_surface_read_call_count = 0; + cmux_test_surface_read_active_call_count = 0; + cmux_test_surface_read_maximum_concurrent_call_count = 0; pthread_mutex_unlock(&cmux_test_surface_read_mutex); } @@ -153,6 +159,42 @@ bool cmux_test_ghostty_surface_read_blocking_is_active(void) { return active; } +bool cmux_test_ghostty_surface_read_wait_until_call_count( + uint32_t expected_count, + uint32_t timeout_milliseconds +) { + const struct timespec timeout = { + .tv_sec = timeout_milliseconds / 1000, + .tv_nsec = (timeout_milliseconds % 1000) * 1000000, + }; + pthread_mutex_lock(&cmux_test_surface_read_mutex); + while (cmux_test_surface_read_call_count < expected_count) { + const int result = pthread_cond_timedwait_relative_np( + &cmux_test_surface_read_condition, + &cmux_test_surface_read_mutex, + &timeout + ); + if (result != 0) break; + } + const bool reached = cmux_test_surface_read_call_count >= expected_count; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return reached; +} + +uint32_t cmux_test_ghostty_surface_read_call_count(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + const uint32_t count = cmux_test_surface_read_call_count; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return count; +} + +uint32_t cmux_test_ghostty_surface_read_maximum_concurrent_call_count(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + const uint32_t count = cmux_test_surface_read_maximum_concurrent_call_count; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return count; +} + void cmux_test_ghostty_surface_read_release(void) { pthread_mutex_lock(&cmux_test_surface_read_mutex); cmux_test_surface_read_released = true; @@ -166,6 +208,9 @@ void cmux_test_ghostty_surface_read_blocking_reset(void) { cmux_test_surface_read_started = false; cmux_test_surface_read_released = true; cmux_test_surface_read_target = NULL; + cmux_test_surface_read_call_count = 0; + cmux_test_surface_read_active_call_count = 0; + cmux_test_surface_read_maximum_concurrent_call_count = 0; pthread_cond_broadcast(&cmux_test_surface_read_condition); pthread_mutex_unlock(&cmux_test_surface_read_mutex); } @@ -431,6 +476,14 @@ bool ghostty_surface_read_screen_tail_vt( const struct timespec timeout = cmux_test_surface_free_timeout(); pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_read_call_count += 1; + cmux_test_surface_read_active_call_count += 1; + if (cmux_test_surface_read_active_call_count + > cmux_test_surface_read_maximum_concurrent_call_count) { + cmux_test_surface_read_maximum_concurrent_call_count = + cmux_test_surface_read_active_call_count; + } + pthread_cond_broadcast(&cmux_test_surface_read_condition); if (cmux_test_surface_read_should_block && surface == cmux_test_surface_read_target) { cmux_test_surface_read_started = true; @@ -446,6 +499,8 @@ bool ghostty_surface_read_screen_tail_vt( cmux_test_surface_read_should_block = false; cmux_test_surface_read_target = NULL; } + cmux_test_surface_read_active_call_count -= 1; + pthread_cond_broadcast(&cmux_test_surface_read_condition); pthread_mutex_unlock(&cmux_test_surface_read_mutex); return false; } diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 7c592d0c8a64..237bdce22ade 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -114,6 +114,11 @@ void cmux_test_ghostty_surface_free_blocking_reset(void); void cmux_test_ghostty_surface_read_blocking_begin(void *surface); bool cmux_test_ghostty_surface_read_wait_until_started(void); bool cmux_test_ghostty_surface_read_blocking_is_active(void); +bool cmux_test_ghostty_surface_read_wait_until_call_count( + uint32_t expected_count, + uint32_t timeout_milliseconds); +uint32_t cmux_test_ghostty_surface_read_call_count(void); +uint32_t cmux_test_ghostty_surface_read_maximum_concurrent_call_count(void); void cmux_test_ghostty_surface_read_release(void); void cmux_test_ghostty_surface_read_blocking_reset(void); uint32_t cmux_test_ghostty_tty_name_call_count(void); From 35b87c0889356abc6b5f2630c5f2624883318945 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:32:51 -0700 Subject: [PATCH 31/39] fix: serialize native screen-tail reads --- .../TerminalSurfaceRuntimeScreenTailReader.swift | 16 ++++++++++++++++ ...rminalSurfaceRuntimeTeardownCoordinator.swift | 12 ++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift new file mode 100644 index 000000000000..2e2fc86ceffe --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift @@ -0,0 +1,16 @@ +/// Serializes bounded screen-tail formatting across the terminal runtime. +/// +/// Callers acquire a native-access borrow before their first suspension. The +/// reader owns admission after that suspension so remote disconnects cannot +/// fan out one blocking Ghostty formatter per surface. Cancelled queued reads +/// release their borrow without entering the native runtime. +actor TerminalSurfaceRuntimeScreenTailReader { + func read( + _ request: TerminalSurfaceRuntimeScreenTailRequest, + borrow: TerminalSurfaceRuntimeNativeAccessBorrow + ) -> String? { + defer { borrow.release() } + guard !Task.isCancelled else { return nil } + return request.read() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 0ac7b800636a..f3c199d283bd 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -41,6 +41,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private let isolatedHibernationQueues: [DispatchQueue] private nonisolated let beginSurfaceTeardown: @Sendable (ghostty_surface_t) -> Void + private nonisolated let screenTailReader = + TerminalSurfaceRuntimeScreenTailReader() private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() @@ -98,18 +100,12 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { request.nativeAccessGate.acquireBorrow() } - /// Reads a bounded screen tail away from the main actor under an admitted borrow. -#if compiler(>=6.2) - @concurrent -#else - @Sendable -#endif + /// Reads a bounded screen tail under globally serialized native admission. nonisolated func readScreenTailVT( _ request: TerminalSurfaceRuntimeScreenTailRequest, borrow: TerminalSurfaceRuntimeNativeAccessBorrow ) async -> String? { - defer { borrow.release() } - return request.read() + await screenTailReader.read(request, borrow: borrow) } /// Queues a native-surface free from any isolation (the surface model's From c3d4e129914c58c95f795df3d9225cc01fedc6e7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:47:08 -0700 Subject: [PATCH 32/39] Update Ghostty process-group reuse safety --- docs/ghostty-fork.md | 42 +++++++++++++++++++++++++----------------- ghostty | 2 +- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index cee7de2a64b4..39dd7e64595c 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,12 +12,12 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `47e9bd4c9`, which treats the PTY's -foreground job-control group as a teardown target distinct from the original -session-leader group and drives both through one bounded shutdown state machine. -It builds on `81b4de4f5`, which preserves the stable POSIX process-group identity -after its direct-child leader exits and sends each graceful/escalation signal -exactly once. It includes `d462c1d97`, the fork-main merge of the Hangul +The submodule pinned by this branch is `90ba327fc`, which keeps process-group +teardown fail-closed after the direct child has been reaped: a cached numeric +group id is no longer trusted, while a foreground group freshly observed +through the retained PTY remains eligible for bounded shutdown. It builds on +`f66cfbd6f`, which integrates descendant and foreground job-control group +reaping into fork `main`. It includes `d462c1d97`, the fork-main merge of the Hangul NFC/NFD font-resolution integration from https://github.com/manaflow-ai/ghostty/pull/185, including its test at `0316a8de8` and fix at `3fbdd078d`. It also includes `9513174f2`, the @@ -106,6 +106,7 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - https://github.com/manaflow-ai/ghostty/pull/187 - https://github.com/manaflow-ai/ghostty/pull/188 - https://github.com/manaflow-ai/ghostty/pull/192 + - https://github.com/manaflow-ai/ghostty/pull/193 - Commits: - `9be0c8b93` (test: cover subprocesses that ignore SIGHUP) - `5b20c6229` (fix: bound embedded surface process teardown) @@ -116,6 +117,9 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - `babe4266c` (test: cover distinct foreground process-group teardown) - `47e9bd4c9` (fix: reap foreground job-control process groups) - `f66cfbd6f` (merge: integrate descendant reaping into Ghostty `main`) + - `53239618f` (test: reject stale process-group teardown) + - `bc7e9f746` (fix: avoid signalling recycled process groups) + - `90ba327fc` (merge: integrate process-group reuse safety into Ghostty `main`) - Files: - `include/ghostty.h` - `src/Surface.zig` @@ -130,8 +134,13 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). polling liveness with signal `0` for 12 seconds. This preserves cmux's 10-second Claude `SessionEnd` hook budget even when interactive job control puts Claude in a group separate from its shell. - - Retains the process-group id independently from direct-child wait status, - so descendants remain addressable after the group leader has been reaped. + - Retains the process-group id while the direct child's `Command` remains + waitable, so descendants remain addressable without allowing that pid to be + recycled underneath teardown. + - After the process watcher has reaped the direct child, refuses to signal its + cached numeric group id. Only a foreground group freshly observed through + the retained PTY remains attributable to that runtime generation, preventing + delayed teardown from signalling an unrelated process group after id reuse. - Applies one shared graceful/escalation deadline to both groups, sends one SIGKILL to each surviving group, and bounds the final reap wait to three seconds, so a pathological foreground job cannot hold a native teardown @@ -143,20 +152,19 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. - Artifact: - - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-f66cfbd6f2d43ef903376b366476fa0af1d7d7d3-crashsubdir-cmux-crash-sentry-off-v1 - - SHA-256 `284d4af7516a50a893e1c03d6870c3b39ae8678aed16a1fad67475f1c32a1c83` - is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive - passed `scripts/validate-xcframework-archive.py`. + - Publication for `90ba327fc6e0ea614e59a25f3ee5133b91d459da` + is pending; pin its validated archive checksum before merging this branch. - Conflict note: - Preserve the two-phase contract during future embedded-surface or termio merges: the pre-free request must prevent new app-action retains, remain idempotent, and start IO-owned process teardown without freeing native state. Final free must still wait for existing action leases and release the surface - exactly once. POSIX process teardown must retain the stable session-leader - group id, capture and deduplicate the PTY foreground group before signalling, - keep separate direct-child and per-group liveness state, stop targeting a - foreground group after it disappears, and preserve the shared SIGHUP, - signal-`0` polling, SIGKILL, and bounded final-reap deadlines. + exactly once. POSIX process teardown may retain the stable session-leader + group id only while the direct child remains waitable; after the watcher + reaps it, only a foreground group freshly observed through the PTY may be + targeted. Preserve separate direct-child and per-group liveness state, stop + targeting a foreground group after it disappears, and preserve the shared + SIGHUP, signal-`0` polling, SIGKILL, and bounded final-reap deadlines. The renderer line was reviewed in https://github.com/manaflow-ai/ghostty/pull/168, following the merged diff --git a/ghostty b/ghostty index f66cfbd6f2d4..90ba327fc6e0 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit f66cfbd6f2d43ef903376b366476fa0af1d7d7d3 +Subproject commit 90ba327fc6e0ea614e59a25f3ee5133b91d459da From cb95bbb84ea9cc9591a1137ae0f095f1213410c8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:56:37 -0700 Subject: [PATCH 33/39] test: keep queued reads from delaying teardown --- .../TerminalSurfaceRuntimeNativeAccessTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 25dc69a1126e..e9246ab6de10 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -276,6 +276,10 @@ import Testing cancelledGate.requestTeardown { teardownBegun.withLock { $0 += 1 } } + #expect( + teardownBegun.withLock { $0 } == 1, + "a read queued behind another surface deferred process teardown" + ) cmux_test_ghostty_surface_read_release() _ = await firstRead.value From 0c6f5242c8aadce2763927068f83db428aef7186 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 19:02:33 -0700 Subject: [PATCH 34/39] fix: acquire native borrow after read admission --- ...rminalSurfaceRuntimeScreenTailReader.swift | 18 +++++----- ...minalSurfaceRuntimeScreenTailRequest.swift | 10 +++--- ...nalSurfaceRuntimeTeardownCoordinator.swift | 15 ++------ .../TerminalSurface+ScreenSnapshot.swift | 7 +--- ...minalSurfaceRuntimeNativeAccessTests.swift | 34 ++++--------------- 5 files changed, 24 insertions(+), 60 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift index 2e2fc86ceffe..83a9429c6077 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailReader.swift @@ -1,16 +1,16 @@ /// Serializes bounded screen-tail formatting across the terminal runtime. /// -/// Callers acquire a native-access borrow before their first suspension. The -/// reader owns admission after that suspension so remote disconnects cannot -/// fan out one blocking Ghostty formatter per surface. Cancelled queued reads -/// release their borrow without entering the native runtime. +/// Requests may wait here without borrowing their surface, so one blocked +/// formatter cannot defer another surface's process teardown. After admission, +/// the reader atomically acquires that runtime generation before dereferencing +/// its pointer. Cancelled or already-closing requests never enter Ghostty. actor TerminalSurfaceRuntimeScreenTailReader { - func read( - _ request: TerminalSurfaceRuntimeScreenTailRequest, - borrow: TerminalSurfaceRuntimeNativeAccessBorrow - ) -> String? { + func read(_ request: TerminalSurfaceRuntimeScreenTailRequest) -> String? { + guard !Task.isCancelled, + let borrow = request.nativeAccessGate.acquireBorrow() else { + return nil + } defer { borrow.release() } - guard !Task.isCancelled else { return nil } return request.read() } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift index f4bb23086456..db1d82183baa 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeScreenTailRequest.swift @@ -3,11 +3,11 @@ internal import GhosttyKit /// A bounded native screen-tail read ordered before later teardown requests. /// -/// The raw surface pointer remains owned by its ``TerminalSurface``. The caller -/// admits a native-access borrow before its first suspension, and the runtime -/// coordinator executes this request without suspension while that borrow -/// defers process termination and native free. `@unchecked Sendable` is limited -/// to transporting the borrowed pointer onto the concurrent read executor. +/// The raw surface pointer remains owned by its ``TerminalSurface``. The request +/// carries the matching runtime-generation gate while it waits for global read +/// admission. The reader must acquire that gate before dereferencing the pointer; +/// a teardown that wins first permanently rejects the read. `@unchecked Sendable` +/// is limited to transporting that guarded pointer to the reader actor. struct TerminalSurfaceRuntimeScreenTailRequest: @unchecked Sendable { let surface: ghostty_surface_t let maxRows: Int diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index f3c199d283bd..e9bd7143a4e2 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -90,22 +90,11 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { isolatedHibernationAdmission.release(reservation) } - /// Admits a bounded screen-tail read before its first suspension. - /// - /// A close that wins admission first rejects the borrow. A borrow that wins - /// first defers both process termination and native free until release. - nonisolated func acquireScreenTailBorrow( - for request: TerminalSurfaceRuntimeScreenTailRequest - ) -> TerminalSurfaceRuntimeNativeAccessBorrow? { - request.nativeAccessGate.acquireBorrow() - } - /// Reads a bounded screen tail under globally serialized native admission. nonisolated func readScreenTailVT( - _ request: TerminalSurfaceRuntimeScreenTailRequest, - borrow: TerminalSurfaceRuntimeNativeAccessBorrow + _ request: TerminalSurfaceRuntimeScreenTailRequest ) async -> String? { - await screenTailReader.read(request, borrow: borrow) + await screenTailReader.read(request) } /// Queues a native-surface free from any isolation (the surface model's diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift index 64b3c264e255..4c7202bc81fd 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ScreenSnapshot.swift @@ -23,11 +23,6 @@ extension TerminalSurface { maxBytes: maxBytes, nativeAccessGate: runtimeNativeAccessGate ) - guard let borrow = runtimeTeardown.acquireScreenTailBorrow( - for: request - ) else { - return nil - } - return await runtimeTeardown.readScreenTailVT(request, borrow: borrow) + return await runtimeTeardown.readScreenTailVT(request) } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index e9246ab6de10..2eb5186adbb6 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -65,7 +65,7 @@ import Testing nativeAccessGate: nativeAccessGate ) - #expect(coordinator.acquireScreenTailBorrow(for: request) == nil) + #expect(request.nativeAccessGate.acquireBorrow() == nil) #expect(teardownBegun.withLock { $0 } == 1) releaseNativeFree.signal() @@ -98,11 +98,8 @@ import Testing maxBytes: 1, nativeAccessGate: nativeAccessGate ) - let borrow = try #require( - coordinator.acquireScreenTailBorrow(for: request) - ) let readTask = Task { - await coordinator.readScreenTailVT(request, borrow: borrow) + await coordinator.readScreenTailVT(request) } let readStarted = AsyncStream.makeStream() DispatchQueue.global(qos: .userInitiated).async { @@ -168,15 +165,8 @@ import Testing maxBytes: 1, nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate() ) - let firstBorrow = try #require( - coordinator.acquireScreenTailBorrow(for: firstRequest) - ) - let secondBorrow = try #require( - coordinator.acquireScreenTailBorrow(for: secondRequest) - ) - let firstRead = Task { - await coordinator.readScreenTailVT(firstRequest, borrow: firstBorrow) + await coordinator.readScreenTailVT(firstRequest) } let firstReadStarted = AsyncStream.makeStream() DispatchQueue.global(qos: .userInitiated).async { @@ -189,7 +179,7 @@ import Testing try #require(await firstReadStartedIterator.next() == true) let secondRead = Task(priority: .high) { - await coordinator.readScreenTailVT(secondRequest, borrow: secondBorrow) + await coordinator.readScreenTailVT(secondRequest) } let overlapProbe = AsyncStream.makeStream() DispatchQueue.global(qos: .userInitiated).async { @@ -218,7 +208,7 @@ import Testing ) } - @Test func cancelledQueuedScreenTailReadReleasesBorrowWithoutNativeAccess() async throws { + @Test func cancelledQueuedScreenTailReadDoesNotAcquireNativeAccess() async throws { let teardownBegun = OSAllocatedUnfairLock(initialState: 0) let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let firstSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) @@ -244,15 +234,8 @@ import Testing maxBytes: 1, nativeAccessGate: cancelledGate ) - let firstBorrow = try #require( - coordinator.acquireScreenTailBorrow(for: firstRequest) - ) - let cancelledBorrow = try #require( - coordinator.acquireScreenTailBorrow(for: cancelledRequest) - ) - let firstRead = Task { - await coordinator.readScreenTailVT(firstRequest, borrow: firstBorrow) + await coordinator.readScreenTailVT(firstRequest) } let firstReadStarted = AsyncStream.makeStream() DispatchQueue.global(qos: .userInitiated).async { @@ -268,10 +251,7 @@ import Testing withUnsafeCurrentTask { task in task?.cancel() } - return await coordinator.readScreenTailVT( - cancelledRequest, - borrow: cancelledBorrow - ) + return await coordinator.readScreenTailVT(cancelledRequest) } cancelledGate.requestTeardown { teardownBegun.withLock { $0 += 1 } From 01720c72a7d9648a968754a0fb3657f258679c18 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 19:05:10 -0700 Subject: [PATCH 35/39] build: pin process-group safety GhosttyKit --- docs/ghostty-fork.md | 6 ++++-- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 39dd7e64595c..1d38dd818c27 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -152,8 +152,10 @@ gitlinks (`cd1f8e012` and `80d7fb35a`). - Keeps `ghostty_surface_free` as the final synchronization and ownership boundary for renderer, IO, callback userdata, and native allocation release. - Artifact: - - Publication for `90ba327fc6e0ea614e59a25f3ee5133b91d459da` - is pending; pin its validated archive checksum before merging this branch. + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-90ba327fc6e0ea614e59a25f3ee5133b91d459da-crashsubdir-cmux-crash-sentry-off-v1 + - SHA-256 `98e0db46112be10781a593d8eb052a4e395bf78823e29e4bb22e2619c49a7060` + is pinned in `scripts/ghosttykit-checksums.txt`; the downloaded archive + passed `scripts/validate-xcframework-archive.py`. - Conflict note: - Preserve the two-phase contract during future embedded-surface or termio merges: the pre-free request must prevent new app-action retains, remain diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 870097f7481e..947174729e3a 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -120,3 +120,4 @@ f0f8273b700e754b3f0052cea033789aab902fd1 4ea556c6203c4757096093247ebbae85fc4650a 81b4de4f540eeea9be34574ffdd13ec51ee8a340 a95a372c23e9b791b2786d145fa8fef8eb09571ade914b55a600e175920792c7 47e9bd4c90dec35b55b550095eea14498a37508f f0b268f74bff5866fc8f28be701d57fdcd0cf143ad9629e397eb545a29aea99e f66cfbd6f2d43ef903376b366476fa0af1d7d7d3 284d4af7516a50a893e1c03d6870c3b39ae8678aed16a1fad67475f1c32a1c83 +90ba327fc6e0ea614e59a25f3ee5133b91d459da 98e0db46112be10781a593d8eb052a4e395bf78823e29e4bb22e2619c49a7060 From 742d82edaf638d840d1adf481c792fdf4127d584 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 19:20:26 -0700 Subject: [PATCH 36/39] ci: restore app-host validation from main --- scripts/ci/run-app-host-xcodebuild.sh | 80 +++++++++------------------ 1 file changed, 26 insertions(+), 54 deletions(-) diff --git a/scripts/ci/run-app-host-xcodebuild.sh b/scripts/ci/run-app-host-xcodebuild.sh index d0b84e5746bb..2e290694b8d3 100755 --- a/scripts/ci/run-app-host-xcodebuild.sh +++ b/scripts/ci/run-app-host-xcodebuild.sh @@ -110,25 +110,6 @@ kill_stale_app_host() { "$CMUX_RESOLVED_SYSTEM_TEMP_ROOT" } -canonicalize_existing_app_host_config_path() { - local path="$1" - if [ ! -e "$path" ] || [ -L "$path" ]; then - return 1 - fi - - if [ -d "$path" ]; then - (cd "$path" 2>/dev/null && pwd -P) - return - fi - - local parent name resolved_parent - parent="${path%/*}" - name="${path##*/}" - [ -n "$parent" ] || parent=/ - resolved_parent="$(cd "$parent" 2>/dev/null && pwd -P)" || return 1 - printf '%s/%s\n' "${resolved_parent%/}" "$name" -} - validate_app_host_config_paths() { local log_path="$1" local require_evidence="$2" @@ -139,19 +120,13 @@ validate_app_host_config_paths() { return 1 fi - # Ghostty may report either the published /tmp spelling or macOS's resolved - # /private/tmp spelling. Canonicalize the evidence so both aliases compare - # identically without weakening the symlink and scope checks. - local expected_config_path canonical_expected_config_path - expected_config_path="${app_host_home%/}/Library/Application Support/com.mitchellh.ghostty/config.ghostty" - canonical_expected_config_path="$( - canonicalize_existing_app_host_config_path "$expected_config_path" - )" || { - echo "FAIL: isolated app-host configuration sentinel is unavailable" >&2 - return 1 - } - local matches scan_status line reported_path canonical_reported_path - local found_expected_config_evidence=0 + # macOS resolves the published /tmp scope through /private/tmp, while + # Ghostty may report either spelling. Both roots were derived and validated + # above; keep the slash boundary so a same-prefix sibling is still rejected. + local published_expected_config_path resolved_expected_config_path + published_expected_config_path="${app_host_home_input%/}/Library/Application Support/com.mitchellh.ghostty/config.ghostty" + resolved_expected_config_path="${app_host_home%/}/Library/Application Support/com.mitchellh.ghostty/config.ghostty" + local matches scan_status line reported_path if matches="$(grep -E 'cmux DEV.*\[(config|default)\].*path=.*(Library/Application Support/com\.mitchellh\.ghostty/|/\.config/ghostty/)' "$log_path")"; then scan_status=0 else @@ -168,38 +143,35 @@ validate_app_host_config_paths() { if [ -n "$matches" ]; then while IFS= read -r line; do - line="${line%$'\r'}" reported_path="${line#*path=}" - canonical_reported_path="$( - canonicalize_existing_app_host_config_path "$reported_path" - )" || { - echo "FAIL: Ghostty accessed configuration outside the isolated app-host home" >&2 - echo "$line" >&2 - return 1 - } - case "$canonical_reported_path" in - "$app_host_home"|"${app_host_home%/}/"*) ;; + case "$reported_path" in + "$app_host_home"|"${app_host_home%/}/"* \ + |"$app_host_home_input"|"${app_host_home_input%/}/"*) ;; *) echo "FAIL: Ghostty accessed configuration outside the isolated app-host home" >&2 echo "$line" >&2 return 1 ;; esac - - case "$line" in - *"[default] reading configuration file path="*|*"[config] reading configuration file path="*) - if [ "$canonical_reported_path" = "$canonical_expected_config_path" ]; then - found_expected_config_evidence=1 - fi - ;; - esac done <<< "$matches" fi - if [ "$require_evidence" = "1" ] \ - && [ "$found_expected_config_evidence" != "1" ]; then - echo "FAIL: app-host configuration evidence is missing" >&2 - return 1 + if [ "$require_evidence" = "1" ]; then + if ! grep -Fq \ + "[default] reading configuration file path=$resolved_expected_config_path" \ + "$log_path" \ + && ! grep -Fq \ + "[config] reading configuration file path=$resolved_expected_config_path" \ + "$log_path" \ + && ! grep -Fq \ + "[default] reading configuration file path=$published_expected_config_path" \ + "$log_path" \ + && ! grep -Fq \ + "[config] reading configuration file path=$published_expected_config_path" \ + "$log_path"; then + echo "FAIL: app-host configuration evidence is missing" >&2 + return 1 + fi fi } From deee27d0e7e7dcc0d964980879e48a84ee2124ce Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 10 Aug 2026 15:54:45 -0700 Subject: [PATCH 37/39] fix: pair terminal native teardown operations --- ...TerminalSurfaceRuntimeNativeTeardown.swift | 19 +++ ...nalSurfaceRuntimeTeardownCoordinator.swift | 55 ++++--- ...erminalSurfaceRuntimeTeardownRequest.swift | 16 +- .../TerminalSurface+RuntimeLifecycle.swift | 10 +- .../Surface/TerminalSurface.swift | 5 +- ...minalSurfaceRuntimeNativeAccessTests.swift | 46 ++++-- ...rfaceRuntimeTeardownCoordinatorTests.swift | 146 +++++++++++------- ...SurfaceTeardownCallbackLifetimeTests.swift | 9 +- cmuxTests/TabManagerUnitTests.swift | 18 ++- 9 files changed, 212 insertions(+), 112 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeTeardown.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeTeardown.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeTeardown.swift new file mode 100644 index 000000000000..3be86387825d --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeNativeTeardown.swift @@ -0,0 +1,19 @@ +internal import GhosttyKit + +/// Couples native process shutdown with the matching final surface free. +/// +/// A runtime generation owns both operations as one value so a custom free +/// cannot accidentally invoke Ghostty's termination API on a foreign pointer. +struct TerminalSurfaceRuntimeNativeTeardown: Sendable { + let beginSurfaceTeardown: @Sendable (ghostty_surface_t) -> Void + let freeSurface: @Sendable (ghostty_surface_t) -> Void + + static let ghostty = TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: { surface in + ghostty_surface_request_process_termination(surface) + }, + freeSurface: { surface in + ghostty_surface_free(surface) + } + ) +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 787d05d59f8f..39c091953423 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -39,23 +39,13 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { private var availableCloseExecutionSlots: Set private let closeTeardownQueues: [DispatchQueue] private let isolatedHibernationQueues: [DispatchQueue] - private nonisolated let beginSurfaceTeardown: - @Sendable (ghostty_surface_t) -> Void private nonisolated let screenTailReader = TerminalSurfaceRuntimeScreenTailReader() private nonisolated let isolatedHibernationAdmission = TerminalSurfaceRuntimeTeardownAdmission() /// Creates the process's teardown coordinator. - /// - /// - Parameter beginSurfaceTeardown: A non-blocking native request that - /// retires the surface from process routing and starts child shutdown. - public init( - beginSurfaceTeardown: @escaping @Sendable (ghostty_surface_t) -> Void = { - ghostty_surface_request_process_termination($0) - } - ) { - self.beginSurfaceTeardown = beginSurfaceTeardown + public init() { availableCloseExecutionSlots = Set( 0..? + ) -> TerminalSurfaceRuntimeTeardownTicket { + enqueueRuntimeTeardown( + id: id, + workspaceId: workspaceId, + reason: reason, + surface: surface, + callbackContext: callbackContext, + manualIOContext: nil, + byteTeeLease: nil, + nativeTeardown: .ghostty + ) + } + + /// Queues a teardown using a paired native lifecycle implementation. + @discardableResult + nonisolated func enqueueRuntimeTeardown( id: UUID, workspaceId: UUID, reason: String, surface: ghostty_surface_t, callbackContext: Unmanaged?, - freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in - ghostty_surface_free(surface) - } + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown ) -> TerminalSurfaceRuntimeTeardownTicket { enqueueRuntimeTeardown( id: id, @@ -130,7 +137,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { callbackContext: callbackContext, manualIOContext: nil, byteTeeLease: nil, - freeSurface: freeSurface + nativeTeardown: nativeTeardown ) } @@ -157,8 +164,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { /// released on the main actor after the free completes. /// - byteTeeLease: The retained PTY tee lease, released on the main /// actor after the free completes. - /// - freeSurface: The free operation; defaults to - /// `ghostty_surface_free`. + /// - nativeTeardown: The paired process-shutdown and surface-free + /// operations for this runtime generation. /// - Returns: A ticket that completes after the native free and userdata releases. @discardableResult nonisolated func enqueueRuntimeTeardown( @@ -174,9 +181,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { executionLane: TerminalSurfaceRuntimeTeardownExecutionLane = .boundedClose, isolatedHibernationReservation: TerminalSurfaceRuntimeTeardownReservation? = nil, - freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in - ghostty_surface_free(surface) - } + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown = .ghostty ) -> TerminalSurfaceRuntimeTeardownTicket { let completion = TerminalSurfaceRuntimeTeardownCompletion() let ticket = TerminalSurfaceRuntimeTeardownTicket(completion: completion) @@ -189,7 +194,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: byteTeeLease, - freeSurface: freeSurface, + nativeTeardown: nativeTeardown, completion: completion ) request.nativeAccessGate.requestTeardown { @@ -197,7 +202,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { // precedes the Task hop. A pending borrow is the only reason to // defer termination, because Ghostty forbids surface API calls // after the termination request. - self.beginSurfaceTeardown(request.surface) + request.nativeTeardown.beginSurfaceTeardown(request.surface) Task { await self.enqueue( request, @@ -293,7 +298,7 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { "workspace=\(request.workspaceToken) reason=\(request.reason)" ) #endif - request.freeSurface(request.surface) + request.nativeTeardown.freeSurface(request.surface) } private nonisolated func finishFree( diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index d4d278176247..54a082764a03 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -1,12 +1,12 @@ -public import Foundation -public import GhosttyKit -public import CmuxTerminalCore +internal import Foundation +internal import GhosttyKit +internal import CmuxTerminalCore -/// A one-shot native-surface free queued on the teardown coordinator. +/// A one-shot native-surface teardown queued on the teardown coordinator. /// /// The native pointer has been removed from all main-thread owner state /// before this request is created; this wrapper only transports the one-shot -/// free. It is `@unchecked Sendable` for exactly that reason: the surface +/// teardown. It is `@unchecked Sendable` for exactly that reason: the surface /// pointer, the `Unmanaged` callback contexts, and the byte-tee lease are /// exclusively owned by the request from creation until the coordinator /// consumes them. @@ -25,7 +25,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let callbackContext: Unmanaged? let manualIOContext: Unmanaged? let byteTeeLease: (any TerminalByteTeeLease)? - let freeSurface: @Sendable (ghostty_surface_t) -> Void + let nativeTeardown: TerminalSurfaceRuntimeNativeTeardown let completion: TerminalSurfaceRuntimeTeardownCompletion #if DEBUG let surfaceToken: String @@ -41,7 +41,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { callbackContext: Unmanaged?, manualIOContext: Unmanaged?, byteTeeLease: (any TerminalByteTeeLease)?, - freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void, + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown, completion: TerminalSurfaceRuntimeTeardownCompletion ) { self.id = id @@ -52,7 +52,7 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { self.callbackContext = callbackContext self.manualIOContext = manualIOContext self.byteTeeLease = byteTeeLease - self.freeSurface = freeSurface + self.nativeTeardown = nativeTeardown self.completion = completion #if DEBUG self.surfaceToken = String(id.uuidString.prefix(5)) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 386b5bcd659f..ae684c797f98 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -321,7 +321,10 @@ extension TerminalSurface { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, - freeSurface: freeSurface + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: { _ in }, + freeSurface: freeSurface + ) ) return } @@ -424,7 +427,10 @@ extension TerminalSurface { byteTeeLease: teeLease, executionLane: .isolatedHibernation, isolatedHibernationReservation: teardownReservation, - freeSurface: freeSurface + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: { _ in }, + freeSurface: freeSurface + ) ) return true } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 3064b0b32508..fc1d13749e31 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -726,7 +726,10 @@ public final class TerminalSurface: Identifiable, ObservableObject { callbackContext: callbackContext, manualIOContext: manualIOContext, byteTeeLease: teeLease, - freeSurface: freeSurface + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: { _ in }, + freeSurface: freeSurface + ) ) return } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 2eb5186adbb6..52b7ac65de35 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -1,5 +1,6 @@ import Dispatch import Foundation +import GhosttyKit import GhosttyRuntimeTestStubs import os import Testing @@ -32,11 +33,7 @@ import Testing @Test func screenTailBorrowIsRejectedAfterTeardownStarts() async { let teardownBegun = OSAllocatedUnfairLock(initialState: 0) let releaseNativeFree = DispatchSemaphore(value: 0) - let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( - beginSurfaceTeardown: { _ in - teardownBegun.withLock { $0 += 1 } - } - ) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) let runtimeLifecycleId = UUID() let nativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() @@ -54,9 +51,14 @@ import Testing callbackContext: nil, manualIOContext: nil, byteTeeLease: nil, - freeSurface: { _ in - _ = releaseNativeFree.wait(timeout: .distantFuture) - } + nativeTeardown: nativeTeardown( + beginSurfaceTeardown: { _ in + teardownBegun.withLock { $0 += 1 } + }, + freeSurface: { _ in + _ = releaseNativeFree.wait(timeout: .distantFuture) + } + ) ) let request = TerminalSurfaceRuntimeScreenTailRequest( surface: surface, @@ -75,11 +77,7 @@ import Testing @Test func teardownWaitsForAnActiveScreenTailBorrow() async throws { let teardownBegun = OSAllocatedUnfairLock(initialState: 0) let nativeFreeCount = OSAllocatedUnfairLock(initialState: 0) - let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( - beginSurfaceTeardown: { _ in - teardownBegun.withLock { $0 += 1 } - } - ) + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) let runtimeLifecycleId = UUID() let nativeAccessGate = TerminalSurfaceRuntimeNativeAccessGate() @@ -121,9 +119,14 @@ import Testing callbackContext: nil, manualIOContext: nil, byteTeeLease: nil, - freeSurface: { _ in - nativeFreeCount.withLock { $0 += 1 } - } + nativeTeardown: nativeTeardown( + beginSurfaceTeardown: { _ in + teardownBegun.withLock { $0 += 1 } + }, + freeSurface: { _ in + nativeFreeCount.withLock { $0 += 1 } + } + ) ) #expect( @@ -268,4 +271,15 @@ import Testing #expect(cmux_test_ghostty_surface_read_call_count() == 1) #expect(teardownBegun.withLock { $0 } == 1) } + + /// Builds a paired fake native teardown without calling Ghostty. + private func nativeTeardown( + beginSurfaceTeardown: @escaping @Sendable (ghostty_surface_t) -> Void, + freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void + ) -> TerminalSurfaceRuntimeNativeTeardown { + TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: beginSurfaceTeardown, + freeSurface: freeSurface + ) + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index dd419a2ea28d..e46d1c3e1d36 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -1,5 +1,6 @@ import Dispatch import Foundation +import GhosttyKit import os import Testing @testable import CmuxTerminal @@ -83,10 +84,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test", surface: surface, callbackContext: nil, - freeSurface: { pointer in - let bits = UInt(bitPattern: pointer) - Task { await recorder.record(bits) } - } + nativeTeardown: nativeTeardown( + freeSurface: { pointer in + let bits = UInt(bitPattern: pointer) + Task { await recorder.record(bits) } + } + ) ) await recorder.waitForFreeCount(1) @@ -109,10 +112,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.batch", surface: surface, callbackContext: nil, - freeSurface: { pointer in - let bits = UInt(bitPattern: pointer) - Task { await recorder.record(bits) } - } + nativeTeardown: nativeTeardown( + freeSurface: { pointer in + let bits = UInt(bitPattern: pointer) + Task { await recorder.record(bits) } + } + ) ) } @@ -140,10 +145,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.stuckClose", surface: surfaces[0], callbackContext: nil, - freeSurface: { _ in - stuckFreeStarted.continuation.yield() - _ = releaseStuckFree.wait(timeout: .distantFuture) - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + stuckFreeStarted.continuation.yield() + _ = releaseStuckFree.wait(timeout: .distantFuture) + } + ) ) var stuckFreeIterator = stuckFreeStarted.stream.makeAsyncIterator() _ = await stuckFreeIterator.next() @@ -155,12 +162,14 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.laterClose", surface: surface, callbackContext: nil, - freeSurface: { pointer in - let bits = UInt(bitPattern: pointer) - freedSurfaceBits.withLock { - _ = $0.insert(bits) + nativeTeardown: nativeTeardown( + freeSurface: { pointer in + let bits = UInt(bitPattern: pointer) + freedSurfaceBits.withLock { + _ = $0.insert(bits) + } } - } + ) ) } @@ -182,14 +191,14 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec @Test func allBlockedCloseSlotsStillBeginLaterProcessTeardown() async throws { let begunSurfaceBits = OSAllocatedUnfairLock(initialState: Set()) - let coordinator = TerminalSurfaceRuntimeTeardownCoordinator( - beginSurfaceTeardown: { surface in - let surfaceBits = UInt(bitPattern: surface) - begunSurfaceBits.withLock { - _ = $0.insert(surfaceBits) - } + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let beginSurfaceTeardown: @Sendable (ghostty_surface_t) -> Void = { + surface in + let surfaceBits = UInt(bitPattern: surface) + begunSurfaceBits.withLock { + _ = $0.insert(surfaceBits) } - ) + } let surfaces = (0..<3).map { _ in UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) } @@ -210,12 +219,15 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.blockedCloseSlot", surface: surface, callbackContext: nil, - freeSurface: { pointer in - blockedFreeStarted.continuation.yield( - UInt(bitPattern: pointer) - ) - _ = releaseBlockedFrees.wait(timeout: .distantFuture) - } + nativeTeardown: nativeTeardown( + beginSurfaceTeardown: beginSurfaceTeardown, + freeSurface: { pointer in + blockedFreeStarted.continuation.yield( + UInt(bitPattern: pointer) + ) + _ = releaseBlockedFrees.wait(timeout: .distantFuture) + } + ) ) } var blockedFreeIterator = blockedFreeStarted.stream.makeAsyncIterator() @@ -228,9 +240,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.closeBeyondBlockedSlots", surface: surfaces[2], callbackContext: nil, - freeSurface: { _ in - laterFreeCount.withLock { $0 += 1 } - } + nativeTeardown: nativeTeardown( + beginSurfaceTeardown: beginSurfaceTeardown, + freeSurface: { _ in + laterFreeCount.withLock { $0 += 1 } + } + ) ) #expect( @@ -284,10 +299,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec byteTeeLease: nil, executionLane: .isolatedHibernation, isolatedHibernationReservation: isolatedReservation, - freeSurface: { _ in - isolatedFreeStarted.continuation.yield() - _ = releaseIsolatedFree.wait(timeout: .distantFuture) - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + isolatedFreeStarted.continuation.yield() + _ = releaseIsolatedFree.wait(timeout: .distantFuture) + } + ) ) var isolatedFreeIterator = isolatedFreeStarted.stream.makeAsyncIterator() _ = await isolatedFreeIterator.next() @@ -306,9 +323,11 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec byteTeeLease: nil, executionLane: .isolatedHibernation, isolatedHibernationReservation: secondReservation, - freeSurface: { _ in - secondIsolatedFreeCount.withLock { $0 += 1 } - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + secondIsolatedFreeCount.withLock { $0 += 1 } + } + ) ) let closeTicket = coordinator.enqueueRuntimeTeardown( id: UUID(), @@ -316,9 +335,11 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec reason: "test.close", surface: closeSurface, callbackContext: nil, - freeSurface: { _ in - closeFreeCount.withLock { $0 += 1 } - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + closeFreeCount.withLock { $0 += 1 } + } + ) ) #expect(await closeTicket.wait(timeout: .seconds(1))) @@ -370,10 +391,12 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec byteTeeLease: nil, executionLane: .isolatedHibernation, isolatedHibernationReservation: blockingReservation, - freeSurface: { _ in - isolatedFreeStarted.continuation.yield() - _ = releaseIsolatedFree.wait(timeout: .distantFuture) - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + isolatedFreeStarted.continuation.yield() + _ = releaseIsolatedFree.wait(timeout: .distantFuture) + } + ) ) var isolatedFreeIterator = isolatedFreeStarted.stream.makeAsyncIterator() _ = await isolatedFreeIterator.next() @@ -388,9 +411,11 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec byteTeeLease: nil, executionLane: .isolatedHibernation, isolatedHibernationReservation: staleReservation, - freeSurface: { _ in - freeCount.withLock { $0 += 1 } - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + freeCount.withLock { $0 += 1 } + } + ) ) #expect(await ticket.wait(timeout: .seconds(1))) @@ -416,9 +441,11 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec callbackContext: nil, manualIOContext: nil, byteTeeLease: lease, - freeSurface: { _ in - recorder.record("surface.free") - } + nativeTeardown: nativeTeardown( + freeSurface: { _ in + recorder.record("surface.free") + } + ) ) for await event in recorder.events where event == "tee.release" { @@ -426,4 +453,17 @@ private final class LifetimeRecordingByteTeeLease: TerminalByteTeeLease, @unchec } #expect(recorder.snapshot() == ["surface.free", "tee.release"]) } + + /// Builds a paired fake native teardown without calling Ghostty. + private func nativeTeardown( + beginSurfaceTeardown: @escaping @Sendable (ghostty_surface_t) -> Void = { + _ in + }, + freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void + ) -> TerminalSurfaceRuntimeNativeTeardown { + TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: beginSurfaceTeardown, + freeSurface: freeSurface + ) + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index 74eba5afb95f..d9b2d34aab55 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -318,9 +318,12 @@ import Testing callbackContext: nil, manualIOContext: nil, byteTeeLease: RecordingTerminalByteTeeLease(recorder: recorder), - freeSurface: { _ in - recorder.record(.nativeFree) - } + nativeTeardown: TerminalSurfaceRuntimeNativeTeardown( + beginSurfaceTeardown: { _ in }, + freeSurface: { _ in + recorder.record(.nativeFree) + } + ) ) let completed = await recorder.waitForEventCount(2) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 9d0e3e81e3d2..44fd09217a5b 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -10,7 +10,7 @@ import UserNotifications import CmuxGit import CmuxSidebarGit import CmuxSidebar -import CmuxTerminal +@testable import CmuxTerminal import CmuxSettings #if canImport(cmux_DEV) @@ -1566,14 +1566,24 @@ final class TabManagerCloseCurrentTabSpamTests: XCTestCase { return } - let fakeSurface: ghostty_surface_t = UnsafeMutableRawPointer(bitPattern: 0x5282)! - terminalPanel.surface.installRuntimeSurfaceForTesting(fakeSurface) + let runtimeReady = expectation( + forNotification: .terminalSurfaceDidBecomeReady, + object: terminalPanel.surface + ) + terminalPanel.surface.createSurface(for: terminalPanel.surface.surfaceView) + wait(for: [runtimeReady], timeout: 5.0) + guard terminalPanel.surface.surface != nil else { + XCTFail("Expected a live terminal runtime surface") + return + } terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) let nativeFreeStarted = expectation(description: "native free started") - TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { _ in + TerminalSurface.runtimeSurfaceFreeOverrideForTesting = { runtimeSurface in XCTAssertFalse(Thread.isMainThread, "Native surface free must not run on the main thread") nativeFreeStarted.fulfill() + ghostty_surface_request_process_termination(runtimeSurface) + ghostty_surface_free(runtimeSurface) } defer { TerminalSurface.runtimeSurfaceFreeOverrideForTesting = nil From 9ac0ef5e25019fc167236e92d0526a2cbf0c6f48 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 10 Aug 2026 16:32:47 -0700 Subject: [PATCH 38/39] test: cover native read cleanup and hook timeout --- ...minalSurfaceRuntimeNativeAccessTests.swift | 20 ++++++++++ .../GhosttyRuntimeTestStubs.c | 37 ++++++++++++++++++- .../include/GhosttyRuntimeTestStubs.h | 2 + tests/test_claude_wrapper_hooks.py | 17 ++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift index 52b7ac65de35..ee69920b4091 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeNativeAccessTests.swift @@ -144,6 +144,26 @@ import Testing #expect(nativeFreeCount.withLock { $0 } == 1) } + @Test func successfulScreenTailReadReturnsUTF8AndFreesNativeText() async { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + cmux_test_ghostty_surface_read_success_begin() + defer { + cmux_test_ghostty_surface_read_blocking_reset() + surface.deallocate() + } + + let request = TerminalSurfaceRuntimeScreenTailRequest( + surface: surface, + maxRows: 1, + maxBytes: 64, + nativeAccessGate: TerminalSurfaceRuntimeNativeAccessGate() + ) + + #expect(await coordinator.readScreenTailVT(request) == "screen tail ✓") + #expect(cmux_test_ghostty_surface_free_text_call_count() == 1) + } + @Test func screenTailReadsDoNotOverlapNativeFormatting() async throws { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() let firstSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 51f25594117b..247267d0c727 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -52,6 +52,8 @@ static void* cmux_test_surface_read_target = NULL; static uint32_t cmux_test_surface_read_call_count = 0; static uint32_t cmux_test_surface_read_active_call_count = 0; static uint32_t cmux_test_surface_read_maximum_concurrent_call_count = 0; +static bool cmux_test_surface_read_should_succeed = false; +static uint32_t cmux_test_surface_free_text_call_count = 0; static struct timespec cmux_test_surface_free_timeout(void) { return (struct timespec) { @@ -218,10 +220,26 @@ void cmux_test_ghostty_surface_read_blocking_reset(void) { cmux_test_surface_read_call_count = 0; cmux_test_surface_read_active_call_count = 0; cmux_test_surface_read_maximum_concurrent_call_count = 0; + cmux_test_surface_read_should_succeed = false; + cmux_test_surface_free_text_call_count = 0; pthread_cond_broadcast(&cmux_test_surface_read_condition); pthread_mutex_unlock(&cmux_test_surface_read_mutex); } +void cmux_test_ghostty_surface_read_success_begin(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_read_should_succeed = true; + cmux_test_surface_free_text_call_count = 0; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); +} + +uint32_t cmux_test_ghostty_surface_free_text_call_count(void) { + pthread_mutex_lock(&cmux_test_surface_read_mutex); + const uint32_t count = cmux_test_surface_free_text_call_count; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); + return count; +} + void cmux_test_ghostty_renderer_realized_begin(void* surface) { cmux_test_renderer_realized_target = surface; cmux_test_renderer_realized_call_count = 0; @@ -440,7 +458,13 @@ void ghostty_surface_request_process_termination(void *surface) { } void ghostty_surface_free_text(void *surface, ghostty_text_s *text) { (void)surface; - (void)text; + if (text == NULL || text->text == NULL) return; + + free((void *)text->text); + memset(text, 0, sizeof(*text)); + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_free_text_call_count += 1; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); } float ghostty_surface_font_size(void *surface) { return surface == cmux_test_font_surface @@ -507,9 +531,18 @@ bool ghostty_surface_read_screen_tail_vt( cmux_test_surface_read_target = NULL; } cmux_test_surface_read_active_call_count -= 1; + const bool should_succeed = cmux_test_surface_read_should_succeed; pthread_cond_broadcast(&cmux_test_surface_read_condition); pthread_mutex_unlock(&cmux_test_surface_read_mutex); - return false; + + if (!should_succeed || text == NULL) return false; + static const char success_text[] = "screen tail \xE2\x9C\x93"; + char *owned_text = malloc(sizeof(success_text) - 1); + if (owned_text == NULL) return false; + memcpy(owned_text, success_text, sizeof(success_text) - 1); + text->text = owned_text; + text->text_len = sizeof(success_text) - 1; + return true; } void ghostty_surface_read_text(void) {} void ghostty_surface_refresh(void) {} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 3789413b4981..da5149442772 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -122,6 +122,8 @@ uint32_t cmux_test_ghostty_surface_read_call_count(void); uint32_t cmux_test_ghostty_surface_read_maximum_concurrent_call_count(void); void cmux_test_ghostty_surface_read_release(void); void cmux_test_ghostty_surface_read_blocking_reset(void); +void cmux_test_ghostty_surface_read_success_begin(void); +uint32_t cmux_test_ghostty_surface_free_text_call_count(void); uint32_t cmux_test_ghostty_tty_name_call_count(void); void cmux_test_ghostty_renderer_realized_begin(void *surface); void cmux_test_ghostty_renderer_realized_reset(void); diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 59d44540e362..8e3c7e623bda 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -616,9 +616,22 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: ) # Claude's SessionEnd lifecycle is part of orderly PTY teardown, so it # needs the same bounded callback budget as the other lifecycle hooks. - session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}]) + session_end_hooks = [ + hook + for group in hooks.get("SessionEnd", []) + for hook in group.get("hooks", []) + ] + session_end_hook = next( + ( + hook + for hook in session_end_hooks + if hook.get("command") + == '"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}" hooks claude session-end' + ), + None, + ) expect( - any(h.get("timeout") == 10 for h in session_end_hooks), + session_end_hook is not None and session_end_hook.get("timeout") == 10, f"SessionEnd hook should have a 10-second timeout, got {session_end_hooks}", failures, ) From f1335e6c0d0b7a306e2ac7957f43578e5480956b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 10 Aug 2026 16:58:54 -0700 Subject: [PATCH 39/39] test: count every native text free invocation --- .../Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 247267d0c727..14b14d76081d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -458,13 +458,13 @@ void ghostty_surface_request_process_termination(void *surface) { } void ghostty_surface_free_text(void *surface, ghostty_text_s *text) { (void)surface; + pthread_mutex_lock(&cmux_test_surface_read_mutex); + cmux_test_surface_free_text_call_count += 1; + pthread_mutex_unlock(&cmux_test_surface_read_mutex); if (text == NULL || text->text == NULL) return; free((void *)text->text); memset(text, 0, sizeof(*text)); - pthread_mutex_lock(&cmux_test_surface_read_mutex); - cmux_test_surface_free_text_call_count += 1; - pthread_mutex_unlock(&cmux_test_surface_read_mutex); } float ghostty_surface_font_size(void *surface) { return surface == cmux_test_font_surface