Smoke (Windows) fails intermittently in
test_revocation_during_result_barrier_cannot_ack_published_bytes with a bare
TimeoutError. It hit two PRs in a row today, one of them documentation-only,
which rules out any change under review.
The wait is a busy loop:
while not barrier_finished.is_set():
await asyncio.sleep(0)
asyncio.sleep(0) yields to the loop but never sleeps, so this runs the loop
flat out on the one thread the upload task also needs to reach
_durable_replace and set the event. On a loaded Windows runner the waiter
starves the worker it is waiting for, and the 1 s cap fires with nothing
actually wrong — a failure with no signal in it, which is worse than no test.
_await_event parks the wait on a worker thread with asyncio.to_thread, leaving
the loop free. Deterministic, and faster: the test drops from a full second of
spinning to the time the work actually takes.
Deliberately narrow. The other spin-waits in this file sit inside
"assert elapsed < 0.2" blocks that exist to prove the gRPC loop stayed
RESPONSIVE during a blocking call — spinning is the measurement there, and
converting them would delete the assertion's meaning.
123 tests across both worker files pass; the target test passes three runs in
a row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
CodeRabbit: running_backend_version accepts a /system/info body that merely
CONTAINS "model_checkpoint" or "data_dir". That is a substring sniff, and it is
fine for the decision it was written for — whether to attach to a healthy
same-version backend. It is not fine for this one, which ends in a message
naming a process for the user to kill. Any service can serve that body.
port_holder now requires the x-omnivoice-backend header that backend/main.py
stamps on every response, the same gate startup_progress already applies for
the same reason: a foreign process on our port must not narrate our UI, and it
certainly must not be the thing we point a user's kill command at. Unmarked
means Foreign, which keeps the conservative wording and offers no command.
Two tests against a real one-shot loopback responder: a spoofed body with no
marker is Foreign and gets no terminal command, and the marker is what makes a
responder ours. The first fails with the header check disabled.
244 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two more review findings, both real.
CodeRabbit: pinning the START of the dying run's slice was only half the fix. A
start with an unbounded end still does not identify one run — the replacement
writes BELOW those lines, and a tail reads the last N of the file, so the newer
run's healthy startup is exactly what the dead run's crash marker would get.
read_dead_run_tail closes the range: it is called after the settle, and takes
the end from wherever the current run now begins, which is either still the
pinned start (nothing replaced it) or the replacement's own offset — precisely
where this run's slice ends. An end that is not a usable boundary degrades to
the rest of the file, matching how an unusable start already degrades.
CodeRabbit: settle_err_log moved every handle out of the list and then waited
without that lock, so two callers could interleave — the second found an empty
list, concluded there was nothing to wait for, and read the log while the first
was still waiting for exactly the drainer it needed. A settlement lock makes
each caller's return mean the waiting is genuinely done.
Two tests: a dead run's slice stops where the replacement begins (and the
unbounded read really does return the newer run, so the assertion is not
vacuous), and an unusable end degrades rather than capturing nothing.
241 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1998 by @yangfan-yf-yf. Correct finding: the PowerShell example I added
in #1993 generated the administrator key with `python -c`, and the whole point
of the Docker path is that the host does not need Python. On a Windows host
without it, the very first line of the setup fails.
One thing on top. The key is also accepted as an `?api_key=` query parameter
(core/auth.py), and raw Base64 carries `+`, `/` and `=`. A `+` in a query
string decodes to a space, so a user who pasted such a key into a URL would get
a silent mismatch with nothing to explain it. The Bash line next to it uses
`secrets.token_urlsafe` and never had this shape, so the two now agree:
trim the padding, map `+` to `-` and `/` to `_`.
Verified in Windows PowerShell 5.1 (5.1.26100): the block parses and runs, and
the key is 43 URL-safe characters — the same shape `secrets.token_urlsafe(32)`
produces. validate-install-docs.py and both docker/changelog test files pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The backend-lifecycle harness asserted a literal — "is already in use, so the
backend could not" — while its own comment said the point was "the exact
phrasing BootstrapSplash.detectHints localizes". Those are not the same thing,
and the gap showed: rewording the message by who actually holds the port kept
the matcher firing and still failed the test.
It now asserts the real contract, the same one the Rust unit tests pin: the
message mentions a port, says it is in use after that, and names the port
number. Any wording that satisfies detectHints satisfies this; any that does
not, fails — which is the failure worth catching, because it silently costs
the user the localised hint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Four tests in backend/tests/ cannot pass on a stock Windows checkout. The
`test` job runs that session on Linux only, so all four were invisible to CI
and hit every Windows contributor on their first `pytest` run — with failures
that have nothing to do with whatever they changed. Same class as #1990.
- test_contained_subprocess_waitid_fallback.py simulates macOS by deleting
os.waitid, then drives the fallback with os.waitpid/os.WNOHANG and
start_new_session. Windows has none of those; os.WNOHANG is an
AttributeError before the first assertion. The module is POSIX-only by
premise, so it says so.
- test_invalid_or_missing_desktop_drain_fd_fails_safe asserts a RuntimeError
that cannot be raised off POSIX: backend_drain_fd returns None there before
it reads the environment. The file already had this skipif on its sibling.
- test_mps_proxy_survives_fatal_child_exit_and_recovers raced the OS. The
child calls os._exit and the parent raises the moment its pipe hits EOF —
before the process is reaped. Asserting poll() on the next line is a race
Linux won and Windows lost every time. It waits for the death now, which is
what the test actually claims.
Then the reason all four survived: nothing runs this session on Windows. The
smoke matrix already does a full `uv sync` there, so the session costs forty
seconds and now runs as a step in it. Verified green on Windows before adding
the gate — 355 passed, 8 skipped — so this cannot break main.
Kept to Windows deliberately: that is the platform I can verify here, and a
gate added blind on macOS would be a guess about a host I cannot run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Four review findings on the attempt id, all real.
Greptile, P1 — the status snapshot could mismatch. The bump and the stage write
were separate, so a restart landing between them returned the PREVIOUS attempt's
stage stamped with the new attempt's id: the splash then recorded
`installing_deps` as work this attempt did, which is precisely the #1894
fabrication the id exists to remove. `begin_attempt_with` now takes the stage
lock across both writes, and `bootstrap_status` reads stage and attempt under
that same lock. The pair a reader observes is always self-consistent.
CodeRabbit, major — an output pump is a thread reading a pipe, and it outlives
the run it drains. It stamped each line with the counter's value at read time,
so a restart relabelled the dying run's trailing output as the new attempt's
evidence. `emit_log_for_attempt` takes the attempt explicitly, and all four
pumps (the backend's stdout and stderr, and both sides of `run_streaming`)
capture theirs when they start. Every other call site runs inside the attempt
it describes and keeps reading the counter.
CodeRabbit, minor — the tests that advance the process-global counter raced
each other under cargo's threaded runner, so one could read a value another had
just moved. They serialize on a lock now, like the env-var tests above them.
CodeRabbit, minor — the backfill-to-live seam deduplicated on stage plus text,
and installer output repeats itself constantly. Across a restart that is not a
replayed line, it is the new attempt's own evidence, and dropping it can remove
the only proof for a stage the poll never samples. The attempt is part of the
identity now.
Two new tests: a new attempt never carries the previous stage, and a repeated
line belonging to a different attempt is kept. The second fails against the
previous dedup key. 241 Rust lib tests and 2853 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two review findings on the settle, both real.
Greptile, P1: settling can take up to two seconds, and a Retry arriving in that
window installs a new run and moves ERR_LOG_RUN_START past the dying run's
output. Reading "the current run" after the wait would then hand the dead
process's crash marker the REPLACEMENT's healthy startup — the cross-run
attribution #1510 exists to prevent, reintroduced through the wait added to fix
the tail. Every death path now pins the offset BEFORE settling and reads from
it, via read_error_log_tail_from.
CodeRabbit: a single drainer slot loses a timed-out handle the moment a new run
installs its own. Dropping a JoinHandle detaches the thread, so nothing can
ever wait for that run's output again and both guarantees quietly stop holding.
The slot becomes a list: a settle drains it, joins what finished, and puts back
what is still running, ahead of anything a concurrent spawn pushed.
Two tests: a pinned offset still names the dying run's slice after a respawn
moved the current one, and an unfinished drainer survives another run
installing its own. 239 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Greptile, security, on the reclaim guidance: the convenient one-liner does not
preserve the identity port_holder established.
- `lsof -ti tcp:3900` matches CONNECTED CLIENTS as well as the listener, so
piping it into kill can end a process that merely talks to VoiceStudio.
- Windows `findstr :3900` matches `:39001` and established connections too.
And the identity itself is a fact about the moment the message was written. By
the time a user runs a command it has to be re-established, and only they can
do that.
So both platforms now get two steps: a lookup restricted to the LISTENING
socket that prints the pid and process name, and a kill of that pid once the
user has confirmed what it is. A test pins that the guidance never pipes a
lookup into kill and always shows something to confirm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The code half of #1931 landed already: `torchaudio.set_audio_backend()` is
guarded, so the torch 2.9.x upgrade a Blackwell card needs no longer trades one
`ml_imports` crash for another. Two things were still missing.
The changelog said the upgrade was documented. It was not — nothing in docs/
mentions sm_120, Blackwell, or the 50-series at all, so a user hitting a native
access violation inside `import torch` had the issue thread and nothing else.
troubleshooting.md now carries it: why the pinned torch 2.8.0 cannot work
(no sm_120 kernels in the wheel — not a setting, not a workaround), the trio
that has to move together, the verification command that proves the kernels
arrived, and the fact that the change is to the repo's own pins so a later pull
will undo it.
The part most likely to be missed is that there are TWO pin lists.
`constraint-dependencies` governs `uv sync`/`uv lock`/`uv run`;
deploy/torch-constraints.txt governs the `uv pip install` paths, which ignore
project-level uv settings. Editing one leaves the other behind, which is what
`RuntimeError: operator torchvision::nms does not exist` looks like from the
outside. Both are named.
The second gap: nothing protected the guard. CI runs the pinned torch 2.8.0,
where `set_audio_backend` still exists, so deleting the `hasattr` as a
"simplify this no-op" cleanup would pass every test in the suite and restore a
hard startup crash for every RTX 50-series user. tests/ now walks the backend
AST and fails on any reach for a torchaudio API that 2.9 removed unless
something proves it is there — a `hasattr`/`getattr` check or a `try`. Fails
with the guard removed, passes with it.
Not addressed here, because it is already fixed: the reporter's third
observation, that launching through the desktop shell hung inside `import
torch`'s native init, is the OpenBLAS/stdin-pipe deadlock closed under #1952.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The port-conflict failure asserted "already in use by another application"
without ever asking who held the port. In the reports behind #1933 (and its
duplicates #1935, #1936, #1937 — same machine, same 45-minute window) the
holder was the user's OWN orphaned backend from an earlier run. So the app
told them to quit a copy of VoiceStudio that has no window, and there was no
action in the message that could have worked.
@Chang-Jin-Lee diagnosed this precisely on #1936, including the observation
that the identity check already exists: `running_backend_version(port)` asks
`/system/info` who is there, and is already trusted for the more consequential
decision of whether to attach to a healthy same-version backend. It simply was
not consulted on this path.
So it is now. `port_holder()` returns one of three answers, and
`port_conflict_message()` words the failure from it:
- our own backend at this version (or one too old to report one) — say so,
say it has no window to quit, and give the terminal command that ends it;
- our own backend at a different version — name the version, which is what
identifies it, and give the same command;
- anything else — the existing wording, now actually justified.
The terminal command is only ever offered for a listener that identified
itself as ours. An unidentified one keeps the conservative wording: a user must
never be told to go kill a process that may not be theirs. A listener that
accepts a connection but does not answer `/system/info` counts as
unidentified, which is the reading that cannot do harm.
All three sites that reported this — take-ownership, respawn, and the
early-exit path on EXIT_PORT_IN_USE — go through the one builder now.
What is deliberately unchanged: `kill_orphan_on_port` still refuses to signal
a PID discovered through lsof/netstat. That refusal is correct — the reuse race
is real, and a matching foreign service must never be terminated. This changes
what the user is told, not what the app is willing to kill.
Five Rust tests, one per branch plus the suffix behaviour, and one that pins
every wording against the `detectHints` matcher — that regex is what turns
these English strings into the localised `bootstrap.hint_port`, and an earlier
draft of one message silently lost the translation by saying "is held by". The
frontend test that pinned the old literals now pins the new ones.
241 Rust lib tests and 2851 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The report in #1927 is a Windows access violation 13 seconds after "Loading
VoiceStudio model on device: cuda" — a native fault inside the compute stack,
which produces no Python traceback because the process is executing bad machine
code. What the user was shown was "Backend died (exit code -1073741819)", a
timestamp, an uptime, and a log ending mid-startup. The issue they filed has an
empty description, which is the honest response to being handed a number and no
next step.
The classification already existed and is good: `crashCauseHint` distinguishes
a native fault (a GPU driver disagreeing with the bundled CUDA runtime, or a
partially downloaded weight file), an exit 78 port conflict, an OOM kill and a
half-built Python environment, and names concrete actions including the
crash-isolated engines. It just never reached this surface — the only place it
rendered was the message on a stream dropped by a crash, and a crash with no
request in flight has no stream to drop.
So the details dialog renders it. A sentinel marker is deliberately excluded:
it cannot know a crash happened at all (sleep, force-quit and a stopped VM
leave the same trace), so it has no cause to explain, and asserting one would
be the #1375 fabrication in a new place.
Three tests: the access violation gets the compute-stack guidance, a port
conflict gets its own rather than the GPU one, and a sentinel gets none. The
first two fail against the previous component.
2853 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The crash report in #1850 carries a stderr tail that stops 58 seconds before
the death it is meant to explain. That is not a quiet backend — it is a race.
`wait()` returns the moment the child exits, but stderr is drained by a
separate Rust thread reading a pipe and appending to backend_err.log. The two
crash-marker sites read that file immediately on detecting death, so the
drainer's in-flight lines — the traceback that names the cause — land after the
tail is taken. The report then shows a log that simply stops, and the crash is
undiagnosable no matter how good the rest of the capture is. Every silent
"exit code 1" report is a candidate for this.
The machinery to wait already existed for a different reason: #1510 joins the
drainer before a respawn records its start offset, so a dying run's buffered
tail cannot be attributed to the new run. It was just never applied to the
death paths. `join_previous_err_drainer` becomes `settle_err_log`, called from
the crash-marker sites in both the startup and supervisor paths as well as
before a respawn.
One behaviour change while it moves: when the bound expires the handle is now
handed back rather than dropped. Dropping detaches the thread, and every later
caller — including the respawn that #1510 protects — silently loses the ability
to wait for that run's output at all. Bound stays at 2 s, so a wedged pipe still
cannot stall crash recording.
Regression tests: a drainer that writes a traceback 120 ms after death (the
tail contains it now, contains only "steady state" before), and a wedged
drainer that outlives the bound (the slot still holds it). The three tests that
install into the process-global drainer slot now serialize on a lock — they
were racing each other under cargo's threaded runner.
238 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
@yangfan-yf-yf pushed two more commits to #1987 after the first pass landed.
Two things in them were worth taking:
- an explicit `compose pull` step, so the platform override is proven before
`up -d` rather than discovered when the pull inside it fails; and
- a PowerShell form. An ARM64 Windows host cannot use `export`, and the
surrounding page only ever shows Bash — so the guidance did not actually
reach the users most likely to need it.
Not taken: the same commits also moved `--platform linux/amd64` into the
default `docker pull` / `docker run` quick start. That is a no-op for the
amd64 majority and contradicts the Architecture section directly above, which
introduces the flag as the conditional ARM64 step. The canonical command stays
the one almost everyone should run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The first-run splash decided which bootstrap attempt a piece of evidence
belonged to by inferring attempt boundaries from the `bootstrap_status` stage,
which is sampled about once a second. Inference from a sampled signal cannot be
airtight, and two routes slipped through it:
- a retry that goes failed -> checking -> starting_backend inside one sample
window, where the poll sees no restart stage at all; and
- the supervisor's own venv rebuild, which re-enters `checking` with no
`failed` stage and no click behind it. If the poll samples the same stage
name on either side of it, the sequence is `installing_deps` ->
`installing_deps` — literally no signal that anything restarted, and the
previous attempt's completed steps stayed on screen as this attempt's work.
That is the #1894 fabrication arriving by a route stage inference cannot
close.
The producer knows the answer exactly, so it now says so. `ATTEMPT` is a
monotonic counter bumped wherever the bootstrap really restarts —
`respawn_backend`, which both retry commands and the scoped reset funnel
through, and the automatic venv rebuild. `bootstrap_status` returns it beside
the stage (a flattened `BootstrapStatus`, so the wire shape the frontend
already reads is unchanged), and every `bootstrap-log` line carries it too.
The splash scopes stage evidence by equality on that id and the boundary
heuristics are gone: `RESTART_STAGES`, the leaving-`failed` rule, the
wall-clock `attemptStart`, and the `selfInitiatedRef` guard that existed only
to stop the poll re-stamping a boundary the UI had already opened. `beginAttempt`
is now presentation only — it clears the visible log for a retry the user asked
for.
Two new tests cover what only an id can carry: a Rust-side restart the poll
cannot see at all, and a log line from the previous attempt that must not count
toward this one. Both fail against the previous component and pass now. Rust
side: 240 lib tests green, including four on the counter and the status shape.
Frontend: 2847 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Creating a symlink on Windows needs SeCreateSymbolicLinkPrivilege, which a
normal account does not hold unless Developer Mode is on. GitHub's hosted
Windows runners hold it, so seven unguarded call sites passed in CI and failed
only on a contributor's own machine, with WinError 1314 and no connection to
whatever they were working on:
tests/backend/services/test_audiocpp_backend.py (5)
tests/test_exports_api.py (1)
tests/test_storage_report.py (1)
The repo already knew about this — tests/test_hf_cache_repair.py carries a
private _symlink_or_skip helper whose docstring describes exactly this failure.
The pattern simply never reached the other files, which is the whole class of
the bug: a convention that lives in one module's private helper gets rewritten
from scratch, or forgotten, at every new call site.
So the helper is now a `symlink_or_skip` fixture in tests/conftest.py, and
tests/test_symlink_guards.py walks the AST of every test module and fails on a
raw symlink_to / os.symlink that has no way to skip. Guarded means the fixture,
a try, a skipif marker (module-level pytestmark included), or a test that has
already run a skipping helper — the three legitimate existing patterns, which
it recognises rather than forcing a rewrite.
Coverage is unchanged: the full pytest job runs on Linux, where nothing skips.
Fails before (7 errors, then the guard reports the offending files), passes
after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
tests/test_no_hardcoded_cjk.py rejects CJK outside frontend/src/i18n/, and the
refreshed tree annotated README_CN.md with the characters themselves. The file
name already says which language it is; the annotation does not need to be in
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1981 by @Dawcraft, which refreshes docs/STRUCTURE.md to match the tree
as it actually is — the old file still described a root-level layout that the
2026-07-12 cleanup removed, and pointed at a tests/services/ mirror that has
not existed since the tests/backend/ reorganisation.
Verified every path, directory and CI claim in the refreshed file against the
repo: the router auto-include list, the isolated backend/tests/ pytest step,
the smoke-matrix job and its HF_HUB_OFFLINE guard, and every file the tree
names. One number was off — backend/services/ holds 78 modules, not 79.
Off-by-one in a doc is the symptom; the class is a count nothing checks, which
is wrong the week after it is written. tests/test_structure_doc.py now pins
the router count, the service count and the engine-adapter list to the tree,
so the next module to land fails the suite with the line to update instead of
quietly aging the doc. Fails before the fix (79 != 78), passes after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1987 by @yangfan-yf-yf, which closes#1921.
The published images are linux/amd64 only, and the quick start reached image
resolution before saying so — an ARM64 user met "no matching manifest for
linux/arm64/v8" with no explanation. Verified against docker.yml, which says
so in its own comment: "only building linux/amd64".
One gap in the original: the platform override was documented for docker pull
and docker run, but Compose has no per-command --platform flag, so the
recommended Compose command still resolved the missing ARM64 manifest and
failed exactly as before. DOCKER_DEFAULT_PLATFORM covers it, with the same
caveat the rest of the section makes — emulation, not native support, and only
the CPU profile makes sense under it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The existing case pinned the literal string "Invalid source language code",
which the #1960 fix replaces with a message that names the offending code. It
now asserts what the test is actually about — a 400 that identifies the code —
so improving the guidance again does not fail it for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1960.
The report was "400 Bad Request: Invalid source language code" and nothing
else. That cannot be acted on or triaged: it does not say which of the ninety
or so codes was wrong, so neither the user nor a maintainer reading the
auto-filed issue can tell whether the picker offered something the backend does
not accept, or a stale preference from an older build is still being sent.
I could not determine the cause from the report, which is exactly the problem.
Naming the code makes the next one answerable instead of guessing at this one.
The value is a language code chosen from a menu, not private data, and the
engine validator a few lines away already echoes its input the same way.
Also adds the check I actually wanted while investigating: a test that reads
the picker's own LANG_CODES and asserts the backend accepts every one of them,
so a code added to the menu cannot silently become a 400. It passes today —
the menu and the allowlist do agree — which is how I ruled that out as the
cause rather than assuming it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1773.
The 500 handler has always put error_class in the response body, but nothing
lifted it onto the Error object — and the auto bug reporter reads the Error. So
every unclassified 500 filed "VoiceStudio hit an internal error; check the
backend log for details." and nothing else: identical reports, none of them
triageable, with the distinguishing datum sitting unused in the payload that
produced them.
#1956 did exactly this for the streaming path. The classic path had been
carrying the field on the wire the whole time; it just never survived the hop
onto the exception.
Only a string is kept. A 404 or a validation error has no class, and an empty
one would put a blank line in every report; a non-string is ignored rather than
stringified. Both pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1949.
Settings offers three notations. Only Respelling substitutes text today; IPA
and CMU rows save cleanly, are validated, get a badge and can be toggled on,
then get dropped before term matching and are never read again.
That much is Phase 1 behaving as designed. The defect is that it was INVISIBLE:
"Test a sentence" answered "No entries match — spoken as written" for a term
that does match. Not a degraded answer, a wrong one — and it sent the user off
to re-type an entry that was already correct, or to convert it to Respelling,
where a phoneme string is then read as graphemes.
docs/specs/01-expressive-tts.md asked for exactly the opposite: such entries
"passed through and flagged 'phoneme not honored on this engine' (parity-rule:
visible degradation)". That flag was never implemented. This is it.
The dry run reports inert entries separately, and the panel names them. The
substitution path is deliberately untouched — this does NOT start feeding raw
phoneme strings into the grapheme stream, which is the thing Phase 1 refuses on
purpose, and a test pins that it still refuses.
Not Phase 2. Lowering IPA/CMU to engine markup is a real feature per engine and
stays open; what changes here is that the gap is now honest rather than silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1847.
The splash is the only surface with a Show/Copy affordance for these lines, and
it unmounts the moment the stage flips to ready — so on a successful first run
the whole install log was gone for good, with no completion pause and nowhere
to retrieve it. A user who wanted to check what had just been installed, or
attach it to a bug report, had nothing.
The lines are written to bootstrap.log beside backend.log now, so everything
about a run is in one directory and a bug report does not have to hunt in two.
Truncated once per process rather than appended forever: a bootstrap is a
single episode and the useful question is always "what happened this time".
That also bounds the file across repeated retries without needing a hook on
every restart path. The docs say so, and say to copy it first if you need a
superseded attempt.
Best effort throughout — a log that cannot be written must never take the
bootstrap down with it, and a test pins that it does not.
The counter half of this issue (Activity frozen at 200) was already fixed on
main by #1918; I verified that before starting rather than assuming the whole
issue was open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1974.
The dev launcher only treated a port holder as ours when it ran out of the git
checkout. A backend the Tauri shell spawned lives under a per-app directory
named after the bundle id instead, so the launcher saw its OWN orphaned backend
as a stranger, refused to free port 3900, and aborted the run with "Refusing to
stop unrelated process" and no way forward but Task Manager.
Ownership now also accepts the app's reverse-DNS identifier in the executable
path or the command line. A bundle id is specific enough to be safe: nothing
else on the machine carries it, which is the point of the namespace.
The guard itself is unchanged in spirit — a foreign listener on the port is
still refused, and a test pins that widening ownership did not widen it to
everything, including a process from some other vendor's bundle.
Known limit, since I hit it in this repo: on Windows the check is given the
command line and executable path but not the working directory, so a backend
started by hand from an arbitrary interpreter — a bare `uvicorn` whose only
link to the checkout is a relative --app-dir — is still not recognised. That is
a different shape from the reported one and needs the cwd, which this code path
does not currently have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1858.
Whichever app registers a global shortcut first wins, and the default collides
with 1Password Quick Access on macOS — so for a large share of installs the
hotkey the onboarding screen advertises silently does nothing.
Registration failure was a Rust-side log line and nothing else. There was no
publish on the error path, so the frontend kept reporting whatever accelerator
had been REQUESTED, with no way for any screen to know the OS had refused it.
The failure is published now, carrying the outcome in `backend` and still
naming the accelerator so the UI can say WHICH combination is taken.
Surfaced as its own state rather than folding into the existing "no hotkey
registered" badge. That one means "not checked yet"; this means "this exact
combination belongs to another app, pick a different one" — different
situations needing different actions.
Detection rather than a new default, deliberately. Any default can collide with
something, so changing the value would move the problem rather than remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1898.
On Windows the shell terminates the backend's job object with no graceful
phase — a console-less GUI child has no reliable control event — so the
backend never runs its lifespan shutdown and never clears its own run
sentinel. Every deliberate quit came back on the next launch as "The backend
did not shut down cleanly last run — it likely crashed or was killed". The
backend-side fix in #1895 only helps platforms where teardown actually begins.
A process about to be killed cannot record its own intent, so the shell records
it: the sentinel is retired immediately before the tree is terminated, on the
one path that knows the stop is deliberate. Anything that dies WITHOUT passing
through that path still leaves its sentinel behind and is still reported as a
crash, which is the property worth keeping.
Best effort by design. The data directory comes from the running backend, so if
it cannot be reached the file stays and the next launch reports a crash — the
same behaviour as before, never worse. A test pins that specifically: silently
erasing evidence of a real crash would be worse than a false positive.
Split into a pure file-level half and the port lookup so the behaviour is
testable without standing up a stub server. Verified on Windows with a real
toolchain: 233 Rust tests pass, including the three new ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1857.
The CSS honours prefers-reduced-motion in about a dozen separate blocks, but
that is the OS switch and nothing else. Someone who wants a calm app without
turning motion off system-wide had no way to ask for it, and someone whose OS
setting is not respected by their environment had no recourse at all.
Settings → Appearance → Reduce motion sets data-motion="reduce" on the root,
and one blanket rule covers the whole tree including pseudo-elements. That
shape is deliberate: a per-component list is what let the header status dot
keep pulsing under Reduce Motion (Part B of the same issue, fixed separately),
and a single rule cannot have that gap.
Additive by design. The media query is left untouched and keeps working on its
own, so turning this off never re-enables motion for someone whose system asked
for less. A test pins that the two stay independent.
Durations go to 0.01ms rather than none: a zero duration skips animationend /
transitionend, which strands anything waiting on them. Imperceptible, still
fires. Also pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1973. Lands #1975 by @CoDe-ReDz.
The app shipped six themes, all dark, and "auto" stayed dark on a light-mode
OS — so a user looking for a light mode found nothing. Light text on a dark
background causes halation for people with astigmatism, which makes this an
accessibility gap rather than a preference.
One correction to the contributor's palette: --chrome-fg-muted at #586e75 gives
4.39:1 against --chrome-bg #eee8d5, just under the 4.5:1 AA threshold for
normal text. Raised to #4d5f66 (5.45:1) in both the explicit light block and
the prefers-color-scheme mirror, keeping it in the Solarized family.
For the record, the two contrast failures the review bot flagged as P1 are not
real: --color-fg-subtle measures 6.66:1 and --chrome-fg-dim 5.86:1, both
comfortably AA. The token that actually failed was one it did not mention.
Everything else the bots raised was already handled on the branch: both theme
labels go through t() with real translations in all 21 locales, and the
header's white-to-grey gradient is overridden for the explicit light theme and
the auto mirror alike.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1826.
A degenerately short generation reaches a convolution whose kernel is wider
than the tensor it was handed, and torch reports that in its own terms —
"Calculated padded input size per channel: (1). Kernel size: (2). Kernel size
can't be greater than actual input size". It arrived doubly wrapped in
"Underlying error:" and named nothing the user could change, when the fix on
their side is simply to type more than one character.
It is worth classifying for a second reason: this is not transient. The generic
wrapper told the user to "retry once", and this class fails identically on
every retry, so the advice actively wasted their time. The new remedy says so.
Matched on torch's own wording, which nothing else produces, so it is safe on
the context-free surfaces — and it needs to be, because that is exactly how it
reaches the user, through the generic 500 and the streaming error frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1849.
UiScaleSetup is a client-side zoom — it makes no backend calls at all — but it
was gated on backendReady. So on a clean install the user watched the entire
bootstrap, and answered the macOS Accessibility prompt, at whatever size the
app had guessed, and was offered the size control only once all of that had
finished. Someone who cannot comfortably read the UI had to get through the
least readable part of the product first.
The gate now runs as soon as the store has hydrated, which is its only real
prerequisite: uiScaleConfigured lives in the store, and reading it earlier
would flash the screen at someone who had already chosen a scale.
Pinned at the source level. Rendering App in jsdom to observe the ordering
would need the whole backend, store and Tauri surface mocked — a far more
fragile test than the two facts it pins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1879.
mlx-audio raises a bare ValueError in its own vocabulary — "No conditionals
available. Either provide audio_prompt/audio_prompt_sr for voice cloning, or
ensure conds.safetensors is in the model directory." — and the generate route
passed it straight through as the 400 detail. The user was told to supply an
argument they have no way to name and to check for a file they have never heard
of, when what happened is simply that they asked to clone with nothing to clone
from.
Classified now, with a remedy in the user's terms: pick a profile that has a
saved reference clip, or record one. It also notes that a designed voice with
no saved reference cannot be cloned from, which is the case that produces this.
The route still passes through every ValueError it cannot classify. Most are
VoiceStudio's own validation messages and are exactly what the user should
read, so replacing them wholesale would have been a regression — tests pin four
of them as untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Refs #1931.
torchaudio 2.9 removed set_audio_backend(). soundfile has been the only backend
since 2.0, so the call was already a no-op — but unguarded it raises
AttributeError inside the ml_imports startup phase, and a failure there takes
the whole backend down: the desktop app sits on "starting backend" forever and
/health stays 503.
The group hitting it is not hypothetical. RTX 50-series (Blackwell, sm_120)
cards have no kernels in the pinned torch 2.8.0, so those users MUST move to
torch 2.9.x, which brings torchaudio 2.9 with it. Being forced to upgrade and
then crashing on a line that does nothing is the whole defect.
This does NOT raise the torch pin. Doing that changes the CUDA build on every
platform, in Docker and in CI, so it is the owner's call rather than something
to slip into a bug fix — the issue stays open for it. What lands here is the
half that is safe: the guard, plus a troubleshooting section with the exact
upgrade recipe and the command to confirm the card is visible, so an affected
user has a supported path today.
The guard is tested at the source level: reproducing it needs a real torchaudio
2.9 in the environment, which the pinned test env does not have. One test also
pins that the guard actually WRAPS the call, since a hasattr elsewhere in the
file would satisfy a naive substring check while the real call stayed bare.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1808.
#1797 moved the compute-time budget into Settings → Performance & Device, but
three branches of _timeout_guidance still told the user to raise
OMNIVOICE_GENERATE_TIMEOUT_S. That sends someone to set an environment variable
for a value the app now exposes as a control — and on Windows, setting one
durably is the trap this project's own docs warn against.
Nothing about the mechanism changed: the variable still works and still takes
precedence over the setting. Only which of the two the message names.
Two existing tests asserted the env var appears in that text. They predate
#1797 and were pinning the behaviour this issue reports as wrong, so they now
assert the control instead. A third test guards the whole class rather than the
three instances, so a branch added later cannot quietly reintroduce it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1866.
Model Catalogue → Engines showed "Engine unavailable. Check installation and
configuration." and "Last error: A previous engine check failed." for engines
the user had simply never installed. Neither names a missing package, a missing
step, or a next action, and the second reads like a crash or a poisoned cache
rather than "you have not installed this yet" — so a normal, expected state
looked like a fault.
The probe's own sentence still cannot cross the boundary: it carries exception
text, local paths and sometimes credentials, which is why it was replaced in
the first place. What changed is that the private diagnostic is now CLASSIFIED
into a VoiceStudio-owned category — package not installed, needs configuring,
file missing or unreadable — exactly the shape _public_routing_reason already
uses for routing. Anything unrecognised keeps the old generic sentence rather
than asserting a cause the probe never gave.
test_docs_url_survives_the_public_metadata_scrub pinned the generic wording
while testing something else; it now asserts what it is actually about, that no
private text survives.
Also skips the exec-bit placeholder test on Windows, where os.access(X_OK) is
true for any existing file so the assertion cannot fail — it errored the whole
module on a Windows checkout. Pre-existing, unrelated to this change, and in
the way of running these tests at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1845. Closes#1886.
The widget window is created always-on-top, and the setup state had no time
limit at all. On a clean macOS install the pill sat over the first-run setup
window — covering the disk-space line and the Start installation button — and
over every other application, until Accessibility was granted or the user
dismissed it by hand. There was no cap and no safety net: the stranded-pill
reconcile only runs while idle, and this state is not idle.
A permission the user has not granted yet does not outrank what they are
actually doing, and mid-setup they usually cannot grant it yet anyway. The
prompt now gets a bounded claim on the screen and then steps aside.
Polling deliberately continues after the window hides, so granting
Accessibility later still returns the widget to idle on its own — what expires
is the pill's claim on the screen, not the reconciliation. The hide is latched
so it fires once rather than fighting anything that legitimately shows the
window again; both properties have a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1856.
The mandatory-only install path ships no speech-to-text model, and the
dictation step rendered its three script cards regardless. Every card came up
red with "No speech-to-text model is installed", and the step's own copy
invited the user to press the hotkey or hit Replay, neither of which can
transcribe anything. That is the final screen of first-run setup, so the last
thing a new user saw was three failures they were told to cause.
The step now checks readiness the same way the component already checks for
its bundled sample WAVs, and when no model is installed it offers the model
chooser in place of the cards — the same picker the Transcriptions page uses,
so the user installs one and continues rather than reading an error three
times. A model already on disk can be selected without a download.
`checking` deliberately keeps the cards: the probe resolves in well under a
second, and flashing the install panel first would be worse than the wait.
A test pins that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1957.
A download failed with nothing but the OS sentence: "[WinError 448] The path
cannot be traversed because it contains an untrusted mount point". That is a
Windows rule about the VOLUME — Dev Drives, mounted VHD/ReFS volumes and
junctions into another user profile all trigger it — so retrying the same link
can never work, and the message names nothing the user can change.
Classified now, with a remedy that points at Settings → Storage and gives the
fsutil escape hatch for a folder that has to stay put. Matched on the numeric
code first, since Windows translates the sentence, with the English phrase as a
fallback. Allowlisted for context-free surfaces because it arrives through the
global 500 handler, which otherwise attaches no hint at all — and its trigger
is unmistakable, so it cannot land on an unrelated failure.
A test pins that the offending path never comes back in the payload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two CI failures, both mine to fix.
The warning I added to the Colab ASR cell used \n escapes inside the notebook
JSON, and they landed as real newlines, so the cell's Python had an
unterminated string and tests/test_colab_asr_setup.py could not exec it. The
block prints line by line now, with no escapes to get wrong.
test_tauri_log_clear_reports_truncate_failure patched _tauri_log_candidates,
but #1925 moved Clear onto _tauri_plugin_log_candidates, so the patch no longer
reached the code under test and the real resolver was consulted instead. It
passed on a machine with a shell log on disk and failed on a clean runner.
Patches both halves, matching the fixture in test_tauri_log_clear.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
#1877 completes the zh-CN translation and drops its ratchet to zero, but the
PR never ran today's gates — it has been conflicting, so CI reported nothing —
and the file it lands does not pass tests/test_locale_parity.py.
Three things fixed here:
- Twelve keys were declared twice inside the same object (timing_concise,
autofit_quality, the plan_* set, the role_* set). Python's parser rejects a
duplicate key outright, so the whole suite errored rather than failing one
assertion. Deduped keeping the first occurrence, which is the block #1877
actually translated.
- The `player` section appeared twice: the complete new one and an older
two-key stub. JSON keeps the LAST, so the stub silently won and six keys
vanished at runtime. The stub is gone.
- `settings.hf_source_*_label` appeared twice with slightly different wording.
The file is rewritten as canonical JSON (indent 2, non-ASCII preserved), which
is byte-identical to how en.json already serialises, so the format matches the
other locales exactly. zh-CN now has zero keys missing and zero beyond en.
Also fixes the review finding on #1959: the capture route picks its engine from
a `mode` form field, not an `accurate` flag, so parametrising on `accurate`
sent a field the route ignores and ran the default fast path twice. Both
engines are exercised now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S