Skip to content

Query provider databases in process and stop blocking cooperative threads on subprocess waits - #38

Merged
euforic merged 10 commits into
mainfrom
euforic/wonderful-mcnulty-8b981e
Oct 30, 2026
Merged

euforic merged 10 commits into
mainfrom
euforic/wonderful-mcnulty-8b981e

Conversation

@euforic

@euforic euforic commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Summary

Two changes that make refreshes cheaper without removing any safety check.

1. Codex stats and activity query SQLite in process (241ac6b)

ProfileStatsScanner (queryRows, tableColumns, profileStateRows, querySingleRow) and LocalActivityReader used to launch /usr/bin/sqlite3 for every query, about five processes per Codex profile per refresh. They now go through a new SQLiteReadOnly.query, which uses the SQLite C API:

  • SQLiteReadOnly.databaseArgument still validates the path first (parent resolved, symlinked or non-regular database and sidecars rejected), and immutable=1 is still used only when no -wal/-shm exists.
  • Connections open with SQLITE_OPEN_READONLY | SQLITE_OPEN_NOFOLLOW. They also set trusted_schema=OFF and query_only=ON, and refuse any statement SQLite doesn't report as read-only.
  • sqlite3_progress_handler stops a query at the existing timeout or when Task.isCancelled becomes true.
  • Output bytes and rows are counted as values are copied. Stats keep their 8 MiB budget. Activity keeps its configured byte budget and also gets a row limit equal to its LIMIT.
  • Rows come back typed and in source order. Values containing a newline or the separator can no longer split rows. The readabilityHandler / readDataToEndOfFile ordering race in queryRows is gone, because there is no pipe any more.
  • Fail-closed decoding: text that isn't valid UTF-8, or a missing or mistyped required activity column, fails that source. SQLite errors still report "Database could not be read."

2. Subprocess waits no longer hold cooperative threads (ae88f7b)

  • BoundedSubprocess now runs each process from dispatch sources on a private serial queue: root exit, pipe output, the deadline, and the group SIGTERM → 1 s → SIGKILL escalation. A new async overload hands the result back through a continuation with a cancellation handler. Async callers suspend instead of blocking. Sync callers keep the sync overload, which now waits on one semaphore instead of polling every 10 ms.
  • A race found during testing: the kqueue exit event can arrive before the child becomes reapable, so a WNOHANG reap in the exit handler can miss it. The handler now does a blocking waitpid on the private queue, which returns promptly once the exit event has fired.
  • AppServerRateLimitClient is async end to end. It waits on initialize, response, output-closed and process-exit milestones, which come from the readability handler and a process source. GroupedSubprocess.terminate() suspends between group checks instead of calling Thread.sleep. Timeout, process-group kill, cancellation and output-limit behavior are unchanged.

Verification

  • swift test: 416 tests, 0 failures, 20 opt-in skips. PerformanceRegressionTests passes.
  • New tests use real files and processes (no mocks or stubs):
    • SQLite timeout and cancellation, using a real endless recursive CTE and a slow view
    • write refusal, symlink refusal, byte and row budgets, non-UTF-8 text
    • reading committed WAL content while a writer holds the sidecars open
    • async run and cancellation killing a TERM-ignoring process group
    • 3× more concurrent async waits than cores finishing in about 1.0 s; the sync path took 3.0 s in the same setup
  • ./script/benchmark_resources.sh 9af151b <out>, SQLite scenario (nine samples per revision): sqlite3 launches 5 → 0 per refresh. Wall time 287.6 → 247.9 ms; child CPU 271.7 → 0 ms and parent CPU 4.8 → 244.9 ms, so the work moves into the app. 50,000 sessions and zero query errors in both. The status rows also differ, but only because the runner's baseline branch calls the older status API on purpose; they don't measure this change. Details are in docs/resource-usage-benchmarks.md.
  • Merges cleanly with Fix refresh and subprocess bugs; rebuild the interface with native macOS components #36 (checked with git merge-tree); the merged tree keeps its POSIX_SPAWN_CLOEXEC_DEFAULT changes.

Notes for review

  • Threat model: provider databases are now parsed inside the AgentDock process, not a child sqlite3 process. The read-only, no-follow, trusted_schema=OFF, timeout and output bounds limit what a hostile database can do, but a SQLite parser bug would now hit the app process.
  • A read blocked in the kernel (for example on a hung filesystem) can't be interrupted in process the way a child could be killed. Profile databases are local, under the profile root.
  • Not changed here: status refresh ps snapshots still use the sync runner on the launcher actors, behind sync snapshot protocols. LocalChatSession and ClaudeUsageClient still launch sqlite3.

