From 42bad436aa249457f36c9d7319e751fee2f958a6 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 4 Sep 2026 14:05:36 +0800 Subject: [PATCH 1/2] fix(fallback): start the hedge when the primary fails, not when its timer expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swift hedge always slept the full `hedgeAfter` before starting the secondary, even when the primary had already failed and there was nothing left to wait for. The docstring has promised the opposite since it was written — "a primary that *fails* rather than stalls hands over immediately" — and the C# and Kotlin ports both implement it (`FallbackTranscriber.cs:107-110`, `FallbackTranscriber.kt:82`). Only Swift did not. Measured on this machine today, with Gemini returning a non-transient 400 from a geoblocked egress IP: 09:29:39.467 transcribing model=gemini-3.6-flash provider=google 09:29:41.151 error response status=400 ("not available in your current location") 09:29:41.152 giving up attempt=1 transient=no 09:29:47.901 primary stalled; starting the fallback after=8.0s 09:29:49.021 fallback answered first model=grok-stt provider=xai ms=9554 The primary was definitively dead at 1.7s and the fallback did not start until 8.4s. Every dictation paid ~6.7s of dead air; ten did so before this was found. The hedge now races its sleep against a one-shot signal the primary opens when it throws. Cancellation is excluded from that signal, so the hedge having already won does not count as the primary failing, and success never yields — a normal dictation must leave the hedge asleep so the winner's `cancelAll` keeps it from ever costing a second request. `testAFailingPrimaryFallsBackWithoutWaitingOutTheDelay` existed and passed against the broken code: it used a 20 ms hedge and asserted only which transcript came back, and "secondary" comes back either way. It now uses a realistic 8 s hedge and asserts the elapsed time, which is the actual claim. The fixture is a 400 rather than a 500 so no retry backoff lands inside the measurement. Against the old implementation it fails at 8.11s; against this one it passes in 0.026s. Full suite: 602 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) --- .../DoNotTypeCore/FallbackTranscriber.swift | 44 ++++++++++++++++--- .../FallbackTranscriberTests.swift | 23 +++++++++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Sources/DoNotTypeCore/FallbackTranscriber.swift b/Sources/DoNotTypeCore/FallbackTranscriber.swift index bf77d42..8d1e719 100644 --- a/Sources/DoNotTypeCore/FallbackTranscriber.swift +++ b/Sources/DoNotTypeCore/FallbackTranscriber.swift @@ -94,18 +94,30 @@ public struct FallbackTranscriber: Sendable { } return try await withThrowingTaskGroup(of: Outcome?.self) { group in + // Opened when the primary fails, so the hedge stops waiting on a backend that is not + // going to answer. Only failure yields here. A primary that *succeeds* has to leave + // the hedge asleep, because the winner cancelling its siblings is the whole reason a + // normal dictation never pays for a second request. + let (primaryFailed, reportPrimaryFailure) = AsyncStream.makeStream() + group.addTask { - Outcome( - result: try await primary.transcribeLong( - audio: audio, context: context, styled: styled, - onProgress: onProgress), - attribution: attribution(primary, wasFallback: false)) + do { + return Outcome( + result: try await primary.transcribeLong( + audio: audio, context: context, styled: styled, + onProgress: onProgress), + attribution: attribution(primary, wasFallback: false)) + } catch { + // Cancellation is the hedge having already won, not the primary failing. + if !(error is CancellationError) { reportPrimaryFailure.yield() } + throw error + } } group.addTask { // The hedge. Sleeping inside the group rather than scheduling separately means // cancellation of the winner's siblings also cancels a hedge that never fired, // so a fast primary costs nothing at all — not even a pending timer. - try? await Task.sleep(for: hedgeAfter) + await Self.waitToHedge(for: hedgeAfter, orUntil: primaryFailed) try Task.checkCancellation() // Logged at info: this is the app spending a second request on the user's behalf, // and a fallback that fires on every dictation is a misconfigured `hedgeAfter` @@ -154,6 +166,26 @@ public struct FallbackTranscriber: Sendable { } } + /// Waits out the hedge delay, or gives up on it the moment the primary fails. + /// + /// Racing the two legs inside a child group is what keeps both cancellable: `Task.sleep` + /// throws when cancelled and an `AsyncStream` iterator returns nil, so the group always + /// drains. Awaiting the signal directly would pin the hedge to something that never arrives + /// on the common path, where the primary answers and no failure is ever reported. + private static func waitToHedge( + for delay: Duration, orUntil primaryFailed: AsyncStream + ) async { + await withTaskGroup(of: Void.self) { group in + group.addTask { try? await Task.sleep(for: delay) } + group.addTask { + var failures = primaryFailed.makeAsyncIterator() + _ = await failures.next() + } + _ = await group.next() + group.cancelAll() + } + } + private func attribution( _ service: TranscriptionService, wasFallback: Bool ) -> Attribution { diff --git a/Tests/DoNotTypeCoreTests/FallbackTranscriberTests.swift b/Tests/DoNotTypeCoreTests/FallbackTranscriberTests.swift index a74a16f..e1f309b 100644 --- a/Tests/DoNotTypeCoreTests/FallbackTranscriberTests.swift +++ b/Tests/DoNotTypeCoreTests/FallbackTranscriberTests.swift @@ -59,16 +59,35 @@ final class FallbackTranscriberTests: XCTestCase { } /// Nothing to wait for: a primary that fails hands over rather than burning the hedge delay. + /// + /// The delay is a realistic eight seconds and the elapsed time is asserted, because neither + /// was true before and the gap that hid was a real one. With a 20 ms hedge and only the + /// transcript checked, this test passed against an implementation that always slept the full + /// delay: "secondary" comes back either way, just six seconds later. Which backend answered + /// is not the claim being made here — *when* it started is. + /// + /// The failure is a 400 rather than a 500 so that `isTransient` is false and no retry backoff + /// lands inside the measurement. That is also the shape of the failure this was written for: + /// Gemini answering `HTTP 400: This API is not available in your current location` in about a + /// second, after which there is nothing left to wait for. func testAFailingPrimaryFallsBackWithoutWaitingOutTheDelay() async throws { let hedger = FallbackTranscriber( primary: service( "primary", delay: .milliseconds(5), text: "", - failure: ProviderError.http(status: 500, body: "boom")), + failure: ProviderError.http(status: 400, body: "location not supported")), secondary: service("secondary", delay: .milliseconds(10), text: "secondary"), - hedgeAfter: .milliseconds(20)) + hedgeAfter: .seconds(8)) + let clock = ContinuousClock() + let started = clock.now let outcome = try await hedger.transcribe(audio: audio, context: nil) + let elapsed = clock.now - started + XCTAssertEqual(outcome.result.transcript.transcript, "secondary") + XCTAssertTrue(outcome.attribution.wasFallback) + XCTAssertLessThan( + elapsed, .seconds(1), + "the hedge waited out its delay after the primary had already failed") } /// The primary's error is the one that explains the user's configuration, so it is the one From cf2300b8642c6a055980dc2c141e94c2a6ad4bb8 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Fri, 4 Sep 2026 14:07:35 +0800 Subject: [PATCH 2/2] test(fallback): assert the handover timing on Windows and Android too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C# and Kotlin ports already hedge the moment the primary fails, but their tests asserted only which transcript came back — the same blind spot that let the Swift port sleep out the full delay undetected for as long as it has. A port that regressed to the Swift behaviour would still have passed here. Both now measure the elapsed time and assert it stays under a second, and both use an 8 s hedge so all three suites describe the same scenario with the same numbers rather than 5 s here and 8 s there. Measured with the delay in place: C# and Kotlin both return in ~0.02 s. Windows: 629 tests, 0 failures; `dotnet format whitespace --verify-no-changes` clean. Android: 282 JVM unit tests, 0 failures, the case itself at 0.021 s. Co-Authored-By: Claude Opus 5 (1M context) --- .../donottype/core/FallbackTranscriberTest.kt | 21 ++++++++++++++----- .../FallbackTranscriberTests.cs | 18 ++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/android/app/src/test/kotlin/app/donottype/core/FallbackTranscriberTest.kt b/android/app/src/test/kotlin/app/donottype/core/FallbackTranscriberTest.kt index 69744c6..67adbb8 100644 --- a/android/app/src/test/kotlin/app/donottype/core/FallbackTranscriberTest.kt +++ b/android/app/src/test/kotlin/app/donottype/core/FallbackTranscriberTest.kt @@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.system.measureTimeMillis /** * The Kotlin port of `FallbackTranscriber` has to behave like the Swift one. @@ -58,14 +59,24 @@ class FallbackTranscriberTest { /** Nothing left to wait for: a failing primary hands over without burning the delay. */ @Test fun `a failing primary falls back without waiting out the delay`() = runBlocking { - val outcome = FallbackTranscriber( - primary = backend(5, "", ProviderException("boom")), - secondary = backend(10, "secondary"), - hedgeAfterMillis = 5_000, - ).transcribe("primary", "p-model", "secondary", "s-model") + lateinit var outcome: FallbackTranscriber.Outcome + val elapsedMillis = measureTimeMillis { + outcome = FallbackTranscriber( + primary = backend(5, "", ProviderException("boom")), + secondary = backend(10, "secondary"), + hedgeAfterMillis = 8_000, + ).transcribe("primary", "p-model", "secondary", "s-model") + } assertEquals("secondary", outcome.result.transcript.transcript) assertTrue(outcome.attribution.wasFallback) + // The elapsed time is the claim, not which transcript came back: "secondary" arrives + // either way, just eight seconds later. This case passed against a Swift port that always + // slept the full delay, because only the transcript was ever checked. + assertTrue( + "the hedge waited out its delay after the primary had already failed ($elapsedMillis ms)", + elapsedMillis < 1_000, + ) } /** The primary's error explains the user's configuration, so it is the one they see. */ diff --git a/windows/DoNotType.Core.Tests/FallbackTranscriberTests.cs b/windows/DoNotType.Core.Tests/FallbackTranscriberTests.cs index ff9873a..9f90141 100644 --- a/windows/DoNotType.Core.Tests/FallbackTranscriberTests.cs +++ b/windows/DoNotType.Core.Tests/FallbackTranscriberTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using DoNotType.Core; using Xunit; @@ -47,17 +48,30 @@ public async Task AStalledPrimaryIsOvertakenByTheHedge() Assert.Equal("secondary", outcome.Attribution.Provider); } - /// Nothing left to wait for: a failing primary hands over immediately. + /// + /// Nothing left to wait for: a failing primary hands over immediately. + /// + /// The elapsed time is the claim here, not which transcript came back — "secondary" + /// arrives either way, just eight seconds later. This case passed against a Swift port that + /// always slept the full delay, because only the transcript was ever checked. This port has + /// always hedged on failure; the assertion is what stops it from quietly regressing to + /// match. + /// [Fact] public async Task AFailingPrimaryFallsBackWithoutWaitingOutTheDelay() { + var clock = Stopwatch.StartNew(); var outcome = await new FallbackTranscriber( Backend(5, "", new ProviderException("boom")), "primary", "p-model", Backend(10, "secondary"), "secondary", "s-model", - TimeSpan.FromSeconds(5)).TranscribeAsync(); + TimeSpan.FromSeconds(8)).TranscribeAsync(); + clock.Stop(); Assert.Equal("secondary", outcome.Result.Transcript.Text); Assert.True(outcome.Attribution.WasFallback); + Assert.True( + clock.Elapsed < TimeSpan.FromSeconds(1), + $"the hedge waited out its delay after the primary had already failed ({clock.Elapsed})"); } /// The primary's error explains the user's configuration, so it is the one shown.