diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc1606d..6a1d683f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - The `programa codex install-hooks` and `uninstall-hooks` legacy aliases. Use `programa codex install-integration` and `uninstall-integration`. ### Fixed +- Launching a second build of Programa no longer cuts the running app's command line tool and agent hooks off. The new copy used to delete the running app's socket and take it over. - Dragging the sidebar edge resizes the sidebar again instead of moving the whole window. - The Window menu's Fill, Center, Move & Resize, Full Screen Tile and Move to Display items, and their shortcuts, work again. The main window had been marked not movable, which makes macOS disable all of them. - The notifications popover now opens in minimal mode, the default layout. Show Notifications (⌘I), the sidebar bell and the menu bar icon's Show Notifications did nothing there. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f045b089..ef7d81c2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -880,6 +880,19 @@ class TerminalController { if let errnoCode = ensureSocketParentDirectoryExists(path: path) { return .failure(path: path, stage: "create_directory", errnoCode: errnoCode) } + // Another running app (a second build, a bench, a tagged app on the default path) may be + // serving this path; unlinking it would silently cut that app's CLI and agent hooks off. + // Only a path proven stale or missing is unlinked. An inconclusive probe (EMFILE, EACCES, + // a listener owned by another uid) fails as a bind error so the default path falls back. + switch UnixDomainFDPassing.probeConnection(to: path) { + case .stale, .missing: + break + case .live(let probeFD): + close(probeFD) + return .failure(path: path, stage: "bind", errnoCode: EADDRINUSE) + case .indeterminate(let probeErrno): + return .failure(path: path, stage: "bind", errnoCode: probeErrno) + } if unlink(path) != 0, errno != ENOENT { return .failure(path: path, stage: "unlink", errnoCode: errno) } @@ -1001,8 +1014,11 @@ class TerminalController { case .pathTooLong: close(newServerSocket) return - case .failure: - NSLog("TerminalController: Failed to bind socket") + case .failure(let failedPath, let failedStage, let failedErrnoCode): + NSLog( + "TerminalController: Failed to bind socket path=%@ stage=%@ errno=%d", + failedPath, failedStage, failedErrnoCode + ) close(newServerSocket) return } diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index b9e52587..48c6f3cc 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -1058,6 +1058,44 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } + func testStartLeavesALiveListenerFromAnotherProcessInPlace() throws { + let path = makeSocketPath("live-owner") + defer { try? FileManager.default.removeItem(atPath: path) } + + // Another app is serving the path. + let foreignListener = socket(AF_UNIX, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(foreignListener, 0) + defer { Darwin.close(foreignListener) } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + strncpy(UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), ptr, maxLength - 1) + } + } + let bound = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(foreignListener, $0, socklen_t(MemoryLayout.size)) + } + } + XCTAssertEqual(bound, 0) + XCTAssertEqual(listen(foreignListener, 4), 0) + let foreignInode = try XCTUnwrap( + FileManager.default.attributesOfItem(atPath: path)[.systemFileNumber] as? UInt64 + ) + + TerminalController.shared.start(tabManager: TabManager(), socketPath: path, accessMode: .allowAll) + defer { TerminalController.shared.stop() } + + XCTAssertEqual( + try FileManager.default.attributesOfItem(atPath: path)[.systemFileNumber] as? UInt64, + foreignInode, + "start must not unlink and rebind a path another process is listening on" + ) + XCTAssertTrue(SocketConnectProbe.canConnect(at: path, timeout: 0.5)) + } + #if DEBUG func testProgramaOnlyRejectsSameUserWhenPeerProcessCannotBeVerified() throws { TerminalController.shared.setSocketPeerPIDProviderForTesting { _ in nil }