fix: MockTTS destructor must not stop the shared playback thread#100
Conversation
TTS.playback is a class-level attribute shared by every TTS instance in the process. The inherited TTS.__del__ chains into TTS.stop() -> TTS.playback.stop(), so when an earlier PlaybackServiceHarness's MockTTS is garbage-collected its destructor terminated whatever PlaybackThread was *currently* registered there — which by then belongs to a later, still-running harness. The victim thread had _terminated set and exited its loop, so its queued speak never played and ovos.audio.output.ended was never emitted, hanging the next speak() until timeout. GC timing made this a flaky TimeoutError that surfaced only after several harness create/destroy cycles (e.g. mid-file in a consumer's test/end2end suite). Override MockTTS.__del__ as a no-op: the harness already manages playback-thread lifecycle explicitly via PlaybackService.shutdown() on context exit, so a mock instance must never tear down the shared thread on collection. Add regression tests: a deterministic guard that fires a stale mock's destructor while a later harness owns TTS.playback and asserts the live thread is neither terminated nor unable to keep speaking, plus a many-sequential-harnesses smoke test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthrough
ChangesMockTTS destructor isolation fix
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Checking in! Here's how the automated tests are looking. 🧐I've aggregated the results of the automated checks for this PR below. 🔍 LintThe automated pipeline is running smoothly. 🚂 ❌ ruff: issues found — see job log 🏷️ Release PreviewThe release announcement is being drafted in multiple languages. 🗣️ Current:
✅ PR title follows conventional commit format. 🚀 Release Channel Compatibility Predicted next version:
📋 Repo HealthEnsuring the repo isn't allergic to new features. 🤧 ✅ All required files present. Latest Version: ✅ 🔒 Security (pip-audit)I've audited the packages. Safety first! 🦺 ✅ No known vulnerabilities found (77 packages scanned). ⚖️ License CheckEnsuring our project is well-protected legally. 🛡️ ✅ No license violations found. Policy: Apache 2.0 (universal donor). StrongCopyleft / NetworkCopyleft / WeakCopyleft / Other / Error categories fail. MPL allowed. 🔨 Build TestsI've fired up the furnaces and forged your changes. ⚒️ ✅ All versions pass
📊 CoverageCoverage report incoming! Every line counts. 🎯 ❌ 53.2% total coverage Files below 80% coverage (17 files)
Full report: download the Powered by OVOS scripts and a bit of magic. ✨ |
ovoscope 1.0.2a1 ships the MockTTS.__del__ no-op fix (OpenVoiceOS/ovoscope#100): a garbage-collected mock from an earlier PlaybackServiceHarness no longer stops the shared TTS.playback thread owned by a later harness, which had been flaking the test/end2end/ suite (TimeoutError on the 2nd+ speak). Floor-pinned (>=) so pip resolves the prerelease with no --pre. Full test/end2end/ now green (57/57). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: adopt AUDIO-1 spec output topics (dual-namespace) Subscribe to the OVOS-AUDIO-1 spec-named topics additively, alongside the legacy mycroft.*/speak:* names, so both namespaces drive the same handlers: - ovos.utterance.speak.b64 (SpecMessage.SPEAK_B64, §3.4) + legacy speak:b64_audio - ovos.audio.queue (SpecMessage.AUDIO_QUEUE, §4.1) + legacy mycroft.audio.queue - ovos.audio.play_sound (SpecMessage.AUDIO_PLAY_SOUND, §4.2) + legacy mycroft.audio.play_sound - ovos.audio.is_speaking (SpecMessage.AUDIO_IS_SPEAKING, §5.3) + legacy mycroft.audio.speak.status - ovos.audio.stop (SpecMessage.AUDIO_STOP, §6) + legacy mycroft.audio.speech.stop The b64 handler now also emits ovos.audio.speech (SpecMessage.AUDIO_SPEECH, §3.4/§4.3) with the synthesised audio and ovos.mic.listen on listen=True, in addition to the legacy correlated .response. The speaking-status handler replies on ovos.audio.is_speaking and guards against answering its own reply (shared query/reply topic name). The universal ovos.stop broadcast stays wired (§6). Floor-pin ovos-spec-tools>=0.16.1a2 (first release carrying the six new SpecMessage members). Tests drive the service over a FakeBus and assert each of the six topics works under BOTH the legacy and the spec name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: don't hand-emit the legacy b64 reply — the bus client mirrors it handle_b64_audio emitted both ovos.audio.speech (AUDIO_SPEECH) AND the legacy speak:b64_audio.response (via message.response). The bus client's emit_legacy flag already mirrors ovos.audio.speech onto speak:b64_audio.response (ovos-spec-tools MIGRATION_MAP), so the legacy reply went on the wire twice. Emit the spec topic only and let the migration bridge legacy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: bump bus-client + spec-tools floors for AUDIO-1 dual-namespace handle_b64_audio relies on the bus-client emit_legacy migration and the AUDIO MIGRATION_MAP entries (added to ovos-spec-tools in the 0.17.x line, PR#55). Pin prerelease floors that guarantee the dual-namespace behavior is present, resolved by pip without --pre: ovos_bus_client >=2.2.0a1 -> >=2.5.1a1,<3.0.0 ovos-spec-tools >=0.16.1a2 -> >=0.17.3a1,<1.0.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: ovoscope e2e for b64 synthesised-audio dual-namespace delivery OVOS-AUDIO-1 §3.4/§4.3: handle_b64_audio now emits the synthesised audio on the spec topic ovos.audio.speech only; the bus client mirrors it onto the legacy speak:b64_audio.response (ovos-spec-tools MIGRATION_MAP). Add an additive ovoscope end-to-end test booting a real PlaybackService over a real bus with an offline MockTTS, driving both the legacy (speak:b64_audio) and the spec (ovos.utterance.speak.b64) input topics, and asserting the reply reaches a subscriber on BOTH ovos.audio.speech and speak:b64_audio.response with the expected b64 payload. Also covers listen=True re-opening the mic via ovos.mic.listen. Floor the ovoscope test dep to >=1.0.1a1 (first line whose transitive ovos-core no longer caps ovos-bus-client<2.0.0) and wire the gh-automations ovoscope.yml end-to-end workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: bump ovoscope floor to >=1.0.2a1 for the MockTTS shutdown fix ovoscope 1.0.2a1 ships the MockTTS.__del__ no-op fix (OpenVoiceOS/ovoscope#100): a garbage-collected mock from an earlier PlaybackServiceHarness no longer stops the shared TTS.playback thread owned by a later harness, which had been flaking the test/end2end/ suite (TimeoutError on the 2nd+ speak). Floor-pinned (>=) so pip resolves the prerelease with no --pre. Full test/end2end/ now green (57/57). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Problem
TTS.playbackis a class-level attribute shared by every TTS instance in the process. The inheritedTTS.__del__chains intoTTS.shutdown() -> TTS.stop() -> TTS.playback.stop(). So when an earlierPlaybackServiceHarness'sMockTTSis garbage-collected, its destructor stops whateverPlaybackThreadis currently registered there — which, by then, belongs to a later, still-running harness.The victim thread gets
_terminatedset and exits its run loop, so its queued speak never plays andovos.audio.output.endedis never emitted. The nextspeak()then hangs until timeout. Because GC timing is nondeterministic, this surfaced as a flakyTimeoutErroronly after several harness create/destroy cycles.This was caught when ovos-audio wired up the
ovoscopeCI job (OpenVoiceOS/ovos-audio#171): itstest/end2end/suite went red withTimeoutError: Speech playback for '...' did not finish within 10.0satovoscope/audio.py(thespeak()wait), on pre-existing tests that simply do 2+ speaks per harness after enough preceding harnesses.Root cause (traced)
At the stall, the active harness's
playback_thread.is_alive() == False/_terminated == True, with a speak item still queued and no live consumer.Fix
Override
MockTTS.__del__as a no-op. The harness already manages the playback-thread lifecycle explicitly viaPlaybackService.shutdown()on context exit, so a mock instance must never tear down the shared thread on collection.Tests
test_stale_mock_destructor_does_not_kill_live_thread— deterministic guard: fire a stale mock's destructor while a later harness ownsTTS.playback; assert the live thread is neither_terminatednor unable to keep speaking. Fails ondev, passes with the fix.test_many_sequential_harnesses_each_complete_speaks— many create/destroy cycles with forced GC; every speak must complete.Verification
test/end2end/test_playback_service_extended_e2e.py1 failed, 12 passed(3/3 runs)13 passed(3/3 runs)Full
ovoscopeaudio harness unit suite:44 passed. Full ovos-audiotest/end2end/:57 passed(was2 failed, 55 passed).🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests