Mac: clarify runtime comments

This commit is contained in:
Peter Steinberger
2025-12-09 00:08:19 +01:00
parent bc01488a75
commit 5674c9f4c2
12 changed files with 21 additions and 4 deletions

View File

@@ -162,6 +162,7 @@ actor AgentRPC {
}
Task.detached { [weak self] in
// Ensure all waiters are failed if the worker dies (e.g., crash or SIGTERM).
process.waitUntilExit()
await self?.stop()
}
@@ -188,7 +189,7 @@ actor AgentRPC {
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
// Handle event envelopes (unsolicited)
// Event frames are pushed without request/response pairing (e.g., heartbeats).
if let event = self.parseHeartbeatEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.heartbeatNotification, object: event)

View File

@@ -20,6 +20,8 @@ actor ConnectionWaiter {
private var pendingResult: Result<Void, Error>?
func wait() async throws {
// Acts like a one-shot Future; if the connection resolves before wait() is called,
// stash the result so the waiter resumes immediately.
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
if let pending = pendingResult {
pendingResult = nil
@@ -292,7 +294,7 @@ final class ControlChannel: ObservableObject {
proc.standardOutput = outPipe
proc.standardError = errPipe
try proc.run()
// Give ssh a brief moment; if it exits immediately, surface the error.
// Give ssh a brief moment; if it exits immediately we surface stderr instead of silently failing.
Thread.sleep(forTimeInterval: 0.2) // 200ms
if !proc.isRunning {
let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -381,6 +383,7 @@ final class ControlChannel: ObservableObject {
getsockname(socket, withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 }
}, &len)
// Asking the kernel for port 0 yields an ephemeral free port; reuse it for the SSH tunnel.
port = UInt16(bigEndian: addr.sin_port)
return port
}

View File

@@ -654,6 +654,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
private var state: AppState?
private let xpcLogger = Logger(subsystem: "com.steipete.clawdis", category: "xpc")
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
// Only clients signed with this team ID may talk to the XPC service (hard-fails if mismatched).
private let allowedTeamIDs: Set<String> = ["Y5PE65HELJ"]
let updaterController: UpdaterProviding = makeUpdaterController()

View File

@@ -97,6 +97,7 @@ final class RelayProcessManager: ObservableObject {
workingDirectory: FilePath(cwd))
{ execution, stdin, stdout, stderr in
self.didStart(execution)
// Consume stdout/stderr eagerly so the relay can't block on full pipes.
async let out: Void = self.stream(output: stdout, label: "stdout")
async let err: Void = self.stream(output: stderr, label: "stderr")
try await stdin.finish()
@@ -143,6 +144,7 @@ final class RelayProcessManager: ObservableObject {
self.status = .restarting
self.logger.warning("relay crashed (code \(code)); restarting")
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
try? await Task.sleep(nanoseconds: 750_000_000)
self.startIfNeeded()
}

View File

@@ -41,6 +41,7 @@ enum ShellRunner {
_ = await waitTask.value // drain pipes after termination
return Response(ok: false, message: "timeout")
}
// Whichever completes first (process exit or timeout) wins; cancel the other branch.
let first = await group.next()!
group.cancelAll()
return first

View File

@@ -257,6 +257,7 @@ enum CommandResolver {
extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0)
}
var seen = Set<String>()
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
return (extras + current).filter { seen.insert($0).inserted }
}

View File

@@ -98,6 +98,7 @@ actor VoicePushToTalk {
self.triggerChimePlayed = true
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) }
}
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
await VoiceWakeRuntime.shared.pauseForPushToTalk()
await MainActor.run {
VoiceWakeOverlayController.shared.showPartial(transcript: "")
@@ -189,6 +190,7 @@ actor VoicePushToTalk {
}
let transcript = result?.bestTranscription.formattedString
let isFinal = result?.isFinal ?? false
// Hop to a Task so UI updates stay off the Speech callback thread.
Task.detached { [weak self, transcript, isFinal] in
guard let self else { return }
await self.handle(transcript: transcript, isFinal: isFinal)

View File

@@ -290,6 +290,7 @@ enum VoiceWakeForwarder {
let nanos = UInt64(max(timeout, 0.1) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
if process.isRunning {
// SIGTERM is enough to stop ssh; keeps stdout/stderr readable for diagnostics.
process.terminate()
}
}

View File

@@ -165,6 +165,7 @@ final class VoiceWakeOverlayController: ObservableObject {
guard let window else { return }
if !self.model.isVisible {
self.model.isVisible = true
// Keep the status item in listening mode until we explicitly dismiss the overlay.
AppStateStore.shared.triggerVoiceEars(ttl: nil)
let start = target.offsetBy(dx: 0, dy: -6)
window.setFrame(start, display: true)
@@ -276,7 +277,6 @@ final class VoiceWakeOverlayController: ObservableObject {
}
private func scheduleAutoSend(after delay: TimeInterval, sendChime: VoiceWakeChime) {
guard let forwardConfig, forwardConfig.enabled else { return }
self.autoSendTask?.cancel()
self.autoSendTask = Task<Void, Never> { [weak self, sendChime] in
let nanos = UInt64(max(0, delay) * 1_000_000_000)

View File

@@ -125,7 +125,7 @@ actor VoiceWakeRuntime {
self.currentConfig = config
self.lastHeard = Date()
self.cooldownUntil = nil
// Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart.
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return }
@@ -253,6 +253,7 @@ actor VoiceWakeRuntime {
VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed)
}
// Keep the "ears" boosted for the capture window so the status icon animates while recording.
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
self.captureTask?.cancel()
@@ -269,6 +270,7 @@ actor VoiceWakeRuntime {
while self.isCapturing {
let now = Date()
if now >= hardStop {
// Hard-stop after a maximum duration so we never leave the recognizer pinned open.
await self.finalizeCapture(config: config)
return
}
@@ -337,6 +339,7 @@ actor VoiceWakeRuntime {
self.lastHeard = Date()
}
// Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices.
let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold)))
Task { @MainActor in
VoiceWakeOverlayController.shared.updateLevel(clamped)

View File

@@ -135,6 +135,7 @@ final class WebChatServer: @unchecked Sendable {
}
let fileURL = root.appendingPathComponent(path)
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
// Simple directory traversal guard: served files must live under the bundled web root.
guard fileURL.path.hasPrefix(root.path) else {
self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
return

View File

@@ -137,6 +137,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
Task { @MainActor [weak self] in
guard let self else { return }
do {
// Recreate the tunnel silently so the window keeps working without user intervention.
let base = try await self.startOrRestartTunnel()
self.loadPage(baseURL: base)
} catch {