Skip to content

apple-speech: collector subscription race and unbounded wait on results #137

Description

@kiki830621

Follow-up from #121 verification (raised by the cross-model leg; not resolved in PR #135).

1. The collector may not be subscribed before audio is fed

AppleSpeechEngine.transcribe starts an unstructured Task to drain transcriber.results, then feeds audio:

let collector = Task { for try await result in transcriber.results {  } }
defer { collector.cancel() }
let analyzer = SpeechAnalyzer(modules: [transcriber])
_ = try await analyzer.analyzeSequence(from: file)

Creating a Task schedules it; it does not guarantee it has run, let alone subscribed to the sequence, before the current task proceeds. It is a legal schedule for analyzeSequence to consume some or all of a short cached file before the collector's first poll.

Live probing established that results produced before subscription are lost — so the failure mode is a short or empty transcript returned without an error, which is this project's cardinal sin.

Not observed in practice: every probe (en/ja/zh, 4 corpora) returned the expected segments, and the benchmark numbers landed where the recorded transcripts say they should. So this is a latent race, not a reproduced bug — but it is unguarded, and "worked on my machine under light load" is exactly the evidence a scheduling race is expected to produce.

Direction: establish subscription before feeding — if makeAsyncIterator() is what subscribes, create the iterator synchronously and hand that to the collector. Task.yield() is not a correctness guarantee and should not be used as one.

2. collector.value has no upper bound

If the analyzer fails internally while both producer calls return successfully, and results never terminates, try await collector.value waits forever. There is no timeout anywhere in this engine, unlike ExternalProcessEngine, which has one.

Direction: a generous fixed deadline after finalizeAndFinishThroughEndOfInput() returns — it need not be derived from audio length. On expiry, fail with "results sequence did not terminate"; do not return the partially collected results, which would be a silent truncation.

Testing

Both need tests that do not depend on a live macOS 26 host — extract the collector orchestration behind a seam that accepts a fake AsyncSequence, so "producer must not start before the collector is ready" and "non-terminating sequence must time out" are assertable. A short-file stress loop under executor load would cover the race end-to-end where a 26 host is available.

Not in scope here

PR #135 already cancels the orphaned collector on every exit path, which fixes the leak. This issue is only about the two remaining ordering/liveness gaps.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions