From 213bda5e142b2ce86c7d65460d477b7fce2ebc76 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 25 Feb 2026 15:36:51 -0800 Subject: [PATCH] Fix sidebar git branch recovery after sleep/wake (#494) --- .../cmux-bash-integration.bash | 33 +++- .../cmux-zsh-integration.zsh | 35 +++- Sources/AppDelegate.swift | 49 ++++-- ...ssue_494_sleep_wake_git_branch_recovery.py | 166 ++++++++++++++++++ 4 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 tests/test_issue_494_sleep_wake_git_branch_recovery.py diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 3a1c2428..bf407773 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -3,9 +3,9 @@ _cmux_send() { local payload="$1" if command -v ncat >/dev/null 2>&1; then - printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only + printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only elif command -v socat >/dev/null 2>&1; then - printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH" + printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" elif command -v nc >/dev/null 2>&1; then # Some nc builds don't support unix sockets, but keep as a last-ditch fallback. # @@ -40,9 +40,12 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" +_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" _CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" _CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" _CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" +_CMUX_PR_JOB_STARTED_AT="${_CMUX_PR_JOB_STARTED_AT:-0}" +_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" @@ -82,6 +85,28 @@ _cmux_prompt_command() { local now=$SECONDS local pwd="$PWD" + # Post-wake socket writes can occasionally leave a probe process wedged. + # If one probe is stale, clear the guard so fresh async probes can resume. + if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + fi + fi + + if [[ -n "$_CMUX_PR_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 + elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 + fi + fi + # Resolve TTY name once. if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -109,6 +134,7 @@ _cmux_prompt_command() { if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 fi fi @@ -128,6 +154,7 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_GIT_JOB_PID=$! + _CMUX_GIT_JOB_STARTED_AT=$now fi # Pull request metadata (number/state/url): @@ -136,6 +163,7 @@ _cmux_prompt_command() { if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 fi fi @@ -169,6 +197,7 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_PR_JOB_PID=$! + _CMUX_PR_JOB_STARTED_AT=$now fi fi diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 323ff506..29a4be37 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -4,9 +4,9 @@ _cmux_send() { local payload="$1" if command -v ncat >/dev/null 2>&1; then - print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only + print -r -- "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only elif command -v socat >/dev/null 2>&1; then - print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH" + print -r -- "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" elif command -v nc >/dev/null 2>&1; then # Some nc builds don't support unix sockets, but keep as a last-ditch fallback. # @@ -41,6 +41,7 @@ typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD="" typeset -g _CMUX_GIT_LAST_RUN=0 typeset -g _CMUX_GIT_JOB_PID="" +typeset -g _CMUX_GIT_JOB_STARTED_AT=0 typeset -g _CMUX_GIT_FORCE=0 typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" @@ -49,7 +50,9 @@ typeset -g _CMUX_HAVE_ZSTAT=0 typeset -g _CMUX_PR_LAST_PWD="" typeset -g _CMUX_PR_LAST_RUN=0 typeset -g _CMUX_PR_JOB_PID="" +typeset -g _CMUX_PR_JOB_STARTED_AT=0 typeset -g _CMUX_PR_FORCE=0 +typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 @@ -188,6 +191,30 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + # Post-wake socket writes can occasionally leave a probe process wedged. + # If one probe is stale, clear the guard so fresh async probes can resume. + if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 + _CMUX_GIT_FORCE=1 + fi + fi + + if [[ -n "$_CMUX_PR_JOB_PID" ]]; then + if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 + elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 + _CMUX_PR_FORCE=1 + fi + fi + # CWD: keep the app in sync with the actual shell directory. # This is also the simplest way to test sidebar directory behavior end-to-end. if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then @@ -242,6 +269,7 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]] || (( _CMUX_GIT_FORCE )); then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" + _CMUX_GIT_JOB_STARTED_AT=0 else can_launch_git=0 fi @@ -264,6 +292,7 @@ _cmux_precmd() { fi } >/dev/null 2>&1 &! _CMUX_GIT_JOB_PID=$! + _CMUX_GIT_JOB_STARTED_AT=$now fi fi @@ -285,6 +314,7 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true _CMUX_PR_JOB_PID="" + _CMUX_PR_JOB_STARTED_AT=0 else can_launch_pr=0 fi @@ -321,6 +351,7 @@ _cmux_precmd() { fi } >/dev/null 2>&1 &! _CMUX_PR_JOB_PID=$! + _CMUX_PR_JOB_STARTED_AT=$now fi fi diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 219d5b19..ca35edfb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1535,6 +1535,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } lifecycleSnapshotObservers.append(sessionResignObserver) + + let didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfEnabled(source: "workspace.didWake") + } + } + lifecycleSnapshotObservers.append(didWakeObserver) + } + + private func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { return nil } + return (mode: mode, path: SocketControlSettings.socketPath()) + } + + private func restartSocketListenerIfEnabled(source: String) { + guard let tabManager, + let config = socketListenerConfigurationIfEnabled() else { return } + sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ + "mode": config.mode.rawValue, + "path": config.path, + "source": source + ]) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode) } private func disableSuddenTerminationIfNeeded() { @@ -3231,28 +3263,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @objc func restartSocketListener(_ sender: Any?) { - guard let tabManager else { + guard tabManager != nil else { NSSound.beep() return } - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - guard mode != .off else { + guard socketListenerConfigurationIfEnabled() != nil else { TerminalController.shared.stop() NSSound.beep() return } - - let socketPath = SocketControlSettings.socketPath() - sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ - "mode": mode.rawValue, - "path": socketPath - ]) - TerminalController.shared.stop() - TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode) + restartSocketListenerIfEnabled(source: "menu.command") } private func setupMenuBarExtra() { diff --git a/tests/test_issue_494_sleep_wake_git_branch_recovery.py b/tests/test_issue_494_sleep_wake_git_branch_recovery.py new file mode 100644 index 00000000..9830b36c --- /dev/null +++ b/tests/test_issue_494_sleep_wake_git_branch_recovery.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Regression guard for issue #494 (post-wake sidebar git updates freezing).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + + required_paths = [zsh_path, bash_path, app_delegate_path] + missing_paths = [str(path) for path in required_paths if not path.exists()] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + zsh_content = read_text(zsh_path) + bash_content = read_text(bash_path) + app_delegate = read_text(app_delegate_path) + + failures: list[str] = [] + + require( + zsh_content, + "_CMUX_GIT_JOB_STARTED_AT", + "zsh integration is missing git probe start tracking", + failures, + ) + require( + zsh_content, + "_CMUX_PR_JOB_STARTED_AT", + "zsh integration is missing PR probe start tracking", + failures, + ) + require( + zsh_content, + "_CMUX_ASYNC_JOB_TIMEOUT", + "zsh integration is missing async probe timeout guard", + failures, + ) + require( + zsh_content, + "now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", + "zsh integration no longer clears stale git probe PID after timeout", + failures, + ) + require( + zsh_content, + "now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", + "zsh integration no longer clears stale PR probe PID after timeout", + failures, + ) + require( + zsh_content, + "ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only", + "zsh integration missing ncat socket timeout", + failures, + ) + require( + zsh_content, + "socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"", + "zsh integration missing socat socket timeout", + failures, + ) + + require( + bash_content, + "_CMUX_GIT_JOB_STARTED_AT", + "bash integration is missing git probe start tracking", + failures, + ) + require( + bash_content, + "_CMUX_PR_JOB_STARTED_AT", + "bash integration is missing PR probe start tracking", + failures, + ) + require( + bash_content, + "_CMUX_ASYNC_JOB_TIMEOUT", + "bash integration is missing async probe timeout guard", + failures, + ) + require( + bash_content, + "now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", + "bash integration no longer clears stale git probe PID after timeout", + failures, + ) + require( + bash_content, + "now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT", + "bash integration no longer clears stale PR probe PID after timeout", + failures, + ) + require( + bash_content, + "ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only", + "bash integration missing ncat socket timeout", + failures, + ) + require( + bash_content, + "socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"", + "bash integration missing socat socket timeout", + failures, + ) + + require( + app_delegate, + "NSWorkspace.didWakeNotification", + "AppDelegate is missing wake observer for socket listener recovery", + failures, + ) + require( + app_delegate, + "restartSocketListenerIfEnabled(source: \"workspace.didWake\")", + "Wake observer no longer re-arms the socket listener", + failures, + ) + require( + app_delegate, + "private func restartSocketListenerIfEnabled(source: String)", + "Missing shared socket-listener restart helper", + failures, + ) + + if failures: + print("FAIL: issue #494 regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: issue #494 sleep/wake recovery guards are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())