Skip to content

Fix 21 safety and reliability defects found by audit - #131

Merged
dovvnloading merged 34 commits into
mainfrom
codex/qa-reliability-pass-2
Aug 15, 2026
Merged

Fix 21 safety and reliability defects found by audit#131
dovvnloading merged 34 commits into
mainfrom
codex/qa-reliability-pass-2

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Fixes all 21 defects confirmed by a safety/reliability audit of this repo. Every fix has a regression test that was verified to fail before the fix and pass after.

main is untouched until this merges.

High severity

  • One long answer wiped the entire conversation history. fit_history_to_context stopped walking the moment the newest exchange alone exceeded the budget, discarding every older exchange too and returning "No history available." — so right after the model wrote a long answer, the next message went out with no context at all. Reproduced: a 10-exchange thread ending in a 35k-character answer kept 0 exchanges; after the fix, 10.
  • Quitting during generation hung the app and orphaned llama-server. Shutdown awaited workers with no bound, and ran before the only call that terminates the llama-server child. Now bounded, with runtime teardown guaranteed to run.
  • llama-server had no Windows Job Object. Any hard exit left it alive holding the whole model in RAM/VRAM. Sandboxed execution workers already got this exact policy; the inference server didn't.
  • Sessions expired after one hour with no renewal path, leaving a dead screen with no control on it. Expiry now slides on use, capped at an absolute maximum.
  • The Ollama client had no HTTP timeout. A connected-but-unresponsive Ollama locked the generation slot permanently — every later message 409'd.

Medium severity

  • Stop didn't actually stop — cancellation is now threaded into the in-flight model call
  • DNS rebinding defeated the sandbox's private-address blocklist (validated address is now pinned through the connection, including redirects)
  • Settings saves byte-copied the entire chat database; settings now have their own file, with a one-time adoption migration so upgrades don't revert to defaults
  • The answer printed twice after leaving and returning to a chat mid-generation
  • One bad model permanently disabled GPU inference for every model
  • Crash-loop guard froze the status endpoint while tearing a process down
  • Attachments were budgeted last, so a document attached mid-conversation was cut to a fragment
  • Saving settings with an empty model inventory erased the configured chat model
  • Per-chat generation overrides reverted after one message and leaked into the next chat
  • The streaming bubble remounted every frame in long transcripts
  • Memory writes had no lock, so a saved memo could vanish
  • The window monitor polled a DB-touching endpoint 10×/second forever, with a 1.2s failure threshold
  • A 100 MB runtime directory was re-hashed on every 2-second status poll
  • Build staging directories were never reclaimed (413 MB had accumulated in this repo)
  • Every source build re-ran a full npm ci because the cache marker could never hit
  • Non-ASCII input turned two unauthenticated 401 paths into unhandled 500s

Test plan

  • python -m pytest tests/ -q — 547 passed, 1 skipped (baseline before this work: 514)
  • npx vitest run — 168 passed (baseline: 165)
  • tsc --noEmit, eslint, ruff — clean
  • Each fix verified to fail before and pass after, by stashing the source change and re-running
  • All 6 pre-push gates passed

🤖 Generated with Claude Code

dovvnloading and others added 30 commits August 12, 2026 10:30
Avoid re-hashing the ~100MB llama.cpp runtime directory on every
/api/v1/system poll (every 2s while idle) by memoizing the verification
result against a cheap (path, size, mtime_ns) fingerprint, recomputing
the full SHA-256 walk only when that fingerprint changes.
…n-ASCII input

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sweep orphaned .cortex-frontend-build-* staging directories left by a
killed or crashed build before starting a new one, logging what was
reclaimed. Also move the npm-install cache marker and node_modules out
of the disposable per-build staging directory into a stable cache
keyed on the package-lock.json hash, so an unchanged lockfile can
actually skip npm ci instead of always missing the cache.
An empty local-model inventory (Ollama down, a failed refresh) is a
routine, recoverable state, but saveDraft unconditionally wrote null
over modelSettings.chat whenever the picker had nothing to offer,
silently erasing a still-valid configured model on any unrelated
settings save.
Overrides tuned before the first message were stored under the
"new chat" placeholder key and never migrated once a real thread id
existed, so they reverted after one message and leaked into whatever
new chat the user started next.
Virtuoso's components.Footer was a fresh arrow function on every
render, and a changed function identity there makes Virtuoso remount
the Footer subtree -- tearing down and rebuilding the streaming
bubble on every token in a long transcript. Keep Footer's own
identity stable via useCallback and read the latest content through
a ref, so React updates the existing instance instead.
…arving attachments

fit_history_to_context broke out of its greedy walk the moment the
single newest exchange alone exceeded the budget, discarding every
older exchange too -- right after the model produces one long answer,
the next message was sent with zero history and no indication it was
gone. Candidate sizes are not monotonic once a newly-unpaired trailing
assistant message is dropped, so the walk now keeps going instead of
stopping at the first miss.

Separately, attachments were fit against whatever the history fitter
left behind, and history claimed the whole budget first -- a document
attached mid-conversation could be cut to a few percent of its content
with no signal to the user. Attachments are now fit first against a
placeholder history to reserve their own room, and history is sized
around that reservation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…batch

