-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Fix Claude no-flicker sidebar status routing #6058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
austinywang
wants to merge
61
commits into
main
Choose a base branch
from
issue-6048-no-flicker-sidebar-status
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 23 commits
Commits
Show all changes
61 commits
Select commit
Hold shift + click to select a range
a02fc02
test: cover Claude no-flicker stale surface status
austinywang 34c734e
fix: route Claude hook status through owning pane
austinywang d20e8b1
ci: refresh Swift file length budget
austinywang 326c188
fix: avoid PID snapshot during Claude cleanup routing
austinywang bcaab63
fix: avoid workspace probe in Claude binding correction
austinywang af1ee9c
test: cover Claude PID-only stale surface routing
austinywang 7f189b0
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang 6fac6a3
fix: bound Claude hook process binding lookup
austinywang 63a7fd0
merge: sync with origin main
austinywang 7ca7953
fix: validate ambient Claude workspace fallback
austinywang 854277a
fix: unwrap Claude binding source
austinywang bd79085
test: move Claude no-flicker coverage to Swift Testing
austinywang 564dbbb
fix: validate PID-bound Claude workspace
austinywang 251061f
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang ec79ecd
fix: harden Claude hook routing tests
austinywang 38bdb27
fix: isolate Claude process snapshot socket
austinywang 3c824d7
fix: authenticate Claude snapshot socket
austinywang a36582e
test: cover Claude no-flicker binding races
austinywang d27c0c8
fix: validate Claude hook terminal binding
austinywang b4c5af3
test: cover stale Claude session workspace
austinywang 56b31ce
fix: ignore stale Claude session workspace
austinywang 5d13d75
test: cover stale Claude session surface
austinywang 8e22c0f
fix: prefer Claude PID for stale stored surface
austinywang 52b3d7e
test: cover transient Claude workspace probe
austinywang 936e857
fix: keep Claude target on transient probe failure
austinywang 525d538
fix: allow PID-bound Claude hooks without env target
austinywang 91399f2
fix: restrict Claude hook target recovery metadata
austinywang 6543d16
test: cover live Claude PID over stored PID
austinywang ec4680e
fix: prefer live Claude hook PID
austinywang 91cb753
fix: let live Claude binding override stored surface
austinywang f9a5e4e
test: cover Claude PID persistence refresh
austinywang 89d185c
fix: refresh stored Claude hook PID
austinywang 8a74a9e
merge: sync with origin main
austinywang 0b1e4db
test: expand Claude live PID assertions
austinywang 82c3dcd
fix: no-op Claude hooks when PID recovery misses
austinywang 2554098
fix: validate recovered Claude hook targets
austinywang 440c9e2
docs: clarify Claude hook binding priority
austinywang cad2dc0
fix: avoid Claude hook process snapshots on hot path
austinywang 0e75fba
fix: keep Claude hook PID recovery bounded
austinywang 3a06dd3
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang c498113
fix: defer generic hook PID inference
austinywang 6be88c4
fix: preserve PID metadata for explicit hooks
austinywang 0087600
test: assert stored PID skips process snapshot
austinywang 97b94d2
fix: ignore raw TTY for Claude hook recovery
austinywang 777fa15
fix: prefer live Claude workspace binding
austinywang ada9f71
test: tidy Claude raw TTY assertions
austinywang 9fa0dcc
fix: avoid persisting PID on fallback surfaces
austinywang 988f7c4
test: split Claude raw TTY setup
austinywang bb851aa
fix: avoid republishing stored Claude PID
austinywang f0f115f
fix: tighten Claude hook PID fallback
austinywang f7c59e5
test: assert Claude stored PID skips snapshot
austinywang 312b107
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang 644e9d5
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang f790246
test: assert closed-workspace mapped Claude hook no-ops
austinywang 1004514
fix: no-op Claude hooks when mapped session target is gone
austinywang 88b81e0
merge: resolve conflicts with main
austinywang 9a1d76b
chore: regenerate Swift file length budget
austinywang 39bfeee
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang d5060ac
docs: avoid conflict marker scan false positives
austinywang 76fd88f
chore: regenerate Swift file length budget
austinywang c53e1d0
fix: tighten Claude hook routing and feed telemetry
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,344 @@ | ||
| import Darwin | ||
| import Foundation | ||
|
|
||
| struct ClaudeHookRoutingTestSupport { | ||
| final class BundleMarker: NSObject {} | ||
|
|
||
| struct ProcessRunResult: Sendable { | ||
| let status: Int32 | ||
| let stdout: String | ||
| let stderr: String | ||
| let timedOut: Bool | ||
| } | ||
|
|
||
| final class MockSocketServerState: @unchecked Sendable { | ||
| private let lock = NSLock() | ||
| private var commands: [String] = [] | ||
|
|
||
| func append(_ command: String) { | ||
| lock.lock() | ||
| commands.append(command) | ||
| lock.unlock() | ||
| } | ||
|
|
||
| func snapshot() -> [String] { | ||
| lock.lock() | ||
| let value = commands | ||
| lock.unlock() | ||
| return value | ||
| } | ||
| } | ||
|
|
||
| struct HookContext: @unchecked Sendable { | ||
| let cliPath: String | ||
| let socketPath: String | ||
| let listenerFD: Int32 | ||
| let state: MockSocketServerState | ||
| let root: URL | ||
| let workspaceId: String | ||
| let surfaceId: String | ||
|
|
||
| func cleanup() { | ||
| Darwin.close(listenerFD) | ||
| unlink(socketPath) | ||
| try? FileManager.default.removeItem(at: root) | ||
| } | ||
| } | ||
|
|
||
| func makeHookContext(name: String) throws -> HookContext { | ||
| let root = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) | ||
| let socketPath = makeSocketPath(String(name.prefix(6))) | ||
| try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) | ||
| return HookContext( | ||
| cliPath: try BundledCLITestSupport.bundledCLIPath(for: BundleMarker.self), | ||
| socketPath: socketPath, | ||
| listenerFD: try bindUnixSocket(at: socketPath), | ||
| state: MockSocketServerState(), | ||
| root: root, | ||
| workspaceId: "11111111-1111-1111-1111-111111111111", | ||
| surfaceId: "22222222-2222-2222-2222-222222222222" | ||
| ) | ||
| } | ||
|
|
||
| func startMockServer( | ||
| listenerFD: Int32, | ||
| state: MockSocketServerState, | ||
| handler: @escaping @Sendable (String) -> String | ||
| ) -> DispatchSemaphore { | ||
| let finished = DispatchSemaphore(value: 0) | ||
| DispatchQueue.global(qos: .userInitiated).async { | ||
| while true { | ||
| var clientAddr = sockaddr_un() | ||
| var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) | ||
| let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in | ||
| ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in | ||
| Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) | ||
| } | ||
| } | ||
| guard clientFD >= 0 else { | ||
| if errno == EINTR { continue } | ||
| finished.signal() | ||
| return | ||
| } | ||
| DispatchQueue.global(qos: .userInitiated).async { | ||
| defer { | ||
| Darwin.close(clientFD) | ||
| finished.signal() | ||
| } | ||
|
|
||
| var pending = Data() | ||
| var buffer = [UInt8](repeating: 0, count: 4096) | ||
| while true { | ||
| let count = Darwin.read(clientFD, &buffer, buffer.count) | ||
| if count < 0 { | ||
| if errno == EINTR { continue } | ||
| return | ||
| } | ||
| if count == 0 { return } | ||
| pending.append(buffer, count: count) | ||
|
|
||
| while let newlineRange = pending.firstRange(of: Data([0x0A])) { | ||
| let lineData = pending.subdata(in: 0..<newlineRange.lowerBound) | ||
| pending.removeSubrange(0...newlineRange.lowerBound) | ||
| guard let line = String(data: lineData, encoding: .utf8) else { continue } | ||
| state.append(line) | ||
| let response = handler(line) + "\n" | ||
| let responseData = Data(response.utf8) | ||
| responseData.withUnsafeBytes { rawBuffer in | ||
| guard let base = rawBuffer.baseAddress else { return } | ||
| var sent = 0 | ||
| while sent < rawBuffer.count { | ||
| let wrote = Darwin.write(clientFD, base.advanced(by: sent), rawBuffer.count - sent) | ||
| if wrote < 0 { | ||
| if errno == EINTR { continue } | ||
| return | ||
| } | ||
| if wrote == 0 { return } | ||
| sent += wrote | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return finished | ||
| } | ||
|
|
||
| func runProcess( | ||
| executablePath: String, | ||
| arguments: [String], | ||
| environment: [String: String], | ||
| standardInput: String, | ||
| timeout: TimeInterval | ||
| ) -> ProcessRunResult { | ||
| let process = Process() | ||
| process.executableURL = URL(fileURLWithPath: executablePath) | ||
| process.arguments = arguments | ||
| process.environment = environment | ||
| let stdoutPipe = Pipe() | ||
| let stderrPipe = Pipe() | ||
| let stdinPipe = Pipe() | ||
| process.standardOutput = stdoutPipe | ||
| process.standardError = stderrPipe | ||
| process.standardInput = stdinPipe | ||
|
|
||
| do { | ||
| try process.run() | ||
| stdinPipe.fileHandleForWriting.write(Data(standardInput.utf8)) | ||
| try? stdinPipe.fileHandleForWriting.close() | ||
| } catch { | ||
| return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) | ||
| } | ||
|
|
||
| let exitSignal = DispatchSemaphore(value: 0) | ||
| DispatchQueue.global(qos: .userInitiated).async { | ||
| process.waitUntilExit() | ||
| exitSignal.signal() | ||
| } | ||
| let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut | ||
| if timedOut { | ||
| process.terminate() | ||
| _ = exitSignal.wait(timeout: .now() + 1) | ||
| if process.isRunning { | ||
| Darwin.kill(process.processIdentifier, SIGKILL) | ||
| _ = exitSignal.wait(timeout: .now() + 1) | ||
| } | ||
| } | ||
| return ProcessRunResult( | ||
| status: process.terminationStatus, | ||
| stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", | ||
| stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", | ||
| timedOut: timedOut | ||
| ) | ||
| } | ||
|
|
||
| func baseHookEnvironment(context: HookContext) -> [String: String] { | ||
| [ | ||
| "HOME": context.root.path, | ||
| "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", | ||
| "CMUX_SOCKET_PATH": context.socketPath, | ||
| "CMUX_WORKSPACE_ID": context.workspaceId, | ||
| "CMUX_SURFACE_ID": context.surfaceId, | ||
| "CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path, | ||
| "CMUX_CLI_SENTRY_DISABLED": "1", | ||
| "CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1", | ||
| ] | ||
| } | ||
|
|
||
| func agentLaunchEnvironment( | ||
| context: HookContext, | ||
| kind: String, | ||
| executable: String, | ||
| arguments: [String]? = nil | ||
| ) -> [String: String] { | ||
| [ | ||
| "CMUX_AGENT_LAUNCH_KIND": kind, | ||
| "CMUX_AGENT_LAUNCH_EXECUTABLE": executable, | ||
| "CMUX_AGENT_LAUNCH_CWD": context.root.path, | ||
| "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated(arguments ?? [executable]), | ||
| ] | ||
| } | ||
|
|
||
| func claudeForkLaunchEnvironment(context: HookContext, parentSessionId: String) -> [String: String] { | ||
| agentLaunchEnvironment( | ||
| context: context, | ||
| kind: "claude", | ||
| executable: "/usr/local/bin/claude", | ||
| arguments: ["/usr/local/bin/claude", "--resume", parentSessionId, "--fork-session"] | ||
| ) | ||
| } | ||
|
|
||
| func seedClaudeForkHookStore( | ||
| context: HookContext, | ||
| parentSessionId: String, | ||
| parentSurfaceId: String, | ||
| activeSessionId: String | ||
| ) throws { | ||
| let now = Date().timeIntervalSince1970 | ||
| let store: [String: Any] = [ | ||
| "version": 1, | ||
| "sessions": [ | ||
| parentSessionId: [ | ||
| "sessionId": parentSessionId, | ||
| "workspaceId": context.workspaceId, | ||
| "surfaceId": parentSurfaceId, | ||
| "cwd": context.root.path, | ||
| "agentLifecycle": "running", | ||
| "startedAt": now, | ||
| "updatedAt": now, | ||
| ], | ||
| ], | ||
| "activeSessionsByWorkspace": [ | ||
| context.workspaceId: [ | ||
| "sessionId": activeSessionId, | ||
| "updatedAt": now, | ||
| ], | ||
| ], | ||
| ] | ||
| try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted]) | ||
| .write( | ||
| to: context.root.appendingPathComponent("claude-hook-sessions.json"), | ||
| options: .atomic | ||
| ) | ||
| } | ||
|
|
||
| static func v2Response( | ||
| id: String, | ||
| ok: Bool, | ||
| result: [String: Any]? = nil, | ||
| error: [String: Any]? = nil | ||
| ) -> String { | ||
| var payload: [String: Any] = ["id": id, "ok": ok] | ||
| if let result { payload["result"] = result } | ||
| if let error { payload["error"] = error } | ||
| let data = try? JSONSerialization.data(withJSONObject: payload, options: []) | ||
| return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" | ||
| } | ||
|
|
||
| static func malformedRequestResponse(id: String? = nil, raw: String) -> String { | ||
| v2Response( | ||
| id: id ?? "unknown", | ||
| ok: false, | ||
| error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw] | ||
| ) | ||
| } | ||
|
|
||
| static func surfaceListResponse(id: String, surfaceId: String) -> String { | ||
| v2Response( | ||
| id: id, | ||
| ok: true, | ||
| result: ["surfaces": [["id": surfaceId, "ref": "surface:1", "focused": true]]] | ||
| ) | ||
| } | ||
|
|
||
| static func jsonObject(_ line: String) -> [String: Any]? { | ||
| guard let data = line.data(using: .utf8) else { return nil } | ||
| return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] | ||
| } | ||
|
|
||
| private func makeSocketPath(_ name: String) -> String { | ||
| let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) | ||
| return URL(fileURLWithPath: NSTemporaryDirectory()) | ||
| .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") | ||
| .path | ||
| } | ||
|
|
||
| private func bindUnixSocket(at path: String) throws -> Int32 { | ||
| unlink(path) | ||
| let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) | ||
| guard fd >= 0 else { | ||
| throw posixError("socket") | ||
| } | ||
|
|
||
| var addr = sockaddr_un() | ||
| addr.sun_family = sa_family_t(AF_UNIX) | ||
| let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) | ||
| let utf8 = Array(path.utf8) | ||
| guard utf8.count < maxPathLength else { | ||
| Darwin.close(fd) | ||
| throw NSError(domain: "cmux.tests", code: 1, userInfo: [ | ||
| NSLocalizedDescriptionKey: "socket path is too long: \(path)", | ||
| ]) | ||
| } | ||
| _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in | ||
| pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in | ||
| for index in 0..<utf8.count { | ||
| buffer[index] = CChar(bitPattern: utf8[index]) | ||
| } | ||
| buffer[utf8.count] = 0 | ||
| } | ||
| } | ||
|
|
||
| let bindResult = withUnsafePointer(to: &addr) { pointer in | ||
| pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in | ||
| Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) | ||
| } | ||
| } | ||
| guard bindResult == 0 else { | ||
| Darwin.close(fd) | ||
| throw posixError("bind") | ||
| } | ||
| guard Darwin.listen(fd, 1) == 0 else { | ||
| Darwin.close(fd) | ||
| throw posixError("listen") | ||
| } | ||
| return fd | ||
| } | ||
|
|
||
| private func base64NULSeparated(_ values: [String]) -> String { | ||
| var data = Data() | ||
| for value in values { | ||
| data.append(contentsOf: value.utf8) | ||
| data.append(0) | ||
| } | ||
| return data.base64EncodedString() | ||
| } | ||
|
|
||
| private func posixError(_ operation: String) -> NSError { | ||
| NSError(domain: "cmux.tests", code: Int(errno), userInfo: [ | ||
| NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))", | ||
| ]) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.