fix: Han audit round 2 — SkillApi retention, accuracy gate, false-green assertions, race windows - #123
Conversation
Memory (BLOCKER): SkillApi.bus is a process-wide class attribute that
SkillManager points at the harness FakeBus during boot. Left there after
stop(), it pinned the whole MiniCroft object graph — ~633MB per stopped
instance — so a suite that boots several harnesses kept all of them alive.
stop() now restores it and clears the handlers still on the harness bus.
Bus-coverage tracker: start_tracking() wraps bus.emit. An assertion failure
between start and stop left the wrapper installed for the rest of the process
and stacked one more wrapper per test. The tracked block is now in a
try/finally.
Session chaining: a response with no session in its context raised a bare
KeyError. It now says which source_message cannot be chained, and why.
CaptureSession: an end-of-test message emitted OUTSIDE a capture window
counted towards the next capture, which then returned at once with an empty
message list — a vacuous pass. A capture is now armed under the same lock
that resets the counter, and only an armed generation counts. __del__ is a
no-op when the MiniCroft has no bus.
Mock TTS: `if not self._stopped: bus.emit(...)` was a TOCTOU against stop().
Both sides now hold one lock, so the flag flip and the emit are exclusive.
Default session: the snapshot and the restore now use the SAME bus-client API
family (to_dict/from_dict or serialize/deserialize) — pairing them across
families rebuilt a wrong session. When the snapshot itself failed, the restore
no longer degrades to a total no-op: active_skills is put back explicitly.
GUI assertions: namespace and page comparisons used substring matching, so
they could not fail on a near match ("weather" passed on any namespace
containing it). They compare by equality now, with opt-in exact=False for the
old behaviour. assert_namespace_cleared also matches gui.clear.namespace, the
topic the GUI service really emits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The --ovoscope-accuracy-min gate set its failure flag in pytest_terminal_summary and read it in pytest_sessionfinish. sessionfinish runs FIRST, so the flag was always read before it was set and the gate could never change the exit status — a CI job with 0% accuracy still exited 0. The gate is now computed (and cached on the config) in sessionfinish; terminal_summary only prints the cached result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
diff: a file without "expected_messages" fell back to an empty list, so two unrelated JSON files compared []-vs-[] and the CLI reported "Identical" with exit 0. It now raises ValueError, and the CLI turns that into a clean error. _dict_diff uses a sentinel, so an expected None no longer matches an absent key. bus_coverage: stop_tracking() restores bus.emit only while it is still this tracker's own wrapper, so it cannot clobber another tracker's wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCPPlayerHarness started eight mock.patches before constructing the real player; PlaybackServiceHarness claimed TTS.queue and the _active singleton before its patcher started; PipelineHarness booted a MiniCroft and then wired the sink outside any guard. A failure in the middle left process-wide state patched for the rest of the run. Each __enter__ now unwinds through its own teardown before propagating. PipelineHarness also clears the sink's stale verdict: an explicit intent failure left the PREVIOUS match on _last_match, and match_result did not reset it between utterances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- wakeword_probe: apply_hotword_compat() patched HotWordEngine.__init__ for the rest of the process. It is now a `hotword_compat()` context manager scoped to the engine construction it exists for. - listener: `any(e.found_wake_word() ...)` short-circuits, and found_wake_word() is a destructive read — later engines stayed latched and reported a stale detection on the NEXT call. Every latch is read first. - listener: the except ImportError around AudioTransformersService covered the constructor too, so a constructor bug surfaced as "install ovos-dinkum-listener". The guard now covers only the import. - listener: a WAV that will not parse is logged before falling back to raw PCM. - voice_loop: shutdown() detaches the bus capture handler, so a harness torn down without the context manager stops collecting messages. - media_provider: real-provider calls run under a timeout (call_timeout, default 30s) instead of hanging the run on an unresponsive server. - tts_intelligibility: a reference-STT failure is logged and marked (transcript=None plus transcribe_failed / transcribe_error in the report) instead of silently scoring wer=1.0 as if the TTS were unintelligible. Rendered file names use a sha1 prefix, not a randomised 32-bit hash. - setup_skill: a failed SKILL.md download reports an error and exits 1 instead of leaving a half-installed skill and claiming success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds test_audit_round2.py — one adversarial test per audit finding, plus direct coverage of helpers that had none: E2EPipelineHarness config restore (including two subclasses back to back), wait_for_failure on timeout, the adapt register/detach round trip, diff._dict_diff on nested dicts and lists of dicts, the CLI on a missing and on a failing fixture, _parse_setup_py_entry_points on good and malformed input, the PipelineHarness match/failure sequence, and an anti-vacuity guard that an injected failure really does propagate out of End2EndTest. test_capture_session.py and TestCaptureSessionDel now drive a SimpleNamespace(bus=FakeBus()) stub instead of booting a MiniCroft: the class only touches minicroft.bus, and each boot retained hundreds of MB. The GUI tests asserted namespaces that only CONTAINED the expected value — exactly the false green being fixed — so they now assert the real namespace, with new cases proving a near match fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (20)
📝 WalkthroughWalkthroughThe change hardens ovoscope lifecycle cleanup, session restoration, capture handling, fixture validation, accuracy-gate exit status, GUI assertions, provider timeouts, wake-word processing, TTS reporting, setup failures, and regression coverage. ChangesAudit round 2 hardening
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
✨ 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 |
The test bypassed __init__ with __new__ and set only the attributes match_result used at the time, so it broke the moment match_result read one more attribute. Constructing the object normally keeps it honest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The results of your automated verification are here! 📜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 PreviewChecking if we're ready for the big release. 🏁 Current:
✅ PR title follows conventional commit format. 🚀 Release Channel Compatibility Predicted next version:
🔒 Security (pip-audit)Checking for any insecure data transmissions. 📡 ✅ No known vulnerabilities found (79 packages scanned). ⚖️ License CheckLegal eagle here! Checking those licenses. ⚖️ ✅ No license violations found. Policy: Apache 2.0 (universal donor). StrongCopyleft / NetworkCopyleft / WeakCopyleft / Other / Error categories fail. MPL allowed. 📋 Repo HealthA quick checkup for the repository! 🩺 ✅ All required files present. Latest Version: ✅ 🔨 Build TestsConstruction of your features is officially finished. 🏠 ✅ All versions pass
📊 CoverageCoverage report incoming! Every line counts. 🎯 ❌ 58.6% total coverage Files below 80% coverage (15 files)
Full report: download the Your automated guardian for repository health 🛡️ |
) OVOSSkill.get_response()/ask_yesno() (ovos-workshop 7.0.6) spawn a killable background thread that re-prompts forever when num_retries=-1 (the default) and nothing ever answers it. On a real voice satellite the listener/GUI eventually emits mycroft.skills.abort_question on user silence; ovoscope's synchronous FakeBus never generates one, so any skill handler that calls get_response()/ask_yesno() without a queued follow-up utterance hangs the calling thread indefinitely (OVOSSkill._wait_response(), ovos_workshop/skills/ovos.py ~1802-1809 - "while not ans: time.sleep(0.1)", no deadline). MiniCroft now arms a short watchdog timer on "skill.converse.get_response.enable" that emits the existing "mycroft.skills.abort_question" bus message (no new message type) if ".disable" hasn't fired first - mirroring what a real listener/GUI would send on silence. This lets get_response()/ask_yesno() resolve to None promptly instead of looping every get_response_timeout (20s default) forever. Watchdog timers are keyed by (skill_id, session_id) - the same two-part scope ovos-workshop's own @killable_event("mycroft.skills.abort_question", check_skill_id=True) already uses to decide which stalled thread an abort is for. An adversarial review of an earlier version of this fix caught a real regression here: a flat list of pending timers meant ANY skill's ".disable" cancelled EVERY other skill's still-pending watchdog too, so in a fleet-style MiniCroft running multiple skills concurrently, one skill finishing its (answered) get_response() would silently disarm a different skill's watchdog - if that second question was never answered, it hung forever again, defeating the fix for exactly the multi-skill case it needs to hold up under. Scoping by (skill_id, session_id) and cancelling only the matching entry on ".disable" fixes it; all remaining timers are still cancelled in stop() the same way the existing mock-TTS timers already are. The existing nested-speak_dialog(wait=True) mock-TTS handshake (recognizer_loop:audio_output_start/end) already resolves correctly for nested calls via its own per-call Timer; a regression test pins that behavior alongside the get_response fix and its concurrency scoping. Field evidence: OpenVoiceOS/ovos-skill-alerts#138 "Update 3" - py-spy captures showing OVOSSkill._real_wait_response threads parked forever on the ovoscope FakeBus, and PR #123's CI job silently hanging to the 30-minute kill on 4 of 5 Python versions after the last test finished. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) (#130) OVOSSkill.get_response()/ask_yesno() (ovos-workshop 7.0.6) spawn a killable background thread that re-prompts forever when num_retries=-1 (the default) and nothing ever answers it. On a real voice satellite the listener/GUI eventually emits mycroft.skills.abort_question on user silence; ovoscope's synchronous FakeBus never generates one, so any skill handler that calls get_response()/ask_yesno() without a queued follow-up utterance hangs the calling thread indefinitely (OVOSSkill._wait_response(), ovos_workshop/skills/ovos.py ~1802-1809 - "while not ans: time.sleep(0.1)", no deadline). MiniCroft now arms a short watchdog timer on "skill.converse.get_response.enable" that emits the existing "mycroft.skills.abort_question" bus message (no new message type) if ".disable" hasn't fired first - mirroring what a real listener/GUI would send on silence. This lets get_response()/ask_yesno() resolve to None promptly instead of looping every get_response_timeout (20s default) forever. Watchdog timers are keyed by (skill_id, session_id) - the same two-part scope ovos-workshop's own @killable_event("mycroft.skills.abort_question", check_skill_id=True) already uses to decide which stalled thread an abort is for. An adversarial review of an earlier version of this fix caught a real regression here: a flat list of pending timers meant ANY skill's ".disable" cancelled EVERY other skill's still-pending watchdog too, so in a fleet-style MiniCroft running multiple skills concurrently, one skill finishing its (answered) get_response() would silently disarm a different skill's watchdog - if that second question was never answered, it hung forever again, defeating the fix for exactly the multi-skill case it needs to hold up under. Scoping by (skill_id, session_id) and cancelling only the matching entry on ".disable" fixes it; all remaining timers are still cancelled in stop() the same way the existing mock-TTS timers already are. The existing nested-speak_dialog(wait=True) mock-TTS handshake (recognizer_loop:audio_output_start/end) already resolves correctly for nested calls via its own per-call Timer; a regression test pins that behavior alongside the get_response fix and its concurrency scoping. Field evidence: OpenVoiceOS/ovos-skill-alerts#138 "Update 3" - py-spy captures showing OVOSSkill._real_wait_response threads parked forever on the ovoscope FakeBus, and PR #123's CI job silently hanging to the 30-minute kill on 4 of 5 Python versions after the last test finished. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Round 2 of the Han audit. Provenance: adversarial validation of the round-1
fixes, a resilience sweep of the harness modules, and a test-quality audit of
the suite itself.
Every finding below has a regression test in
test/unittests/test_audit_round2.pythat fails on the pre-fix code (verified by re-running the new suite against
the base commit: 18 of them fail there).
A. Memory retention (BLOCKER — root-caused and measured)
SkillApi.busis a process-wide class attribute.SkillManagerpoints it atthe harness
FakeBusduring boot and nothing ever put it back, so a stoppedMiniCroft stayed reachable through that bus and its handlers.
Measured cost: ~633MB retained per stopped MiniCroft. A suite that boots
several harnesses kept every one of them in memory until the process ended.
MiniCroft.__init__now snapshotsSkillApi.busnext to_original_sm_bus,and
stop()restores it. As defence in depth,stop()also removes thelisteners on its own bus and clears
_handler_guards/_dedup_registrations.Regression test: boot and stop two MiniCrofts, collect, and assert at most one
MiniCroftobject survives (the first-boot residue from lazily built modulesingletons is known and allowed).
B. Dead CI gate (BLOCKER)
--ovoscope-accuracy-minsetconfig._ovoscope_accuracy_gate_failedinpytest_terminal_summaryand read it inpytest_sessionfinish. sessionfinishruns FIRST, so the flag was always read before it was set: the gate could
never fail a CI run. A job at 0% accuracy printed "gate FAILED" and exited 0.
The gate is computed (and cached on the config) in
pytest_sessionfinish;pytest_terminal_summaryonly prints the cached result. The regression testruns pytest in a subprocess with a seeded result and asserts the exit status.
C. Silent state corruption / false green
bus.emitoutside anytry/finally. Anassertion failure left the wrapper installed for the rest of the process and
stacked one more per test. Now in a
finally, andstop_tracking()restoresonly while
bus.emitis still that tracker's own wrapper.source_messageonto a response with no session raised a bareKeyError; it now names the message that cannot be chained.OCPPlayerHarnessstarted eightmock.patches before an unguardedOCPMediaPlayer(...), andPlaybackServiceHarnessclaimedTTS.queueandthe
_activesingleton before its patcher started. A failure in the middleleft process-wide state patched for the rest of the run. Both unwind now.
ovoscope diffon a file with noexpected_messagescompared[]vs[]and reported "Identical" with exit 0. It raises
ValueErrornow, and_dict_diffuses a sentinel so an expectedNonediffers from an absent key.not fail on a near match. They compare by equality now (page by basename),
with opt-in
exact=Falsefor the old behaviour.assert_namespace_clearedalso matches
gui.clear.namespace, the topic the GUI service really emits —it could not match any real message before.
D. Race windows (from adversarial validation of round 1)
if not self._stopped: bus.emit(...), whichstop()couldinterleave. Both sides hold one lock now, so the flag flip and the emit are
exclusive.
CaptureSessioncounted an end-of-test message emitted OUTSIDE a capturewindow, so the next capture returned at once with an empty message list — a
vacuous pass. A capture is armed under the same lock that resets the counter,
and only the armed generation counts. The public API is unchanged.
to_dict()output withdeserialize(). The API family used at snapshot time is recorded and thematching loader is used. When the snapshot itself failed, the restore no
longer degrades to a total no-op:
active_skillsis put back explicitly.PipelineHarness.__enter__left a booted MiniCroft running if the wiringafter the boot raised.
E. Resilience sweep
apply_hotword_compat()patchedHotWordEngine.__init__permanently; it isnow the
hotword_compat()context manager.any(e.found_wake_word() ...)short-circuited over a destructive read, solater engines stayed latched and reported a stale detection on the next call.
wer=1.0, which reads as a TTSregression. It is logged and marked (
transcript=None,transcribe_failed,transcribe_errorin the report). Rendered file names use a sha1 prefixinstead of a randomised 32-bit
hash().SKILL.mddownload now reports an error and exits 1.except ImportErroraroundAudioTransformersServicecovered theconstructor too; it now covers only the import.
MiniVoiceLoop.shutdown()detaches the bus capture handler._wav_to_audio_datalogs a WAV parse failure before falling back to raw PCM.CaptureSession.__del__is a no-op when the MiniCroft has no bus.call_timeout, default 30s).PipelineHarness: an explicit intent failure left the PREVIOUS match on thesink's
_last_match, andmatch_resultdid not reset it between utterances.F. Test-suite quality
test_capture_session.pyandTestCaptureSessionDelbooted a real MiniCroftto exercise a class that only touches
minicroft.bus; both use aSimpleNamespace(bus=FakeBus())stub now, matching the canonical race tests intest_audit_round1.py.New direct coverage for helpers that had none:
E2EPipelineHarnesssetUpClass/tearDownClass config restore (including two subclasses back to
back),
wait_for_failureon timeout, the adapt register/detach round trip,_dict_diffon nested dicts and lists of dicts, the CLI on a missing and on afailing fixture,
_parse_setup_py_entry_pointson good and malformed input,the PipelineHarness match/failure sequence, and an anti-vacuity guard that an
injected failure really does propagate out of
End2EndTest.execute().Fixes by Claude (opus), orchestrated by Claude Fable.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes