Repository navigation
Description
What happened
Loading/hovering/clicking pictures returned by Claude tools can make the remote bridge unresponsive while images load. The confirmed code defect is synchronous history scanning/extraction on the bridge event loop, despite an async outer endpoint.
A synthetic test using the actual reader and four distinct tool-image references, each returning the same 68-byte PNG fixture delayed a zero-delay timer for about 1.6 seconds on a 133 MB synthetic history. The timer never ran during those lookups and every returned image byte matched the fixture. This isolates history-processing cost from large image payloads.
How often
Repeatedly during live image loading. The synthetic reader test consistently exhibits timer starvation; exact latency varies with hardware/load/history size.
Ovis version
0.4.22, hub revision a4ff0c942716602c43362f05c8511984e2bcae44, remote bundle 41 (Bun 1.4.2).
Source analysis was also checked at upstream 761f7bce634684c332263af43fe3d06355f92616.
How it runs
Not sure which original installer option was used. Managed hub installation started as a service, with Ovis's managed SSH bridge/runtime on the affected remote PC.
Server PC
Hub: Gentoo Linux x86_64, herdr 0.9.5. Remote: Debian 13 x86_64 VM, 12 vCPUs, 40 GiB assigned RAM, herdr 0.9.3.
Device and browser
macOS desktop client. Exact OS/browser versions were not captured. The evidence below also uses direct backend/parser diagnostics independent of the browser.
How the device reaches the server
tailscale serve to the hub, then Ovis's SSH transport to the remote PC. Synthetic tests bypass the browser and transport.
How it signs in
Not recorded for the browser connection. Local diagnostics used existing authenticated bridge access. No credentials are included.
Agent and PC
Claude Code on the remote Debian VM. The exact version of the long-running Claude process was not captured.
Screenshots or logs
Text-only retained completion-log metadata (fixed routes; queries/payloads omitted):
| Route | Recorded response latency |
|---|---|
/api/pane/conversation/image |
96,194.719 ms |
/api/pane/prompt |
82,854.460 ms |
/api/pane/conversation |
62,764.392 ms |
These records lack wall-clock timestamps and do not prove that a particular image caused a particular simultaneous prompt delay, or explain every contributor to the 96-second response. They establish severe endpoint latency. The source and synthetic timer test independently establish event-loop blocking. No screenshots, real images or private transcript contents are included.
Confirmed cause
- The image route awaits
conversationImage(), but its Claude branch calls synchronoustranscriptImage(). transcriptImage()searches history for the image record, scans earlier records to verify its tool call, extracts the base64 string and decodes it before returning.- Its readers use
openSync/readSync. Windowed memory bounds do not bound uninterrupted event-loop time. Separate images repeat history searches. - The generators have
yieldpoints, but Claude's wrappers drain them using synchronousfinish(). Generator steps do not schedule other requests. Codex/OMP paths already usefinishAsync(), which yields to the event loop.
Adding an await around the existing synchronous function alone would leave this work blocking.
Synthetic reproduction and measured results
The script imports the actual reader, generates valid synthetic tool-use/result JSONL and a valid tiny PNG, and verifies exact returned bytes for each distinct ref. It directs Herdr socket variables to a nonexistent temporary socket before import, uses no live pane endpoints/history, and removes its fixtures afterward. Filesystem caches were not deliberately dropped.
Installed Bun 1.4.2 / remote-bundle-41 reader:
| Transcript bytes | Distinct images | Lookup batch | Zero-delay timer fires after |
|---|---|---|---|
| 65,980,924 | 4 | 989.45 ms | 989.59 ms |
| 133,025,020 | 4 | 1,594.87 ms | 1,594.98 ms |
In both cases the timer never ran during the lookups. These are direct-reader tests, not a full browser/HTTP transport reproduction, and do not reproduce a 96-second stall. The same synchronous source path was verified at the pinned upstream revision above; these timings were measured with the installed bundle.
Save below as /tmp/ovis-image-loop-repro.ts and run from a prepared Ovis checkout:
bun /tmp/ovis-image-loop-repro.ts ./server/conversation.ts 128 4On affected code, timerRanDuringLookups is false and zeroDelayTimerMs closely follows the whole synchronous batch duration. This is a diagnostic benchmark, not a fixed-millisecond regression threshold.
Standalone synthetic reproduction
import { appendFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
// Run from an Ovis checkout, or pass its server/conversation.ts as the first argument.
// Synthetic records only; does not call a pane endpoint or read any real conversation.
const root = mkdtempSync(join(tmpdir(), "ovis-image-loop-repro-"));
process.env.HERDR_SOCKET = join(root, "no-herdr.sock");
process.env.HERDR_SOCKET_PATH = process.env.HERDR_SOCKET;
const source = resolve(process.argv[2] ?? "server/conversation.ts");
const mib = Math.min(128, Math.max(1, Number(process.argv[3] ?? 64)));
const requests = Math.min(8, Math.max(1, Number(process.argv[4] ?? 4)));
try {
const { transcriptImage, forgetTranscriptState } = await import(pathToFileURL(source).href);
const path = join(root, "synthetic.jsonl");
const uuid = "77777777-2222-3333-4444-555555555555";
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=";
const png = Buffer.from(data, "base64");
const jsonl = (entry: object) => JSON.stringify(entry) + "\n";
writeFileSync(path, jsonl({ type: "assistant", message: { role: "assistant", content: [
{ type: "tool_use", id: "toolu_synthetic_read", name: "Read", input: { file_path: "synthetic.png" } },
] } }));
// Many individually valid, small records. No large-image decoding is needed to cause the stall.
const padding = jsonl({ type: "system", subtype: "synthetic-padding", padding: "x".repeat(4096) });
const batch = padding.repeat(128);
const target = (mib - 1) * 1024 * 1024;
while (statSync(path).size + batch.length < target) appendFileSync(path, batch);
appendFileSync(path, jsonl({ type: "user", uuid, message: { role: "user", content: [
{ type: "tool_result", tool_use_id: "toolu_synthetic_read", content: Array.from({ length: requests }, () => ({ type: "image", source: { type: "base64", media_type: "image/png", data } })) },
] } }));
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
let timerFired = false;
let timerDelayMs = 0;
const timer = new Promise<void>((resolve) => setTimeout(() => {
timerFired = true;
timerDelayMs = performance.now() - started;
resolve();
}, 0));
const lookups: number[] = [];
for (let i = 0; i < requests; i++) {
const before = performance.now();
const image = transcriptImage(path, `${uuid}:0:${i}`);
lookups.push(performance.now() - before);
if (image?.mediaType !== "image/png" || !Buffer.from(image.bytes).equals(png)) {
throw new Error("The synthetic tool image did not resolve to its original PNG bytes");
}
}
const lookupBatchMs = performance.now() - started;
const timerRanDuringLookups = timerFired;
await timer;
const round = (n: number) => Math.round(n * 100) / 100;
console.log(JSON.stringify({
synthetic: true,
transcriptBytes: statSync(path).size,
imageBytes: png.length,
imageRequests: requests,
distinctImageRefs: true,
imageVerified: true,
perImageMs: lookups.map(round),
lookupBatchMs: round(lookupBatchMs),
zeroDelayTimerMs: round(timerDelayMs),
timerRanDuringLookups,
bunVersion: Bun.version,
}));
forgetTranscriptState();
} finally {
rmSync(root, { recursive: true, force: true });
}Proposed fix
Resolve/authorize the pane and history on the request side, then offload scanning, record validation and image extraction to a bounded bulk-reader lane. Pass authority-bound file/history metadata, not whole transcripts; workers should not perform ambient pane discovery, authentication or control RPCs. Revalidate session/history/file identity after waiting. Keep the current /clear, active-branch, first-visible-tool-answer, hidden-record, type and 8 MiB image-size restrictions.
Coalesce identical in-flight image requests. A capped byte-offset index can later avoid repeated history searches, but cache hits must still pass current history authorization. Queue metadata only, transfer exact owned image buffers, and separately bound decoded caches, coalesced waiters and response buffering.
A smaller mitigation is a complete Claude generator path driven by finishAsync(), using stepped equivalents for every nested history-boundary/search/shape/string/tool-call scan. That schedules between steps, but a slow readSync syscall can still block the bridge. Worker isolation also addresses that case.
Worker placement and starvation constraints
Please preserve capacity for command discovery, request-driven task lists, ordinary file routes and bridge/control traffic. The current file pool has four workers, two shared scan slots and a one-task-list-scan cap. Simply adding images and historical backfill to the two shared scan slots is insufficient: one blocked job of each kind could exclude a new command scan even while ordinary-file workers remain free.
Suggested design, subject to maintainer agreement: reuse/refactor the existing pool mechanisms into process-wide isolated bulk lanes, with one image worker and one historical-reconciliation worker under a hard combined physical cap of two. Keep the existing file pool's admission capacity unchanged. Exact counts are proposed defaults; reserved critical capacity and bounded aggregate work are the requirements.
Use independently bounded lane queues, per-session coalescing/admission and fair scheduling. History work should checkpoint and requeue bounded chunks. Do not allocate pools per pane/request/server instance. Timed-out, cancelled, orphaned and terminating-but-not-closed workers retain their slots until they actually return/close; retries must not spawn replacement workers around the cap. Never fall back to synchronous bridge-thread scanning when busy. These limits isolate application scheduling, not OS CPU/storage bandwidth.
Regression/acceptance coverage
- Hold the scanner's actual disk operation: unrelated HTTP, timers and WebSocket pings remain responsive; preserve correct decoded bytes.
- Request multiple distinct thumbnails and overlapping requests for the same ref; validate coalescing, admission, bounded memory and recovery.
- Gate image and historical workers simultaneously: new command/task-list/file work keeps its existing capacity. Flooding a gallery cannot consume critical waiting-queue slots.
- Repeated timeouts/abort/termination/restart do not grow physical workers past their caps; a late obsolete result is discarded, with cleanup after actual close.
- Enforce per-history fairness, bounded queues/caches and safe last-owner cleanup.
- Rerun existing branch/clear/escaped-record/size-limit tests; replacing/truncating history or switching panes mid-read cannot serve an image under obsolete authority.
Related: #790 established bounded blocking-file isolation, #1951 introduced tool-image thumbnails, and #2511 isolates task-list reads. This issue covers Claude transcript image extraction. No live sessions or installed source were changed for the investigation.
Related cold-history lifecycle issue: #2601. Both proposals need bounded scheduling, but they have separate reproductions and can be fixed independently.
Activity
duytbp commentedon Nov 2, 2026
The cooperative CPU fix merged as PR #2604. Disk-read isolation is now submitted separately as PR #2610, from updated main.
The follow-up runs Claude image inspection, the existing robust scanner and final generation checks on one process-wide worker. It keeps an eight-job metadata queue and bounded coalesced waiters, preserving all capacity in the existing file/command/task pool. Timeouts, cancellation and shutdown retain the physical slot until actual worker close; shared descriptor tracking prevents cleanup during a read still in flight.
Pane/session authorization remains on the bridge and is revalidated after awaited work. Existing image bytes, headers and 8 MiB limits are preserved; busy/deadline cases use the existing retryable policy. Warm parsed-history caches remain reusable, with no decoded-image cache added.
PR #2610 includes real held-disk responsiveness, saturation/cancellation/reacquisition, actual-close cleanup, initial authorization and invalidation, and stale-generation regressions. Validation and the measured latency tradeoff are recorded in its description. The broader history framework remains outside this image-only change.
fix(conversation): yield during Claude image scans (#2604)
devswha commentedon Nov 2, 2026
Closing: #2604 fixed the reproduced freeze (an image scan no longer monopolises the bridge; on a local disk other requests wait under ~25 ms during a 125 MB scan). Single blocking disk reads only stall noticeably when the transcript sits on a network mount (measured in #2610: 0.7-1 s over sshfs), which is a niche setup; #2610 stays open as a possible future approach.