fix: reliability quick-fixes batch (10 findings)
fix: context-window budget allocator (history wipe + attachment starvation)
JobRegistry.shutdown() awaited every pending worker with no bound at
all, including one stuck inside a synchronous call that never polls
cancel_event -- a model HTTP request with no read deadline, for
example. That could hang app shutdown, and the llama-server child
process behind it, for as long as that call took. Workers that have
already begun committing their result still get an unbounded wait (a
commit must finish, or persisted state and the event stream diverge);
everything else is now bounded by a grace period and re-checked
afterward, so a worker that started committing during the grace
period still gets its due wait.

Separately, the lifespan's runtime teardown (the only thing that
actually terminates the llama-server child) ran strictly after job
shutdown returned, so an exception there skipped it entirely. It now
runs regardless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: bound generation shutdown so llama-server is never orphaned
…tops

Cancellation was only checked between phases -- once the single
blocking model HTTP call started, nothing aborted it, so Stop left
the composer locked and the generation slot occupied for the rest of
that call (up to the read timeout), burning GPU on output that would
be discarded regardless.

Both chat clients now accept an optional cancellation_event. When one
is given (only the real chat turn passes one -- title and translation
calls don't), the call switches to a streamed request and checks the
event between chunks, closing the response as soon as it fires
instead of only after the model finishes on its own. Callers that
never pass a cancellation_event keep today's exact single-shot
request, so nothing else changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: thread cancellation into the chat-client call so Stop actually stops
llama-server was spawned with no Windows Job Object, so any hard exit
of Cortex -- Task Manager, a crash, the launcher supervisor's own
shutdown timeout -- left it running and holding the entire model
resident in RAM/VRAM with no owner. The default launcher now creates
a kill-on-close Job Object and assigns each launched process to it,
reusing the same job across restarts; sandboxed execution workers
already get this exact policy, it was simply missing here.

The crash-loop guard tore the process down while still holding the
state lock the class documents as held for microseconds only, so the
runtime-status endpoint froze for the whole termination grace wait
right as the guard fired. It now computes the verdict under the lock,
releases it, and terminates outside -- the same pattern
_terminate_and_reset already used six lines away.

The known-bad GPU backend marker was a single global string, so one
oversized model failing on vulkan permanently pushed every other
model -- and every other context size -- to cpu too. It is now scoped
to the exact (model, num_ctx, runtime release) that failed, and
expires after 24 hours so a driver update or freed VRAM gets a retry
rather than needing a manual reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: harden llama-server process lifecycle (orphans, locking, GPU bans)
expires_at was fixed at issuance, so the desktop app hard-locked after
exactly one hour of continuous use -- the frontend has no way to reach
a fresh bootstrap token once its only credential is destroyed after
the initial handoff, so the only recovery was a full quit and
relaunch. Every successful authenticate() now extends expires_at by
the full TTL again, capped at issued_at + a 24-hour absolute maximum
so a session cannot renew itself forever. A session that genuinely
goes idle for longer than the TTL still expires normally, since
nothing calls authenticate() to renew it while idle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fix: slide the session expiry forward on every authenticated request
initialMountRef is a per-instance ref, so it was true on every mount --
including a return from /settings, which unmounts ChatPage but leaves
the module-level generation store populated with its accumulated text.
The cursor was forced back to 0 while beginGeneration (the only thing
that would clear the buffers) was skipped precisely because the job id
still matched, so the backend replayed every event onto text that was
already there and the answer rendered concatenated with itself.

The rewind now happens only when the store is genuinely cold for that
job -- a real page reload, which is the case the ref was written for.
A route remount resumes from the persisted cursor instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dovvnloading and others added 4 commits August 15, 2026 13:12
…k guard

_validate_network_url resolved the hostname, rejected the URL if any
returned address was private/loopback/link-local, then threw that
resolution away and handed the hostname back to urllib -- which
resolved it a second time when opening the socket. A nameserver
answering differently across those two lookups passed the check and
then connected to whatever it wanted: the local Ollama API on
127.0.0.1:11434, other localhost dev servers, router and NAS admin
pages. The redirect handler had the same gap one hop later.

Validation now returns the address it approved, and the request is
made through connection classes that dial exactly that address. The
hostname still travels in the Host header and in TLS SNI, and
server_hostname is still passed to wrap_socket, so certificate
validation is unchanged -- only the address dialed is forced. Redirects
re-validate and re-pin rather than only re-validating.

The connection classes are built per connection, not once up front, so
each redirect hop dials what its own validation approved instead of
the first hop's address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every settings write takes a full-file backup copy before touching the
database. Settings lived inside the chat database, so each save
byte-copied the entire transcript store: toggling the theme stalled on
a full-database copy, permanently doubled the history's disk
footprint, and on a constrained disk failed outright -- an unrelated
settings save rejected because the chat database was large. The
produced .bak could also be a torn, unopenable SQLite file.

Settings now live in cortex_settings.sqlite, following the existing
execution_database precedent for keeping a concern out of the chat
store. A one-time adoption copies an existing colocated settings row
into the new file on first run; without it every existing install
would silently revert to defaults on upgrade. The source row is left
in place so downgrading to a previous build stays a non-event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit 2cd0fea into main Aug 15, 2026
2 checks passed
@dovvnloading
dovvnloading deleted the codex/qa-reliability-pass-2 branch August 15, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant