* 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>
52 lines
1.9 KiB
Python
52 lines
1.9 KiB
Python
"""Cross-device-safe filesystem primitives.
|
|
|
|
``os.replace`` is atomic only within one filesystem; across devices it raises
|
|
``OSError(EXDEV)`` (surfacing to Windows users as ``[Errno 18]`` / ``[Errno 22]``
|
|
in past issue reports — the D:-drive/relocated-models class, #763/#479). Every
|
|
current call site derives its temp file from the destination directory, which
|
|
keeps same-device semantics — but nothing *enforced* that, and the next writer
|
|
that stages in ``%TEMP%`` and renames into a user-relocated data/models dir on
|
|
another drive reintroduces the whole class. This helper is the enforcement
|
|
point: replace when possible, degrade to copy+fsync+replace when the OS says
|
|
the two paths live on different devices.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
import shutil
|
|
|
|
|
|
def safe_replace(src: str, dst: str) -> None:
|
|
"""``os.replace`` with a cross-device fallback.
|
|
|
|
Same-device: identical to ``os.replace`` (atomic). Cross-device (EXDEV):
|
|
copy to a temp sibling of ``dst`` (same device as the destination), fsync,
|
|
then atomically replace — and remove ``src``. Not atomic *end-to-end*
|
|
across devices (impossible), but the destination itself still only ever
|
|
transitions atomically from old content to complete new content.
|
|
"""
|
|
try:
|
|
os.replace(src, dst)
|
|
return
|
|
except OSError as e:
|
|
if e.errno != errno.EXDEV:
|
|
raise
|
|
tmp = f"{dst}.xdev-tmp-{os.getpid()}"
|
|
try:
|
|
shutil.copyfile(src, tmp)
|
|
with open(tmp, "rb+") as f:
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, dst)
|
|
finally:
|
|
try:
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass # best-effort temp cleanup; the replace above already landed or raised
|
|
try:
|
|
os.remove(src)
|
|
except OSError:
|
|
pass # src may be gone already (another EXDEV fallback won the race)
|