Files
VoiceStudio/tests/test_user_env_authoritative.py
d6f24dafd5 feat(hardening): six recurrence guards from the closed-issue-history audit (#1141)
* 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>
2026-07-13 23:19:58 +05:30

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"