Commit Graph
438 Commits
Author SHA1 Message Date
973f7a77a6 fix(generate): a deadline is not an unrecognized error (#1368) (#1373)
* fix(generate): a deadline is not an unrecognized error (#1368)

Reported on macOS/MPS with indextts2:

    TTS engine stopped mid-generation with an error OmniVoice doesn't
    recognize. Retry once; if it keeps failing, please report it with the
    full trace. Underlying error: TimeoutError:

Nothing follows that last colon. TimeoutError is routinely raised with an
empty message, so the user was asked to report a trace that says nothing,
about the one failure mode whose cause is entirely known.

The classification chain already refuses to blame VRAM for network (#880)
and config (#919) failures. A deadline is the same kind of thing -- a
known class with a specific remedy -- and it was falling through to the
catch-all. Worse, a timeout whose message happened to contain an OOM-ish
word would have hit the memory branch and sent the user to Flush for
memory they never ran out of, which is exactly the class bug #880 fixed.

_is_timeout_failure() matches TimeoutError (and asyncio/futures aliases),
the project's own GpuJobTimeoutError by name since it is not a subclass,
and stringified forms from sidecars that wrap the child's error. Checked
BEFORE the OOM branch. "read timed out" is explicitly left to the network
branch: that is a dying model download, which it explains better.

The message names the time limit, the three usual causes, and
OMNIVOICE_GENERATE_TIMEOUT_S so someone on slow hardware has a way
through rather than only an explanation. The "Underlying error" tail is
appended only when the exception actually carries a message -- otherwise
it rendered as a bare `TimeoutError:`, a sentence stopping mid-thought.
Testing that emptiness against str(e), not _safe_exc_text(), which always
prefixes the type name and so is never empty.

Likely the in-the-wild face of #1367: indextts2 is a sidecar, and a
first-use weights download overrunning the 300s generate budget produces
precisely this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(generate): drop placeholder-free f-prefixes (#1368)

CodeRabbit on #1373: after the interpolation moved into `_tail`, the
message literals no longer interpolate anything, so the f-prefixes were
dead weight and Ruff F541. Ruff does not run in CI, so this is
consistency with the surrounding code rather than a broken gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:43:29 +05:30
e8bb174a0b fix(startup): explain a lost port race instead of exiting 1 in silence (#1364) (#1370)
* fix(startup): explain a lost port race instead of exiting 1 in silence (#1364)

#1223 gave a port conflict a dedicated exit code, and handles the race
between our pre-probe and uvicorn's real bind by re-probing after uvicorn
dies. That only helps while the other process is STILL holding the port.

The common case is an orphaned backend from the previous session which is
itself shutting down. It releases the port between uvicorn's failed bind
and our re-probe, the probe reports "free", and the user gets a bare
`exit code 1` -- for a crash we had already fully diagnosed. Reported on
Windows with the tell-tale ordering: `Application startup complete`
(uvicorn's lifespan runs before the bind), then `[Errno 10048] error
while attempting to bind`, then a plain exit 1.

uvicorn already hands us the answer: its startup does
`logger.error(exc); sys.exit(1)` with the OSError itself as the record's
message, so the errno is available as an object -- no locale-dependent
string matching, which is the trap #1223 exists to avoid. Observe that
record, believe it first, and keep the re-probe as the fallback.

The watcher also pins the uvicorn.error level to at most ERROR: a filter
only runs on records the logger emits, so a higher level would drop the
bind failure and silently restore the unexplained exit 1. No-op today
(uvicorn defaults to INFO and nothing here raises it) -- it stops the
mechanism being disarmed at a distance later.

Two regression tests, both driving the real uvicorn. The race is
simulated deterministically by making both probes report the port free
while it is genuinely held, which is exactly the state the race leaves
us in; verified to exit 1 without the watcher and 78 with it. The
assertion is on our own wording, not "already in use" -- that phrase is
also in uvicorn's own English log line, so matching it would pass against
the unfixed build and would be the very locale-dependent match #1223
forbids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(startup): pin what the bind watcher actually depends on (#1364)

Review round on #1370.

greptile P1 claimed uvicorn's logging setup removes the filter, making
the watcher inert. Measured against the installed uvicorn: it does not.
`dictConfig` replaces a logger's HANDLERS and leaves its FILTERS alone,
and the end-to-end test already exercised the real `uvicorn.run` and got
exit 78.

The conclusion was right for a different reason, though, and it caught a
genuine mistake: uvicorn resets `uvicorn.error`'s LEVEL from its config
during startup -- after the defensive `setLevel(ERROR)` this added. That
guard was dead code offering false assurance, so it is gone. The real
precondition is that the guarded `uvicorn.run()` must not raise log_level
above ERROR, since a filter only runs on records the logger emits.

Both behaviours are now pinned by tests that measure the installed
uvicorn rather than assuming it, so a version that starts clearing
filters, or a change that quietens the guarded serve call, fails loudly
instead of silently restoring the unexplained exit 1. The log_level test
is scoped to the guarded call -- the --health-check smoke path sets
log_level="warning", which is below ERROR and irrelevant.

Also pins the production wiring itself (CodeRabbit): the end-to-end tests
rebuild the guard from extracted source, so they would still pass if
main.py stopped installing the filter or stopped consulting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(startup): check log_level by AST, not regex (#1364)

CodeRabbit on #1370: the regex only recognised string literals, so
`log_level=settings.level` -- or any computed value -- matched nothing
and the assertion passed while verifying nothing.

That is the same class of bug as #1357's pin that did not apply: a check
that looks present and is inert. Parsed with ast now; a non-literal is an
explicit failure rather than a silent skip, and the guarded call is
located by walking to the addFilter and taking the uvicorn.run after it
instead of by source order.

Verified against three mutations of main.py:

    literal critical    -> FAILED
    computed value      -> FAILED
    literal warning     -> passed   (below ERROR, must not fail)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:14:27 +05:30
c819372449 fix(install): make the torch pin reach the Colab and Docker installs (#1357) (#1369)
* fix(install): make the torch pin reach the Colab and Docker installs (#1357)

#1358 pinned the trio in `[tool.uv] constraint-dependencies`, which fixes
`uv sync` / `uv lock` / `uv run`. It does nothing for `uv pip install` --
that is the pip-compatible interface and ignores project-level uv
settings -- and `uv pip install --system --no-cache .` is exactly what
both the Colab notebook and deploy/Dockerfile run.

Measured on one Python 3.12 environment, same command, pin present:

    without --constraint:  torch 2.13.0  torchaudio 2.11.0  torchvision 0.28.0
    with    --constraint:  torch 2.8.0   torchaudio 2.8.0   torchvision 0.23.0

So the reported install path was still resolving the three on their bare
lower bounds (`torch>=2.4`, `torchvision>=0.19`), free to move torch past
a torchvision built for an older ABI -- which is the reported failure,
`operator torchvision::nms does not exist`, against the preinstalled
torchvision in Colab's /usr/local/lib/python3.12/dist-packages/.

The pins move to deploy/torch-constraints.txt and are passed explicitly
at both call sites. No local version segment, so PEP 440 matches the
base images' +cu128 and +rocm6.4 builds instead of replacing them -- the
property the ROCm image depends on.

Also extends the Docker guard, which asserted on torch and torchaudio
only, omitting the one package that actually broke. It now imports
torchvision.ops and touches nms, so an ABI mismatch fails the build
rather than shipping.

Recurrence: docker.yml builds only on push to main, never on a PR, so
nothing would have caught a silent regression here before it shipped.
tests/test_torch_constraints_are_applied.py fails if the file drifts from
pyproject, if either call site drops --constraint, if the Dockerfile stops
COPYing the file, or if the guard stops covering torchvision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(install): assert the constraint in the argv, not the cell text (#1357)

CodeRabbit on #1369, both findings valid.

The notebook check scanned the whole cell, so it passed when --constraint
was deleted from the run([...]) list but its explanatory comment
survived -- exactly the "the pin looks present but does not apply" shape
this PR exists to fix. It now parses the cell with ast and asserts
--constraint is in the argument list AND immediately followed by the
constraints file. Verified by deleting the flag from the argv while
keeping the comment: the test fails.

Also drops test_the_notebook_is_still_valid_json_and_has_its_cells --
it passed before the change and duplicated JSON parsing the constraint
test already does. A tautology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:03:57 +05:30
debpalashandClaude Opus 5 f259a3481f docs(changelog): record the PocketTTS engine (#1306, #1328)
#1328 merged without a CHANGELOG entry. Per the changelog rule that is
the immediate next commit rather than backlog, since release.yml extracts
the section verbatim as the release body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:16:47 +05:30
3183bf5fcd fix(engines): downmix along the channel axis, not axis 0 (#1328) (#1366)
* fix(engines): downmix along the channel axis, not axis 0 (#1328)

Found while reviewing #1328. Every subprocess sidecar guards its PCM
conversion with a defensive `arr.mean(axis=0)`, which is correct only for
channels-first audio. For a channels-last (N, 2) array `squeeze()` keeps
both axes and the mean runs across TIME: every output sample becomes the
mean of two neighbouring samples and the render collapses to 2 samples.
That is not a downmix, it is a destroyed waveform played back as noise.

Unreachable in all five today because every engine returns mono -- which
is precisely why it could sit there being wrong. Nothing runs it, so
nothing reports it, and the first engine or SDK version to emit stereo
gets noise with no error anywhere.

Pick the channel axis instead of assuming it, and loop so a stray extra
axis reduces the whole way to mono; previously a (2, N, 2) array stayed
2-D after one mean and produced a PCM buffer whose length disagreed with
the n_samples in the frame -- a desynchronized audio frame rather than a
merely wrong-sounding one.

Downmixing correctly rather than raising (the choice PocketTTS made on
#1328): these five are shipping engines, and turning a render that works
today into an error is a regression risk that the actual defect -- the
wrong axis -- does not require taking.

The sidecars run under different interpreters (confucius4 and dots.tts
each have their own venv), so they cannot import a shared helper and the
duplication cannot be refactored away. The recurrence guard is therefore
a test that holds all five to the same behaviour at once, so a sixth copy
pasted into a new sidecar fails there rather than shipping: 20 of its 30
cases fail before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(engines): let a broken sidecar fail instead of skipping

CodeRabbit Major on #1366: the blanket `except Exception -> pytest.skip`
turned a syntax error or an import-time regression in any of the five
sidecars into a skip, so this regression suite could pass CI while
running nothing.

All five are stdlib-only at import (torch and the model load lazily on
the first synthesize), so there is no optional dependency to tolerate --
an import failure here is a real defect in a shipping engine. Unguarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 23:50:06 +05:30
c117bca09e fix(release): rebuild + cryptographically verify the preview updater manifest (#1327) (#1362)
* fix(release): rebuild + cryptographically verify the preview updater manifest

Since ~2026-07-13 every nightly matrix leg logs 'Signature not found for
the updater JSON. Skipping upload...' - tauri-action uploads the bundles
and .sig companions but never refreshes latest.json. Combined with the
'Clear this arch's stale preview updater bundle' step (which deletes and
replaces the version-less macOS tar.gz every night), the preview
manifest's darwin signatures no longer match the published files: macOS
Preview users hit 'The signature verification failed' on every update
(latest.json frozen at 2026-07-13, tar.gz replaced nightly).

Two changes, both in the single post-matrix preview-notes job (no
per-leg race):

1. Rebuild latest.json from the release's real assets and their .sig
   companions, then clobber-upload. The manifest can no longer drift
   from the files it describes, regardless of what tauri-action's own
   updater-JSON path does or skips.

2. Extend the existing manifest verification with a cryptographic
   check: every signature in latest.json must verify (minisign
   file sig + trusted-comment sig) against the artifact it points at,
   using the updater pubkey from tauri.conf.json. Parity and version
   format both passed for 2+ weeks while every darwin entry was
   unverifiable - this is the check that was missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): refuse a preview manifest built from two different runs

Bot review findings on this branch, all fixed here:

- The AppImage and MSI were picked independently by highest run number and
  the larger N became *the* version, so a matrix where one leg failed or
  was re-run published a manifest advertising X.Y.Z-5 while handing Windows
  users the -4 MSI. That is the same manifest/artifact drift this job
  exists to end, reintroduced by the fix for it. Require both legs to come
  from one run and fail loudly otherwise: leaving the previous manifest in
  place is a visible, already-understood state; shipping a mismatched one
  is not. The darwin tarballs carry no run number, so the signature check
  in the following step is what pins those to the published bytes.
- persist-credentials: false on the checkout — nothing here pushes to git.
- Floor-pin the cryptography install; this step decides whether a signed
  manifest is trustworthy, so it is the one dependency worth a bound.

tests/test_release_preview_manifest_rebuild.py runs the step body extracted
from release.yml against stubbed gh, so it cannot drift from the workflow.
Fails before / passes after on the mismatch case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): note the preview updater manifest fix (#1327)

Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): verify the preview manifest before publishing it, not after

Two more review findings on this branch, both valid, both about the
manifest being wrong in a way the existing checks structurally cannot see.

greptile P1 — verification ran AFTER the clobber-upload. A manifest that
failed the check was already live and stayed served; the job merely went
red, and every macOS Preview user stayed broken until someone noticed.
Verification now runs against the file about to be published, and the
upload is the last thing in the step. A refusal leaves the previous
manifest in place, which is a visible, already-understood state.

CodeRabbit — the darwin entries were not tied to this run. The version
comes from the AppImage name; the macOS tarballs were only checked for
existence. Signature verification cannot help there, because a stale
tarball and its stale .sig match each other perfectly — so a run whose
macOS legs never uploaded would advertise this version while serving Mac
users the previous build, and since those clients keep reporting the old
version the updater would re-offer it forever. They are now bound by
upload time, with two minutes of slack for legs that finish apart.

The selection rules move out of the YAML heredoc into
scripts/build_preview_manifest.py. Three findings in a row have been about
WHICH artifacts may be described together, and a heredoc can only be
tested by extracting it and stubbing a shell — which is what the previous
test file did, asserting against gh stubs rather than against the rules.
build_manifest is pure: assets in, manifest out, ManifestRefused on
anything it will not describe.

14 tests, including both new refusals and two that pin the workflow still
calls the module and still uploads last — an inline copy would pass every
other test and ship the original bug.

Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Pinkers01 <pinky.bouw@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:41:45 +05:30
Palash DebnathandClaude Opus 5 680c57f9b9 fix(dub): say when a clone reference is gone instead of rendering a default voice (#1331) (#1361)
* fix(dub): say when a clone reference is gone instead of rendering a default voice

Reported on Discord: "if you re-dub individual sentences the voice isn`t
taken from the video — you have to re-dub everything for the voice clone
to work."

Clone references are FILE PATHS into the job`s extracted-clip directory,
and the whole job dict — those paths included — is persisted to
dub_history.job_data so saved projects reopen after a restart. The job
therefore outlives its clips. Reopen a saved dub once the clip directory
has been cleaned, regenerate one line, and every resolution branch hands
the engine a path that is no longer there. Nothing checked it, and an
engine given a missing reference renders UNCLONED rather than failing — so
the line comes back in a default voice matching nothing else in the dub,
with no error anywhere. A full re-dub re-extracts the clips, which is
exactly why that appears to fix it and is the workaround the reporter
found unaided.

Diagnostic ONLY, deliberately: the reference is passed to the engine
unchanged. Nulling it would not alter what the user hears — the engine
already falls back — and it would decide on the engine`s behalf that a
path it cannot stat is unusable, which is untrue for anything resolved
inside a sidecar`s own namespace. The defect is the silence, not the
fallback. (The first cut did null it, and broke seven existing tests that
legitimately assert a synthetic path reaches the engine; that was the
right signal.)

Warns once per segment per job, so a 300-segment dub whose clips were
cleaned logs which lines lost their reference rather than one line per
retry — "some of them" is not actionable.

Root cause of the cleanup itself is not addressed here; this is what makes
the next report carry the paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dub): key the missing-ref memo on the path, not the segment alone

greptile P1, valid. The single-segment preview endpoint has no segment
identity to pass — it is a "render this text" call — so every preview
shared the key "preview" and only the FIRST missing reference in a job was
ever reported. Every later one, with a different path, was silenced: the
de-duplication meant to stop repetition was swallowing new facts.

Keying on (segment, path) fixes it without an API change, and is more
correct on the render path too: a segment rebound to a second missing clip
is no longer mistaken for the one already reported.

SegmentPreviewRequest also gains an optional segment_id, diagnostic-only
and defaulted to None so existing callers are unaffected — a caller that
supplies it gets the line named instead of a bare "preview".

Three tests; the distinct-paths one fails against the segment-only key
while the repeat-suppression one keeps passing, so the fix cannot be a
blanket removal of the de-duplication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:56:52 +05:30
1cbd1c72fa fix(colab): pin torchvision==0.23.0 to resolve torchvision::nms operator missing error (#1357) (#1358)
* fix(colab): pin torchvision==0.23.0 to resolve torchvision::nms operator missing error (#1357)

This resolves the runtime import failure of transformers.HiggsAudioV2TokenizerModel due to torchvision mismatch with torch 2.8.0. Changes:

- pyproject.toml: Added torchvision>=0.19 to dependencies, pinned to 0.23.0 in constraint-dependencies, and configured the pytorch-cuda source index.

- uv.lock: Regenerated lockfile to resolve torchvision 0.23.0+cu128.

- bootstrap.rs: Added torchvision to rocm_torch_reinstall_args and updated the matching unit test.

* fix(bootstrap): pin versions in ROCm reinstall path and add CHANGELOG entry (#1358)

* test(rocm): pin bootstrap.rs to pyproject constraint, mechanically

The PR adds a "Keep in sync with [tool.uv.constraint-dependencies]" comment
above rocm_torch_reinstall_args. That is the right instruction and a
comment cannot enforce it, so it becomes a test — CLAUDE.md`s convention is
that a rule a reviewer has to remember belongs in one.

It matters more here than the usual lockstep case: an AMD user`s install
does not come from uv.lock at all. bootstrap.rs shells out to pip against
the ROCm index, so whatever it names there is the Torch stack that user
actually runs, and a drift produces no install-time error — it surfaces
later as "operator torchvision::nms does not exist" or a silent CPU
fallback, which is #972 and #1357 from two different directions.

Three cases: the constraint block still pins the trio (so the rest cannot
pass vacuously), the ROCm pins equal it, and all three are reinstalled
together — a subset leaves the others on CUDA wheels the ROCm build cannot
pair with. Both drift cases fail against the previous, unpinned form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:27:36 +05:30
Palash DebnathandClaude Opus 5 097589295f fix(tts): a chunk that renders to nothing is no longer dropped in silence (#1330) (#1360)
* fix(tts): a chunk that renders to nothing is no longer dropped in silence

Reported on Discord: "this app dosent generate me the last few sentences.
for rest this app is a banger" — clean audio, just missing the end.

Ruled out by direct probe rather than by reading: split_text_into_chunks
preserves every non-whitespace character (including text with no terminal
punctuation), concatenate_audio_chunks joins everything it is given, and
trim_trailing_silence cuts only from the last VOICED sample so it cannot
remove speech. Those three are pinned by a test now so the elimination
does not have to be redone.

What was left is the filter at the top of the join:

    chunks = [c for c in chunks if c is not None and c.shape[-1] > 0]

When an engine returns nothing for one slice of text, skipping it is still
the right joining behaviour — the alternative is a crash or a gap. Doing
it in silence is not: the waveform looks perfect and is simply short, so
the failure can only be found by reading along while listening.

It now counts the drops, logs at WARNING (this is output the user paid
compute for), and names the lost text where the caller can supply it —
wired through all three generation call sites and audiobook.

Audiobook also pre-filtered before calling, which both hid the same drop a
second time and misaligned rendered from chunks so the concat could not
name what was lost. It passes the full list now.

Not the root cause of the engine returning nothing — that needs a
reproducing input, which is exactly what this makes obtainable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(audiobook): the one-survivor branch skipped the drop reporting

Both reviewers caught the same hole, and they were right: a span that
splits into several chunks where only ONE renders returned that chunk
directly, skipping the join — and therefore skipping the reporting the
join does. The chapter came back short and said nothing, which is this
very bug one branch over. Zero survivors had the same problem.

Rather than add two more conditions to an inline branch, the decision
moves into chunked_tts.join_rendered_chunks: kept/dropped, report, and
return None when nothing rendered so the caller`s dead-render handling
still owns that case instead of receiving a silence buffer. One place that
can be wrong, and a testable one — a second inline copy is how the hole
appeared to begin with, so a test pins that audiobook routes through it.

Also, on CodeRabbit`s test note: test_reporting_never_breaks_the_join now
asserts the report was ATTEMPTED, not merely that nothing blew up —
otherwise it would pass on a build where the reporting does not exist.
test_chunking_itself_loses_no_text stays, with its docstring saying plainly
that it pins an eliminated hypothesis rather than a fixed defect: "the
chunker drops the tail" was the first explanation for #1330, ruling it out
took a probe, and a future splitter change that did lose the tail would
reproduce the reported symptom exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:08:36 +05:30
Palash DebnathandClaude Opus 5 1a4b95890a fix(llm): LM Studio translation failed on a placeholder model name (#1332) (#1359)
* fix(llm): LM Studio translation failed on a placeholder model name (#1332)

Reported as a clean A/B: translation works through Ollama and fails
through LM Studio on the same machine. The difference is one line in the
provider table. LM Studio shipped `local-model` as its default_model,
which is a placeholder, not a model id — LM Studio serves whatever the
user has loaded and 404s a name it does not know. Ollamas default is
`llama3.1`, a real name people actually pull, so the identical code path
worked there.

No name we ship can be right, because the answer depends on what the user
loaded. So ask the server: resolve_model now discovers from /v1/models for
providers whose default is a placeholder, positioned BELOW any explicit
env or stored setting so it can never override a deliberate choice, and
above the default so a server that is down leaves the caller where it was.

Cached per provider — translation resolves the model per segment and a
round-trip each time would trade a broken setup for a slow one — and
dropped whenever a base_url or model edit could invalidate it, since a
stale id would make the users change look like it did nothing.

Also: a 404 from a LOCAL provider is almost never a wrong URL, because the
request reached the server. The generic "check the model name and Base URL
path" sends the user to audit a URL that works, so a local 404 now names
the models that ARE loaded, or says the server has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): bound the discovery cache both ways; do not over-claim a 404

Three review findings, all valid, all about the cache being permanent in
one direction or absent in the other.

- A failed probe was not remembered, so a stopped LM Studio cost a 5s
  timeout on EVERY translated segment — a 200-segment dub would spend
  1000s discovering nothing, worse than the bug being fixed. Remembered
  for 30s: short enough that starting the server recovers in seconds
  rather than needing a restart.
- A successful discovery was cached forever, so swapping the loaded model
  inside LM Studio 404d every translation until an app restart. Now a 300s
  TTL, plus an immediate invalidation when a local 404 proves the cached
  name is one the server rejects.
- _local_models collapsed a FAILED listing into [], which let the error say
  "reports no loaded models" about a lookup that never happened — a
  confident wrong diagnosis replacing a vague right one. None vs [] are
  now distinct, and the generic 404 text stands when nothing was
  established.

The cache therefore cannot be a dict[str, str]: "no entry" and "we looked
and there was nothing" have to be distinguishable for the negative case to
be cacheable at all.

Five tests, three failing before this change. They age the cache entry
rather than patching time.monotonic — that name is the stdlib`s, shared
with sqlite and logging, and freezing it breaks the settings store
underneath the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:48:56 +05:30
Palash DebnathandClaude Opus 5 c03e3f2525 fix(gpu): record WHERE a wedged job was stuck when its budget expires (#1338) (#1355)
* fix(gpu): record WHERE a wedged job was stuck when its budget expires

Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of
actual compute time and was abandoned" (#1338, #1329, #1348) — two of them
on an RTX 3050 and an RTX 3060, rendering a single sentence. That is not a
machine too slow for the job. The message says "too heavy for the
available compute" because it is the only story the timeout path can tell.

And nothing in the log could contradict it. The timeout branch logged THAT
the budget was exceeded, reset the pool, and returned. The worker cannot
be cancelled, so it was still running on a real stack — and we threw that
away, which is why every report of this class arrives undiagnosable and
the only advice available is "reproduce it under a debugger".

sys._current_frames() reads the frame of every live thread including one
wedged inside a C call, which is exactly this case. Filtered to gpu-pool
workers so the log names the stuck job rather than the web server, capped
at 25 frames, and it can never raise — a diagnostic that throws would
replace a real GpuJobTimeoutError with an unrelated crash.

Ordering is load-bearing and asserted: the capture runs BEFORE reset(),
because reset() swaps in a fresh executor and the wedged thread then stops
being identifiable as a pool worker — the diagnostic would still run, still
log, and be empty, which looks like it worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gpu): redact home paths from captured stacks; label stale workers

Both review findings on this PR, both valid.

CodeRabbit (CWE-532): traceback frames carry absolute source paths, which
on a user machine start with their home directory — their account name.
This log lands in backend.log, which goes into diagnostic bundles and
prefilled bug reports, so it has to be sanitized like every other surfaced
text. Reuses core.failure.sanitize rather than inventing a second answer;
if sanitizing itself fails the stacks are dropped, not logged raw.

greptile P1: a wedged worker survives reset() — it cannot be cancelled and
keeps running under the same gpu-pool name the replacement pool uses. The
second timeout in a session would log both with nothing to tell them
apart, and the stale one is the more misleading, since it names an
operation that is not the job that just failed. The live pool is now
identified through its own thread set and the others are marked STALE.

That set comes from ThreadPoolExecutor._threads, which is private, so
unknown internals degrade to labelling nothing rather than to failing —
a diagnostic that vanishes because an attribute moved is worse than an
unlabelled one, and that degrade path has its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:52:18 +05:30
Palash DebnathandClaude Opus 5 95de0afb02 fix(appimage): the preload probe never ran, and tested the wrong value (#1333) (#1356)
* fix(appimage): the preload probe never ran, and tested the wrong value

Two CodeRabbit Majors on #1354, landed after merge because I merged before
reading them. Both silently DISABLED the feature rather than breaking
loudly, which is the shape that survives a green suite.

1. `command -v true` answers with the shell BUILTIN — the bare word "true",
   not a path — so `[ -x "true" ]` was false on every host, the probe always
   failed, the preload never happened, and #1333 was left exactly as it was.
   A builtin never involves the dynamic loader, so it could not have tested
   anything even if it had run. Now resolves a real binary (/usr/bin/true,
   /bin/true, or /bin/sh -c : as the guaranteed last resort).

2. The probe took our library alone, but the exported value appends any
   inherited LD_PRELOAD — so the probe could pass while the environment the
   app actually gets fails. It now probes the final value.

The suite missed both because every existing case overrides the probe via
OMNIVOICE_APPRUN_PRELOAD_PROBE. The new default-probe case is what closes
that hole; the inherited-entry case is the discriminator for (2). Both fail
against the previous AppRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): drop a probe fallback that could never resolve; cover /bin/sh

CodeRabbit, valid. The PATH entry looked up `coreutils`, which is not an
executable name, so that branch could never resolve — a fallback in shape
only. Deleted rather than repaired: a PATH lookup is what caused the
original builtin bug, and the list already terminates at /bin/sh, which is
present on any host that can run this script.

That left the real last resort untested, which is how the branch above it
shipped broken in the first place. `sh` needs `-c :` where `true` needs no
argument, and with no argument `sh` reads stdin and hangs — so the new case
points the probe override at the real /bin/sh and fails loudly if that
branch is wrong (verified: breaking the argument turns it red).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:32:55 +05:30
Palash DebnathandClaude Opus 5 810b598739 fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333) (#1354)
* fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333)

Recording from the AppImage failed with "No microphone found" on a Debian
13 host whose audio stack the reporter verified healthy (pactl, wpctl,
gst-launch with both pulsesrc and pipewiresrc), while the same build`s raw
binary recorded fine. GST_DEBUG=2 named it:

  WARN GST_REGISTRY gst_registry_binary_check_magic:
    Binary registry magic version is different : 1.23.90 != 1.3.0
  GStreamer element appsink not found. Please install it.

linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but not the
plugins: those are dlopen`d, so nothing static can see them to copy. The
bundled core falls back to the host plugin directory, whose plugins were
built against the host core, the version check rejects them, and the scan
yields nothing. appsink is one of the casualties and it is the element
WebKit hands a capture stream to, so getUserMedia() rejects NotFoundError.

Same class as #1258 (frozen bundled library against a host that moved on)
in a different library, which is why OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 did
nothing for the reporter. Since we ship no plugins, the host core is the
only one that can agree with the plugins that will load — so prefer it,
with OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 as the escape hatch.

Also isolate the registry cache. GStreamer keys ~/.cache/gstreamer-1.0/
registry.<arch>.bin by architecture alone, so two cores of different
versions clobber each other`s file: that makes the failure depend on which
app ran last, and the AppImage corrupts the cache for every other
GStreamer app on the machine. Both directions go away with a private path.

AppRun.test.sh covers host-present, host-absent and opt-out; all three
fail against the previous AppRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(appimage): cover the ldconfig discovery path; docs fixes

CodeRabbit, all three valid:

- every GStreamer case forced ldconfig to fail, so the runtime-only-host
  fallback (no -dev package, hence no .pc file) was never exercised. The
  cases now select their discovery path, and the new ldconfig one fails if
  that branch is removed.
- MD040: the GST_DEBUG fence had no language tag.
- the registry cache path follows XDG_CACHE_HOME when set; ~/.cache is only
  the default. Documented, along with WHY the shared file is a problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): compose LD_LIBRARY_PATH once; host WebKit stays first

CI caught a real regression, not a flaky test. The GStreamer block prepended
its own directory, which put it AHEAD of the host WebKit dir — and "host
WebKit first" is the invariant #1258 turns on. On a host where the two
libraries live in different directories that silently changes which WebKit
resolves.

It only showed on Linux because the WebKit ldpath cases do not stub away a
real host GStreamer, so the runner had one to find and macOS did not.
Reproduced locally with an ldconfig shim, and confirmed the ordering is what
fixes it: with the old order the suite is 19/2, with this one 21/0.

Both decisions now compose one path in one place — host WebKit, host
GStreamer, bundle, inherited — so neither preference is weakened and the
ordering is stated where it is applied rather than implied by two
independent prepends. Same directory for both (the common case) is not
listed twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): preload the host GStreamer instead of hoisting its libdir

greptile P1, valid. The host GStreamer lives in a general system library
directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that directory
ahead of ${HERE}/usr/lib replaced EVERY other bundled library with the
host copy — loader symbol errors, startup crashes, or a blank window on a
distro we never built against. One library needs to come from the host and
the mechanism has to be that narrow.

LD_PRELOAD names exactly that library and leaves the search path alone, so
the WebKit ordering from #1258 is untouched too (and this removes the
composed-LD_LIBRARY_PATH block that only existed to keep the two
prepends from fighting). The preload is inherited by the Python backend,
where nothing links GStreamer and it is inert — the accepted cost.

Tests now assert both halves: the library IS preloaded, and the libdir is
NOT hoisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): verify the host GStreamer loads before preloading it

greptile P1, valid. The host core links GLib and the bundle ships GLib too,
resolved bundle-first — so a host GStreamer built against newer GLib than
we bundle fails its relocations and the app does not start at all. That is
strictly worse than the broken microphone this PR fixes. Taking host GLib
as well is not an option either: GLib is what WebKit is built against, so
pulling it from the host reopens #961/#1258.

Rather than predict the pairing, test it. The loader processes LD_PRELOAD
for any binary, so running `true` under the exact environment the app will
get is a complete check of whether the library loads there — a missing
dependency or an unresolved version tag ("version GLIB_2.84 not found")
fails it and nothing else runs. On failure the preload is skipped, the app
starts on the bundled core, and a warning names the mismatch so the user
has a thread to pull rather than a silent half-fix.

OMNIVOICE_APPRUN_PRELOAD_PROBE lets the suite choose the outcome, matching
the existing OMNIVOICE_APPRUN_WK_MARKER precedent; the new case fails if
the guard is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:48:47 +05:30
Palash DebnathandClaude Opus 5 e60c5364ea fix(errors): strip terminal colour codes before a failure reaches the user (#1344) (#1353)
* fix(errors): strip terminal colour codes before a failure reaches the user (#1344)

yt-dlp colourizes stderr whenever it thinks a terminal is attached, and the
frozen backend's pipes are enough for it to think so. A restricted-video
failure surfaced as `download: ^[[0;31mERROR:^[[0m [youtube] …`, which reads
as an OmniVoice bug rather than a message from YouTube.

Fixed at build_failure, the choke point every surfaced failure passes
through, so the whole class is covered — ffmpeg, uv, pip, cargo and
anything else that colours its output, not just the reported command.

Order matters and is pinned by a test: strip_ffmpeg_banner anchors on
"ffmpeg version " at the start of a line, so a leading colour code would
hide it and quietly reinstate #1309 for any colour-emitting ffmpeg build.
The pattern covers CSI and OSC (window title / hyperlink) sequences, not
just SGR colour, and never empties a non-empty message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): escape-only text falls back to the error class, not to bytes

CodeRabbit, both valid:

- strip_ansi kept the original when stripping left nothing visible, so
  build_failure copied raw escape bytes into reason/error/detail — the very
  thing this PR exists to stop. build_failure already falls back to the
  exception class name for an empty reason, and a class name is a real
  answer where a run of escapes is not, so let it do that.
- the test file bound `from core import failure` at import time. Sibling
  suites reload and purge core.* between tests, so that alias can outlive
  the module the app uses and the file would assert against a stale copy
  while looking green — #1269 exactly. Resolved via a fixture at call time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:26:11 +05:30
Paolo Antinori 202f1285aa fix(dub): transcribe overlay label is engine-agnostic (not hardcoded Whisper) (#1352)
The dub overlay said "Transcribing with Whisper…" whatever ASR engine was actually running, in all 21 languages — a user debugging a slow or failing transcription would go read Whisper's docs.

Contributor fixed 16 locales; the remaining 5 (ar, hi, id, ja, pl) transliterate the brand rather than keeping the Latin spelling, so the sweep missed them. Those are corrected, the guard is extended to every transcription stage label rather than the one reported, and its boundary is ASCII-letter-based — Python's \b is unicode-aware, so \bwhisper\b does not match "Whisperで文字起こし中".

Thanks @paoloantinori!
2026-08-04 10:56:52 +05:30
Palash DebnathandClaude Opus 5 8f9f778307 fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333) (#1339)
* fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333)

`scripts/desktop-prod.sh` emulates a first install, so wiping is its default: it
removes the app data dir, `~/.omnivoice` (the SQLite database, every voice
profile, all outputs), the Tauri logs and the WebKit profile. `--keep-data` is
the only thing that suppresses that block.

`--skip-build` is an independent flag that only skips the cargo compile, and
`desktop-prod:run` passed it alone — while the script's own header calls that
command "re-launch last build (skip compile)" and its closing banner tells you
to use it that way. So "just start it again without recompiling" silently
deleted the developer's voice profiles and project database, every time.

The fix is in the package scripts rather than the flag parsing: making
--skip-build imply --keep-data would remove a legitimate combination (fresh
data without paying for a recompile). The two stay independent, and the help
text now says so.

desktop-fresh:run is deliberately untouched — that script is a stricter
new-user emulation, so wiping is the point of its name.

Tests pin all three rules, and were confirmed fail-before by reverting the
desktop-prod:run line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): kill the live instance before every launch, not only before a wipe

Greptile P1 on this PR, and it was a regression I introduced.

The app registers tauri_plugin_single_instance, and that callback ignores the
incoming argv — it just refocuses the window the RUNNING process already owns.
So starting a second copy over a live one does nothing visible.

That was previously masked: kill_running_instances sat inside the
`KEEP_DATA = false` branch, so every run happened to kill first *because*
every run wiped. Adding --keep-data to the re-launch aliases removed the wipe
and would have taken the kill with it — `desktop-prod:run:pill` would have left
the user in studio mode with --pill silently discarded, and plain
`desktop-prod:run` would have refocused the OLD build instead of the one just
compiled, which is the entire point of that command.

The kill is now unconditional, before the wipe branch. Its two reasons are
independent — zombie-backend-after-wipe, and single-instance-swallows-argv —
and only the first was ever about wiping. Adjusted its closing line, which
said "safe to wipe" and now also runs when nothing is being wiped.

New test asserts the call is not nested inside the KEEP_DATA branch;
confirmed fail-before by moving it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): scope the kill to this checkout, warn about an installed app

Making kill_running_instances unconditional (so --keep-data re-launches
still get past single-instance) widened the blast radius of its pgrep:
"OmniVoice Studio.app" also matches an installed /Applications copy, so
desktop-prod:run would kill the shipped app a developer was using and take
their unsaved work with it. That was previously masked — the kill only ran
on wipe runs, where a clean slate had been asked for explicitly.

Scope the pattern to ${TAURI_DIR}/target/debug/, which covers both launch
shapes and nothing else. An installed instance still gets named rather than
ignored: single-instance keys on the bundle id, so it swallows this launch
too, and silence would just trade one confusing failure for another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:44:22 +05:30
Palash DebnathandClaude Opus 5 8da377a1cb fix(audiobook): say why a chapter failed, and don't hang when an engine stops (#1321) (#1325)
The report arrived as a raw traceback pasted out of a log file — because that is the only place the reason existed. A failed chapter was a red row and the word "failed"; the SSE event carried the literal string "chapter failed to render", the symptom the user could already see.

- Both the per-chapter and the terminal all-failed events are now built with the shared `core.failure` builder: sanitized text, a guaranteed non-empty reason, error class, docs deeplink and hint. `error` keeps mirroring `reason`, so older frontends and the Stories exporter are unaffected. The chapter list shows the reason inline, full text in the row tooltip.
- **A silent infinite hang on the same path.** asyncio refuses to put `StopIteration` into a Future — `_copy_future_state` raises TypeError inside the event loop's own callback, so the `run_in_executor` future is never completed and the caller waits forever, with no error, event or timeout. Reachable from ordinary input: VoxCPM's `next_and_close` is a bare `next(gen)`, so a generator ending without yielding raises it straight into a GPU-pool worker. Fixed at the pool boundary (`_ResilientGpuPool.submit` plus a matching `_cpu_pool` subclass) as `WorkerStopIteration(RuntimeError)`, keeping the original as `__cause__`.
- An all-failed render now marks the job failed. It previously returned without touching job history, so the row stayed `running` and the next startup's orphan sweep read a hopeless render as interrupted and offered it as resumable (Greptile P1).

Tests: 6 pool-guard tests (two hang without the fix, bounded so a hang fails rather than stalls CI), longform e2e extended for both event shapes, the empty-`str(exc)` floor, and both sides of the job-status branch, plus the chapter-list component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:50 -07:00
Palash DebnathandClaude Opus 5 a89c5e8fb3 test: patch the model_manager module main actually uses (#1269) (#1324)
`tests/backend/conftest.py` purges `main` / `core*` / `api*` / `services*` from `sys.modules` after every test it owns, and `backend/tests/` modules bind `import services.model_manager as mm` at COLLECTION time — so in a combined `pytest tests/ backend/tests/` session the alias and the live module are two different objects. Two tests read the wrong one; CI's split invocation hid both.

- `test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel` patched the stale alias then drove main's lifespan, which loads through the live module. It now runs inside a purge/restore context and resolves from `sys.modules` after importing main, so the failure is deterministic standalone.
- The autouse `_clean_model_manager_shutdown_state` fixture cleaned only `sys.modules` while `test_shutdown_state_isolation.py` dirtied the alias. It now cleans the live module and any module-typed alias in the requesting test module — the idiom already used there for `asr_backend` — taking both the `import x.y as z` binding (package attribute) and the `sys.modules` entry.
- New fail-before/pass-after pair guards the alias half of the fixture in isolation.

Test-infrastructure only; no production code touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:23 -07:00
Palash DebnathandClaude Opus 5 168e8e5c61 fix(macos): declare the floor the app actually delivers (13.3, not 12) (#1314)
* fix(macos): declare the floor the app actually delivers (13.3, not 12)

The app declared minimumSystemVersion 12.0 and the docs promised Monterey,
while the frontend required Safari 16.4 in three independent places: Vite's
default build target (baseline-widely-available = safari16.4), Tailwind v4's own
documented floor, and `@property` throughout its generated utilities. On
Monterey's WKWebView 15.6 the focus ring and accent surfaces resolve invalid,
and a bundled dependency ships a RegExp lookbehind that is a PARSE-time
SyntaxError no polyfill can reach.

Option B — actually supporting 15.6 — means setting build.target back,
replacing 64 color-mix() calls, dropping Tailwind v4 and replacing that
dependency, indefinitely, for an OS that stopped receiving security updates in
late 2024. The council was unanimous on A, and the precedent is uniform (Chrome
117, Electron 27, VS Code, Firefox 116).

minimumSystemVersion is also the guard: macOS itself refuses to launch a bundle
below it, so a Monterey user gets an explicit OS refusal rather than an app that
opens to a blank window — which matters because the Tauri updater has no
per-OS gating of its own.

Docs updated in the same change (README support table, docs/install/macos.md)
and the webCompat floor assertion re-derived to 16.4, so the post-floor API
list must be revisited the next time the floor moves.

Closes #1268

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(macos): raise the floor in the macOS overlay too, and assert it

Greptile P1, and correct: Tauri merges tauri.macos.conf.json OVER the base
config for a macOS build, and that file carried its own
minimumSystemVersion: 12.0. Changing the base config alone decided nothing —
the shipped bundle would have stayed Monterey-installable while the base
config, the README and the install docs all said 13.3.

Worse, the guard I added read only the base config, so it would have gone on
passing. A test that validates the wrong file is not a guard; it now asserts
both, with a comment saying why the overlay is the one that ships.

Also per review: the changelog entry was an editorial paragraph rather than a
one-line entry, and the section was missing ### Docs. Both fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(webcompat): the module header still described the old 12.0 floor

The floor moved to 13.3/Safari 16.4 in this PR and the test was re-derived,
but webCompat.js still told the next reader the oldest supported WebView was
15.6 — which would make every fill here look mandatory instead of retained
for Linux's unpinnable WebKitGTK (CodeRabbit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:44:41 -07:00
Palash Debnath 15afc6611d fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244) (#1265)
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)

The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:

    Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...

No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.

Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.

The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.

* fix(review): the ordering change was a no-op; name the host libdir explicitly

CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.

Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.

CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.

CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
2026-07-29 15:36:45 -07:00
Palash DebnathandClaude Opus 5 b01c635bfe release: v0.4.2 (#1279)
Version files (package.json + the three mirrors) and both lockfiles bumped
0.4.1 → 0.4.2; [Unreleased] renamed to [0.4.2] — 2026-07-28.

Ships five user-facing fixes: the update toast, the restart guard that used
to discard in-flight work, the shutdown-is-not-a-crash 503, the self-healing
half-downloaded model, and the dub-history purge ordering — plus the
"Dismiss" mistranslation in five locales.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:56:37 -07:00
Palash DebnathandClaude Opus 5 c3d0d6b123 feat(update): announce updates as a toast with actions, not a wall of text (#1272)
* fix(dub): purge order was hash-dependent, and the cap could evict a live marker

main went red on my own test. Two distinct defects, both mine.

1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
   which markers a cap-forced trim discarded was luck. That is why the test
   passed locally and failed in CI — verified: the old code passes at seeds
   0/7/42 and fails at 12345. Now a de-duplicated list in caller order.

2. The size cap could evict markers the CURRENT purge had just recorded. Those
   are the newest and the likeliest to still be held by a running job, so
   dropping one is precisely the resurrection this mechanism exists to prevent.
   A 'clear history' larger than the cap forced exactly that. The cap now never
   touches the current purge, making the real bound cap + one purge — stated
   plainly rather than implied.

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.

* feat(update): announce updates as a toast with actions, not a wall of text

A version's release notes ARE the whole changelog section — v0.4.1's was 42
bullets. Any surface that renders them inline becomes unusable: older builds put
them in a blocking OS dialog that filled the screen and had to be dismissed
before the app could be touched.

Removing that dialog left the opposite failure. The only remaining signal was a
6-pixel dot beside the version number in the footer, which is easy to never
notice — so users either got shouted at or told nothing.

A toast is the middle: it names the version, offers Install and restart /
What's new / Later, and leaves. The notes stay one click away in Settings →
Updates, where there is room. Keyed by version so the 6-hourly re-check
replaces rather than stacks, and it never auto-dismisses — an update the user
hasn't answered is still true. Install declines while a generation is running,
since the relaunch would lose it.

Strings added to all 21 locales, translated rather than English-filled.

Tests pin the shape that matters: the toast takes no notes prop at all, renders
under 200 characters, and cannot stack duplicates.

* fix(update): don't relaunch while work is in flight; share the busy check

Review of #1272 found the restart guard was `dubStep === 'generating'` and
nothing else. Installing an update relaunches the process, so that permitted
throwing away a dub upload, a transcription, a translation, an export or a
standalone TTS synth. The same narrow check was written twice — in the new
toast and in UpdatesPanel — so the two could also drift apart.

Replaced with a single `isAppBusy(state)` in utils/appBusy, unioning the
signals the store actually has: the dub state machine, the floating status
pill (which every long background operation already pushes to), and
`ttsGenerating` — a new transient store field mirroring useTTS's local
`isGenerating`, which lived in a hook where no global check could see it.
`dubStep === 'editing'` is deliberately not busy: it waits on the user, and
counting it would block updates for as long as a transcript stays open.

Also from review:

- A failed lazy import of the toast fell into the outer catch, whose
  setUpdateIdle() erased the update that had just been found. The
  announcement is optional; the available state is not.
- "Dismiss" was machine-translated into the employment sense — terminate an
  employee — in de/ja/ru/zh-CN/zh-TW, on close buttons and one aria-label.
  Swept every locale rather than the four lines that were flagged.
- Hoisted the test's mock state with vi.hoisted.

CodeRabbit's changelog finding is declined: Highlights bullets carry no issue
refs by design, which tests/test_changelog_style.py enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shutdown): a quit mid-generate is not a 500, and not a bug report (#1276)

#1174 made a model load interrupted by shutdown benign for the background
preload, but a *request* that triggered a load took the generic unhandled-
exception path: crash log, ERROR traceback, and an error-journal entry that
feeds the bug-report pipeline. Quitting the app with a generate queued
surfaced "500 Internal Server Error: model load skipped: backend shutting
down" and offered to file a GitHub issue for a normal teardown.

Nothing failed — the process is exiting. The handler now answers 503 with
Retry-After and an actionable detail, ahead of the crash-log/journal writes.

Frontend half of the same bug: toastErrorWithReport offered "Report" for any
error. A 503 means "not now, try again" by definition, so it now shows the
backend's message without the report action — covering a still-warming
backend too, not just this shutdown path.

Fail-before/pass-after tests on both sides, including that the shutdown case
leaves no crash-log entry and no journal record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(models): repair a half-downloaded model whichever way it reports (#1273)

transformers has two unrelated wordings for "this snapshot has no weight
shard", sharing no words:

  hub load   "<repo> does not appear to have a file named …"
  local dir  "Error no file named model.safetensors, … found in directory …"

The self-heal and the failure classifier both matched only the first. The
second is what a load of a *subfolder* inside a cached snapshot raises —
exactly where an interrupted download leaves a half-written repo — so the
reporter got neither the automatic repair (delete broken entries →
re-download → retry) nor an actionable hint, just a raw 500. Their disk had
10.6 GB free, i.e. a download that ran out of room.

The phrase list now lives once in core.failure, so the healer and the error
text cannot disagree about what an interrupted download looks like. Both
fragments of the second wording must match — "no file named" alone is
ordinary English and must not claim the class.

Also hardens the #1276 handler found by running both suites in one session:
services.model_manager can be imported under two module names, making two
distinct ModelLoadInterruptedByShutdown classes and breaking a bare
isinstance — which silently restored the 500 this fix exists to remove. Now
matched by isinstance OR class name, with a test that raises a same-named
class from a different module.

Verified against the full tests/ + backend/tests/ session: the only remaining
failures are the four pre-existing #1269 isolation leaks, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): count synths in flight at the chokepoint, not in one caller

Review round 2. Both Greptile P1s were the same weakness in my first pass:
tracking the synth in useTTS meant only the Generate tab counted (voice
previews, the compare modal, the stories editor and profile previews call
generateSpeech directly and were invisible), and a boolean meant two
overlapping syntheses cleared each other — whichever settled first reported
"idle" while the other was still running, so Install and restart discarded it.

Moved to `api/generate.ts`, around the one `/generate` call all seven paths
share, as a count with a `finally` release (so an abort or a network error
frees it too). A future synth caller is covered without opting in.
Fail-before verified: the overlap test fails against the boolean version.

Also from review:

- UpdatesPanel re-reads the busy state at click time; the render-time snapshot
  only exists to disable the button, and work can start after the last render.
- The 503 now carries the allowed-origin CORS headers. Without them the
  browser reports a bare CORS failure and the actionable detail — the whole
  point of the fix — never reaches the user. Both error responses build them
  through one helper now.
- Log `request.url.path`, not the full URL: a query string can carry tokens
  and newlines.
- `update.busy` said "finish your dub first" in all 21 locales, but the guard
  now covers uploads, transcription, translation, export and synth. Rewritten
  as work-in-progress wording, translated per locale.
- ru `common.dismissStatus` was "reject status"; missed in the earlier sweep.

Declined: CodeRabbit's request for a `(#NNN)` ref on the Highlights bullet —
Highlights carry no refs by design (CLAUDE.md), and tests/test_changelog_style.py
enforces it. Also declined gating the cache re-download behind a confirmation:
the self-heal already existed and already ran for the sibling wording, this
only stops it missing half the class, and it stays behind the existing
_hf_offline() check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): grey out Install while work is running

CI lint caught `busy` as unused after the click-time check moved into the
handler — and that exposed a real gap: `busy` had never been wired to
anything. The comment claimed it disabled the button; it didn't. Clicking
Install during a synth just bounced a toast back.

Now it does what it said: the Install and Restart buttons are disabled while
work is in flight, with `update.busy` as the tooltip. The click-time read
stays the authority, since work can start between the last render and the
click — the disabled state is the explanation, not the safety.

Adds the first UpdatesPanel test. The case it pins hardest is the inverse of
the bug: a busy predicate stuck at true would make the app permanently
un-updatable, which is worse than what this fixes. So it asserts enabled when
idle and during dub 'editing' (which waits on the user), disabled during an
upload, a translation, and one or more synths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:32:32 -07:00
Palash Debnath 9736fd4859 release: v0.4.1 (#1239)
* release: v0.4.1

Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped
across the single source of truth (frontend/package.json) and its three
toolchain mirrors; [Unreleased] renamed to the release section that
release.yml extracts verbatim as the GitHub Release body.

Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md
updated to 0.4.1 (docs-sync rule).

* release: #1239 review — sync Cargo.lock to 0.4.1

Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a
`cargo build --locked` (and the Tauri bundler's own locked build) would fail
on the mismatch. Regenerating locally updated it but it was never staged.

* release: re-sync [0.4.1] after the fix merges, date it 2026-07-27

Picks up everything merged since the section was first written: the first-run
wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup
crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256,
#1257, #1262).

Deliberately NOT included:
  - the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it
    needed six rounds of correction, the last two finding that the fix did not
    close the reported case and that its own bound reintroduced it;
  - the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending
    confirmation on a Mesa 26.1 host, which nobody has run.
2026-07-26 14:48:05 -07:00
Palash Debnath b66b09ceaa fix(dub): deleting a dub no longer resurrects it (#1252, #1253) (#1270)
* fix(dub): deleting a dub no longer resurrects it (#1252, #1253)

Split out of #1264. The other six fixes there are independent error-message
changes that needed no corrections; this one is a concurrency change that
needed five rounds, each finding something real in work that looked finished
and tested:

  1. review: merge and save were split, so a delete between them had the row
     written straight back;
  2. review: dict membership cannot express 'withdrawn' — an absent key means
     'not written yet' for a new job and 'deleted' for an established one;
  3. fail-before check: the race test was not testing the race (it asserted
     WHAT happened, never WHEN, and 'save after delete' IS the resurrection);
  4. review: the gate sat in two ingest helpers while eight direct save_job
     call sites bypassed it;
  5. direct check of the reported scenario: the tombstone covered in-flight
     ingests only, so a delete during a RENDER — the common case — still
     resurrected the job.

Riding a release on that record is not a good trade, so it ships on its own.

What it does now: the withdrawal is recorded when a job is DELETED, held in a
bounded LRU (there is no moment at which a delete stops mattering), and checked
inside save_job so every caller inherits it. Re-importing an id is the only
thing that revives it. The lock is re-entrant because the atomic helpers call
save_job while holding it.

Carries #1252's message half too: str(KeyError) is the repr of the key, which
is how the user's own job id became the entire error text.

* fix(dub): expire withdrawal markers by age, not by count

Greptile P1 — the sixth real finding on this fix, and reachable by ordinary
use. DELETE /dub/history selects every row with no limit, so a user with a
large history clearing it mid-render pushed that very job's marker out of a
count-bounded LRU, and the render then wrote it straight back.

Age is the honest policy: what matters is how long ago the delete happened, not
how many others followed it. Six hours outlives any realistic render or
transcribe. The count cap stays only as a memory backstop, raised far above any
real history and documented as such.

Verified fail-before: restoring count-based eviction fails both new tests.

* docs(changelog): the dub delete-resurrection fix (#1252, #1253)
2026-07-26 14:25:32 -07:00
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)

#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.

#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.

#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.

#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.

* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)

#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.

#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.

#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.

* fix(review): close the delete race, narrow the tool match, sanitize the fallback

Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.

CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.

CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.

CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.

CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.

Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.

* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests

Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.

CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.

CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.

CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.

CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.

CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.

* fix(review): a withdrawal must survive the job's first write

CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.

The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.

Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.

That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.

CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.

* test(dub): the race test was not testing the race

Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.

The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.

Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].

That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.

Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.

* fix(dub): gate the withdrawal at save_job, not at its callers

Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.

Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.

Verified fail-before: removing the gate fails the new test.

* fix(dub): the withdrawal only covered ingests, so it covered almost nothing

Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.

The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.

Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.

Verified fail-before: the previous scoping fails three of the new tests.

* refactor: move the dub delete-resurrection fix to its own PR (#1270)

The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.

Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
2026-07-26 12:39:29 -07:00
Palash Debnath e3fa40a952 fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) (#1263)
* fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245)

`useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)` on
mount. That method landed in Safari 16.0, but tauri.conf.json declares
`minimumSystemVersion: "12.0"` and docs/install/macos.md promises macOS 12 —
which ships WKWebView 15.6. So on the floor we advertise, the first React
render threw, the tree unmounted, and the backend was never started: the
reporter's whole session was one `view:launchpad` and "no backend response
this session".

Fixed as a class, not a call site. `utils/webCompat.js` fills the gap once,
imported first in main.jsx before any app chunk; a deterministic test fails CI
if app code reaches for any other post-15.6 API that nothing fills in.

* fix(review): correct the version facts, widen the guard, stop overclaiming

Standing review found three factual errors and one overclaim. Verified each
against caniuse/WebKit before changing anything — two of my labels were wrong
and one of the reviewer's corrections would itself have been wrong to apply
blindly.

- Array#toSorted/toReversed/toSpliced are Safari 16.0, not 16.4 (caniuse).
- String#isWellFormed is 16.4, not 17.0 (WebKit 16.4 release notes).
- abort(reason) shipped in Safari 15.4, so it IS honoured on our 15.6 floor —
  my comment claimed the opposite, which invited 'simplifying' the polyfill to
  a bare abort() and silently turning every TimeoutError into an AbortError.
- Array#with was missing from the denylist. It is the change-by-copy sibling
  most likely to be reached for (arr.with(i, v) in React state), so it was the
  most likely next instance of the exact bug this guards. Added, with
  Array.fromAsync, String#toWellFormed, Element#checkVisibility and URL.parse.

The overclaim: the changelog said the app 'opens again, instead of a dead
window'. It launches — but Tailwind v4's own floor is Safari 16.4, vite's
default target is 16.4, and index.css uses color-mix() (16.2) 64 times, so
Monterey renders it wrong. A bundled dependency also ships a RegExp lookbehind
literal, which is a parse-time SyntaxError no polyfill can reach. Changelog now
claims only what is true; the floor question is #1268.

The guard's blind spots — syntax, dependencies, CSS, computed access — are now
stated in the test file rather than left implied.
2026-07-26 11:01:06 -07:00
Paolo Antinori 67d1f6a43d feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS — configurable host allowlist for MCP transport security (#1249) (#1250)
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)

Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.

Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.

* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style

* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)

* docs(mcp): add security note for remote agent connections (coderabbit)
2026-07-26 02:02:23 -07:00
Palash DebnathandClaude Opus 4.8 e6ca31d7a6 fix(first-run): keep Continue + the HF token box on screen; no status bar yet (#1241)
* fix(first-run): keep Continue + the HF token box on screen; no status bar yet

Reported with a screenshot: on Models & engines the "Set token" card was
clipped at the window edge and the Continue button was off-screen entirely —
unreachable without resizing.

Root cause is one bug, and it explains both halves of the report. App.jsx sizes
`.app-wizard-wrap` to stop above the fixed LogsFooter, but SetupWizard's own
root was `fixed inset-0` — so it laid itself out against the VIEWPORT, escaped
that box, and put its pinned footer row underneath the status bar. The row was
already correctly pinned (shrink-0, outside the scroller); it was simply
painted over.

- SetupWizard's root is `absolute inset-0`, filling the frame it is given, plus
  `pb-4` so the pinned row clears the window edge.
- The wizard and the pre-wizard splash render no LogsFooter at all: it is
  studio chrome, and the rule is that it appears once you land on home.
- `.app-wizard-wrap` reserves nothing below it any more, so no dead 28px gap
  is left where the footer used to be.

Verified by driving the real frontend in Chromium against a stub backend, not
only in jsdom: before, `.logs-footer` is present and overlaps the action row;
after, it is absent and every button sits within the viewport.

Regression test pins both halves — either one alone reintroduces the clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(test): oxfmt; changelog refs the wizard PR (#1241), not issue #1240

* test(first-run): pin the bottom clearance and check each pre-studio branch

CodeRabbit (#1241): the root-class test passed with `pb-4` removed, and the
LogsFooter count-of-1 would still pass if the mount MOVED from the studio into
the splash. Both now assert the thing they mean.

* style(test): oxfmt the SetupWizardChrome assertions

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 01:59:37 -07:00
debpalash 854306fef8 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 06:02:58 +05:30
debpalash 3e2b089698 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:24 +05:30
debpalash 8c77a82727 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:22 +05:30
debpalash f67fde919c docs(changelog): repair the [Unreleased] structure mangled by a merge resolution
Resolving the repeated [Unreleased] conflicts (every PR appends to the same
block) with a line-level union dropped the blank lines around each ###
header and moved #1237's Highlights bullet into ### Fixed. The style linter
doesn't check blank lines, so it went unnoticed until the next merge — where
the misplaced bullet failed the ref/credit rule and made main red.

No content change: same entries, correct structure.
2026-07-23 05:28:32 +05:30
debpalash 6d42db5052 Merge main into fix/low-vram-preflight-1226
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:25 +05:30
debpalash 95fae6df0d Merge main into fix/download-resume-oom-1224
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:19 +05:30
debpalash 59c7e5a3bd Merge main into fix/port-in-use-1223
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:12 +05:30
debpalash 09b60901e4 Merge main into fix/lazy-model-import-1229
# Conflicts:
#	CHANGELOG.md
#	backend/core/failure.py
2026-07-23 05:24:33 +05:30
debpalash 2009ef329c Merge main into fix/dub-download-errno22-1225
# Conflicts:
#	CHANGELOG.md
#	backend/core/failure.py
2026-07-23 05:22:47 +05:30
debpalash ca5ca99247 Merge main into fix/synth-error-classes-1227-1221
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:22:12 +05:30
debpalash 9b2659733a Merge main into fix/lazy-model-import-1229
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:22:05 +05:30
debpalashandClaude Opus 4.8 1d3d611b2c fix(synth): #1227/#1221 review — assert the marker through classify()
The shared-marker test used inspect.getsource(), which would pass while the
literal survived only in a comment and the classifier had stopped using it —
the exact break it exists to catch. Asserts through classify() now.

CHANGELOG: name AppLocker / Software Restriction Policy too (the taxonomy
covers WinError 1260), and don't imply the Smart App Control toggle is the
remedy on a managed PC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 04:24:54 +05:30
debpalashandClaude Opus 4.8 57a8571769 fix(startup): #1229 review — print versions before the model-stack import
CodeRabbit: the Colab sanity check resolved the tokenizer first and printed
versions only on success, so the one broken path it exists to catch reported
neither the installed versions nor CUDA status — the single most useful line
for diagnosing a Colab environment. Versions now print first.

CHANGELOG: contributor credit on all three entries, refs last.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 04:03:31 +05:30
debpalashandClaude Opus 4.8 4fc494383b fix(download): #1224 review — replace the source-grep tests with real ones
CodeRabbit's Major finding was fair and the most useful of the set: three of
my tests asserted that a NAME appeared in a module's source. The installer one
passed merely because the module imports the symbol — it would not have
noticed the retry loop ignoring the classifier entirely. Tests that can't fail
for the reason they exist are worse than no tests.

Replaced with behaviour:

- the installer's retry decision is now a named helper, tested against a REAL
  httpx.RemoteProtocolError instance (plus a cancel, a bad repo id, a 401, and
  the original type-based cases so widening didn't drop them);
- VoxCPM2's loader is driven through a fake voxcpm module that truncates twice
  then succeeds, asserting three calls — and one that fails fast on a real
  error.

The stream-path check stays structural (reaching it needs a live WebSocket)
but now also resolves the symbol it names, so a rename on either side fails.

Two P1s fixed as well:

- the closed-client reset incremented the same counter as the download
  retries, leaving a resumable multi-GB download one attempt short of its
  configured budget. The two budgets are now genuinely independent.
- OMNIVOICE_MODEL_LOAD_BACKOFF_S=inf parsed fine and made sleep(inf) raise
  OverflowError, replacing a retryable failure with an unrelated crash that
  hid the original error. Non-finite values fall back to the default.

CHANGELOG entries shortened (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:59:31 +05:30
debpalashandClaude Opus 4.8 027c08f1ff fix(dub): #1225 review — the preflight error had no class, and ENOENT no facts
Two P1s, both correct, and both the same shape as the bug being fixed:

- The preflight OSError I added ("Can't save the download: …") matched neither
  an errno nor a download marker, so classify() returned "" and the user got
  NO hint — the exact dead end this PR exists to remove. Reworded to carry
  both signals; a test now asserts the class and that the hint names the data
  directory.

- classify() covered ENOENT but _with_target_facts' own signature list did
  not, so a job folder that vanished after preflight produced a
  disk-classified error that never named the folder.

That second one is a drift class, not a one-off: two lists answering "is this
a disk problem?" will diverge again. They now share
failure.is_os_write_refusal(), with a test asserting both consumers agree
across all four errnos.

CHANGELOG entries shortened with refs last (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:53:30 +05:30
debpalashandClaude Opus 4.8 63d40c33ce fix(synth): #1227/#1221 review — classify on a marker, not on shared wording
Two P1s, both correct:

- The enriched write failure lost its class. _describe_write_failure rewrites
  the message, so the word "libsndfile" no longer appears — classify()
  returned "" and the auto bug report and docs deeplink had nothing to name.
  Worse, my own end-to-end test allowed "" as a pass, which is exactly why it
  went unnoticed. audio_io now emits a stable AUDIO_WRITE_FAILED_MARKER,
  failure.py matches that, and the assertion is exact.

- "error opening" was far too broad. It appears whenever a model, archive or
  config file fails to open, so any such failure was handed the audio-file
  remedy (check your disk, add an antivirus exclusion). Dropped in favour of
  the marker; a regression test pins that a corrupt-model-archive error keeps
  its own guidance.

A third test pins the marker across the two modules — core/ cannot import
services/, so the string is duplicated by necessity, and a reword on either
side would silently un-classify every enriched write failure.

CHANGELOG entries shortened with refs last (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:45:19 +05:30
debpalashandClaude Opus 4.8 d800f77e26 fix(rocm): #1228 review — a remap is only a fix if the build ships the target
Two P1s, both correct:

- Being IN the override map was treated as proof of compatibility. If the
  wheel ships neither the native arch nor the remap target, setting
  HSA_OVERRIDE_GFX_VERSION only changes WHICH kernel is missing — gfx1151 with
  a gfx1030-only build was routed to the GPU and would fail at launch. Both
  arch_unsupported() and _configure_rocm_if_needed() now require the target to
  be present, and fall back to CPU otherwise.

- An EMPTY arch list means the build's metadata is unavailable, not that the
  GPU is unsupported. The remap branch read that unknown state as a confirmed
  mismatch and would push a natively-supported gfx1151 onto foreign gfx1100
  kernels. Now fails open and changes nothing, matching the fail-open contract
  the rest of the probe follows.

ROCM_GFX_OVERRIDES values are now the target gfx NAME rather than the HSA
version string, so the "is the target present?" check is a direct membership
test; hsa_override_for() derives the env-var form, covered by a test that
every entry in the map converts cleanly.

Also: CHANGELOG entries shortened with refs last, and the MD028 blank line
between the two docker.md blockquotes (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:33:26 +05:30
debpalashandClaude Opus 4.8 6715551c2a fix(routing): #1226 review — scope the VRAM floor to where it was measured
Two P1s, both correct:

- MPS mislabel. HostCaps.vram_gb on MPS is a heuristic (system RAM / 2) for a
  UNIFIED memory pool, so an 8 GB Mac reports 4.0 "VRAM" — comparing that to a
  floor measured on discrete CUDA hardware would warn every small Mac about an
  engine that runs fine there. The caveat is now dedicated-VRAM families only
  (cuda/rocm); MPS has a different memory model and no measured floor.

- Engine-agnostic timeout. _timeout_guidance serves EVERY job on the GPU pool
  (reference transcribe, stream assemble, watermarking, dub steps, CPU-only
  engines on a GPU host), and a hardcoded 6 GB threshold applied without
  knowing whose job it is would confidently misdiagnose most of them. The
  floor is now passed in via run_on_gpu_pool_guarded, defaulting to 0 — so the
  under-provisioned wording is opt-in and only the TTS generate dispatches opt
  in. A test asserts every "TTS generate" dispatch passes it, so the branch
  can't become unreachable in production.

CHANGELOG entries reworded to end with their refs (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:28:22 +05:30
debpalashandClaude Opus 4.8 6e565c57d2 fix(startup): #1223 review — the OSError handler was inert; probe the port instead
Greptile's P1 was correct and load-bearing: uvicorn does not let a bind failure
reach the caller. It catches the OSError inside its own startup, logs the raw
errno, and raises SystemExit(1) — so the `except OSError` around uvicorn.run()
never fired and the whole backend half of this fix was a no-op. Verified
empirically against the installed uvicorn, not assumed.

Replaced with a pre-bind probe using uvicorn's own socket options (SO_REUSEADDR
off Windows, so a TIME_WAIT socket uvicorn could bind is never reported as
taken), plus a SystemExit fallback that re-probes to cover losing the race
between probe and bind.

Two tests now pin this: one drives real uvicorn against a held port and asserts
exit 78 with the actionable message; the other documents uvicorn's swallowing
behaviour so a future refactor back to the "obvious" `except OSError` shape
fails loudly instead of silently restoring "Backend died (exit code 1)".

Also from review:

- Reverted rendering crashCauseHint in the crash dialog (both bots, and
  CLAUDE.md's localization rule): it would put hardcoded English into a
  localized surface. The port conflict already reaches users through the
  LOCALIZED bootstrap.hint_port, which is the correct channel.
- Pinned that every Rust-side port message contains a phrase detectHints
  matches, since that is what converts an English Rust message into the
  localized hint. This caught a real gap: the respawn message said "is held
  by", which the matcher missed — that path would have silently lost the
  translated guidance, the same failure mode as #1223 one layer up.
- CHANGELOG entries shortened to the one-line house style (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:22:13 +05:30
debpalashandClaude Opus 4.8 6cfef5c0cf fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
Two users on 4 GB cards (GTX 1650 Ti, Quadro P2000) ran the `omnivoice`
engine, waited out the full compute budget, and were told the job "was too
heavy for the available compute … most often the GPU is VRAM-starved".

The 300s-vs-372s spread between the two reports is purely text length
(`300 + (len-1200)/40`, so 372s ⇒ ~4080 chars) — one bug, not two. Nothing
about the budget is device-aware, and nothing needs to be: the real defect is
that until the moment it failed, routing showed a clean green "accelerated".
`resolve_routing` matched on GPU *family* only, so a 4 GB card and a 24 GB
card were indistinguishable, and no engine declared a VRAM requirement
anywhere in the repo.

- `TTSBackend.min_vram_gb` — advisory metadata alongside `gpu_compat`. Only
  `omnivoice` declares one (6 GB), derived from the pool's own measured
  per-job budget (`_GPU_VRAM_PER_JOB_GB = 5.0`) plus resident weights.
  Inventing floors for engines with no measured figure would put confident
  numbers in the UI that nothing backs.
- `resolve_routing` takes the floor and emits an accelerated-with-caveat
  reason when the host is below it. Reuses the existing caveat channel, so
  the Settings matrix and the synth-time routing notice surface it with no UI
  change. Advisory, never blocking: drivers page to system RAM, and short
  inputs fit where long ones don't. Kernel-risk still outranks it, and a
  failed VRAM probe (0.0) never guesses.
- `_timeout_guidance` names the actual card and its VRAM, and leads with
  "pick a lighter engine" instead of wording that reads as transient
  contention the user can flush their way out of.

Regression test: tests/test_low_vram_advisory.py (8 of 12 fail before),
including that the 300/372 spread really is just text length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:03:32 +05:30
debpalashandClaude Opus 4.8 ed321c56e6 fix(download): retry a truncated model download instead of aborting (#1224)
The reporter's captured log tail, just before the backend was SIGKILLed:

  httpx.RemoteProtocolError: peer closed connection without sending complete
  message body (received 4084175097 bytes, expected 4580080592)

A 4.6 GB model died at 4.0 GB — the single most retry-worthy failure in the
download path, and it was retried nowhere:

- the installer's loop caught (HfHubHTTPError, LocalEntryNotFoundError,
  OSError). httpx.RemoteProtocolError inherits Exception, NOT OSError, so it
  escaped all five attempts. The loop now decides by CLASSIFICATION rather
  than exception type, so the next transport error with a novel type doesn't
  reopen the same hole.
- is_hf_connectivity_error — the single source of truth for "transient
  download failure" — had no truncation signature, so a widened catch alone
  would still have called it permanent. It now knows the httpx wording plus
  the urllib3/http.client equivalents (IncompleteRead, "connection broken",
  "response ended prematurely").
- the engine load path had no retry at all. VoxCPM2 and MOSS-TTS-Nano now go
  through the existing _retry_once_with_fresh_hf_client hook, widened to
  retry transient download failures with a bounded, backed-off budget. The
  HF cache is resumable (correctly-sized blobs are skipped by hash), so a
  retry continues rather than restarting.

The closed-client path (#880) keeps its single-shot budget deliberately: it's
a client-state bug, not a network condition, so a fresh session hitting it
again means repeating won't help. Both budgets are now pinned by tests.

Also logs the low-memory advisory in the STREAMING synth path. /generate has
done this since the earlier 16 GB-Mac reports, but the streaming path — which
the desktop UI tries first — did not, so the load most likely to tip a machine
into an OS OOM kill was the one load leaving no trail in the captured stderr
tail a SIGKILL report has to go on.

Regression test: tests/test_truncated_download_retry.py (9 of 13 fail before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:47:22 +05:30
debpalashandClaude Opus 4.8 8695b478cd fix(startup): report a port conflict as a port conflict (#1223)
`ERROR: [Errno 10048] error while attempting to bind on address
('127.0.0.1', 3900)` — port taken, almost certainly by an orphan from a
previous session. The user saw "Backend died (exit code 1)".

Three gaps, each independently enough to lose the diagnosis:

- uvicorn's bind failure propagated as a bare OSError → exit 1. main.py now
  catches it on the locale-independent errno (48 macOS/BSD, 98 Linux, 10048
  Windows), prints what happened, and exits EX_CONFIG (78).
- `detectHints` matched only /port.*in use|address.*in use/. Windows'
  WSAEADDRINUSE wording — "only one usage of each socket address is normally
  permitted" — contains neither phrase, AND the OS translates it (this report
  was in Russian), so no English phrase can be relied on. It now matches the
  errnos and the new exit code. The correct hint string was already in
  en.json, simply unreachable on Windows.
- Every caller of `kill_orphan_on_port` killed, slept a fixed interval, and
  spawned unconditionally — a holder we cannot kill (another user's process,
  taskkill blocked by policy, a TIME_WAIT socket the Windows LISTENING filter
  can't even see) was indistinguishable from success. `free_port_or_report`
  re-probes and the bootstrap now fails with an explanation instead of
  spawning into a port it never reclaimed.

Also renders `crashCauseHint` in the crash details dialog. It already knew a
port conflict isn't a memory problem — but was only wired to the stream-drop
path, so the one screen a user opens for an explanation showed a bare exit
code. It now also knows exit 78.

Regression tests: frontend/src/test/portInUseHint.test.js (the reporter's
Russian log line verbatim; 6 of 10 fail before) and
tests/test_port_in_use_exit.py, which pins the exit code across Python, Rust
and TypeScript so the three can't silently diverge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:36:06 +05:30