Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ extension RemoteCLIRelayServer.Session {
NSLocalizedDescriptionKey: "failed to create local relay socket",
])
}
// `close()` cancels a round trip by shutting this socket down, and the
// local server can hang up first. Either fails an in-flight write with
// EPIPE, which without SO_NOSIGPIPE also raises SIGPIPE and ends any
// host process that has not ignored it (the app ignores it only through
// Ghostty's startup). Darwin refuses the option once the peer is gone,
// so it is set before connecting.
var noSIGPIPE: Int32 = 1
guard setsockopt(
fd,
SOL_SOCKET,
SO_NOSIGPIPE,
&noSIGPIPE,
socklen_t(MemoryLayout<Int32>.size)
) == 0 else {
Darwin.close(fd)
throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [
NSLocalizedDescriptionKey: "failed to create local relay socket",
])
}
return fd
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ final class PolicyFakeUnixSocketServer: @unchecked Sendable {
private var _requests: [Data] = []
private let listenFD: Int32
private var shouldStop = false
private let servedConnection = DispatchSemaphore(value: 0)

var requests: [Data] {
lock.lock()
Expand All @@ -53,6 +54,18 @@ final class PolicyFakeUnixSocketServer: @unchecked Sendable {
guard fd >= 0 else {
throw NSError(domain: "PolicyFakeUnixSocketServer", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "socket() failed errno=\(errno)"])
}
// Accepted sockets inherit SO_NOSIGPIPE from the listener. The relay
// closes its end without reading a reply whenever it abandons a round
// trip (a refused peer, a session closed mid-forward), and the reply
// write in `serve` must then fail with EPIPE instead of killing the
// test process. Setting it after accept fails with EINVAL once the
// relay has already closed.
var noSIGPIPE: Int32 = 1
guard setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, socklen_t(MemoryLayout<Int32>.size)) == 0 else {
let optionErrno = errno // capture before close() can overwrite errno
Darwin.close(fd)
throw NSError(domain: "PolicyFakeUnixSocketServer", code: Int(optionErrno), userInfo: [NSLocalizedDescriptionKey: "setsockopt(SO_NOSIGPIPE) failed errno=\(optionErrno)"])
}
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(path.utf8CString)
Expand Down Expand Up @@ -116,6 +129,13 @@ final class PolicyFakeUnixSocketServer: @unchecked Sendable {
_ = Darwin.write(client, raw.baseAddress, raw.count)
}
Darwin.close(client)
servedConnection.signal()
}

/// Waits until one more accepted connection has been read to EOF and
/// answered (or its answer has failed).
func waitForServedConnection(timeout: TimeInterval = 5) -> Bool {
servedConnection.wait(timeout: .now() + timeout) == .success
}

func close() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxFoundation
import Foundation
import Testing
@testable import CmuxRemoteWorkspace
Expand Down Expand Up @@ -377,6 +378,36 @@ struct RemoteCLIRelayPolicyTests {
}
}

@Test("the local socket stand-in survives a relay that abandons its round trip")
func abandonedLocalRoundTripDoesNotKillTheTestProcess() throws {
let unixServer = try PolicyFakeUnixSocketServer()
defer { unixServer.close() }
// No second local account exists in tests, so expect a user ID the
// fake socket's owner cannot have. The relay connects, refuses the
// peer and closes without writing, which is also what the stand-in
// sees when a relay session closes in the middle of a forward.
let server = try RemoteCLIRelayServer(
localSocketPath: unixServer.path,
relayID: relayID,
relayTokenHex: tokenHex,
commandRewriter: PolicyPassthroughRewriter(),
localSocketPeerCheck: UnixSocketPeerCheck(expectedUserID: geteuid() &+ 1)
)
defer { server.stop() }
let port = try server.start()
let exchange = try runPolicyRelayExchange(
port: port,
relayID: relayID,
tokenHex: tokenHex,
commandLine: #"{"id":"abandoned","method":"system.ping","params":{}}"#
)
#expect(exchange.responseLines.first?["ok"] as? Bool == false)
// The stand-in reads to EOF, which arrives only once the relay has
// closed its socket, and then writes its reply into that closed socket.
#expect(unixServer.waitForServedConnection())
#expect(unixServer.requests == [Data()])
}

@Test("owned decoys cannot forward unrelated routing to the local socket", arguments: [
"terminal_id", "preferred_workspace_id", "target_surface_id", "tab_id", "target_terminal_id"
])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import CmuxFoundation
import Darwin
import Foundation
import Testing
@testable import CmuxRemoteWorkspace

/// Adds an inert parameter large enough that the relay's write to the local
/// socket cannot finish until the local server reads.
private struct PaddingRelayRewriter: RemoteRelayCommandRewriting {
let paddingBytes: Int

func rewriteRemoteRelayCommandLine(
_ commandLine: Data,
workspaceAliases: [UUID: UUID],
surfaceAliases: [UUID: UUID]
) -> Data {
guard let line = String(data: commandLine, encoding: .utf8),
let data = line.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8),
var request = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return commandLine
}
var params = request["params"] as? [String: Any] ?? [:]
params["_cmux_remote_workspace_id"] = UUID().uuidString
params["_cmux_remote_relay_request_authentication_code"] = "test"
params["padding"] = String(repeating: "x", count: paddingBytes)
request["params"] = params
return (try? JSONSerialization.data(withJSONObject: request)).map { $0 + Data([0x0A]) } ?? commandLine
}
}

