Session log corruption when two harness processes share one DSH_HOME (stale live session appends after another process resumes) #3099
Replies: 5 comments
|
Reference implementation on our fork: branch What it does:
Verification:
Known residual: the check-then-write window of one batch, documented in both persistence READMEs. Full exclusion would need a cross-process lock (direction 2) — happy to explore that next if you want it. 中文:fork 参考实现见上面的分支与 commit 链接。协调器为每个会话记录「游标建立时的持久修订值」,每次 append 前复核、写后刷新;发现日志被其他进程推进就以指明原因的错误拒绝写入,瞬时 I/O 失败回滚后仍可正常重试。四个后端的协调器合约套件全绿,公开接口零改动。残余的一个批次级 check-then-write 窗口已写进两个持久化 README。 |
|
Follow-up on contributing this back: I attempted to open a cross-fork PR, but
中文:跨仓库 PR 被仓库权限拒绝(非成员无法创建),如需合入可直接抓取上面的分支(基于当前 master 的单提交),或放开限制后我来提。 |
|
Two more real-world data points from my logs — same root cause, one new variant, and one reader-side robustness gap worth knowing about. Variant: crash-repair × resume interleaving with a live foreign writer (offset −3). In two more sessions the damage was: Process B cold-loaded a session whose turn was open, synthesized the Reader-side observation: the zstd path misclassifies an uncommitted seq gap as physical corruption. When the interleaved tail contains no later Both logs repaired locally: one by dropping the orphan tail (the 0457 case above), two by deleting the three disproved synthetic/marker lines — the live writer's continuation was byte-contiguous with the pre-closer log, so no renumbering was needed. The revision-guard branch prevents all of these at write time. |
|
补一组发布版 rc.7 的独立、无模型 A/B 复现,结果与本帖一致。 环境:macOS arm64、Node 25.8.2、 共享 root两个 live persistence owner 打开同一 root 下的同一 session。第二个 owner 恢复并推进日志后,第一个 stale owner 的后续 append 没有被拒绝。最终持久事件序列为: 后续 cold load 拒绝该日志,分类为 committed region 中的 seq gap。 隔离 root 对照两个 owner 使用相同 session id,但分别写入两个独立 root。两边均可冷加载,事件序列都是: 因此 session id 相同不是触发条件;关键条件是多个 live writer 共享同一可写 session root。这个对照也支持当前操作规避:Web、headless、ACP 和自动测试分别使用独立 |
|
I turned the rc.7 evidence in this thread into a source-backed operator runbook: https://sandbaseai.github.io/deepseek-harness-handbook/deepseek-harness-single-writer.html The operating boundary is intentionally narrower than “shared root always corrupts”: the dangerous condition is two independent live processes reaching the same Session artifact. Their coordinators, liveness maps, and cursors are process-local. The official persistence README also states that its revision freshness check does not add cross-process writer exclusion. The guide therefore gives two explicit deployment choices:
It also calls out two easy traps: different ports do not imply different storage, and a closed browser tab does not prove the Web Host stopped. After suspected overlap, the runbook stops all writers and preserves offline copies instead of presenting event renumbering or live-log edits as a general repair. All runtime claims link to the pinned rc.7 source. Thanks for the incident and independent A/B evidence. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Two harness processes sharing the same sessions root (e.g. the default
~/.dsh) can silently interleave appends to one session's JSONL log. Every single-writer guard is per-process, and the JSONL append path validates only the coordinator's in-memory cursor, so the interleave is never detected at write time. The next cold load then refuses the log withcorrupt session log: seq gap in committed region …, the web UI showshistory unavailable … (internal), and the session becomes permanently unloadable — restarting does not help.What happened (real incident)
A 44,802-event session log ended up with exactly one anomaly (lines abbreviated):
Every cold load now fails with
corrupt session log: seq gap in committed region at line 541 (expected 6576, got 6575).Root cause
Session.appendassignsseqfrom the in-memory log length: packages/core/session/src/index.ts#L629.Sessionconstructor appendssession/end-seedat the seed boundary (index.ts#L545), andattachPreparedpersists it as the unpublished suffix (coordinator.ts#L1184-L1207). The durable log now ends at seq 6575.Sessionobject is still alive (its browser tab stayed open) with log length 6575 — it never saw the marker. Its next append reuses seq 6575.state.cursor(coordinator.ts#L698-L702) —6575 === 6575passes.O_APPENDand writes at whatever the current EOF is (session-persistence-jsonl/src/index.ts#L651-L654). The duplicate lands right after B's marker. No cross-process check exists anywhere on the append path.All single-liveness enforcement is in-process:
prepare()refuses a second live session only via the localctx.sessionsstore (coordinator.ts#L724), and theonCreatedcollision logic uses the coordinator's per-process maps. Nothing coordinates two OS processes sharing one root.Why I believe the scenario is legitimate
DSH_HOMEis a realistic setup: the documented commands have users rundsh webalongsidepnpm dsh --profile headless "task"or the ACP server, and nothing in the docs declares a sessions root single-process-exclusive.materializePosixpublishes vialink()precisely so that "two processes materializing the same id concurrently cannot clobber each other" (index.ts#L544-L546), and read paths retry on revision changes because "continuous external writers may delay completion".PRIMARY KEY (session_id, seq)(schema.ts#L145) turns a stale append into an immediate UNIQUE violation. Only the JSONL backend corrupts silently.Minimal reproduction
Two independent
Contextinstances over one JSONL root faithfully emulate two processes (each has its own SessionStore/coordinator; the filesystem is the only shared channel — exactly as with two OS processes):Resulting file:
Candidate fix directions
readStoredRevision). Remember the revision at which each in-memory cursor was established; before eachappendBatch, re-read and compare; on mismatch, reject loudly ("another process is appending to this session log"), and refresh the remembered revision after each successful write. O(1)statper batch, backend-agnostic, and turns silent corruption into an immediate, actionable error. A check-then-write TOCTOU window of one batch remains.I maintain a fork and have implemented direction (1) with regression tests; I will link the branch in a comment below. Happy to open a PR if you are interested.
中文摘要:两个共享同一
~/.dsh的 harness 进程可以向同一会话的 JSONL 日志交错写入:进程 B 恢复会话时把session/end-seed落盘,进程 A 里仍然存活的旧 Session 对象对此无感知,继续用seq = 内存日志长度分配序号;协调器只校验内存游标,appendLines用 O_APPEND 写到文件当前末尾,于是产生重复 seq。写入时完全无感,之后的冷加载按设计拒绝「已提交区域」的缺口,会话永久无法打开,重启无效。SQLite 后端靠主键约束会在写入时直接报错,只有 JSONL 后端静默损坏。我在 fork 里实现了「写前比对 durable revision」的乐观检查作为修复,链接见下方评论。All reactions