@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.
- package/README.md +205 -18
- package/dist/client.js +23 -11
- package/dist/disk-mirror.js +2 -2
- package/dist/fs-core.worker.js +101 -21
- package/dist/sync/binary-sidecar.js +147 -0
- package/dist/sync/sync-engine.js +119 -169
- package/dist/sync/watch-expander.js +202 -0
- package/dist/worker-files.cjs +32 -0
- package/dist/worker-files.js +35 -0
- package/dist/worker-lib/.tsbuildinfo +1 -0
- package/dist/worker-lib/engine-shared.d.ts +79 -0
- package/dist/worker-lib/engine-shared.d.ts.map +1 -0
- package/dist/worker-lib/engine-shared.js +6 -3
- package/dist/worker-lib/paths.d.ts +4 -0
- package/dist/worker-lib/paths.d.ts.map +1 -0
- package/dist/worker-lib/protocol.d.ts +119 -0
- package/dist/worker-lib/protocol.d.ts.map +1 -0
- package/dist/worker-lib/protocol.js +73 -0
- package/dist/worker-lib/rpc-types.d.ts +68 -0
- package/dist/worker-lib/rpc-types.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.d.ts +58 -0
- package/dist/worker-lib/wal-codec.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.js +8 -5
- package/dist/zip.js +1 -1
- package/package.json +25 -2
- package/src/__checks__/types-smoke.ts +61 -0
- package/src/agent-tools.ts +3 -3
- package/src/client-retry.test.ts +76 -0
- package/src/client.ts +112 -45
- package/src/disk-mirror.ts +2 -2
- package/src/fs-core-handover.test.ts +215 -0
- package/src/fs-core-opid-replay.test.ts +158 -0
- package/src/fs-core-recovery-lock.test.ts +225 -0
- package/src/fs-core-recovery.ts +115 -13
- package/src/fs-core-write-ops.ts +14 -11
- package/src/fs-core.worker.ts +26 -11
- package/src/worker-files.test.ts +39 -0
- package/src/worker-files.ts +43 -0
- package/src/worker-lib/engine-shared.ts +19 -5
- package/src/worker-lib/protocol.test.ts +37 -0
- package/src/worker-lib/protocol.ts +184 -0
- package/src/worker-lib/wal-codec.ts +8 -5
- package/src/zip.ts +1 -1
- package/sync/binary-sidecar.test.ts +150 -0
- package/sync/binary-sidecar.ts +187 -0
- package/sync/sync-engine-degraded.test.ts +309 -0
- package/sync/sync-engine.test.ts +20 -91
- package/sync/sync-engine.ts +134 -181
- package/sync/truth-port.ts +3 -3
- package/sync/watch-expander.test.ts +96 -0
- 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
|
+
})
|
package/src/fs-core-recovery.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } //
|
|
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:
|
|
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
|
})
|
package/src/fs-core-write-ops.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
// ──
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 })
|
package/src/fs-core.worker.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fs-core worker — ProjectFS
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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: {
|
|
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
|
-
//
|
|
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 //
|
|
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
|
+
})
|