04410a458d5f3b3a011913a6c2c941e24f666468
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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! |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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) |
||
|
|
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> |
||
|
|
854306fef8 | docs(changelog): rebuild [Unreleased] after the main merge | ||
|
|
3e2b089698 | docs(changelog): rebuild [Unreleased] after the main merge | ||
|
|
8c77a82727 | docs(changelog): rebuild [Unreleased] after the main merge | ||
|
|
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. |
||
|
|
6d42db5052 |
Merge main into fix/low-vram-preflight-1226
# Conflicts: # CHANGELOG.md |
||
|
|
95fae6df0d |
Merge main into fix/download-resume-oom-1224
# Conflicts: # CHANGELOG.md |
||
|
|
59c7e5a3bd |
Merge main into fix/port-in-use-1223
# Conflicts: # CHANGELOG.md |
||
|
|
09b60901e4 |
Merge main into fix/lazy-model-import-1229
# Conflicts: # CHANGELOG.md # backend/core/failure.py |
||
|
|
2009ef329c |
Merge main into fix/dub-download-errno22-1225
# Conflicts: # CHANGELOG.md # backend/core/failure.py |
||
|
|
ca5ca99247 |
Merge main into fix/synth-error-classes-1227-1221
# Conflicts: # CHANGELOG.md |
||
|
|
9b2659733a |
Merge main into fix/lazy-model-import-1229
# Conflicts: # CHANGELOG.md |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|