diff --git a/apps/macos/Sources/Clawdis/FileHandle+SafeRead.swift b/apps/macos/Sources/Clawdis/FileHandle+SafeRead.swift new file mode 100644 index 000000000..7cd160969 --- /dev/null +++ b/apps/macos/Sources/Clawdis/FileHandle+SafeRead.swift @@ -0,0 +1,28 @@ +import Foundation + +extension FileHandle { + /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. + /// + /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and + /// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which + /// will abort the process. + func readToEndSafely() -> Data { + do { + return try self.readToEnd() ?? Data() + } catch { + return Data() + } + } + + /// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF. + /// + /// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid + /// Objective-C exceptions terminating the process. + func readSafely(upToCount count: Int) -> Data { + do { + return try self.read(upToCount: count) ?? Data() + } catch { + return Data() + } + } +} diff --git a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift index afd645bcd..f11f3083f 100644 --- a/apps/macos/Sources/Clawdis/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdis/GatewayEnvironment.swift @@ -202,7 +202,7 @@ enum GatewayEnvironment { do { try process.run() process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let data = pipe.fileHandleForReading.readToEndSafely() let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return Semver.parse(raw) } catch { diff --git a/apps/macos/Sources/Clawdis/PortGuardian.swift b/apps/macos/Sources/Clawdis/PortGuardian.swift index 6cb529eb8..327bced09 100644 --- a/apps/macos/Sources/Clawdis/PortGuardian.swift +++ b/apps/macos/Sources/Clawdis/PortGuardian.swift @@ -257,7 +257,7 @@ actor PortGuardian { } catch { return nil } - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let data = pipe.fileHandleForReading.readToEndSafely() guard !data.isEmpty else { return nil } return String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdis/RuntimeLocator.swift b/apps/macos/Sources/Clawdis/RuntimeLocator.swift index a40e068e4..e75a7922d 100644 --- a/apps/macos/Sources/Clawdis/RuntimeLocator.swift +++ b/apps/macos/Sources/Clawdis/RuntimeLocator.swift @@ -132,7 +132,7 @@ enum RuntimeLocator { do { try process.run() process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let data = pipe.fileHandleForReading.readToEndSafely() return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) } catch { return nil diff --git a/apps/macos/Sources/Clawdis/ShellExecutor.swift b/apps/macos/Sources/Clawdis/ShellExecutor.swift index 580b098fb..893924d05 100644 --- a/apps/macos/Sources/Clawdis/ShellExecutor.swift +++ b/apps/macos/Sources/Clawdis/ShellExecutor.swift @@ -24,8 +24,8 @@ enum ShellExecutor { let waitTask = Task { () -> Response in process.waitUntilExit() - let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let err = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let out = stdoutPipe.fileHandleForReading.readToEndSafely() + let err = stderrPipe.fileHandleForReading.readToEndSafely() let status = process.terminationStatus let combined = out.isEmpty ? err : out return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined) diff --git a/apps/macos/Sources/Clawdis/ToolsSettings.swift b/apps/macos/Sources/Clawdis/ToolsSettings.swift index 00bab1f9e..2bdafd168 100644 --- a/apps/macos/Sources/Clawdis/ToolsSettings.swift +++ b/apps/macos/Sources/Clawdis/ToolsSettings.swift @@ -463,7 +463,7 @@ private enum ToolInstaller { process.standardOutput = pipe process.standardError = pipe process.terminationHandler = { proc in - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let data = pipe.fileHandleForReading.readToEndSafely() let output = String(data: data, encoding: .utf8) ?? "" continuation.resume(returning: (proc.terminationStatus, output)) } diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 5e84cc719..c4cc10c6c 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -191,7 +191,7 @@ enum CLIInstaller { do { try proc.run() proc.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let data = pipe.fileHandleForReading.readToEndSafely() let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if proc.terminationStatus == 0 { return output.isEmpty ? "CLI helper linked into \(targetList)" : output diff --git a/apps/macos/Sources/Clawdis/WebChatTunnel.swift b/apps/macos/Sources/Clawdis/WebChatTunnel.swift index b675b69eb..7a70cfd1a 100644 --- a/apps/macos/Sources/Clawdis/WebChatTunnel.swift +++ b/apps/macos/Sources/Clawdis/WebChatTunnel.swift @@ -74,9 +74,9 @@ final class WebChatTunnel { // Consume stderr so ssh cannot block if it logs. stderrHandle.readabilityHandler = { handle in - let data = handle.availableData + let data = handle.readSafely(upToCount: 64 * 1024) guard !data.isEmpty else { - // EOF: stop monitoring to avoid spinning on a closed pipe. + // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. Self.cleanupStderr(handle) return } @@ -223,15 +223,25 @@ final class WebChatTunnel { private static func drainStderr(_ handle: FileHandle) -> String { handle.readabilityHandler = nil - let data = handle.readDataToEndOfFile() - try? handle.close() - return String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + defer { try? handle.close() } + + do { + let data = try handle.readToEnd() ?? Data() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } catch { + self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)") + return "" + } } #if SWIFT_PACKAGE static func _testPortIsFree(_ port: UInt16) -> Bool { self.portIsFree(port) } + + static func _testDrainStderr(_ handle: FileHandle) -> String { + self.drainStderr(handle) + } #endif } diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift index 58aee1a70..f242ab2e9 100644 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift @@ -263,7 +263,7 @@ enum BrowserCLI { return try String(contentsOfFile: jsFile, encoding: .utf8) } if options.jsStdin { - let data = FileHandle.standardInput.readDataToEndOfFile() + let data = FileHandle.standardInput.readToEndSafely() return String(data: data, encoding: .utf8) ?? "" } if let js = options.js, !js.isEmpty { diff --git a/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift b/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift new file mode 100644 index 000000000..eb2c3d26a --- /dev/null +++ b/apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift @@ -0,0 +1,25 @@ +import Foundation + +extension FileHandle { + /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. + /// + /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and + /// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which + /// will abort the process. + func readToEndSafely() -> Data { + do { + return try self.readToEnd() ?? Data() + } catch { + return Data() + } + } + + /// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF. + func readSafely(upToCount count: Int) -> Data { + do { + return try self.read(upToCount: count) ?? Data() + } catch { + return Data() + } + } +}