fix(mac): harden remote tunnel recovery

This commit is contained in:
Peter Steinberger
2026-01-03 00:02:27 +01:00
parent 88ed58b3d0
commit 9dd613edf7
7 changed files with 108 additions and 3 deletions

View File

@@ -158,6 +158,7 @@
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
- macOS remote: harden SSH tunnel recovery/logging, honor `gateway.remote.url` port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
- macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).
- iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.

View File

@@ -84,6 +84,7 @@ final class ControlChannel {
}
func configure() async {
self.logger.info("control channel configure mode=local")
self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
@@ -102,6 +103,9 @@ final class ControlChannel {
case let .remote(target, identity):
do {
_ = (target, identity)
let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
self.logger.info(
"control channel configure mode=remote target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
} catch {
@@ -261,6 +265,15 @@ final class ControlChannel {
if mode == .local {
GatewayProcessManager.shared.setActive(true)
}
if mode == .remote {
do {
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
} catch {
self.logger.error(
"control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
}
}
do {
try await GatewayConnection.shared.refresh()

View File

@@ -165,6 +165,8 @@ actor GatewayEndpointStore {
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
do {
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token()
let password = self.deps.password()
@@ -174,6 +176,7 @@ actor GatewayEndpointStore {
} catch {
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}

View File

@@ -204,6 +204,16 @@ struct MenuContent: View {
} label: {
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
}
if self.state.connectionMode == .remote {
Button {
Task { @MainActor in
let result = await DebugActions.resetGatewayTunnel()
self.presentDebugResult(result, title: "Remote Tunnel")
}
} label: {
Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath")
}
}
Button {
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
} label: {
@@ -462,6 +472,21 @@ struct MenuContent: View {
return "System default"
}
@MainActor
private func presentDebugResult(_ result: Result<String, DebugActionError>, title: String) {
let alert = NSAlert()
alert.messageText = title
switch result {
case let .success(message):
alert.informativeText = message
alert.alertStyle = .informational
case let .failure(error):
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
}
alert.runModal()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {

View File

@@ -44,6 +44,7 @@ struct NodeMenuEntryFormatter {
static func roleText(_ entry: NodeInfo) -> String {
if entry.isConnected { return "connected" }
if self.isGateway(entry) { return "disconnected" }
if entry.isPaired { return "paired" }
return "unpaired"
}

View File

@@ -48,6 +48,16 @@ final class RemotePortTunnel {
}
let localPort = try await Self.findPort(preferred: preferredLocalPort)
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
let resolvedRemotePort = remotePortOverride ?? remotePort
if let override = remotePortOverride {
Self.logger.info(
"ssh tunnel remote port override host=\(sshHost, privacy: .public) port=\(override, privacy: .public)")
} else {
Self.logger.debug(
"ssh tunnel using default remote port host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
}
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
@@ -58,7 +68,7 @@ final class RemotePortTunnel {
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-N",
"-L", "\(localPort):127.0.0.1:\(remotePort)",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -114,6 +124,45 @@ final class RemotePortTunnel {
return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle)
}
private static func resolveRemotePortOverride(for sshHost: String) -> Int? {
let root = ClawdisConfigFile.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else {
return nil
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
let sshKey = Self.hostKey(sshHost)
let urlKey = Self.hostKey(host)
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
guard sshKey == urlKey else {
Self.logger.debug(
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
return nil
}
return port
}
private static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
let digits = CharacterSet(charactersIn: "0123456789.")
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
return trimmed
}
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
}
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
if let preferred, self.portIsFree(preferred) { return preferred }

View File

@@ -13,7 +13,10 @@ actor RemoteTunnelManager {
tunnel.process.isRunning,
let local = tunnel.localPort
{
if await self.isTunnelHealthy(port: local) { return local }
if await self.isTunnelHealthy(port: local) {
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
return local
}
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
tunnel.terminate()
self.controlTunnel = nil
@@ -24,7 +27,11 @@ actor RemoteTunnelManager {
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
self.isSshProcess(desc)
{
if await self.isTunnelHealthy(port: desiredPort) { return desiredPort }
if await self.isTunnelHealthy(port: desiredPort) {
self.logger.info(
"reusing existing SSH tunnel listener localPort=\(desiredPort, privacy: .public) pid=\(desc.pid, privacy: .public)")
return desiredPort
}
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
}
return nil
@@ -41,6 +48,10 @@ actor RemoteTunnelManager {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
self.logger.info(
"ensure SSH tunnel target=\(settings.target, privacy: .public) identitySet=\(identitySet, privacy: .public)")
if let local = await self.controlTunnelPortIfRunning() { return local }
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
@@ -48,6 +59,8 @@ actor RemoteTunnelManager {
remotePort: GatewayEnvironment.gatewayPort(),
preferredLocalPort: desiredPort)
self.controlTunnel = tunnel
let resolvedPort = tunnel.localPort ?? desiredPort
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
return tunnel.localPort ?? desiredPort
}