Repository navigation
[Bug] 0.2.1-rc.2: experimental Inspector terminates the Host at a ~69-byte retained-state budget gap (field crash, quantified) #10259
HaoyanZhang123
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
The experimental Inspector plugin can terminate the Desktop Host with an uncaught exception when the live Cordis tree grows past a byte threshold. The defect is a byte-accounting gap of ~69 bytes between the component that prunes the tree snapshot and the component that validates it.
This is a field report from the shipped 0.2.1-rc.2 release build with a real, non-synthetic trigger. It adds quantitative evidence to discussion #9271, which described the same mismatch. As of 0.2.1-rc.2 the defect is still present — the fix from that thread is not in any published build I could verify.
Environment
@deepseek-ai/dsh-desktop0.2.1-rc.2@deepseek-ai/dsh-experimental-inspector-profilebundleCrash report:
%APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-2026-10-19T05-44-49-773Z-host.logObserved crash
Production trigger
A single
run_codetool call contained a sequentialfor…ofloop over 6 members, calling thesubagenttool once per iteration. The spawns occurred 13:44:48.197 → 13:44:48.817. The host died at 13:44:49.773, i.e. ~1 s after the batch began.turn/end reason=interruptedat 13:44:48.817.Caveat: the crash log contains no tree size or node count, so the causal link is temporal correlation plus a consistent mechanism — the Inspector publishes the whole Cordis tree on every
internal/plugin/internal/statusevent, and each in-process subagent constructs fiber/plugin contexts. I am not claiming that 6 subagents is a hard threshold; the relevant condition is mutation while the retained snapshot is near its budget.Why it happens
The retained-state path has two different byte budgets, and they measure different objects.
CordisTreeCollector.snapshot()prunes the bare payload:with the limit supplied by the publish site as payload-only:
InspectorSourceBuffer.stateFits()instead measures the retained record wrappers:where each retained value is
{ monotonicMs, topic, payload }(record(), ~1061). The envelope{"monotonicMs":<epoch-ms>,"topic":"cordis/tree","payload":…}costs 52–69 bytes depending on the magnitude of
performance.now()(69 at realistic epoch-ms values), and the collector never accounts for it. ThensetState()throws:So the collector prunes to
128*1024 - 4096 = 126976bytes (DEFAULT_MAX_SOURCE_FRAME_BYTES, index.js:1752) whilesetStateactually needs ≤ 126907 — a 69-byte danger band.cordis/treeis the only retained state topic:createInspectorService()exposespublishbut notsetState, and line 2322 is the solesetStatecaller. So this is a pure accounting mismatch, not a multi-topic or concurrent-write race.Quantitative model
Replaying the shipped
pruneLast()loop against realistically shaped trees:maxCordisNodes = 2048cap, so byte pruning always fires before node truncation.The band is 69 bytes and the prune step is ~128 bytes, so once pruning is required the loop is very likely to land inside the band. Model-based result, not a runtime measurement of the failing snapshot.
Why it is fatal rather than degraded
publish()/schedule()have notry/catch:The throw happens inside the microtask, after the Cordis emitter that scheduled it has already unwound, so no caller can catch it. It surfaces as an
uncaughtException, whichinstallFailLoud(indsh-app-boot) turns intoproc.exit(1)after writing thedsh: fatal uncaught exception:line.Note the asymmetry that makes this look like an oversight rather than a policy: non-retained
publish()silently drops records that exceed the frame limit (enqueue(), ~line 1070), while retainedsetState()throws. A retained debugging topic should not be able to kill the Host.Raising
maxSourceFrameBytesdoes not helpThis is the most tempting but wrong remedy: both ceilings (
maxSourceFrameBytes - 4096andmaxSourceFrameBytes - 4096 - envelope) move together, so the gap is invariant at 128 KiB, 256 KiB and 1 MiB. Raising the limit only moves the cliff to a larger tree and makes the failure harder to attribute.Suggested fix
Two layers, ideally both:
index.js:2404), e.g.maxBytes: spec.maxSourceFrameBytes - 4096 - 68, mirrored on the Client face (client.js:3534). Any change must be applied symmetrically to both the collector andstateFits(); adjusting only one reintroduces a gap.setStateshould roll back (it already does), warn, and drop/replace with a truncated snapshot instead of throwing; and thequeueMicrotask(publish)call should be guarded withtry/catch. This removes the crash class regardless of future budget arithmetic.Workaround
Disable the Inspector entry. In the composed profile (
%USERPROFILE%\.dsh\profiles\desktop\cordis.yml) it appears as:Because the composed profile is regenerated from bundles, the durable place for the override is the profile's
cordis.patch.ymloverlay — though I have not verified compose precedence in this build. Until fixed, keeping concurrent in-process subagents low also reduces exposure, since the Host dies as soon as byte pruning is required.Related
inspector: Host source is closed, a differentsetStatethrow on the same publish pathNo private conversation data, credentials, or unrelated filesystem paths are included in this report.
All reactions