🤖 Generated with Claude Code

euforic and others added 5 commits October 30, 2026 00:28
ProfileStatsScanner and LocalActivityReader launched /usr/bin/sqlite3 for
every query, about five processes per Codex profile per refresh. They now
use the SQLite C API through SQLiteReadOnly.query:

- the existing path validation still runs first, and immutable=1 is used
  only when no -wal or -shm sidecar exists;
- connections open READONLY | NOFOLLOW, set trusted_schema=OFF and
  query_only=ON, and refuse statements SQLite does not report read-only;
- a progress handler interrupts at the timeout or on task cancellation;
- output bytes and rows are counted as values are copied, keeping the 8 MiB
  stats budget and the activity byte budget plus a row limit at its LIMIT;
- rows arrive typed and in order, so values containing newlines or the
  separator no longer split rows, and the readabilityHandler /
  readDataToEndOfFile ordering race in queryRows is gone with the pipe;
- non-UTF-8 text, or a missing or mistyped required activity column, fails
  that source instead of being repaired.

The fake-sqlite3 timeout test is replaced by real slow views, and the
benchmark runner counts sqlite3 launches only where they still happen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BoundedSubprocess.run busy-polled waitpid and a semaphore with
Thread.sleep, and AppServerRateLimitClient polled semaphores and isRunning
every 100 ms, on Swift concurrency's cooperative threads.

BoundedSubprocess now drives each process from dispatch sources on a
private serial queue: root exit, pipe output, the deadline, and the
SIGTERM-then-SIGKILL group escalation. An async overload bridges completion
to a continuation with a cancellation handler, so async callers suspend;
the synchronous overload waits on one semaphore for synchronous callers.
The exit event can precede the zombie state, so its handler reaps with a
blocking waitpid on that private queue rather than WNOHANG.

The app-server quota read is async end to end. It awaits initialize,
response, output-closed, and process-exit milestones from the readability
handler and a process source, and GroupedSubprocess.terminate suspends
between group checks instead of sleeping. Timeouts, process-group kill,
cancellation, and output limits are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CI toolchain's region-based isolation checker rejects a dynamic Self
capture inside the Task.detached closure; read the query into a local first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lastActiveOrg lookup was the last provider-database sqlite3 launch on
the refresh path. It now uses SQLiteReadOnly.query, which gains an
`under:` root forwarded to databaseArgument so the profile-root containment
check is kept. The query returns host_key, the plain value, and the
encrypted value as typed text and blob columns instead of a hex-and-pipe
string; decryption, host-hash and UUID validation are unchanged, as are
the 2 s timeout and 4 KiB budget, now with a one-row limit. Any query
failure still yields no organization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
euforic and others added 2 commits October 30, 2026 00:49
Desktop status refreshes blocked a launcher actor's cooperative thread for
up to 3 s per `ps` call. The read-only status reads (`statuses`,
`statusBatch`, `stockStatus`, and Claude's `status(for:appURL:)`) now await
an async snapshot, so the wait suspends.

`CodexProcessSnapshotProviding` gains `suspendingProcessSnapshot()`, whose
default calls the synchronous snapshot, so existing synchronous conformers
still conform. The system Codex and Claude providers implement it with the
async `BoundedSubprocess.run` and share one set of `ps` arguments,
timeout, and output bound with their synchronous reads.

Open, close, and focus keep synchronous snapshots and gain no suspension
point. Official Codex open/close use a private synchronous stock status.
Both actors hold only immutable state. Status reads derive results from the
snapshot alone (Codex), or run every helper and main-process identity
check after it returns with no suspension between them (Claude). Status
results never authorize a lifecycle action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…evisions

The runner's baseline branch calls the older per-provider status APIs, so
its status rows cannot compare two revisions that both have `statusBatch`.
`AGENTDOCK_BENCHMARK_BASELINE_DEFINE=CANDIDATE` opts a baseline into the
candidate's calls; the default is unchanged.

Record the installed-status comparison for suspending snapshots: no
regression, same `ps` launches and validator entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
euforic and others added 3 commits October 30, 2026 00:53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@euforic
euforic merged commit 3bd1137 into main Oct 30, 2026
1 check passed
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