* feat(hardening): six recurrence guards from the closed-issue-history audit An agent audit swept every closed issue, clustered the error classes, and checked each for fix + regression test + upgrade/reinstall survival. Six of the "fixed but fragile" gaps are closed here; each guard has a regression test in tests/test_recurrence_hardening.py (9 tests). 1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight unified-memory box could still be OS-killed — the dub path frees memory before ASR loads (#1119) but nothing did before a TTS load. _make_room_before_tts_load() releases the idle capture-ASR model, clone prompts, and allocator caches when free RAM < the unified headroom. Deliberately NOT admission control: the #1111 decision (advisory-only, never refuse a load on an estimate) stands; this only does earlier what idle reclaim does later, and roomy machines skip it entirely. 2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran out of memory (RAM)" for signal 9 instead of guessing VRAM on machines that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.). 3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct heal was gated to design-kind; a clone profile saved by any bypassing client could persist prose that 400s on every use. profiles.py now sanitizes both kinds at the single choke point. 4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR) that don't exist and can't be created are dropped for the run with a loud log line (file untouched — replugging the drive restores the setting). The two #480 precedence tests updated to use creatable paths (they test precedence, not path validity). 5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks every persisted field before restore — one malformed field used to throw mid-restore and silently discard everything after it, and every future field re-opened the #1067 class. Includes a lockstep test failing when useAppData reads a field missing from the schema. 6. safe_replace EXDEV helper (class 7): os.replace across devices raises EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace degrades to copy+fsync+replace. Adopted at the two cross-directory movers (log rotation, persona restore); temp-sibling writers stay on os.replace. Plus: the generate timeout scales with text length (class 4's 503 wave — +1s per 40 chars past the first 1200, env floor respected), so long texts on slow hardware stop dying at exactly 300s with a "set an env var" remedy. Deliberately NOT done, with reasons: - ASR auto-promotion to the crash-isolated engine after a wedge: the code records an explicit owner rule against silent engine switching (asr_backend.py "we never switch engines automatically") — flagged to the owner instead of overridden. - Rust items (webview cache-clear unit test, crash-marker versioning across updates): deferred to their own PR — the local cargo target was reclaimed for disk space, so they can't be verified locally right now. Full suite: 2999 backend + 1243 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): correct PR ref to #1141 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation Both Greptile P1s were real: - "Startup preload skips reclaim": _make_room_before_tts_load() ran only in get_model(); preload_model() calls _load_model_with_timeout() directly, so a memory-tight machine was protected on demand loads but could still be OS-killed during the startup preload — the exact window the guard exists for. The reclaim now lives in _load_model_with_timeout(), the boundary both callers share. - "Read-only paths pass validation": an existing directory on a read-only mount passes makedirs+isdir but fails on first real use, so the stale setting survived validation only to break downloads later. The check now probes actual write capability (create+delete a probe file). New test with a chmod-0o500 dir (skipped under root, where the probe cannot fail). - CodeQL: the two intentional best-effort excepts in fsops.py now carry their explanatory comments. Full suite: 3000 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""Regression tests for #480 — the durable per-user env file (in-app Settings
|
|
source of truth) must OVERRIDE a value the desktop launcher pre-injected, so a
|
|
models directory changed in Settings actually takes effect after restart.
|
|
|
|
Pure unit tests (no Tauri / no real launch); top-level ``tests/`` + runtime
|
|
module import to avoid the sys.modules-isolation collection-order leak.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
|
|
def _user_env():
|
|
from core import user_env
|
|
return user_env
|
|
|
|
|
|
def test_user_env_overrides_preinjected_value(tmp_path, monkeypatch):
|
|
"""A value in the per-user env file must beat one a launcher (Tauri) already
|
|
injected into the environment — the core of #480."""
|
|
envfile = tmp_path / "env"
|
|
# A real, creatable path: since the stale-reinstall hardening, load-time
|
|
# validation drops path keys that can't exist — this test is about
|
|
# PRECEDENCE, so keep the path valid and assert the file value wins.
|
|
new_dir = tmp_path / "new-models-dir"
|
|
envfile.write_text(f"OMNIVOICE_CACHE_DIR={new_dir}\n")
|
|
monkeypatch.setenv("OMNIVOICE_ENV_FILE", str(envfile))
|
|
# simulate Tauri injecting the OLD value before the backend loads the file
|
|
monkeypatch.setenv("OMNIVOICE_CACHE_DIR", "/old/models/dir")
|
|
|
|
loaded = _user_env().load_into_environ()
|
|
|
|
assert loaded is True
|
|
assert os.environ["OMNIVOICE_CACHE_DIR"] == str(new_dir)
|
|
|
|
|
|
def test_user_env_sets_value_absent_from_environ(tmp_path, monkeypatch):
|
|
"""When nothing was pre-injected, the file value is applied as-is."""
|
|
envfile = tmp_path / "env"
|
|
chosen = tmp_path / "chosen-dir"
|
|
envfile.write_text(f"OMNIVOICE_CACHE_DIR={chosen}\n")
|
|
monkeypatch.setenv("OMNIVOICE_ENV_FILE", str(envfile))
|
|
monkeypatch.delenv("OMNIVOICE_CACHE_DIR", raising=False)
|
|
|
|
assert _user_env().load_into_environ() is True
|
|
assert os.environ["OMNIVOICE_CACHE_DIR"] == str(chosen)
|
|
|
|
|
|
def test_user_env_missing_file_is_noop(tmp_path, monkeypatch):
|
|
"""No file -> no-op, and a pre-injected value is left untouched."""
|
|
monkeypatch.setenv("OMNIVOICE_ENV_FILE", str(tmp_path / "does-not-exist"))
|
|
monkeypatch.setenv("OMNIVOICE_CACHE_DIR", "/old")
|
|
|
|
assert _user_env().load_into_environ() is False
|
|
assert os.environ["OMNIVOICE_CACHE_DIR"] == "/old"
|