/// Local socket stand-in that accepts the relay's connection and never reads
/// it, so a forwarded line larger than the socket buffer leaves the relay
/// blocked in its write.
private final class StalledUnixSocketListener {
let path: String
private let listenFD: Int32
private var acceptedFD: Int32 = -1

init() throws {
path = NSTemporaryDirectory() + "cmux-relay-stall-\(UUID().uuidString.prefix(8)).sock"
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(domain: "StalledUnixSocketListener", code: Int(errno))
}
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(path.utf8CString)
precondition(pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path))
let offset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
withUnsafeMutableBytes(of: &address) { raw in
pathBytes.withUnsafeBytes { src in
raw.baseAddress!.advanced(by: offset).copyMemory(from: src.baseAddress!, byteCount: pathBytes.count)
}
}
let len = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count)
let bound = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, len) }
}
guard bound == 0, listen(fd, 1) == 0 else {
let failure = errno // capture before close() can overwrite errno
Darwin.close(fd)
throw NSError(domain: "StalledUnixSocketListener", code: Int(failure))
}
listenFD = fd
}

/// Accepts the relay's connection and waits for its first bytes, which
/// means the relay is inside its write.
func waitForIncomingBytes(timeoutMilliseconds: Int32 = 5_000) -> Bool {
var pendingConnection = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0)
guard poll(&pendingConnection, 1, timeoutMilliseconds) == 1 else { return false }
acceptedFD = accept(listenFD, nil, nil)
guard acceptedFD >= 0 else { return false }
var incomingBytes = pollfd(fd: acceptedFD, events: Int16(POLLIN), revents: 0)
return poll(&incomingBytes, 1, timeoutMilliseconds) == 1
}

/// Drains what the relay wrote and reports whether it then hung up.
func waitForHangUp(timeout: TimeInterval = 5) -> Bool {
guard acceptedFD >= 0 else { return false }
let deadline = Date().addingTimeInterval(timeout)
var scratch = [UInt8](repeating: 0, count: 65_536)
while Date() < deadline {
var readable = pollfd(fd: acceptedFD, events: Int16(POLLIN), revents: 0)
guard poll(&readable, 1, 100) == 1 else { continue }
let count = Darwin.read(acceptedFD, &scratch, scratch.count)
if count == 0 { return true }
if count < 0 { return false }
}
return false
}

func close() {
if acceptedFD >= 0 {
Darwin.close(acceptedFD)
}
Darwin.close(listenFD)
unlink(path)
}
}

extension RemoteCLIRelayServerTests {
@Test("stopping the relay during a blocked local socket write fails the write without SIGPIPE")
func stopInterruptsBlockedLocalSocketWrite() throws {
let localSocket = try StalledUnixSocketListener()
defer { localSocket.close() }
let server = try RemoteCLIRelayServer(
localSocketPath: localSocket.path,
relayID: "relay-1",
relayTokenHex: tokenHex,
commandRewriter: PaddingRelayRewriter(paddingBytes: 1 << 20)
)
defer { server.stop() }
let port = try server.start()
let client = RelayTestClient(port: port)
defer { client.cancel() }

try authenticate(client)
client.send(Data((#"{"id":"relay-test","method":"system.ping","params":{}}"# + "\n").utf8))
#expect(
localSocket.waitForIncomingBytes(),
"The relay must be writing the forwarded line when it stops"
)

// Session teardown shuts the socket down under the blocked write. The
// test process surviving this call is the SIGPIPE assertion.
server.stop()

#expect(
localSocket.waitForHangUp(),
"The relay must close its local socket after the interrupted write"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ private final class FakeUnixSocketServer: @unchecked Sendable {
}

/// Line-oriented TCP client for the relay handshake.
private final class RelayTestClient: @unchecked Sendable {
final class RelayTestClient: @unchecked Sendable {
private let connection: NWConnection
private let queue = DispatchQueue(label: "relay-test-client")
private let lock = NSLock()
Expand Down Expand Up @@ -230,7 +230,7 @@ private final class RelayTestClient: @unchecked Sendable {

@Suite("RemoteCLIRelayServer", .serialized)
struct RemoteCLIRelayServerTests {
private let tokenHex = "00112233445566778899aabbccddeeff"
let tokenHex = "00112233445566778899aabbccddeeff"

@Test("authenticated relay sessions are capacity bounded")
func authenticatedSessionsAreCapacityBounded() throws {
Expand Down Expand Up @@ -642,7 +642,7 @@ struct RemoteCLIRelayServerTests {
})
}

private func authenticate(_ client: RelayTestClient) throws {
func authenticate(_ client: RelayTestClient) throws {
#expect(client.wait { data, _ in data.contains(0x0A) })
let challenge = try #require(client.receivedJSONLines().first)
let nonce = try #require(challenge["nonce"] as? String)
Expand Down
Loading