Skip to content

src/host/store.js: after another host takes over state.lock, write endpoints still answer ok:true but save() silently drops the change #56

Closed

Description

@BlueX888

What happened?

After a second dsh host takes over state.lock (the first host's lock went stale — the machine slept, or the process froze, for longer than the 60 s staleMs), the first host still answers write endpoints with ok: true, but save() then silently refuses to write state.json. The change exists only in memory: the window shows the new Bot, a real DSH Session is even created and renamed for it, but after a reload the Bot is gone, leaving an orphaned Session that belongs to no saved Bot. Only the next request is correctly rejected with bot/read-only.

Root cause: ds-bot/src/host/store.js:140 — refresh() returns early for a host that believes it is the writer (if (holder === undefined) return) without re-validating lock.owns(), so after a takeover holder stays undefined. readOnly() (store.js:49-53) therefore keeps returning undefined, the channel gate (channel.js:98-99) lets non-READS endpoints run, and createSession (roster.js:23-29) creates the Session — but save() then refuses through writable()'s lost-lock branch (store.js:153-158) and returns without writing, after the endpoint has already answered ok: true.

Expected: the first request after the takeover should fail with bot/read-only, exactly like the second one does. Basis in the code:

  • home-lock.js:9: "The token, not the pid, decides ownership: a holder that finds another token has lost the lock." (asserted by test/home-lock.test.js:54-63, "a holder that finds another token in the file knows it lost the lock")
  • store.js:21-23: holder is "undefined while this host may write" — after a takeover that invariant no longer holds, yet nothing re-checks it before a write endpoint is answered.
  • store.js:154-155: "Lost the lock: retake it when nobody else holds it (its file was deleted), else another host took it over and this one stops writing."
  • channel.js:98-99 exists precisely to fail non-READS endpoints with bot/read-only while another host holds the team, and roster.js:24-25: "while another dsh holds the team, a new Session would belong to no saved Bot."

Related: #9 (second group, "锁失败后继续写入…") covers the write path when the lock is broken or lost mid-queue (lockBroken fail-open, queued commits, dispose). This one is the acceptance path: the endpoint gate consults a holder flag that refresh() never re-validates, so the answer is ok: true for a write that is then dropped. The fix branch linked in #9 does not touch refresh()/readOnly().

PRs are collaborator-only right now, so happy to be assigned; I can push a fix branch for review in the meantime (approach: in refresh(), when holder === undefined, re-validate lock.owns() before a non-READS endpoint is answered, mirroring what writable() already does on the save path).

This report was prepared with AI assistance (automated source analysis and the scripted repro below); every quoted output comes from that run.

Steps to reproduce

Two dsh hosts share one DSH_HOME (e.g. dsh Desktop and dsh Web on the same machine, home-lock.js:1-3). Host A freezes for over 60 s (machine asleep / long sync block), host B claims the now-stale lock, A wakes up, and its next action is a write endpoint (create a Bot, change settings). A shows the new Bot with no error; after a reload the Bot is gone.

Deterministic script against a source checkout — save as lost-lock-repro.mjs inside ds-bot/:

import { spawn } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, utimesSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import { apply } from './index.js'
import { fakeContext } from './test/fake-ctx.js'

const home = mkdtempSync(join(tmpdir(), 'ds-bot-lost-'))
const lockPath = join(home, 'state.lock')
const statePath = join(home, 'state.json')
const start = () => {
  const ctx = fakeContext()
  ctx.workspaceRegistry = {
    resolveByPath: async () => undefined, create: async p => ({ id: 'w1', path: p }), archiveSession: async () => {}, get: id => ({ id: 'w1' }),
  }
  ctx.sessionController = {
    create: async () => ({ sessionId: `s${Math.random().toString(36).slice(2, 8)}` }),
    resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup() {} } }),
    selectModel: async () => {},
  }
  ctx.sessionTitle = { rename: async () => {} }
  ctx.sessions = { flush: async () => {} }
  let route; const register = ctx.connection.fetch.register
  ctx.connection.fetch.register = (o) => { route = o; return register(o) }
  apply(ctx, { home })
  return { ctx, call: async (e, p) => (await route.fetch(new Request('http://x/api/bot', { method: 'POST', body: JSON.stringify({ endpoint: e, payload: p }) }))).json() }
}

