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.
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.transcribestarts an unstructuredTaskto draintranscriber.results, then feeds audio:Creating a
Taskschedules it; it does not guarantee it has run, let alone subscribed to the sequence, before the current task proceeds. It is a legal schedule foranalyzeSequenceto 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.valuehas no upper boundIf the analyzer fails internally while both producer calls return successfully, and
resultsnever terminates,try await collector.valuewaits forever. There is no timeout anywhere in this engine, unlikeExternalProcessEngine, 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.