reactormonk
10/15/2025, 4:16 PMfirst() doesn't get cancelled by the timeoutreactormonk
10/15/2025, 4:16 PMprivate suspend fun executeProgram(
program: TVControlProgram,
targetCondition: StateCondition
) {
Timber.tag("TVControlV2").d { " Executing ${program.commands.size} commands for program ${program.id} (non-interruptible)" }
withContext(NonCancellable) {
program.commands.forEachIndexed { index, command ->
Timber.tag("TVControlV2").v { " Command ${index + 1}/${program.commands.size}: ${command.javaClass.simpleName}" }
executeCommand(command)
}
}
// Wait for stabilization - monitors TV state reactively (interruptible)
// Must satisfy BOTH the program's target state AND the overall request target
Timber.tag("TVControlV2").d { " Waiting for stabilization (timeout=${program.stabilizationTimeout}), program target: ${program.targetState}, request target: $targetCondition" }
val startState = getCurrentState()
Timber.tag("TVControlV2").d { " Stabilization starting from state: $startState" }
withTimeout(program.stabilizationTimeout) {
var lastLoggedState: TVStateV2? = null
combine(
cecFlows.tvState,
cecFlows.activeSource
) { tvState, activeSource ->
mapToTVStateV2(tvState, activeSource)
}.first { state ->
// Log state changes
if (state != lastLoggedState) {
Timber.tag("TVControlV2").d { " Stabilization state change: $lastLoggedState -> $state" }
lastLoggedState = state
}
// Check if we've reached the program's immediate target OR the final request target
// Use OR because programs may be intermediate steps toward the final goal
val programTargetReached = program.targetState.matches(state)
val finalTargetReached = targetCondition.matches(state)
val matches = programTargetReached || finalTargetReached
Timber.tag("TVControlV2").v { " Stabilization check: state=$state, programTarget=$programTargetReached, finalTarget=$finalTargetReached, matches=$matches" }
if (matches) {
Timber.tag("TVControlV2").d { " Stabilization complete, state: $state (programTarget=$programTargetReached, finalTarget=$finalTargetReached)" }
}
matches
}
}
}
There is no other suspend stuff around.Joffrey
10/15/2025, 4:18 PMreactormonk
10/15/2025, 4:19 PMtry {
Timber.tag("TVControlV2").d { " Executing program ${program.id} (${program.commands.size} commands)" }
executeProgram(program, activeRequest.targetCondition)
Timber.tag("TVControlV2").d { " Program ${program.id} completed successfully" }
// Success - clear failed programs
activeRequest = activeRequest.copy(failedPrograms = emptySet())
} catch (e: CancellationException) {
throw e
} catch (e: TimeoutCancellationException) {
// Program timed out - mark as failed and try next
Timber.tag("TVControlV2").w(e) { "Program ${program.id} timed out during stabilization" }
activeRequest = activeRequest.copy(
failedPrograms = activeRequest.failedPrograms + program.id
)
} catch (e: Exception) {
// Other error - mark as failed
Timber.tag("TVControlV2").e(e) { "Program ${program.id} failed with exception" }
activeRequest = activeRequest.copy(
failedPrograms = activeRequest.failedPrograms + program.id
)
}
But the log line in TimeoutCancellationException doesn't show upJoffrey
10/15/2025, 4:19 PMTimeoutCancellationException a CancellationException? The first catch will prevent the second from runningreactormonk
10/15/2025, 4:20 PMreactormonk
10/15/2025, 4:20 PMJoffrey
10/15/2025, 4:21 PMwithTimeout and stick to withTimeoutOrNull. The latter checks whether the TimeoutCancellationException is actually its own, so it only returns null when its own timeout expired.Joffrey
10/15/2025, 4:22 PMwithTimeout you cannot know whether the exception comes from the withTimeout that you call, or from another withTimeout higher up that your code is wrapped inreactormonk
10/15/2025, 4:22 PM