@dimina-kit/fs-core 0.2.0-dev.20260711062001 → 0.3.0-dev.6-dev.20260711133419

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Guards `start()`'s writer-lock arbitration in fs-core-recovery.ts: the
3
+ * worker queues for the `navigator.locks` writer lease, serves readonly
4
+ * after a 3s deadline if the lease has not landed yet, and upgrades once a
5
+ * deferred grant arrives. A rejected lock request (the lease will never
6
+ * come — e.g. the browser aborts the request) must not be treated the same
7
+ * as "still queued": before the deadline it must fail startup outright;
8
+ * after the deadline it must kill the worker (FATAL) instead of serving
9
+ * readonly forever with no path back to writer.
10
+ *
11
+ * `navigator`/`BroadcastChannel` are OPFS/Web-Locks browser globals absent
12
+ * from the Node vitest environment, so both are stubbed via `vi.stubGlobal`.
13
+ * `core` is a structural fake — only the fields `start()` actually touches —
14
+ * narrowed through `as unknown as FsCore` (see client.test.ts's
15
+ * `ClientInternals` for the same pattern; no `as any`/`@ts-expect-error`).
16
+ */
17
+ import { afterEach, describe, expect, it, vi } from 'vitest'
18
+ import * as recovery from './fs-core-recovery.js'
19
+ import type { FsCore } from './fs-core.worker.js'
20
+ import type { FsCoreMode } from './worker-lib/protocol.js'
21
+
22
+ type LockRequestFn = (name: string, opts: unknown, cb: (lock: unknown) => Promise<void>) => Promise<unknown>
23
+
24
+ interface FakeCore {
25
+ projectId: string
26
+ root: unknown
27
+ bc: unknown
28
+ mode: FsCoreMode
29
+ releaseLock: (() => void) | null
30
+ writerLockQueued: boolean
31
+ sbHandle: { close: () => void } | null
32
+ walHandle: { close: () => void } | null
33
+ recover: () => Promise<void>
34
+ becomeWriter: () => Promise<void>
35
+ pushFullToQuery: () => void
36
+ welcome: () => void
37
+ event: (e: unknown) => void
38
+ enqueue: (fn: () => unknown) => Promise<unknown>
39
+ onBroadcast: (msg: { type?: string }) => Promise<void>
40
+ }
41
+
42
+ function makeCore(overrides: Partial<FakeCore> = {}): FakeCore {
43
+ return {
44
+ projectId: '',
45
+ root: undefined,
46
+ bc: undefined,
47
+ mode: 'starting',
48
+ releaseLock: null,
49
+ writerLockQueued: false,
50
+ sbHandle: null,
51
+ walHandle: null,
52
+ recover: vi.fn(async () => {}),
53
+ becomeWriter: vi.fn(async () => {}),
54
+ pushFullToQuery: vi.fn(),
55
+ welcome: vi.fn(),
56
+ event: vi.fn(),
57
+ enqueue: vi.fn((fn: () => unknown) => Promise.resolve(fn())),
58
+ onBroadcast: vi.fn(async () => {}),
59
+ ...overrides,
60
+ }
61
+ }
62
+
63
+ /** Stand-in for the global `BroadcastChannel` ctor `start()` calls directly
64
+ * (`new BroadcastChannel('dwc:' + projectId)`) — not reachable through the
65
+ * fake `core` object since `start()` overwrites `core.bc` itself. */
66
+ class FakeBroadcastChannel {
67
+ name: string
68
+ onmessage: ((e: MessageEvent) => void) | null = null
69
+ posted: unknown[] = []
70
+ constructor(name: string) { this.name = name }
71
+ postMessage(msg: unknown): void { this.posted.push(msg) }
72
+ close(): void {}
73
+ }
74
+
75
+ function stubBrowserGlobals(request: LockRequestFn): void {
76
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
77
+ vi.stubGlobal('navigator', {
78
+ storage: { getDirectory: async () => ({ getDirectoryHandle: async () => ({}) }) },
79
+ locks: { request },
80
+ })
81
+ }
82
+
83
+ describe('fs-core-recovery start() — writer-lock arbitration', () => {
84
+ afterEach(() => {
85
+ vi.useRealTimers()
86
+ vi.unstubAllGlobals()
87
+ })
88
+
89
+ it('promotes to writer immediately when the lease is granted before the 3s deadline', async () => {
90
+ vi.useFakeTimers()
91
+ const request: LockRequestFn = vi.fn((_name, _opts, cb) => cb({}))
92
+ stubBrowserGlobals(request)
93
+ const core = makeCore()
94
+
95
+ await recovery.start(core as unknown as FsCore, 'proj-a')
96
+
97
+ expect(core.becomeWriter).toHaveBeenCalledTimes(1)
98
+ expect(core.welcome).toHaveBeenCalledTimes(1)
99
+ // recover()/pushFullToQuery() are only called directly on the readonly
100
+ // (timeout) branch — their absence here is evidence the happy path, not
101
+ // the deadline fallback, was taken.
102
+ expect(core.recover).not.toHaveBeenCalled()
103
+ expect(core.pushFullToQuery).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('a lock request that rejects before the 3s deadline must reject start(), not silently become the writer', async () => {
107
+ vi.useFakeTimers()
108
+ const request: LockRequestFn = vi.fn(() => Promise.reject(new Error('writer lock request aborted')))
109
+ stubBrowserGlobals(request)
110
+ const core = makeCore()
111
+
112
+ await expect(recovery.start(core as unknown as FsCore, 'proj-b')).rejects.toThrow()
113
+
114
+ expect(core.becomeWriter).not.toHaveBeenCalled()
115
+ expect(core.mode).not.toBe('writer')
116
+ })
117
+
118
+ it('falls back to readonly at the 3s deadline, then upgrades once the deferred lock is granted', async () => {
119
+ vi.useFakeTimers()
120
+ let grantCb!: (lock: unknown) => Promise<void>
121
+ const request: LockRequestFn = vi.fn((_name, _opts, cb) => {
122
+ grantCb = cb
123
+ return new Promise(() => {}) // only settles through cb's own held-lock promise, unused here
124
+ })
125
+ stubBrowserGlobals(request)
126
+ const core = makeCore()
127
+
128
+ const startPromise = recovery.start(core as unknown as FsCore, 'proj-c')
129
+ await vi.advanceTimersByTimeAsync(3000)
130
+ await startPromise
131
+
132
+ expect(core.recover).toHaveBeenCalledTimes(1)
133
+ expect(core.mode).toBe('readonly')
134
+ expect(core.welcome).toHaveBeenCalledTimes(1)
135
+ expect(core.becomeWriter).not.toHaveBeenCalled()
136
+
137
+ grantCb({})
138
+ await vi.advanceTimersByTimeAsync(0)
139
+
140
+ expect(core.enqueue).toHaveBeenCalledTimes(1)
141
+ expect(core.becomeWriter).toHaveBeenCalledTimes(1)
142
+ })
143
+
144
+ it('a lock request that rejects only after the 3s deadline must kill the worker (FATAL), not stay readonly forever', async () => {
145
+ vi.useFakeTimers()
146
+ let rejectLock!: (e: unknown) => void
147
+ const request: LockRequestFn = vi.fn(() => new Promise((_resolve, reject) => { rejectLock = reject }))
148
+ stubBrowserGlobals(request)
149
+ const core = makeCore()
150
+
151
+ const startPromise = recovery.start(core as unknown as FsCore, 'proj-d')
152
+ await vi.advanceTimersByTimeAsync(3000)
153
+ await startPromise
154
+
155
+ expect(core.mode).toBe('readonly')
156
+ expect(core.becomeWriter).not.toHaveBeenCalled()
157
+
158
+ rejectLock(new Error('writer lock request aborted'))
159
+ await vi.advanceTimersByTimeAsync(0)
160
+
161
+ expect(core.becomeWriter).not.toHaveBeenCalled()
162
+ expect(core.mode).toBe('dead')
163
+ expect(core.event).toHaveBeenCalledWith(expect.objectContaining({ type: 'FATAL' }))
164
+ })
165
+
166
+ it('releases the writer lock, closes handles, and dies when becomeWriter fails after a timely grant', async () => {
167
+ // The lock callback's held-lock promise settles only through
168
+ // core.releaseLock(); a becomeWriter failure that skips the release keeps
169
+ // every other tab queued behind a dead writer forever.
170
+ vi.useFakeTimers()
171
+ let held!: Promise<unknown>
172
+ const request: LockRequestFn = vi.fn((_name, _opts, cb) => { held = cb({}); return held })
173
+ stubBrowserGlobals(request)
174
+ const core = makeCore({
175
+ becomeWriter: vi.fn(() => Promise.reject(new Error('superblock open failed'))),
176
+ // Simulates handles becomeWriter opened before it threw — failure
177
+ // cleanup must not leave them dangling.
178
+ sbHandle: { close: vi.fn() },
179
+ walHandle: { close: vi.fn() },
180
+ })
181
+
182
+ await expect(recovery.start(core as unknown as FsCore, 'proj-e')).rejects.toThrow()
183
+
184
+ let released = false
185
+ held.then(() => { released = true })
186
+ await vi.advanceTimersByTimeAsync(0)
187
+
188
+ expect(released).toBe(true)
189
+ expect(core.releaseLock).toBeNull()
190
+ expect(core.mode).toBe('dead')
191
+ expect(core.sbHandle).toBeNull()
192
+ expect(core.walHandle).toBeNull()
193
+ })
194
+
195
+ it('a deferred upgrade whose becomeWriter fails releases the lock and reports FATAL instead of staying readonly', async () => {
196
+ vi.useFakeTimers()
197
+ let grantCb!: (lock: unknown) => Promise<void>
198
+ const request: LockRequestFn = vi.fn((_name, _opts, cb) => {
199
+ grantCb = cb
200
+ return new Promise(() => {})
201
+ })
202
+ stubBrowserGlobals(request)
203
+ const core = makeCore({
204
+ becomeWriter: vi.fn(() => Promise.reject(new Error('wal segment open failed'))),
205
+ sbHandle: { close: vi.fn() },
206
+ walHandle: { close: vi.fn() },
207
+ })
208
+
209
+ const startPromise = recovery.start(core as unknown as FsCore, 'proj-f')
210
+ await vi.advanceTimersByTimeAsync(3000)
211
+ await startPromise
212
+ expect(core.mode).toBe('readonly')
213
+
214
+ const held = grantCb({})
215
+ let released = false
216
+ held.then(() => { released = true })
217
+ await vi.advanceTimersByTimeAsync(0)
218
+
219
+ expect(core.becomeWriter).toHaveBeenCalledTimes(1)
220
+ expect(core.mode).toBe('dead')
221
+ expect(core.event).toHaveBeenCalledWith(expect.objectContaining({ type: 'FATAL' }))
222
+ expect(released).toBe(true)
223
+ expect(core.releaseLock).toBeNull()
224
+ })
225
+ })
@@ -12,11 +12,75 @@ import {
12
12
  type SlotInfo, type WalRecord,
13
13
  } from './worker-lib/wal-codec.js'
14
14
  import { normalizePath } from './worker-lib/paths.js'
15
+ import type { CoreWireMessage, FsCoreMode } from './worker-lib/protocol.js'
15
16
  import { epochFloor, OP, OPID_WINDOW, rpcErr, SEGMENT_ROTATE_BYTES, type MirrorEntry } from './worker-lib/engine-shared.js'
16
17
 
17
18
  export { epochFloor }
18
19
 
19
20
  // ── 启动:锁 → 恢复 → 打开写句柄 → 全量同步 query ──
21
+
22
+ /**
23
+ * 发起(或重新发起)写者锁排队。resolve = granted(回调持锁直到
24
+ * core.releaseLock 被调用);reject = 锁仲裁本身失败(不是"被别人占着"——
25
+ * 排队会一直等,reject 只发生在 Web Locks 层异常,如上下文销毁中)。
26
+ * 能跑 fs-core(OPFS SyncAccessHandle)的环境必有 Web Locks,因此 reject
27
+ * 一律按致命处理,绝不允许无锁当写者(互斥基座坏了 ≠ 无人竞争)。
28
+ */
29
+ function queueWriterLock(core: FsCore): Promise<Lock | null> {
30
+ core.writerLockQueued = true
31
+ return new Promise<Lock | null>((resolve, reject) => {
32
+ navigator.locks.request('dwc:writer:' + core.projectId, { mode: 'exclusive' }, (lock) => {
33
+ core.writerLockQueued = false
34
+ core.writerLockHeld = true
35
+ resolve(lock)
36
+ return new Promise<void>((release) => { core.releaseLock = release })
37
+ }).catch((err) => {
38
+ core.writerLockQueued = false
39
+ reject(err instanceof Error ? err : new Error(String(err)))
40
+ })
41
+ })
42
+ }
43
+
44
+ /**
45
+ * 拿到锁之后的升级(becomeWriter)失败时的收尾:升级失败的 worker 既当不了
46
+ * 写者也不该再占着锁——关掉可能已打开的句柄、释放写者锁(后面排队的 tab 才
47
+ * 有机会接手)、置 dead。FATAL 的发出留给调用方(启动路径由 HELLO catch 发,
48
+ * 延迟路径由 wireDeferredWriterUpgrade 发),避免双发。
49
+ */
50
+ function failWriterUpgrade(core: FsCore): void {
51
+ try { core.sbHandle?.close() } catch { /* 尽力关闭;句柄可能没开成 */ }
52
+ try { core.walHandle?.close() } catch { /* 同上 */ }
53
+ core.sbHandle = null
54
+ core.walHandle = null
55
+ if (core.releaseLock) { core.releaseLock(); core.releaseLock = null }
56
+ core.writerLockHeld = false
57
+ core.mode = 'dead'
58
+ }
59
+
60
+ /**
61
+ * 只读服务期间的延迟升级路径:granted → 排队任务里 becomeWriter,失败则
62
+ * 释放锁 + FATAL + dead(不能让后续排队的 tab 堵在一个死写者后面);
63
+ * rejected → 该 worker 永远无法升级写者,同样诚实死掉——绝不静默滞留
64
+ * readonly 让宿主误以为还在正常排队。
65
+ */
66
+ function wireDeferredWriterUpgrade(core: FsCore, granted: Promise<Lock | null>): void {
67
+ granted.then(
68
+ async (lock) => {
69
+ if (!lock || core.mode === 'dead') return
70
+ try {
71
+ await core.enqueue(async () => { await core.becomeWriter() })
72
+ } catch (err) {
73
+ failWriterUpgrade(core)
74
+ core.event({ type: 'FATAL', error: 'writer upgrade failed: ' + String(err) })
75
+ }
76
+ },
77
+ (err) => {
78
+ core.mode = 'dead'
79
+ core.event({ type: 'FATAL', error: 'writer lock arbitration failed: ' + String(err) })
80
+ },
81
+ )
82
+ }
83
+
20
84
  export async function start(core: FsCore, projectId: string): Promise<void> {
21
85
  core.projectId = projectId
22
86
  core.root = await (await navigator.storage.getDirectory()).getDirectoryHandle(projectId, { create: true })
@@ -24,29 +88,58 @@ export async function start(core: FsCore, projectId: string): Promise<void> {
24
88
  core.bc.onmessage = (e: MessageEvent) => core.onBroadcast(e.data)
25
89
 
26
90
  // 排队等锁;3s 拿不到先以只读服务,granted 后升级。禁止 steal。
27
- const granted = new Promise<Lock | null>((resolve) => {
28
- navigator.locks.request('dwc:writer:' + projectId, { mode: 'exclusive' }, (lock) => {
29
- resolve(lock)
30
- return new Promise<void>((release) => { core.releaseLock = release })
31
- }).catch(() => resolve(null))
32
- })
91
+ // 超时前锁仲裁失败 这里直接抛出 HELLO catch FATAL(client 侧 connect
92
+ // 拒绝)——绝不落入 becomeWriter。
93
+ const granted = queueWriterLock(core)
33
94
  const winner = await Promise.race([granted, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 3000))])
34
95
  if (winner === 'timeout') {
35
96
  await core.recover()
36
97
  core.mode = 'readonly'
37
98
  core.pushFullToQuery()
38
99
  core.welcome()
39
- granted.then(async (lock) => {
40
- if (!lock || core.mode === 'dead') return
41
- await core.enqueue(async () => { await core.becomeWriter() })
42
- })
100
+ wireDeferredWriterUpgrade(core, granted)
43
101
  return
44
102
  }
45
- await core.becomeWriter()
103
+ try {
104
+ await core.becomeWriter()
105
+ } catch (err) {
106
+ // 拿到锁但升级失败:释放锁再上抛(HELLO catch 发 FATAL)——否则后面排队
107
+ // 的 tab 永远堵在一个死写者后面。
108
+ failWriterUpgrade(core)
109
+ throw err
110
+ }
46
111
  core.welcome()
47
112
  }
48
113
 
114
+ /**
115
+ * readonly 端主动请求写权交接(协作交接协议的发起半边;接收半边见
116
+ * {@link onBroadcast}):广播 handover-request 让现任写者排干释放,自己的
117
+ * 排队锁请求随之 granted → 升级。何时调用是宿主的策略(如用户在"另一个
118
+ * 标签页持有写权"提示上点"在此接管")——本函数只提供机制,绝不自动重发。
119
+ * 同一 pending 周期内重复调用被合并(不追加锁请求、不重复广播);收到
120
+ * handover-done 广播即周期结束(见 onBroadcast),可再次发起。
121
+ */
122
+ export function requestHandover(core: FsCore): { requested?: true; mode?: FsCoreMode } {
123
+ if (core.mode === 'writer') return { mode: 'writer' }
124
+ if (core.mode === 'draining') throw rpcErr('draining', 'writer is handing over')
125
+ // 只有 readonly 端有交接可言:dead 自己永远升级不了(让健康写者排干是纯伤害),
126
+ // starting 尚未仲裁出身份——都如实回报状态、不广播、不动锁队列。
127
+ if (core.mode !== 'readonly') return { mode: core.mode }
128
+ if (core.handoverRequested) return { requested: true }
129
+ // 锁已到手、becomeWriter 在途(granted 与 mode='writer' 之间的异步窗口):
130
+ // 升级马上完成,再排队/广播只会留下一个日后可能抢锁的陈旧请求。
131
+ if (core.writerLockHeld) return { requested: true }
132
+ core.handoverRequested = true
133
+ if (!core.writerLockQueued) {
134
+ // 曾排干交出写权的 worker:原锁请求已了结,重新排队并接回升级路径。
135
+ wireDeferredWriterUpgrade(core, queueWriterLock(core))
136
+ }
137
+ core.bc.postMessage({ type: 'handover-request' })
138
+ return { requested: true }
139
+ }
140
+
49
141
  export async function becomeWriter(core: FsCore): Promise<void> {
142
+ core.handoverRequested = false // 升级即周期终结:此前发起的交接请求已达成
50
143
  await core.recover() // 旧写者可能刚交出——以盘上状态为准重建
51
144
  // epoch 递增写入候选槽(防御性护栏:记录归属可判别)
52
145
  core.sbHandle = await (await core.root.getFileHandle('superblock', { create: true })).createSyncAccessHandle()
@@ -175,6 +268,8 @@ export async function recover(core: FsCore): Promise<void> {
175
268
  core.appendedGen = core.walGen = core.memGen = core.ackGen = gen
176
269
  core.trimCheckpoints() // 回放会重新加回历史 checkpoint 记录 → 恢复后同样裁剪
177
270
  for (const r of replayed.slice(-OPID_WINDOW)) {
271
+ // 只有 {gen}:WAL 不记录内存态 extras(cpId/turnId/expiresAt/restored),
272
+ // 这些条目服务跨会话去重,同会话超时重试永远命中 live 缓存的完整形状。
178
273
  if (r.meta.opId) core.rememberOpId(r.meta.opId, { gen: r.gen })
179
274
  }
180
275
  if (!segs.length) {
@@ -230,7 +325,7 @@ export function opRead(core: FsCore, { path }: { path: string }): { content: str
230
325
  const norm = normalizePath(path)
231
326
  const e = norm && core.mirror.get(norm)
232
327
  if (!e) throw rpcErr('not-found', String(path))
233
- return { content: e.content, rev: e.rev, gen: core.memGen } // P0:镜像在内存,64KB 路由约束成为空转(见 §2.1)
328
+ return { content: e.content, rev: e.rev, gen: core.memGen } // 镜像常驻内存,读直接命中权威状态,无需按内容大小分流
234
329
  }
235
330
  export function opLs(core: FsCore): { paths: string[]; gen: number } { return { paths: [...core.mirror.keys()].sort(), gen: core.memGen } }
236
331
  export function opStatus(core: FsCore): {
@@ -258,13 +353,19 @@ export function pushFullToQuery(core: FsCore): void {
258
353
  for (const [p, ent] of core.mirror) files[p] = { content: ent.content, rev: ent.rev }
259
354
  core.queryPort.postMessage({ gen: core.memGen, full: true, files })
260
355
  }
261
- export function event(core: FsCore, e: Record<string, unknown>): void { if (core.clientPort) core.clientPort.postMessage(e) }
356
+ export function event(core: FsCore, e: CoreWireMessage): void { if (core.clientPort) core.clientPort.postMessage(e) }
262
357
  export function welcome(core: FsCore): void {
263
358
  core.event({ type: 'WELCOME', epoch: core.epoch, memGen: core.memGen, readonly: core.mode !== 'writer', mode: core.mode })
264
359
  }
265
360
 
266
361
  // ── 协作交接(禁 steal)──
267
362
  export async function onBroadcast(core: FsCore, msg: { type?: string }): Promise<void> {
363
+ if (msg.type === 'handover-done' && core.mode !== 'writer') {
364
+ // 一个交接周期已完成(某个写者排干释放)。无论本 worker 是否是赢家,
365
+ // 合并标志都复位:没抢到锁的 readonly 端可以再次向新写者发起请求。
366
+ core.handoverRequested = false
367
+ return
368
+ }
268
369
  if (msg.type === 'handover-request' && core.mode === 'writer') {
269
370
  core.mode = 'draining'
270
371
  await core.enqueue(async () => {
@@ -273,6 +374,7 @@ export async function onBroadcast(core: FsCore, msg: { type?: string }): Promise
273
374
  core.sbHandle!.close(); core.sbHandle = null
274
375
  core.mode = 'readonly'
275
376
  if (core.releaseLock) { core.releaseLock(); core.releaseLock = null }
377
+ core.writerLockHeld = false
276
378
  core.bc.postMessage({ type: 'handover-done', gen: core.memGen })
277
379
  core.event({ evt: 'writer-lost', gen: core.memGen })
278
380
  })
@@ -11,7 +11,7 @@ import { DERIVED_PREFIXES, normalizePath } from './worker-lib/paths.js'
11
11
  import {
12
12
  AUDIT_CAP, CHECKPOINT_KEEP, GROUP_WINDOW_MS, OP, OP_NAME, OPID_WINDOW, rpcErr, SEGMENT_ROTATE_BYTES,
13
13
  TURN_DEFAULT_TTL_MS, TURN_MAX_OPS, WRITE_OPCODES,
14
- type AuditEntry, type MirrorEntry, type Respond,
14
+ type AuditEntry, type MirrorEntry, type OpIdResult, type Respond,
15
15
  } from './worker-lib/engine-shared.js'
16
16
  import type {
17
17
  CheckpointArgs, EditArgs, MvArgs, RestoreArgs, RmArgs, TurnBeginArgs, TurnEndArgs, WriteArgs,
@@ -46,13 +46,13 @@ export function audit(core: FsCore, entry: AuditEntry): void {
46
46
 
47
47
  /** turn 执法(agent 专属):在 append 前的同一同步块内调用,无让出点,
48
48
  * 撤销(turnEnd/过期)与写入之间不存在竞态窗口。human 写不受限。
49
- * W4 纵深加固(第二道锁,docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):门已
49
+ * 纵深加固(第二道锁):门已
50
50
  * arm(core.agentToken !== null)时,即使 turnId 猜对/偷到、turn 仍活跃,也必须携带匹配
51
51
  * 的 agentToken,否则拒绝 —— 威胁模型是"B realm 内代码拿到裸 window.__FS_CLIENT 后伪造
52
52
  * {actor:'agent', turnId} 直写",令牌只有内核持有(kernel.js 闭包,从不落 window)。
53
53
  * 校验顺序刻意放在 turn 有效性判定之后:turnId 完全不匹配/已过期的旧行为('turn-closed')
54
54
  * 保持不变(fs 域既有 e2e 的等价断言不回归),令牌门只在"turn 确实活跃且 turnId 匹配"这一步
55
- * 追加第二道拒绝,精确对应 blocker #6 的伪造场景。 */
55
+ * 追加第二道拒绝,精确对应上述伪造场景。 */
56
56
  export function checkTurn(core: FsCore, actor: string | undefined, turnId: string | undefined, agentToken: string | undefined): void {
57
57
  if (actor !== 'agent') return
58
58
  const t = core.turn
@@ -73,7 +73,7 @@ export function armAgentToken(core: FsCore, token: unknown): { armed: boolean; i
73
73
  throw rpcErr('agent-token-gate-armed', 'agent token gate already armed with a different token')
74
74
  }
75
75
 
76
- /** §4.7 restore 冲突执法:非 force 时在 appendSync 同步块内调用,无让出点。
76
+ /** restore 冲突执法:非 force 时在 appendSync 同步块内调用,无让出点。
77
77
  * auditLog 是容量 AUDIT_CAP 的环——若其最老条目已晚于 baseGen+1,说明 (baseGen, 最老审计]
78
78
  * 区间的历史已被丢弃(compaction 或环覆盖),无法证明期间没有人类写,一律保守拒绝。 */
79
79
  export function checkRestoreConflict(core: FsCore, baseGen: number): void {
@@ -107,7 +107,7 @@ export function checkWrite(core: FsCore, path: unknown, ifMatch: unknown, _actor
107
107
  }
108
108
 
109
109
  /** 段超阈值 → 影子 compaction(物化 manifest + 新段 + superblock 翻转),
110
- * 而不是裸换段:WAL 长度被真正回收,重放成本有上界(P1 compaction 上线)。 */
110
+ * 而不是裸换段:WAL 长度被真正回收,重放成本有上界。 */
111
111
  export async function rotateIfNeeded(core: FsCore): Promise<void> {
112
112
  if (core.walOffset <= SEGMENT_ROTATE_BYTES) return
113
113
  await core.compactNow()
@@ -143,7 +143,9 @@ export function flushWindow(core: FsCore): void {
143
143
  let actor = 'human'
144
144
  for (const w of core.windowOps) {
145
145
  core.ackGen = Math.max(core.ackGen, w.gen)
146
- if (w.opId) core.rememberOpId(w.opId, { gen: w.gen })
146
+ // 缓存与 respond 同形(含 extra)——超时重试的重放必须携带首个响应的全部
147
+ // 字段(cpId/turnId/expiresAt),见 engine-shared.ts 的 OpIdResult。
148
+ if (w.opId) core.rememberOpId(w.opId, { gen: w.gen, rev: w.gen, ...w.extra })
147
149
  w.respond({ ok: true, result: { gen: w.gen, rev: w.gen, ...w.extra } })
148
150
  if (w.path) paths.push(w.path)
149
151
  if (w.actor === 'agent') actor = 'agent'
@@ -153,7 +155,7 @@ export function flushWindow(core: FsCore): void {
153
155
  core.bc.postMessage({ type: 'fs-change', gen: core.memGen })
154
156
  }
155
157
 
156
- export function rememberOpId(core: FsCore, opId: string, v: { gen: number }): void {
158
+ export function rememberOpId(core: FsCore, opId: string, v: OpIdResult): void {
157
159
  core.opIds.set(opId, v)
158
160
  if (core.opIds.size > OPID_WINDOW) core.opIds.delete(core.opIds.keys().next().value!)
159
161
  }
@@ -233,7 +235,7 @@ export async function makeCheckpoint(core: FsCore, { opId, actor, turnId }: { op
233
235
  return { cpId, gen }
234
236
  }
235
237
 
236
- /** P5 LRU:Map 按插入序,裁掉最老的(活跃 turn 的锚点必然在最近 N 个内)。 */
238
+ /** checkpoint LRU:Map 按插入序,裁掉最老的(活跃 turn 的锚点必然在最近 N 个内)。 */
237
239
  export function trimCheckpoints(core: FsCore): void {
238
240
  while (core.checkpoints.size > CHECKPOINT_KEEP) {
239
241
  core.checkpoints.delete(core.checkpoints.keys().next().value!)
@@ -249,7 +251,7 @@ export async function opCheckpoint(core: FsCore, { actor = 'human', turnId, agen
249
251
  core.scheduleFlush(true)
250
252
  }
251
253
 
252
- // ── P4 turn 能力:铸造(checkpoint 锚 + 激活)→ 执法(checkTurn)→ 撤销 ──
254
+ // ── turn 能力:铸造(checkpoint 锚 + 激活)→ 执法(checkTurn)→ 撤销 ──
253
255
  export async function opTurnBegin(core: FsCore, { turnId, ttlMs, opId }: TurnBeginArgs, respond: Respond): Promise<void> {
254
256
  if (!turnId || typeof turnId !== 'string') throw rpcErr('bad-args', 'turnId required')
255
257
  if (core.turn && Date.now() <= core.turn.expiresAt) {
@@ -280,7 +282,7 @@ export function opDiff(core: FsCore, { turnId }: { turnId?: string }): { turnId:
280
282
  return { turnId, changes, cpId, auditWindow: { cap: AUDIT_CAP, sinceGen: core.auditLog.length ? core.auditLog[0]!.gen : core.memGen } }
281
283
  }
282
284
 
283
- /** §4.7 restore 冲突策略:payload 支持 {cpId, baseGen?, force?}。baseGen 缺省时
285
+ /** restore 冲突策略:payload 支持 {cpId, baseGen?, force?}。baseGen 缺省时
284
286
  * 从 checkpoint 自身记录的 gen 推导(checkpoint 创建时已存 {h, gen})。非 force 时在
285
287
  * appendSync 同步块内做冲突检查(见 checkRestoreConflict);force:true 全部跳过。
286
288
  * turn 执法(checkTurn)不受 force 影响——agent 场景仍必须在活跃 turn 内。 */
@@ -305,7 +307,8 @@ export async function opRestore(core: FsCore, { cpId, baseGen, force = false, ac
305
307
  core.mirror = next
306
308
  core.memGen = core.walGen
307
309
  core.ackGen = gen
308
- if (opId) core.rememberOpId(opId, { gen })
310
+ // 缓存与 respond 同形(restore rev、带 restored)——见 OpIdResult。
311
+ if (opId) core.rememberOpId(opId, { gen, restored: Object.keys(files).length })
309
312
  core.pushFullToQuery()
310
313
  respond({ ok: true, result: { gen, restored: Object.keys(files).length } })
311
314
  core.event({ evt: 'fs-change', gen, actor, count: Object.keys(files).length, restore: cpId })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * fs-core worker — ProjectFS 单写者权威(P0,同 origin 形态)。
2
+ * fs-core worker — ProjectFS 单写者权威(同 origin 形态)。
3
3
  *
4
4
  * 持久层(OPFS,无物化文件树):
5
5
  * <projectId>/blobs/<h2>/<sha256> 内容寻址、不可变、写后 flush
@@ -19,10 +19,11 @@
19
19
  import * as recovery from './fs-core-recovery.js'
20
20
  import * as writeOps from './fs-core-write-ops.js'
21
21
  import type { WalRecord } from './worker-lib/wal-codec.js'
22
- import { rpcErr, type MirrorEntry, type Respond, type TurnState, type WindowOp, type WorkerError } from './worker-lib/engine-shared.js'
22
+ import { rpcErr, type MirrorEntry, type OpIdResult, type Respond, type TurnState, type WindowOp, type WorkerError } from './worker-lib/engine-shared.js'
23
23
  import type {
24
24
  CheckpointArgs, DiffArgs, EditArgs, MvArgs, ReadArgs, RestoreArgs, RmArgs, TurnBeginArgs, TurnEndArgs, WriteArgs,
25
25
  } from './worker-lib/rpc-types.js'
26
+ import type { CoreWireMessage, FsCoreMode } from './worker-lib/protocol.js'
26
27
 
27
28
  /** FileSystemFileHandle.move() postdates this TS lib version's ambient types
28
29
  * (feature-detected at the call site via `if (fh.move) …`, matching the
@@ -35,10 +36,10 @@ declare global {
35
36
 
36
37
  // ───────────────────────── core 主体 ─────────────────────────
37
38
  export class FsCore {
38
- mode: 'starting' | 'writer' | 'readonly' | 'draining' | 'dead' = 'starting'
39
+ mode: FsCoreMode = 'starting'
39
40
  mirror = new Map<string, MirrorEntry>()
40
41
  checkpoints = new Map<string, { h: string; gen: number }>()
41
- opIds = new Map<string, { gen: number }>()
42
+ opIds = new Map<string, OpIdResult>()
42
43
  appendedGen = 0
43
44
  walGen = 0
44
45
  memGen = 0
@@ -59,7 +60,7 @@ export class FsCore {
59
60
  currentSlot = 0
60
61
  turn: TurnState | null = null // {turnId, cpId, expiresAt, ops} —— 内存态:worker 重启即失效(安全默认)
61
62
  auditLog: Array<{ gen: number; opcode: number; actor?: string; turnId?: string; path?: string; from?: string; to?: string; cpId?: string }> = [] // 环形
62
- // W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):只有内核持有的
63
+ // 纵深加固令牌门:只有内核持有的
63
64
  // 随机令牌,一次性置位(armAgentToken)。null = 未 arm(门不生效,checkTurn 不额外校验)——
64
65
  // 保证不起内核的裸 fs 场景(fs 域单测/工具,如 test:fs-smoke/test:fs-wal 直连 client)零回归。
65
66
  // 同 worker 重启即失效(内存态,不落 WAL/持久层),与 this.turn 同一安全默认。
@@ -71,6 +72,15 @@ export class FsCore {
71
72
  lastSegStart!: number
72
73
  lastSegValidEnd!: number
73
74
  manifestCrc!: number
75
+ // 写者锁排队中(granted/仲裁失败时复位)——requestHandover 据此判断是否需要重新排队
76
+ writerLockQueued = false
77
+ // 写者锁已持有(granted 一刻置位;排干释放/升级失败清理时复位)。granted 与
78
+ // mode='writer' 之间有异步窗口(recover/开句柄)——requestHandover 据此避免
79
+ // 在升级在途时再排一个陈旧锁请求
80
+ writerLockHeld = false
81
+ // 交接请求合并标志:一个 pending 周期内重复 requestHandover 不追加锁请求/不重复广播;
82
+ // becomeWriter(自己赢了)或 handover-done 广播(别人赢了)复位
83
+ handoverRequested = false
74
84
 
75
85
  // ── 启动/恢复/只读查询(fs-core-recovery.ts) ──
76
86
  start(projectId: string): Promise<void> { return recovery.start(this, projectId) }
@@ -86,9 +96,10 @@ export class FsCore {
86
96
  opStatus() { return recovery.opStatus(this) }
87
97
  pushDiff(diff: Record<string, MirrorEntry | null>, gen: number): void { recovery.pushDiff(this, diff, gen) }
88
98
  pushFullToQuery(): void { recovery.pushFullToQuery(this) }
89
- event(e: Record<string, unknown>): void { recovery.event(this, e) }
99
+ event(e: CoreWireMessage): void { recovery.event(this, e) }
90
100
  welcome(): void { recovery.welcome(this) }
91
101
  onBroadcast(msg: { type?: string }): Promise<void> { return recovery.onBroadcast(this, msg) }
102
+ requestHandover(): { requested?: true; mode?: FsCoreMode } { return recovery.requestHandover(this) }
92
103
 
93
104
  // ── 写路径/compaction(fs-core-write-ops.ts) ──
94
105
  enqueue<T>(fn: () => T | Promise<T>): Promise<T> { return writeOps.enqueue(this, fn) }
@@ -102,7 +113,7 @@ export class FsCore {
102
113
  rotateIfNeeded(): Promise<void> { return writeOps.rotateIfNeeded(this) }
103
114
  newSegment(startGen: number): Promise<void> { return writeOps.newSegment(this, startGen) }
104
115
  flushWindow(): void { writeOps.flushWindow(this) }
105
- rememberOpId(opId: string, v: { gen: number }): void { writeOps.rememberOpId(this, opId, v) }
116
+ rememberOpId(opId: string, v: OpIdResult): void { writeOps.rememberOpId(this, opId, v) }
106
117
  scheduleFlush(immediate?: boolean): void { writeOps.scheduleFlush(this, immediate) }
107
118
  opWrite(args: WriteArgs, respond: Respond): Promise<void> { return writeOps.opWrite(this, args, respond) }
108
119
  opEdit(args: EditArgs, respond: Respond): Promise<void> { return writeOps.opEdit(this, args, respond) }
@@ -121,10 +132,11 @@ export class FsCore {
121
132
  handleRpc(msg: { id: number; opId?: string; args?: Record<string, unknown>; op: string }): void {
122
133
  const respond: Respond = (r) => this.clientPort!.postMessage({ id: msg.id, ...r })
123
134
  const fail = (e: WorkerError) => respond({ ok: false, code: e.code || 'internal', error: e.message || String(e), ...(e.extra || {}) })
124
- // opId 幂等:已知 opId 直接返回既有结果
135
+ // opId 幂等:已知 opId 原样重放缓存的完整结果(见 OpIdResult)。rev 只在
136
+ // 缓存没有时兜底为 gen(WAL 回放重建的跨会话条目只有 {gen})。
125
137
  if (msg.opId && this.opIds.has(msg.opId)) {
126
138
  const known = this.opIds.get(msg.opId)!
127
- respond({ ok: true, result: { ...known, rev: known.gen, idempotent: true } })
139
+ respond({ ok: true, result: { rev: known.gen, ...known, idempotent: true } })
128
140
  return
129
141
  }
130
142
  const a: Record<string, unknown> = { ...msg.args, opId: msg.opId }
@@ -148,9 +160,12 @@ export class FsCore {
148
160
  read: () => respond({ ok: true, result: this.opRead(a as unknown as ReadArgs) }),
149
161
  ls: () => respond({ ok: true, result: this.opLs() }),
150
162
  status: () => respond({ ok: true, result: this.opStatus() }),
151
- // W4 令牌门铸造(§6 替代方案 A / §8.3):纯内存状态置位,无 I/O、不让出,
163
+ // 令牌门铸造:纯内存状态置位,无 I/O、不让出,
152
164
  // 不入 WAL 序列化链——同步分支即可(同 read/ls/status),不打扰组提交/恢复逻辑。
153
165
  armAgentTokenGate: () => respond({ ok: true, result: this.armAgentToken(a.token) }),
166
+ // 协作交接发起(readonly 端):广播 + 必要时重新排队锁,纯内存/信道操作,
167
+ // 不碰 WAL——同步分支(升级本身走 becomeWriter 的 enqueue 路径)。
168
+ requestHandover: () => respond({ ok: true, result: this.requestHandover() }),
154
169
  }
155
170
  const fn = table[msg.op]
156
171
  if (!fn) { fail(rpcErr('bad-op', String(msg.op))); return }
@@ -167,7 +182,7 @@ const core = new FsCore()
167
182
  self.onmessage = async (e: MessageEvent) => {
168
183
  const msg = e.data
169
184
  if (msg.type === 'HELLO') {
170
- core.clientPort = self // 单客户端 P0:直接用 worker 主端口
185
+ core.clientPort = self // 单客户端形态:直接用 worker 主端口
171
186
  core.queryPort = msg.queryPort || null
172
187
  try {
173
188
  await core.start(msg.projectId)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Guards the worker-artifact distribution contract helper: the file-name
3
+ * list matches what build-workers.js actually emits, and path resolution
4
+ * follows the "literal siblings of client.js" rule on both separators.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { describe, expect, it } from 'vitest'
9
+ import { FS_CORE_WORKER_FILES, resolveWorkerFiles } from './worker-files.js'
10
+
11
+ describe('FS_CORE_WORKER_FILES', () => {
12
+ it('matches the outfiles build-workers.js emits', () => {
13
+ const buildScript = readFileSync(join(__dirname, '..', 'build-workers.js'), 'utf8')
14
+ for (const name of FS_CORE_WORKER_FILES) {
15
+ expect(buildScript).toContain(`dist/${name}`)
16
+ }
17
+ })
18
+ })
19
+
20
+ describe('resolveWorkerFiles', () => {
21
+ it('resolves worker paths as siblings of client.js (POSIX)', () => {
22
+ const r = resolveWorkerFiles('/repo/node_modules/@dimina-kit/fs-core/dist/client.js')
23
+ expect(r.dir).toBe('/repo/node_modules/@dimina-kit/fs-core/dist')
24
+ expect(r.files).toEqual([
25
+ '/repo/node_modules/@dimina-kit/fs-core/dist/fs-core.worker.js',
26
+ '/repo/node_modules/@dimina-kit/fs-core/dist/fs-query.worker.js',
27
+ ])
28
+ })
29
+
30
+ it('preserves Windows separators', () => {
31
+ const r = resolveWorkerFiles('C:\\proj\\node_modules\\@dimina-kit\\fs-core\\dist\\client.js')
32
+ expect(r.dir).toBe('C:\\proj\\node_modules\\@dimina-kit\\fs-core\\dist')
33
+ expect(r.files[0]).toBe('C:\\proj\\node_modules\\@dimina-kit\\fs-core\\dist\\fs-core.worker.js')
34
+ })
35
+
36
+ it('rejects a separator-less input', () => {
37
+ expect(() => resolveWorkerFiles('client.js')).toThrow(/not a path/)
38
+ })
39
+ })