Skip to content
Open
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
5 changes: 5 additions & 0 deletions SilenciApp/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ let package = Package(
name: "SilenciApp",
path: "Sources",
exclude: ["Resources"]
),
.testTarget(
name: "SilenciAppTests",
dependencies: ["SilenciApp"],
path: "Tests/SilenciAppTests"
)
]
)
43 changes: 39 additions & 4 deletions SilenciApp/Sources/Services/PythonBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ final class PythonBridge {
/// Background task that reads stdout lines.
private var readTask: Task<Void, Never>?

/// Timestamp of the last parsed line from the Python subprocess (progress
/// notification or RPC response). Used to drive an idle watchdog instead
/// of a flat wall-clock timeout, since long-running calls like `analyze`
/// emit progress continuously but can legitimately run far past any fixed cap.
private var lastActivityAt: Date = Date()

/// Path to the Python interpreter. Defaults to system `python3`.
/// Overridden at start() to prefer .venv/bin/python if available.
var pythonPath: String = "/usr/bin/python3"
Expand Down Expand Up @@ -297,19 +303,46 @@ final class PythonBridge {
// Write request to stdin.
try stdinHandle.write(contentsOf: lineData)

// Register continuation, then race response vs timeout.
// Register continuation, then race response vs an idle timeout.
let result: AnyCodableValue = try await withCheckedThrowingContinuation { continuation in
self.pendingRequests[requestId] = continuation
self.lastActivityAt = Date()

// Timeout watchdog — fires on a detached task to avoid actor reentrancy issues.
// Idle watchdog — fires on a detached task to avoid actor reentrancy issues.
// Polls rather than sleeping once for the full `timeout`, so a long-running
// call (e.g. `analyze` on a multi-hour video) isn't killed just for taking
// a long time — it's only killed if the Python side goes silent for
// `timeout` seconds straight, since progress notifications reset the clock.
Task.detached { [weak self] in
try? await Task.sleep(for: .seconds(timeout))
await self?.expireRequest(id: requestId)
guard let self else { return }
while true {
try? await Task.sleep(for: .seconds(5))
let (lastActivity, stillPending) = await self.watchdogStatus(for: requestId)
if !stillPending { return }
if PythonBridge.isIdleExpired(lastActivityAt: lastActivity, timeout: timeout) {
await self.expireRequest(id: requestId)
return
}
}
}
}
return result
}

/// Snapshot of last-activity time and pending status for the watchdog loop, taken
/// atomically on the MainActor to avoid racing with `handleResponse`'s updates.
@MainActor
private func watchdogStatus(for id: Int) -> (lastActivityAt: Date, pending: Bool) {
(lastActivityAt, pendingRequests[id] != nil)
}

/// Pure idle-timeout predicate, extracted for unit testing without spinning up a
/// subprocess. Returns true once `now` is at least `timeout` seconds past
/// `lastActivityAt` — the condition under which the watchdog should expire a request.
nonisolated static func isIdleExpired(lastActivityAt: Date, now: Date = Date(), timeout: TimeInterval) -> Bool {
now.timeIntervalSince(lastActivityAt) >= timeout
}

/// Convenience: call a method and decode the result as a specific type.
func call<T: Decodable>(
_ method: String,
Expand Down Expand Up @@ -378,6 +411,8 @@ final class PythonBridge {
/// Route a decoded response to the appropriate handler.
@MainActor
private func handleResponse(_ response: RPCResponse) {
lastActivityAt = Date()

// Progress notification (no id, method == "progress").
if response.id == nil, response.method == "progress" {
if case .object(let obj) = response.params {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import XCTest
@testable import SilenciApp

/// Regression tests for the idle-watchdog fix in `PythonBridge.call()`.
///
/// Before this fix, a long-running call (e.g. `analyze` on a multi-hour video)
/// was killed by a flat wall-clock timeout regardless of progress. These tests
/// cover the extracted `isIdleExpired` predicate directly, so they run in
/// milliseconds without spinning up the Python subprocess.
final class PythonBridgeIdleTimeoutTests: XCTestCase {

func testNotExpiredShortlyAfterActivity() {
let now = Date()
let lastActivity = now.addingTimeInterval(-5)
XCTAssertFalse(PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: 600))
}

func testNotExpiredJustBeforeTimeoutBoundary() {
let now = Date()
let lastActivity = now.addingTimeInterval(-599)
XCTAssertFalse(PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: 600))
}

func testExpiredAtTimeoutBoundary() {
let now = Date()
let lastActivity = now.addingTimeInterval(-600)
XCTAssertTrue(PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: 600))
}

func testExpiredWellPastTimeout() {
let now = Date()
let lastActivity = now.addingTimeInterval(-3600)
XCTAssertTrue(PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: 600))
}

/// The actual bug this PR fixes: a long-running analysis that keeps emitting
/// progress notifications should never expire, no matter how much total time
/// elapses, as long as no single gap between notifications exceeds `timeout`.
/// Simulates 20 progress notifications spaced 100s apart (2000s total —
/// well past the old flat 600s cutoff) and asserts the watchdog never fires.
func testPeriodicProgressPreventsExpiryPastOldFlatCutoff() {
var lastActivity = Date()
let timeout: TimeInterval = 600

for _ in 0..<20 {
let now = lastActivity.addingTimeInterval(100)
XCTAssertFalse(
PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: timeout),
"watchdog should not fire while progress keeps arriving within the timeout window"
)
lastActivity = now // each notification resets the clock, mirroring handleResponse
}
}

/// Conversely: if progress genuinely stops (a real hang), the watchdog must
/// still fire — this isn't a no-op change, it preserves the original safety net.
func testGenuineStallStillExpires() {
let lastActivity = Date()
let now = lastActivity.addingTimeInterval(650) // no activity for 650s > 600s timeout
XCTAssertTrue(PythonBridge.isIdleExpired(lastActivityAt: lastActivity, now: now, timeout: 600))
}
}