From 28a81b6012307189a29e3a5e4b9d40d847a20585 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 14:32:28 +0200 Subject: [PATCH] fix: keep static NDI senders alive, count first stats interval Senders like NDI Test Patterns send a still image once; a held NDI connection now counts as alive so the receiver does not reconnect in a loop. Co-Authored-By: Claude Opus 5.5 --- App/Sources/StatsRecorder.swift | 2 +- .../Sources/MusubiCore/ConnectionWatchdog.swift | 12 +++++++++++- .../MusubiKit/Sources/NDIKit/NDIReceiver.swift | 6 +++++- .../MusubiKit/Tests/MusubiCoreTests/M1Tests.swift | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/App/Sources/StatsRecorder.swift b/App/Sources/StatsRecorder.swift index d0eb0ad..004f215 100644 --- a/App/Sources/StatsRecorder.swift +++ b/App/Sources/StatsRecorder.swift @@ -29,7 +29,7 @@ final class StatsRecorder { var lines = "" for output in outputs { let s = output.snapshot - let previous = lastReceived[output.displayID] ?? s.stats.received + let previous = lastReceived[output.displayID] ?? 0 lastReceived[output.displayID] = s.stats.received let shownFPS = Double(s.stats.received - previous) / elapsed let refresh = screens.first { $0.id == output.displayID }?.refreshRate ?? 0 diff --git a/Packages/MusubiKit/Sources/MusubiCore/ConnectionWatchdog.swift b/Packages/MusubiKit/Sources/MusubiCore/ConnectionWatchdog.swift index 2c19898..0096699 100644 --- a/Packages/MusubiKit/Sources/MusubiCore/ConnectionWatchdog.swift +++ b/Packages/MusubiKit/Sources/MusubiCore/ConnectionWatchdog.swift @@ -1,4 +1,7 @@ /// Decides when a receiver counts as stalled and when to try reconnecting. +/// +/// Frames and a confirmed connection both count as signs of life: some senders (e.g. NDI Test Patterns) +/// send a still image only once, which is not an outage. public struct ConnectionWatchdog: Sendable, Equatable { public enum State: Equatable, Sendable { case connecting @@ -11,6 +14,7 @@ public struct ConnectionWatchdog: Sendable, Equatable { public var retryInterval: Double private var lastFrame: Double? + private var lastAlive: Double? private var lastRetry: Double? private let startedAt: Double @@ -22,11 +26,17 @@ public struct ConnectionWatchdog: Sendable, Equatable { public mutating func frameArrived(at now: Double) { lastFrame = now + connectionConfirmed(at: now) + } + + /// The sender still holds a connection to us, even if no new frames arrive. + public mutating func connectionConfirmed(at now: Double) { + lastAlive = now lastRetry = nil } public func state(at now: Double) -> State { - let reference = lastFrame ?? startedAt + let reference = lastAlive ?? startedAt guard now - reference >= stallAfter else { return lastFrame == nil ? .connecting : .live } let since = lastRetry ?? reference + stallAfter return .stalled(retryIn: max(0, retryInterval - (now - since))) diff --git a/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift b/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift index 1ace20b..5420d17 100644 --- a/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift +++ b/Packages/MusubiKit/Sources/NDIKit/NDIReceiver.swift @@ -108,7 +108,11 @@ public final class NDIReceiver: @unchecked Sendable { lastPerformanceCheck = now var dropped = NDIlib_recv_performance_t() NDIlib_recv_get_performance(instance, nil, &dropped) - lock.withLock { current.stats.updateDroppedByReceiver(dropped.video_frames) } + let connected = NDIlib_recv_get_no_connections(instance) > 0 + lock.withLock { + current.stats.updateDroppedByReceiver(dropped.video_frames) + if connected { watchdog.connectionConfirmed(at: now) } + } } if lock.withLock({ watchdog.shouldRetry(at: now) }) { diff --git a/Packages/MusubiKit/Tests/MusubiCoreTests/M1Tests.swift b/Packages/MusubiKit/Tests/MusubiCoreTests/M1Tests.swift index ef0ac65..f7bd898 100644 --- a/Packages/MusubiKit/Tests/MusubiCoreTests/M1Tests.swift +++ b/Packages/MusubiKit/Tests/MusubiCoreTests/M1Tests.swift @@ -105,6 +105,21 @@ import Testing #expect(dog.state(at: 6.5) == .live) } + @Test func stillImageSourceWithConnectionIsNotStalled() { + var dog = ConnectionWatchdog(now: 0) + dog.frameArrived(at: 0) + for second in 1...10 { dog.connectionConfirmed(at: Double(second)) } + #expect(dog.state(at: 10.5) == .live) + let retry = dog.shouldRetry(at: 10.5) + #expect(!retry) + } + + @Test func connectionWithoutAnyFrameStaysConnecting() { + var dog = ConnectionWatchdog(now: 0) + dog.connectionConfirmed(at: 5) + #expect(dog.state(at: 5.5) == .connecting) + } + @Test func neverConnectedSourceAlsoRetries() { var dog = ConnectionWatchdog(now: 0) let retry5 = dog.shouldRetry(at: 5)