Files
steipete--codexbar/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift
T
2026-07-13 12:22:33 +08:00

151 lines
4.7 KiB
Swift

import Foundation
package final class ProcessPipeCapture: @unchecked Sendable {
package static let defaultMaxBytes = 1 * 1024 * 1024
private let handle: FileHandle
private let onData: (@Sendable () -> Void)?
private let maxBytes: Int
private let condition = NSCondition()
private var data = Data()
private var activeCallbacks = 0
private var isFinished = false
private var didReachEOF = false
private var isStopping = false
private var continuation: CheckedContinuation<Void, Never>?
package init(
pipe: Pipe,
maxBytes: Int = ProcessPipeCapture.defaultMaxBytes,
onData: (@Sendable () -> Void)? = nil)
{
self.handle = pipe.fileHandleForReading
self.maxBytes = max(0, maxBytes)
self.onData = onData
}
package func start() {
self.handle.readabilityHandler = { [weak self] handle in
self?.handleReadableData(from: handle)
}
}
package func finish(timeout: Duration) async -> Data {
let drainTask = Task<Void, Error> {
await self.waitUntilFinished()
}
let join = BoundedTaskJoin(sourceTask: drainTask)
_ = await join.value(joinGrace: timeout)
return self.stopAndSnapshot()
}
package func finishSynchronously(timeout: TimeInterval) -> Data {
let deadline = Date().addingTimeInterval(max(0, timeout))
self.condition.lock()
while !self.isFinished, !self.isStopping {
guard self.condition.wait(until: deadline) else { break }
}
self.condition.unlock()
return self.stopAndSnapshot()
}
/// Waits only for the first complete output line. Useful for helpers whose descendants may inherit stdout
/// after the helper itself exits, preventing EOF even though the caller already has its complete answer.
package func finishFirstLineSynchronously(timeout: TimeInterval) -> Data {
let deadline = Date().addingTimeInterval(max(0, timeout))
self.condition.lock()
while !self.isFinished, !self.isStopping, !self.data.contains(0x0A) {
guard self.condition.wait(until: deadline) else { break }
}
self.condition.unlock()
return self.stopAndSnapshot()
}
package func stop() {
_ = self.stopAndSnapshot()
}
package var reachedEOF: Bool {
self.condition.lock()
defer { self.condition.unlock() }
return self.didReachEOF
}
package static func decodeUTF8(_ data: Data) -> String {
// A byte cap can split the final scalar; lossy decoding preserves the valid captured prefix.
// swiftlint:disable:next optional_data_string_conversion
String(decoding: data, as: UTF8.self)
}
private func handleReadableData(from handle: FileHandle) {
self.condition.lock()
guard !self.isStopping else {
self.condition.unlock()
return
}
self.activeCallbacks += 1
self.condition.unlock()
let chunk = handle.availableData
var continuation: CheckedContinuation<Void, Never>?
self.condition.lock()
if chunk.isEmpty {
self.isFinished = true
self.didReachEOF = true
continuation = self.continuation
self.continuation = nil
} else {
let remainingBytes = max(0, self.maxBytes - self.data.count)
if remainingBytes > 0 {
self.data.append(chunk.prefix(remainingBytes))
}
}
self.activeCallbacks -= 1
if self.activeCallbacks == 0 {
self.condition.broadcast()
}
self.condition.unlock()
if chunk.isEmpty {
handle.readabilityHandler = nil
} else {
self.onData?()
}
continuation?.resume()
}
private func waitUntilFinished() async {
await withCheckedContinuation { continuation in
self.condition.lock()
if self.isFinished || self.isStopping {
self.condition.unlock()
continuation.resume()
return
}
self.continuation = continuation
self.condition.unlock()
}
}
private func stopAndSnapshot() -> Data {
self.handle.readabilityHandler = nil
let continuation: CheckedContinuation<Void, Never>?
let snapshot: Data
self.condition.lock()
self.isStopping = true
while self.activeCallbacks > 0 {
self.condition.wait()
}
self.isFinished = true
continuation = self.continuation
self.continuation = nil
snapshot = self.data
self.condition.unlock()
continuation?.resume()
return snapshot
}
}