Replies: 5 comments
|
Thanks for the write-up and the complete branch — this is exactly the durability gap the session subsystem docs flag, and your diagnosis matches the corruption family already documented here (#1452, and the mech-#12 analysis in #4274: the coordinator asserts A parity observation that strongly validates the approach: the SQLite backend already implements precisely this contract. I fetched
Design layer — rejection is fail-loud, but the session then stays non-durable: when the tail check rejects, the write-behind catch re-queues the batch at the head and sets Minor: only Thanks for building the branch and tests (243/506 green) — this is exactly the kind of contribution the disabled-PR workaround is meant to carry. |
|
I have been running your branch and it holds up. Reviewed rather than taken on trust, since it changes session durability — three things worth reporting back. One suspected bug that is not one
A performance issue worth fixing before this lands
It memoizes cleanly, because the log is append-only: const size = await stat(path).then(stats => stats.size).catch(() => undefined)
if (size === undefined) return undefined
const memo = this.tailSeqByPath.get(path)
if (memo !== undefined && memo.size === size) return memo.tailSeq
const scanned = await this.scanTailSeq(path) // your existing body
this.tailSeqByPath.set(path, { size, tailSeq: scanned })
return scannedplus recording A gap that memo creates, and that your test cannot catchThis is the part I would most want you to see. Your concurrent-writer case passes even with the size guard removed — its foreign write lands while the memo is still cold, so a broken memo is never exercised. I verified that by deleting the guard and watching the suite stay green. A case that does catch it commits two appends first, so the memo is warm before the foreign row arrives: await warm.sessionPersistence.append(m.id, oneTurnLog()) // seq 0..5
await warm.sessionPersistence.append(m.id, [/* seq 6..7 */]) // memo now warm
// ... foreign row appended directly to the file ...
await expect(warm.sessionPersistence.append(m.id, [/* seq 8 */]))
.rejects.toThrow(/another process wrote this session log/)That fails on both compressions without the guard. One dependency worth namingOn Windows this leans entirely on Thanks for publishing the branch with the reconstruction — decoding 92k events frame by frame is what made the two-process story provable rather than plausible. |
|
Both points land. On the memo: agreed, and the warm-memo test is the sharper catch — my size-cache sketch would have shipped with that exact blind spot (cold-cache concurrent-writer test passes either way). Your "commit twice first, then foreign write" fixture is the right regression: it pins the invariant that the guard works off the durable tail, not the memoized one. On the Windows EPERM race — verified at The cleanest fix is a reclassification, not a new probe: treat EPERM as retryable contention when the lock cannot be proven absent. Concretely, in
The one cost: a genuine permission error (read-only directory, ACL) now surfaces as (Unrelated to your race, one carry-over from my first review that you didn't touch: the lock stores the holder's PID, so a contender could liveness-check a stale lock after a crash — |
|
Your reclassification is the right call, and I have been running it — with one variation that avoids the cost you concede. You note that retrying unconditionally means a genuine permission error surfaces as if (verdict === 'vanished') {
if (vanished >= MAX_VANISHED_LOCK_RETRIES) throw error // the original EPERM
vanished += 1
} else {
vanished = 0
}The asymmetry is what makes it work. A release race is resolved by the very next attempt, because the lock really is gone. A genuine denial produces Worth also threading the refusal that ran out the clock into the timeout as throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`, { cause: refusal })On the PID liveness checkGenuinely better evidence than age, and I would not dismiss it — but it needs one hazard stated before it lands: PIDs are recycled. A stale lock whose owner died can have its PID reassigned to an unrelated live process, and If it goes in, the PID probably wants a companion the recycle cannot forge — process start time, or a random token minted per acquisition and re-read under the lock — so "same PID" and "same owner" stop being the same claim. As a follow-up rather than in this change, agreed: the writer lock is the part people will review hardest, and it is worth keeping small. One more note for whoever picks up the memo: |
|
The bounded-retry refinement is strictly better than my unconditional retry — it keeps the raw EPERM for genuine denials with no trade, and the counter-reset on proven contention is the detail that makes it safe under churn (a real release race resolves on the very next attempt because the lock is actually gone). The On the PID liveness check — your recycling objection settles it; I'm dropping the suggestion. The corruption asymmetry is decisive: breaking a lock the real owner still holds corrupts the log, while a stale lock merely bricks it (recoverable, diagnosable, and the failure is loud). Any liveness proxy that can misfire in the break-live-lock direction is a net regression, and PID + start-time is still weak under churn (start-time granularity is seconds on several filesystems, and a busy PID can be recycled within that window). The surviving piece of the idea is cheaper and safer: enrich the lock content to Memo: agreed on the undefined distinction — |
Uh oh!
There was an error while loading. Please reload this page.
会话日志并发写损坏修复:跨进程写锁 + 尾部 seq 校验(附完整修复分支)
问题
两个 DSH 进程同时写同一个会话日志会把日志写坏。每个进程都用自己内存里的 cursor 校验批次(coordinator
appendCore检查event.seq === state.cursor + i),而 JSONL 后端的appendLines追加字节时没有任何跨进程协调。第二个进程如果在第一个进程提交前读取了 cursor,就会追加过期的 seq:文件里出现重叠的序号,下一次读取直接报:历史加载失败(
history unavailable for session ...),且会话无法再打开。这正是 session 子系统文档 里点名的缺口:"tolerating concurrent writers needs a signal beyond the log"(容忍并发写入方需要日志之外的信号)。
实际触发场景:同一个
DSH_HOME被两个活动实例同时打开(例如在 GUI 会话里再启动一个dsh web --no-open做桌面封装),再叠加一个被中断的回合,碰撞几乎必然发生:恢复进程提交合成 closers,而活动进程稍后在同一批 seq 上提交工具调用的真实结果。我做的诊断(真实损坏日志还原)
对一个实际损坏的会话日志(zstd 多帧 JSONL,92291 个事件)逐帧解码后,断裂点处的时间线是:
文件里
seq 70179–70182出现两遍——两个进程各自的 cursor 都"通过"了校验,双双往同一个文件追加。修复方案(已实现 + 已验证)
在
dsh-session-persistence-jsonl的appendLines中:withFileLock(@deepseek-ai/dsh-atomic-write)跨进程串行化每次追加(含崩溃修复追加);tail + 1;不匹配就拒绝并给出明确诊断,而不是写入重叠 seq;scanZstdFrames,不解压 payload)定位最后一帧、只解码该帧;明文只读有界的 64 KiB 尾部;这样第二个并发写入方会在追加时响亮失败,已提交日志保持 seq 连续,不再静默损坏。
验证:
none/zstd):外部写入方推进尾部后,下一次追加被拒绝;尾部匹配时正常追加session-persistence-jsonl:243 个测试通过;session-persistence(含 sqlite):506 个测试通过pnpm typecheck/pnpm lint通过修复分支
完整修复已提交,可以从 fork 直接取:
fix/session-log-concurrent-writer-lock(commitfb529e96)https://github.com/lingdiaan/deepseek-harness/tree/fix/session-log-concurrent-writer-lockpackages/session/session-persistence-jsonl/src/index.ts(核心修复)packages/session/session-persistence-jsonl/tests/jsonl.spec.ts(测试)packages/session/session-persistence-jsonl/{README.md,README.zh.md,package.json,tsconfig.json}.agents/notes/implemented/bug-fix/2026-08-26-session-log-concurrent-writer-lock.{md,zh.md,i18n.yaml}注意到本仓库当前不接受 Pull Request(issues/PR 已禁用),所以把修复发到这里。如果维护者希望合入,我可以按需调整分支、rebase 到最新 master,或补充分支引用供内部合入。
关联讨论:#1452(同一 DSH_HOME 并发写导致 seq 重复)、#1497(非干净退出后重放导致 seq gap)。
All reactions