const A = start()
await A.call('state', {})
console.log('1. A is writer; bots in file:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name))

// A's lock went stale (machine asleep) and a same-machine dsh took it over (a live pid)
const thief = spawn('sleep', ['10'], { stdio: 'ignore' })
writeFileSync(lockPath, JSON.stringify({ pid: thief.pid, host: hostname(), token: 'stolen-token', since: Date.now() }))
utimesSync(lockPath, new Date(), new Date())

// A wakes up: state poll, then a write endpoint, before any save() has run
const sa = await A.call('state', {})
console.log('2. A state readOnly:', JSON.stringify(sa.value.readOnly))
const made = await A.call('create-bot', { name: 'Phantom', pickModel: true })
console.log('3. A create-bot ok:', made.ok, 'value name:', made.value?.name)
console.log('4. state.json bots now:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name), '<- Phantom saved?')
await new Promise(r => setTimeout(r, 100))
console.log('5. state.json bots after save flush:', Object.values(JSON.parse(readFileSync(statePath, 'utf8')).bots).map(b => b.name))

const sa2 = await A.call('state', {})
console.log('6. A state readOnly now:', JSON.stringify(sa2.value.readOnly).slice(0, 60) + '...')
console.log('7. A still shows phantom bot in view:', sa2.value.bots.map(b => b.name).join(','))
const second = await A.call('create-bot', { name: 'Second' })
console.log('8. second create-bot:', second.ok, second.error?.code)
A.ctx.dispose(); thief.kill(); rmSync(home, { recursive: true, force: true })
cd ds-bot
DSH_HARNESS=/path/to/deepseek-harness node --import ./test/harness-resolve.mjs ./lost-lock-repro.mjs

Actual output (verbatim):

1. A is writer; bots in file: [ 'Chief' ]
2. A state readOnly: null
[ds-bot] ds-bot: not saving the Bot team: The Bot team in /var/folders/9d/8rtp59416c760mpq785p9lf00000gp/T/ds-bot-lost-ONbLGO is open in another dsh (Web or Desktop, pid 89562 on bogon). This window shows the team but cannot change it; close the other one and reload.
3. A create-bot ok: true value name: Phantom
4. state.json bots now: [ 'Chief' ] <- Phantom saved?
5. state.json bots after save flush: [ 'Chief' ]
6. A state readOnly now: "The Bot team in /var/folders/9d/8rtp59416c760mpq785p9lf0000...
7. A still shows phantom bot in view: Chief,Phantom
8. second create-bot: false bot/read-only

create-bot answered ok: true and the in-memory view shows Phantom (line 7), but state.json was never written (lines 4-5), and only the second write is rejected as bot/read-only (line 8).

Diagnostics

Source-level repro; the normal Settings → Feedback diagnostics were not available for a checkout run, so the equivalent facts:

  • DS Bot: source checkout of main at bdc5fda64e2ea1e83a4d146440072a2413025df4
  • DSH: deepseek-harness at dsh-v0.2.1-alpha.1 (the version CONTRIBUTING.md pins); host side only, mock scaffolding, no real model, no API keys
  • Node v22.23.2, macOS 15 (darwin 25.6.0, arm64); the affected code is the host side, common to DSH Web and DSH Desktop

How did you install DS Bot?

Git address

Privacy

  • I checked that this report has no API keys, tokens or personal data.

Activity

  1. BlueX888 commented on Oct 12, 2026

    @BlueX888
    Author

    Reproduced this. Root cause: ds-bot/src/host/store.js:140 — refresh() returned early for a host whose holder === undefined without re-validating lock.owns(), so after another dsh replaced the lock token, readOnly() kept answering undefined, the channel ran create-bot, and save() then refused through writable()'s lost-lock branch after the endpoint had already answered ok:true.

    Minimal reproduction:

    cd ds-bot && DSH_HARNESS=/path/to/deepseek-harness node --import ./test/harness-resolve.mjs ./lost-lock-repro.mjs
    

    Observed output:

    2. A state readOnly: null
    3. A create-bot ok: true value name: Phantom
    4. state.json bots now: [ 'Chief' ] <- Phantom saved?
    5. state.json bots after save flush: [ 'Chief' ]
    7. A still shows phantom bot in view: Chief,Phantom
    8. second create-bot: false bot/read-only
    

    Fix is ready on BlueX888:fix/prep-store-lost-lock-write-dropped-ok-true (commit f12eee78). refresh() now re-validates lock.owns() before it skips the follow and re-claims (warns/adopts only when a former reader takes over, while a holder that re-took its deleted lock keeps writing), so the first write after a takeover fails with bot/read-only instead of ok:true with a dropped save.
    Verified with: cd ds-bot && npm test — 313 tests, 0 fail (the 14 web-search cancellations reproduce identically on base bdc5fda); the new regression test 'a host that loses its lock to another dsh refuses changes instead of making a phantom Bot' passes.

    Happy to open the PR once you confirm the approach or assign this to me.

  2. Fei2411 commented on Oct 12, 2026

    @Fei2411
    Owner

    已完成修复

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions