fix(macos): use safe FileHandle reads

This commit is contained in:
Peter Steinberger
2025-12-16 10:41:18 +01:00
parent b443c20cef
commit 64d6d25d65
10 changed files with 77 additions and 14 deletions

View File

@@ -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()
}
}
}

View File

@@ -202,7 +202,7 @@ enum GatewayEnvironment {
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw) return Semver.parse(raw)
} catch { } catch {

View File

@@ -257,7 +257,7 @@ actor PortGuardian {
} catch { } catch {
return nil return nil
} }
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil } guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)? return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -132,7 +132,7 @@ enum RuntimeLocator {
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
return nil return nil

View File

@@ -24,8 +24,8 @@ enum ShellExecutor {
let waitTask = Task { () -> Response in let waitTask = Task { () -> Response in
process.waitUntilExit() process.waitUntilExit()
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile() let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = process.terminationStatus let status = process.terminationStatus
let combined = out.isEmpty ? err : out let combined = out.isEmpty ? err : out
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined) return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)

View File

@@ -463,7 +463,7 @@ private enum ToolInstaller {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
process.terminationHandler = { proc in process.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (proc.terminationStatus, output)) continuation.resume(returning: (proc.terminationStatus, output))
} }

View File

@@ -191,7 +191,7 @@ enum CLIInstaller {
do { do {
try proc.run() try proc.run()
proc.waitUntilExit() proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 { if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output return output.isEmpty ? "CLI helper linked into \(targetList)" : output

View File

@@ -74,9 +74,9 @@ final class WebChatTunnel {
// Consume stderr so ssh cannot block if it logs. // Consume stderr so ssh cannot block if it logs.
stderrHandle.readabilityHandler = { handle in stderrHandle.readabilityHandler = { handle in
let data = handle.availableData let data = handle.readSafely(upToCount: 64 * 1024)
guard !data.isEmpty else { 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) Self.cleanupStderr(handle)
return return
} }
@@ -223,15 +223,25 @@ final class WebChatTunnel {
private static func drainStderr(_ handle: FileHandle) -> String { private static func drainStderr(_ handle: FileHandle) -> String {
handle.readabilityHandler = nil handle.readabilityHandler = nil
let data = handle.readDataToEndOfFile() defer { try? handle.close() }
try? handle.close()
return String(data: data, encoding: .utf8)? do {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 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 #if SWIFT_PACKAGE
static func _testPortIsFree(_ port: UInt16) -> Bool { static func _testPortIsFree(_ port: UInt16) -> Bool {
self.portIsFree(port) self.portIsFree(port)
} }
static func _testDrainStderr(_ handle: FileHandle) -> String {
self.drainStderr(handle)
}
#endif #endif
} }

View File

@@ -263,7 +263,7 @@ enum BrowserCLI {
return try String(contentsOfFile: jsFile, encoding: .utf8) return try String(contentsOfFile: jsFile, encoding: .utf8)
} }
if options.jsStdin { if options.jsStdin {
let data = FileHandle.standardInput.readDataToEndOfFile() let data = FileHandle.standardInput.readToEndSafely()
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
if let js = options.js, !js.isEmpty { if let js = options.js, !js.isEmpty {

View File

@@ -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()
}
}
}