Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions KVMCore/Sources/KVMCore/Networking/GLKVMH264MediaSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ public enum GLKVMH264MediaError: Error, LocalizedError, Equatable {
case frameTooShort
case emptyPayload
case streamEndedBeforeFirstFrame
case firstFrameTimedOut

public var errorDescription: String? {
switch self {
Expand All @@ -24,8 +23,6 @@ public enum GLKVMH264MediaError: Error, LocalizedError, Equatable {
return "GLKVM direct H.264 frame has no video payload."
case .streamEndedBeforeFirstFrame:
return "GLKVM media stream closed before delivering a frame."
case .firstFrameTimedOut:
return "GLKVM media stream did not deliver a frame in time."
}
}
}
Expand Down Expand Up @@ -77,11 +74,11 @@ public final class GLKVMH264MediaSocket: @unchecked Sendable {
}
}

/// - Parameter firstFrameTimeout: when non-nil, the stream fails with `.firstFrameTimedOut`
/// if no decodable frame arrives within this interval. The socket's own 1s heartbeats keep a
/// frameless connection open, so without this a stalled streamer (e.g. mid-restart) would
/// never surface an error.
public func frames(firstFrameTimeout: Duration? = nil) throws -> AsyncThrowingStream<H264StreamFrame, Error> {
/// - Parameter firstFrameGrace: when non-nil and no decodable frame arrives within this
/// interval, the stream yields a single `nil` and keeps running. The device keeps a frameless
/// socket open (heartbeats only) while the host has no HDMI signal — e.g. it is off or
/// hibernated — so the marker lets a caller proceed without video instead of hanging.
public func frames(firstFrameGrace: Duration? = nil) throws -> AsyncThrowingStream<H264StreamFrame?, Error> {
guard let url = mediaURL else { throw GLKVMH264MediaError.invalidURL }

var request = URLRequest(url: url)
Expand All @@ -96,12 +93,11 @@ public final class GLKVMH264MediaSocket: @unchecked Sendable {
webSocketTask.resume()
startHeartbeat(task: webSocketTask)

let timeoutTask: Task<Void, Never>? = firstFrameTimeout.map { timeout in
Task { [weak self] in
try? await Task.sleep(for: timeout)
let graceTask: Task<Void, Never>? = firstFrameGrace.map { grace in
Task {
try? await Task.sleep(for: grace)
guard !Task.isCancelled else { return }
continuation.finish(throwing: GLKVMH264MediaError.firstFrameTimedOut)
self?.cancel()
continuation.yield(nil)
}
}

Expand Down Expand Up @@ -129,7 +125,7 @@ public final class GLKVMH264MediaSocket: @unchecked Sendable {
)
if !firstFrameYielded {
firstFrameYielded = true
timeoutTask?.cancel()
graceTask?.cancel()
}
frameSequencer.recordYield(continuation.yield(frameSequencer.nextFrame(from: stamped)))
}
Expand All @@ -148,7 +144,7 @@ public final class GLKVMH264MediaSocket: @unchecked Sendable {
}

continuation.onTermination = { @Sendable [weak self] _ in
timeoutTask?.cancel()
graceTask?.cancel()
receiveTask.cancel()
self?.cancel()
}
Expand Down
5 changes: 3 additions & 2 deletions KVMCore/Sources/KVMCore/Networking/H264StreamSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ struct H264StreamFrameSequencer {
)
}

mutating func recordYield(_ result: AsyncThrowingStream<H264StreamFrame, Error>.Continuation.YieldResult) {
guard case .dropped(let droppedFrame) = result else { return }
/// `Element` is `H264StreamFrame` or `H264StreamFrame?` (GLKVM's no-signal marker stream).
mutating func recordYield<Element>(_ result: AsyncThrowingStream<Element, Error>.Continuation.YieldResult) {
guard case .dropped(let dropped) = result, let droppedFrame = dropped as? H264StreamFrame else { return }

droppedFrameCount &+= 1
guard droppedFrameCount <= 5 || droppedFrameCount.isMultiple(of: 30) else { return }
Expand Down
19 changes: 10 additions & 9 deletions KVMCore/Sources/KVMCore/Session/GLKVMSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ public final class GLKVMSession: KVMSession {
/// setup a bounded number of times before surfacing an error.
private static let maxConnectAttempts = 3
private static let connectRetryBackoff: Duration = .milliseconds(750)
/// Bounds each attempt's `.connecting` state: if the media stream accepts the socket but never
/// delivers a frame (a stalled streamer, kept alive by the socket's 1s heartbeats), the attempt
/// fails and retries rather than hanging.
private static let connectFirstFrameTimeout: Duration = .seconds(5)
/// Bounds each attempt's `.connecting` state: if the media socket stays up but delivers no
/// frame within this grace, the host has no HDMI signal (off or hibernated) and we hand off
/// without video rather than hanging or failing — the web UI's "No signal" behaviour.
private static let connectFirstFrameGrace: Duration = .seconds(5)

public init(
passwordStore: PasswordStore = KeychainPasswordStore(),
Expand Down Expand Up @@ -136,15 +136,16 @@ public final class GLKVMSession: KVMSession {

let mediaSocket = GLKVMH264MediaSocket(device: configuration.device, authToken: authToken)
var frameIterator = try mediaSocket
.frames(firstFrameTimeout: Self.connectFirstFrameTimeout)
.frames(firstFrameGrace: Self.connectFirstFrameGrace)
.makeAsyncIterator()
localMediaSocket = mediaSocket

// Await the first frame *before* handoff — see NanoKVMSession for the full
// rationale. setStreamerVideoFormatH264() restarts the streamer, so the media
// stream fails on its first receive(), which surfaces only here; awaiting it
// inside the attempt is what lets the bounded retry absorb the restart race.
// A stalled streamer trips the socket's first-frame timeout, which routes here.
// `next()` returning nil means the stream ended; a yielded nil means the grace
// elapsed with the socket still up (no HDMI signal) — hand off without video.
guard let firstFrame = try await frameIterator.next() else {
try Task.checkCancellation()
throw GLKVMH264MediaError.streamEndedBeforeFirstFrame
Expand Down Expand Up @@ -177,14 +178,14 @@ public final class GLKVMSession: KVMSession {
return
}

KVMLog.glkvm.info("GLKVM direct H.264 pipeline started")
KVMLog.glkvm.info("GLKVM direct H.264 pipeline started; firstFrame=\(firstFrame != nil, privacy: .public)")

// Streaming is live — the frame loop is now terminal (no retry mid-stream).
do {
try decoder.decode(firstFrame)
if let firstFrame { try decoder.decode(firstFrame) }
while let frame = try await frameIterator.next() {
try Task.checkCancellation()
try decoder.decode(frame)
if let frame { try decoder.decode(frame) }
}
await MainActor.run {
guard let self, self.generation == myGeneration else { return }
Expand Down
2 changes: 1 addition & 1 deletion project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ settings:
# Single source of truth for app version, shared by both targets via
# $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION) references in their
# Info.plist. CI overrides CURRENT_PROJECT_VERSION per release (timestamp).
MARKETING_VERSION: "1.0.7"
MARKETING_VERSION: "1.0.8"
CURRENT_PROJECT_VERSION: "1"
targets:
KVMConsole:
Expand Down
Loading