DSH plugin stuck on "Loading history": four root causes
When DSH hangs forever on "Loading history…" opening a particular session, re-clicking the same session does not retry, and only refreshing the page recovers it — at least four distinct root causes stack under this one screen symptom: waitForSocket() has no timeout fallback, the openState state machine has three holes, the Function.prototype.toString check is always false on Gecko/WebKit, and a single turn: null step block sits in the session log. All four look identical, so you cannot tell them apart from the screen alone; you must triage with three tests: "did the first frame arrive", "which browser engine is it", and "is there a turn: null in the log". This article splits into "the two client chains → the browser-compat chain → the data-shape chain", each with its test, paste-ready self-check commands, and a matching fix.
Two client defects: a socket waiter with no timeout + holes in the openState machine
These two are robustness gaps in the DSH client itself, independent of the browser engine, and reachable on any platform.
Symptoms: two scales, one presentation
The community report's scene was 0.1.7-rc.2, profile web, Windows 11, Node v24.20.0, with symptoms in two grades (#7802):
- Hangs forever: the UI stays in the loading state, re-clicking the same session does not retry, and only a page refresh recovers it.
- Seconds late: the same session sometimes merely appears a few seconds late, and a refresh also recovers it immediately.
The second grade is valuable: it shows this is not "the data is broken" but that the wait path itself is unreliable — a refresh rebuilds the whole connection, so the wait is bypassed.
Chain 1: waitForSocket() has no timeout, so a socket stuck in CONNECTING stays pending forever
The mechanism in three sentences: the waiter has no timeout, waking only happens on connection failure, and a socket stuck in CONNECTING is neither success nor failure. Specifically (#7802):
waitForSocket()has no timeout;maintain()wakes the waiter only on connect failure;keepAliveclears only after settlement.
⇒ When the socket is stuck in CONNECTING, the waiter stays pending forever and the UI stays in the loading state. The blast radius is all browsers (engine-independent); the offline reproduction of this chain in that thread is ✅, but direct evidence of socket.readyState at the moment of a real-machine hang is still missing (the author marks it unresolved; see the notes at the end).
Chain 2: three holes in doOpen / resync's state machine
The same UI symptom has a second, fully independent origin: the exception is thrown before the state is written. The three holes are (#7802, #6921):
- The exception is thrown before writing
openState⇒ the state is never rewritten; dispose()does not touch the state;resync()'s dispose has no try/finally.
In @deepseek-ai/dsh-api-session-controller@0.1.5-rc.2 (lib/client.js:1979-2006) you can read this "no exit" shape:
:1994 await events.open({ maxMessages: 50 });
:1996 this.openState = "open"; // exit 1: success
:1999 if (!isRemoteFailure(error)) throw error; // non-remote error -> rethrow, state stays "loading"
:2001 this.openState = "error"; // exit 2: remote failure only
Only two exits, and the second is reserved for remote failures. Any non-remote exception is rethrown and openState stays "loading" — the UI has no state to go to and can only show the loading text forever (community line-by-line review, #6921).
Numbered self-check: confirm whether it is 1/2, and whether you can self-rescue
- Confirm "refresh recovers": go back to the session list, refresh the page, and click the same session again. Opens immediately ⇒ very likely in a client chain (1/2) or the compat chain 3.
- Confirm "re-clicking does not retry": while stuck, click the same session row several times. If nothing changes, the waiter is not being rebuilt, matching chain 1's "waiter pending forever" (#7802).
- See whether the remote
session/followfirst frame arrived: open the browser DevTools network panel and watch the remote session stream's first frame at the moment of the hang. Not arrived ⇒ likely chain 1/2; arrived but still loading ⇒ likely chain 3 (see the test table below). - Prefer a "switch browser" control: open the same session in Chrome/Edge. If it works immediately, chain 3 (compat) is pinned down; if it still hangs, go back to chain 1/2 (#7802).
Available fixes: a community patch set + a read-only probe
The community turned the three chains into installable artifacts (none official):
- Patch set
dsh-waitforsocket-timeout(MIT): covers 4 packages and three layers of problems; the script has idempotency, automatic backup, refuses on anchor mismatch (exit 2), and automatic rollback on syntax-check failure, and its anchors were byte-checked against official0.1.7-rc.2(#7802). - Read-only diagnostic probe
dsh-open-watchdog(MIT, v0.3.0): fixes nothing, only records the timeline at the moment you "click a session row" as JSON (when the loading text appears/disappears,openStatesnapshots, network requests, browser-throttling evidence, whetheropen()finally resolved/rejected). The host-side route has four gates: loopback / Host / Origin / content-type (#7802).
One easy trap with the probe: when the browser is not on the host machine you must open the cross-machine switch, otherwise all four gates 403 and the browser side silently swallows it — appearing as "installed, route registered, but not a single record" (#7802):
# Option 1: explicitly allow origins (both default to empty => loopback only)
export DSH_OPEN_WATCHDOG_ALLOW_HOSTS="<the host your browser is on>"
export DSH_OPEN_WATCHDOG_ALLOW_PEERS="<allowed peers>"
# Option 2 (recommended, no gate widened at all): local port forwarding
ssh -N -L 3080:127.0.0.1:3080 <host machine>
The browser-compat chain: the toString check is always false on Gecko / WebKit
This one holds only on Firefox, Safari, and every browser on iOS/iPadOS; V8 engines like Chrome/Edge are fully immune — "switching browsers fixes it" is its fingerprint.
Mechanism: a hard-coded single-line comparison meets a multi-line return
The client uses hasIntrinsicConstructor to decide whether a value can be losslessly JSON-serialized, by comparing the result of Function.prototype.toString against a hard-coded single-line form. The problem:
- Gecko / WebKit return a multi-line form for built-in functions;
- So the comparison is always false;
- ⇒ Every plain object is judged "not lossless JSON", and session loading cannot proceed (#7802).
The UI symptom is identical to chains 1/2 (both stuck on "Loading history…"), because the upstream doOpen rethrows first and writes the error state second, so even hitting the compat chain the UI only shows the loading text (#7802).
A one-line self-test: confirm whether your engine is affected
Run this line in the affected browser's console; if the result contains a newline, you are affected. The command-line equivalent:
node -e "console.log(/\\n/.test(Function.prototype.toString.call(Object)))"
In a browser console that is:
Function.prototype.toString.call(Object)
// if the result contains a newline => your engine hits this check (Gecko / WebKit)
Test table: how to tell the three chains apart
| Test | Chain 1 / Chain 2 | Chain 3 |
|---|---|---|
Did the remote session/follow first frame arrive at the hang? | No | Yes (and still loading) |
| Was the session running at the time? | Triggers only when running (or reconnecting) | Needs an active attempt; cold sessions cannot hit it |
| Your browser | Any | Gecko / WebKit only |
| One-line self-test | — | Function.prototype.toString.call(Object) contains a newline |
(The test table is from the community summary.)
Note chain 3's scope: on 0.1.7-rc.2 the community clarifies that only a response currently being generated fails (the client only validates the current attempt's raw chunk, api/session-controller/src/client/sessions/assistant-stream.ts:79-82); completed conversations load fine (#7802).
An upstream-direction community fix already has tests
A contributor submitted the branch fix/values-native-source-whitespace: a regression test (fails before the fix / passes after) + all 5,827 tests across 238 files in 17 packages pass + clean typecheck + a 45-case V8 / JavaScriptCore comparison; the fix follows the same path as the community patch — normalize whitespace before comparing, keep the toString check. The community author states clearly: if upstream adopts it, their patch should retire (#7802).
Handle chain 3 with steps 1/2/3:
- Work around first: open the session in a V8-based browser (Chrome / Edge) and confirm it loads.
- Confirm the scope: only a response currently being generated fails while completed conversations open ⇒ this is chain 3's rc.2 behavior.
- Wait or patch: wait for upstream to merge the fix with regression tests; if you must self-rescue, the community patch set works, but note it anchors to official versions and must be re-checked before upgrading.
The data-shape chain: one turn: null step block in the log
The essential difference from the previous three is that this one is "data-shape-driven" — no matter how big the session, a single unattributable step block makes the client render state machine spin. It comes from another production measurement (#6921).
Key premise: the data layer is healthy
The affected session had about 330,000 events, 724 messages, 2 frames (macOS 26.3 arm64, DSH 0.1.1-rc.2 line). Before touching anything, the author checked the whole log with read-only tools (#6921):
| Check | Result |
|---|---|
| Offline contract checker | 0 error / 0 warning |
Official Session construction + deriveMessages | ✅ 724 messages (1.8 s) |
| Unclosed turn / step | none |
| seq continuity | 0 jumps |
| Chronological order | 0 regressions |
Official paginate simulation (50-item window) | 5 ms |
More important is the size comparison: in the same environment sessions with about 690,000 and about 360,000 events open fine, while the problematic one with about 330,000 does not ⇒ size alone does not decide it (#6921).
The only anomaly: three consecutive turn: null
The full scan found exactly one structural anomaly — three consecutive events carrying turn: null:
seq 580037 step/start turn: null
seq 580038 assistant/message turn: null <- replace marker
seq 580039 step/end turn: null
These three are the surface-replace marker written when a message was edited: the marker recorded the edited node but did not fill in turn (#6921).
Numbered steps: locate and fix this block
- Back up first: copy the session's log file elsewhere (you must leave a way back before rewriting).
- Scan for
turn: null: run a structural scan over the log for consecutive blocks ofstep/start → assistant/message → step/endwithdata.turnunset. Sketch (read-only, no write-back):
# Read-only scan: list all events whose data.turn is null (adjust the field path to your log format)
jq -c 'select(.data.turn == null) | {seq: .seq, type: .type, turn: .data.turn}' session.jsonl
- Confirm "which turn was open at the time": look around the block for the nearest preceding
turn/start(in this case turn 95) — that is the turn it should belong to. - Change these three events'
turnfromnullto that turn, leaving every other field untouched. - Verify: rerun the contract checker (expect 0 violations), the official load still gives 724 messages, the
turn: nullcount is zero; then open the session, which should render normally.
The author's own words: "Changed these 3 events from turn: null to the turn actually open at the time (turn 95) — changed nothing else", and the client then opened normally (#6921).
Three fix directions and their costs (community advice to maintainers)
The community does not claim this is an official patch but offers three directions (#6921):
- Give the open state machine an exit for every failure, not just remote ones: set
"error"for every failure (keeping a typed payload for remote errors), or add a third terminal state meaning "opened but cannot render". The cost is that some callers currently rely on non-remote errors propagating outward; a separate terminal state avoids the conflict. The author considers this the first of the three to do — it decides between "one failure" and "one hang". - Make unattributable steps non-fatal at the render layer: treat a step/marker with no
turnas unrenderable, skip it, and keep rendering the rest. State the cost clearly: skipping hides content, and readers cannot tell "nothing happened" from "something was skipped"; so the community argues for a visible notice (e.g. a line in the conversation: "1 event could not be attributed and was skipped") rather than a silent skip. - Offline detection (detect only, do not fix): run a structural scan before opening a session, turning "the app cannot open my session" into "this session has one malformed block" ahead of time.
An important cross-reference: the same batch of reports includes #6942 / #6949 / #6952 / #6953 / #6954, whose common shape is "zero tolerance for unexpected input + an error boundary swallowing the exception ⇒ the user sees silence" (blank screen, no notice). When troubleshooting, if "the data is healthy but the UI is blank", lean toward this pattern (#6921).
Troubleshooting notes
One "Loading history…" has at least four root causes; triage with the tests before acting, and above all do not start by editing session data. Nine points:
- Whether a refresh recovers is the cheapest first cut: it recovers ⇒ client chain; it does not ⇒ look at first-frame size or data shape (#7802).
- Use a browser switch as a control: a V8-engine switch fixes it ⇒ the chain-3 compat problem; all hang the same ⇒ chain 1/2 (#7802).
- A one-line engine check:
Function.prototype.toString.call(Object)containing a newline means the compat check holds (#7802). - Chain 1's
CONNECTINGis still inferred: the community itself admits direct evidence ofsocket.readyStateat the hang is missing; do not treat it as settled (#7802). - Chain 2's "trigger" is undetermined: one place attributes it to
dispose(), another to connection generation; neither reproduces a generation change onconnection/reset, and direct logs are missing (#7802). - Do not treat size as the only test: sessions with about 690k and 360k events open fine, while one with about 330k does not (#6921).
- Same symptom, not same cause:
#4513/#4416are scale/performance-type (host event loop and browser main thread double-blocking, measured 535,316 events), unlike this article's data-shape type;#7754,#6966(first frame of 4–9 MB fully pushed), and#6978(parse cost) are yet other chains (#7802, #6921). - Credit prior art honestly: chain 2 was first reported in
#7527(2026-09-22), chain 3 first localized in#5919(2026-09-08) and#5677(2026-09-04), and a near-report of chain 1 is#5056(2026-08-29) — the community itself stresses "not one of the three chains is ours to have found first" (#7802). - Always back up before changing a session log: the
turn: nullfix rewrites events directly, and a mistake creates new malformed data; back up, scan, rewrite, then verify with the contract checker.
When troubleshooting UI-hang problems like these, if you manage plugin installs, update confirmations, system logs, and diagnostics together in DSH Plugin Hub, you can at least first confirm on the installed list whether a particular plugin version caused the load failure before touching session data.

Source: Discussion #7802, Discussion #6921.
FAQ
Try refreshing the page first — if a refresh recovers it immediately, you are almost certainly in this article's three client-side chains (no socket-waiter timeout / holes in the openState state machine / the toString check being always false on Gecko and WebKit), and you can verify with the community patch set or by switching browsers. If refreshing does not help either, consider an oversized first frame or a data-shape problem (a turn=null step block in the log).
In the affected browser's console, run Function.prototype.toString.call(Object). If the result contains a newline, that engine hits the 'hard-coded single-line comparison always false' compat bug, typical on Firefox, Safari, and iOS/iPadOS; V8 engines like Chrome/Edge are immune. Another test is to look at whether the remote session/follow first frame arrived at the moment of hanging: not arrived points to socket/state machine, arrived while still loading points to the compat problem.
Yes. A production measurement: a session with about 330,000 events and 724 messages opened to a blank screen stuck on "Loading history…"; a full scan found only one anomaly — three consecutive events at seq 580037–580039 (step/start, assistant/message, step/end) all carried turn: null. Rewriting those three events' turn to the turn actually open at the time (turn 95) restored the session immediately, with nothing else changed.
The community has an open-source patch set, dsh-waitforsocket-timeout (covers 4 packages and three chains, with idempotency, automatic backup, refusing on anchor mismatch, and rollback on syntax failure; its anchors were byte-checked against official 0.1.7-rc.2), plus a read-only diagnostic probe, dsh-open-watchdog. In addition, derSato submitted a branch with regression tests; if upstream adopts it, the community patch should retire.
Because the three chains have identical UI symptoms but different blast radii: no socket timeout and the state-machine holes are engine-independent, while the Gecko/WebKit toString check only holds on Firefox, Safari, and iOS/iPadOS, with V8 and Hermes immune. So 'switching to Chrome fixes it' is precisely the fingerprint of the compat chain, and you still need to distinguish them by the tests.
Related Terms
- openState (session open state)
- openState is the field in the client session controller that represents a session's open progress, with values including loading / open / error. It is written at only two exits: open after events.open() succeeds, and error only when isRemoteFailure(error) is true; any other exception is rethrown without writing error, so the UI stays on loading forever.— https://github.com/deepseek-ai/deepseek-harness/discussions/6921
- socket waiter
- The socket waiter is the client-side waiter for the remote session-stream socket to become ready. waitForSocket() has no timeout fallback, maintain() only wakes the waiter on connection failure, and keepAlive only clears after settlement; when the socket is stuck in CONNECTING, the waiter stays pending forever and "Loading history" never advances.— https://github.com/deepseek-ai/deepseek-harness/discussions/7802
- hasIntrinsicConstructor check
- The DSH client uses hasIntrinsicConstructor to decide whether a value can be losslessly JSON-serialized: it compares the result of Function.prototype.toString with a hard-coded single-line form. Gecko and WebKit return a multi-line form for built-in functions, so the check is always false and every plain object is judged 'not lossless JSON', leaving session loading stuck on loading.— https://github.com/deepseek-ai/deepseek-harness/discussions/7802
- turn=null step block
- A turn=null step block means three events in the session log — step/start, assistant/message, step/end — all carry data.turn: null. It usually arises when a message-edit operation writes a surface-replace marker in the step gap of an already-open turn: the marker records the edited node but does not fill in turn, so the client cannot attribute it to any turn.— https://github.com/deepseek-ai/deepseek-harness/discussions/6921
Sources
- #7802 — 'Loading history' occasionally hangs forever: the waiter awaiting the socket never settles (with root causes and a community patch)· deepseek-ai (GitHub Discussions)
- #6921 — Session opens blank stuck on "Loading history…" — client render state machine loops forever on a session containing a turn=null step block· deepseek-ai (GitHub Discussions)