From ac5c3b042fd9156422e5c3d91c974010e174c4c0 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 01:29:01 +0800 Subject: [PATCH 01/23] fix(desktop): keep cached Library sessions from mkdir empty custom roots Re-resolve the live binding on each request so a missing custom root stays unavailable instead of recreating an empty library. Same-disk restore continues through existing open/status; a rebuilt path remains binding-moved. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 68 +++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 27 +++++++- docs/dev-rules/plugin-library-storage.md | 5 +- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 70d1873205a..73e59633381 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -364,6 +364,74 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'a.txt'))).toBe(false); }); + it('cached custom session: missing root stays unavailable without mkdir; same disk recovers; recreated path is binding-moved', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + expect(open.location).toBe('custom'); + const live = open as unknown as { libraryGeneration: number; libraryIdentity: string }; + expect(live.libraryGeneration).toBe(1); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + + const parked = `${candidate}.parked`; + await fs.promises.rename(candidate, parked); + expect(fs.existsSync(customRoot)).toBe(false); + + const missingOpen = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!missingOpen.ok || missingOpen.op !== 'open') throw new Error(JSON.stringify(missingOpen)); + expect(missingOpen.state).toBe('unavailable'); + expect(missingOpen.reason).toBe('disk-missing'); + expect(missingOpen.location).toBe('custom'); + const missingStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!missingStatus.ok || missingStatus.op !== 'status') throw new Error(JSON.stringify(missingStatus)); + expect(missingStatus.state).toBe('unavailable'); + expect(missingStatus.reason).toBe('disk-missing'); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'empty.txt'))).toBe(false); + expect((missingOpen as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); + expect((missingOpen as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); + + await fs.promises.rename(parked, candidate); + const recovered = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!recovered.ok || recovered.op !== 'open') throw new Error(JSON.stringify(recovered)); + expect(recovered.state).toBe('ready'); + expect(recovered.reason).toBeUndefined(); + expect(recovered.location).toBe('custom'); + expect((recovered as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); + expect((recovered as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); + const recoveredStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!recoveredStatus.ok || recoveredStatus.op !== 'status') throw new Error(JSON.stringify(recoveredStatus)); + expect(recoveredStatus.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + const retryWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'retry.txt', content: 'after-restore' }); + expect(retryWrite.ok).toBe(true); + expect(fs.existsSync(path.join(customRoot, 'retry.txt'))).toBe(true); + + if (process.platform === 'win32') return; + await fs.promises.rm(candidate, { recursive: true }); + await fs.promises.mkdir(candidate, { recursive: true }); + const moved = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!moved.ok || moved.op !== 'open') throw new Error(JSON.stringify(moved)); + expect(moved.state).toBe('unavailable'); + expect(moved.reason).toBe('binding-moved'); + expect(moved.location).toBe('custom'); + const movedWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'wrong-root.txt', content: 'nope' }); + expect(movedWrite).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'wrong-root.txt'))).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 3fc4e4db3ef..74481a39c7a 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -314,8 +314,12 @@ export class GhostLibrarySlot { await this.teardownSession(ghostId); session = undefined; } + const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); + if (session && !this.sessionMatchesResolution(session, resolution)) { + await this.teardownSession(ghostId); + session = undefined; + } if (!session) { - const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); session = this.createSession(ghostId, resolution, scopeKey); this.sessions.set(ghostId, session); // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 @@ -363,6 +367,22 @@ export class GhostLibrarySlot { } } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ + private sessionMatchesResolution( + session: GhostLibrarySession, + resolution: LibraryLocationResolution, + ): boolean { + const drift = 'drift' in resolution && resolution.root === null ? resolution.drift : null; + if (session.drift !== drift || session.locationKind !== resolution.kind) return false; + const record = 'record' in resolution ? resolution.record : undefined; + if (session.generation !== (record?.generation ?? 0)) return false; + if (drift !== null) return true; + const root = resolution.kind === 'custom' && resolution.root !== null + ? resolution.root + : this.deps.getDefaultRoot(session.ghostId); + return session.vault.getRootDir() === root; + } + private createSession( ghostId: string, resolution: LibraryLocationResolution, @@ -387,11 +407,14 @@ export class GhostLibrarySlot { }); const record = 'record' in resolution ? resolution.record : undefined; const generation = record?.generation ?? 0; + const identityRoot = drift !== null && record + ? path.join(record.realPathAtGrant, ghostId) + : root; const identity = mintLibraryEpochIdentity({ ghostId, ownerScopeKey: scopeKey, generation, - rootDir: root, + rootDir: identityRoot, grantedAt: record?.grantedAt ?? 0, }); return { diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index f511ebf66cb..404a875fb75 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -45,7 +45,10 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 1. **不可用 ≠ 空**:meta 损坏 → `unavailable(corrupt)`;binding 漂移 → `binding-moved` / `disk-missing`。宿主不自动重建、不清空、不回退写默认根、 - 不触发 GC、不判素材已删。插件侧同样语义写进了 FORGE_GUIDE。 + 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 + binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库;同一磁盘 + 对象归位后既有 `open`/`status` 恢复,删后重建的同路径是 `binding-moved` + 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From dd7f150689b30a8cebb3cc60fc12e7dcd05c1628 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 01:35:13 +0800 Subject: [PATCH 02/23] fix(desktop): do not mint unavailable Library identity from default root Unavailable cached custom sessions are not required to keep the live identity. Restore still uses the original disk object; a replaced directory stays binding-moved. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/__tests__/librarySlot.test.ts | 5 ++--- apps/desktop/src/main/cindy-brain/librarySlot.ts | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 73e59633381..26a3cb2391d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -397,8 +397,6 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(candidate)).toBe(false); expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'empty.txt'))).toBe(false); - expect((missingOpen as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); - expect((missingOpen as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); await fs.promises.rename(parked, candidate); const recovered = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -419,7 +417,8 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(customRoot, 'retry.txt'))).toBe(true); if (process.platform === 'win32') return; - await fs.promises.rm(candidate, { recursive: true }); + const replaced = `${candidate}.replaced`; + await fs.promises.rename(candidate, replaced); await fs.promises.mkdir(candidate, { recursive: true }); const moved = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!moved.ok || moved.op !== 'open') throw new Error(JSON.stringify(moved)); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 74481a39c7a..99cdcf1bfcf 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -407,14 +407,11 @@ export class GhostLibrarySlot { }); const record = 'record' in resolution ? resolution.record : undefined; const generation = record?.generation ?? 0; - const identityRoot = drift !== null && record - ? path.join(record.realPathAtGrant, ghostId) - : root; const identity = mintLibraryEpochIdentity({ ghostId, ownerScopeKey: scopeKey, generation, - rootDir: identityRoot, + rootDir: root, grantedAt: record?.grantedAt ?? 0, }); return { From af12a818395275c2ade63b386a9573e0afa4a596 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 03:42:13 +0800 Subject: [PATCH 03/23] feat(desktop): add owner-scoped Library staging adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host H1 is a thin LibraryVault adapter for plugin upload staging. It keeps owner×ghost isolation, hard quota, tombstones, and ACK-gated release without a second media library, new UI, or TCC path. Quota counts durable, orphan blobs, closed tmp, and in-memory uploads without overlap. abort of a commitPending blob is STREAM_INVALID so the landed original stays reserved until ACK. disposeAll/bind/unbind/relocate/delete drain in-flight release behind relocating. First-minted staging roots also fsync parent directory entries; Windows still reports fsynced:false. Signed-off-by: PraiseZhu --- .../main/cindy-brain/__tests__/forge.test.ts | 2 +- .../cindy-brain/__tests__/librarySlot.test.ts | 336 ++++++- .../__tests__/libraryStaging.test.ts | 834 ++++++++++++++++ .../__tests__/libraryVault.test.ts | 78 +- apps/desktop/src/main/cindy-brain/forge.ts | 18 +- apps/desktop/src/main/cindy-brain/index.ts | 72 +- .../src/main/cindy-brain/librarySlot.ts | 307 +++++- .../src/main/cindy-brain/libraryStaging.ts | 889 ++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 191 +++- apps/desktop/src/shared/ghost.ts | 90 +- docs/dev-rules/plugin-library-storage.md | 11 +- 11 files changed, 2788 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts create mode 100644 apps/desktop/src/main/cindy-brain/libraryStaging.ts diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index e4e0fd31ca7..44657f92e6a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -1704,7 +1704,7 @@ describe('FORGE_GUIDE', () => { it('documents library capabilities as a sessionless support list with stable failure reasons', () => { expect(FORGE_GUIDE).toContain("op: 'capabilities'"); - expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs']"); + expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs','staging.begin'"); expect(FORGE_GUIDE).toContain('不等于此刻有窗口 / 已授权 / 库可用'); expect(FORGE_GUIDE).toContain('全部字符串'); expect(FORGE_GUIDE).toContain('数组内混入'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 26a3cb2391d..0de1594ce34 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -25,6 +25,7 @@ import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryD import { LibrarySqlService } from '../librarySqlService.js'; import { classifyGhostLibraryOperationSupport, + GHOST_LIBRARY_CAPABILITIES_V1, type InstalledGhost, } from '../../../shared/ghost.js'; @@ -110,6 +111,7 @@ describe('GhostLibrarySlot', () => { showSaveDialog: (...args: unknown[]) => showSaveDialog(...args), writeClipboardPng: (...args: unknown[]) => writeClipboardPng(...args), syncAgentReadonlyExtraDir: (...args: unknown[]) => syncAgentReadonlyExtraDir(...args), + getStagingRoot: (id) => path.join(tmp, 'library-staging', id), now: () => clock, }; showItemInFolder = vi.fn(); @@ -164,7 +166,11 @@ describe('GhostLibrarySlot', () => { expect(r).toEqual({ ok: true, op: 'capabilities', - capabilities: { version: 1, operations: ['clipboardWrite', 'saveAs'] }, + capabilities: { + version: 1, + operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, + }, }); expect(classifyGhostLibraryOperationSupport(r, 'clipboardWrite')).toBe('supported'); expect(classifyGhostLibraryOperationSupport(r, 'saveAs')).toBe('supported'); @@ -358,7 +364,11 @@ describe('GhostLibrarySlot', () => { expect(capsWhileUnavailable).toEqual({ ok: true, op: 'capabilities', - capabilities: { version: 1, operations: ['clipboardWrite', 'saveAs'] }, + capabilities: { + version: 1, + operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, + }, }); // 绝不落默认根冒充。 expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'a.txt'))).toBe(false); @@ -1314,4 +1324,326 @@ describe('GhostLibrarySlot', () => { expect(resolveLibraryAssetPath(rootA, path.join(rootA, rel))).toBeNull(); expect(resolveLibraryAssetPath('relative-root', ref)).toBeNull(); }); + + it('staging 在 Library 根不可用时仍可 commit;release 只认当前 Library ACK', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const recovery = { sceneId: 's1', nodeId: 'n1' }; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', + taskId: 'task-1', + sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), + sha256: sha, + mime: 'image/png', + recovery, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + expect(resolveLibraryRoot).not.toHaveBeenCalled(); + const chunk = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + expect(chunk).toMatchObject({ ok: true, op: 'staging.chunk', accepted: Buffer.byteLength(body) }); + const commit = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + expect(commit).toMatchObject({ + ok: true, op: 'staging.commit', stagingId: begin.stagingId, taskId: 'task-1', + sourceRevision: 'rev-1', sha256: sha, bytes: Buffer.byteLength(body), mime: 'image/png', durable: true, + }); + const listed = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!listed.ok || listed.op !== 'staging.list') throw new Error(JSON.stringify(listed)); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]?.recovery).toEqual(recovery); + + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + await fs.promises.rename(candidate, `${candidate}.parked`); + const missingRelease = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: '0'.repeat(64), + libraryGeneration: 1, + }); + expect(missingRelease).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + await fs.promises.rename(`${candidate}.parked`, candidate); + + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId: begin.stagingId, released: true }); + const listedAfter = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!listedAfter.ok || listedAfter.op !== 'staging.list') throw new Error(JSON.stringify(listedAfter)); + expect(listedAfter.items).toEqual([]); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(false); + }); + + it('staging.release 在 hash 期间 relocate/dispose 后不得删原件', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-relocate', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const orig = LibraryVault.prototype.hashFile; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + slot.setRelocating(GHOST_ID, true); + return orig.call(this, relPath); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + slot.setRelocating(GHOST_ID, false); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(begin.stagingId); + }); + + it('staging.release 路径形状对但 hash 前缀不符则 ACK_MISMATCH', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const wrong = `assets/aa/${'a'.repeat(64)}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-path', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: wrong, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: wrong, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: (open as { libraryIdentity?: string }).libraryIdentity, + libraryGeneration: (open as { libraryGeneration?: number }).libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + }); + + async function commitAndArchive(taskId: string, payload = 'pixel-bytes') { + const sha = createHash('sha256').update(payload).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId, sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(payload), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + return { stagingId: begin.stagingId, sha, rel, archived, payload }; + } + + it('staging.release ACK 后 tombstone 写入前切根则保留原件', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-ack-then-root'); + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + const sessions = (slot as unknown as { sessions: Map }).sessions; + const live = sessions.get(GHOST_ID); + if (live) { + live.identity = 'f'.repeat(64); + live.generation = live.generation + 1; + } + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', stagingId, 'blob.bin'))).toBe(true); + }); + + it('owner lease 覆盖 ACK await,切账号后不得删原件', async () => { + let leaseHeld = false; + let leaseReleased = false; + await slot.disposeAll(); + slot = new GhostLibrarySlot({ + getGhost: (id) => ghosts.get(id) ?? null, + bindingStore, + getDefaultRoot: (id) => path.join(defaultRootBase, id), + captureOwnerScope: () => captureOwnerScope(), + createVault: (d) => createVault(d), + createSqlService: (d) => createSqlService(d), + getDiskFreeBytes: async () => 1024 ** 4, + workerScriptPath: () => path.join(tmp, 'unused-worker.js'), + betterSqliteModulePath: () => 'better-sqlite3', + showItemInFolder: (...args: unknown[]) => showItemInFolder(...args), + showSaveDialog: (...args: unknown[]) => showSaveDialog(...args), + writeClipboardPng: (...args: unknown[]) => writeClipboardPng(...args), + syncAgentReadonlyExtraDir: (...args: unknown[]) => syncAgentReadonlyExtraDir(...args), + getStagingRoot: (id) => path.join(tmp, 'library-staging', id), + now: () => clock, + captureMutationOwner: () => ({ mode: 'local', dataOwnerId: 'a', generation: 1 }), + beginMutation: () => { + leaseHeld = true; + return () => { leaseReleased = true; leaseHeld = false; }; + }, + }); + const { stagingId, sha, rel, archived } = await commitAndArchive('task-lease-switch'); + expect(leaseHeld).toBe(false); + const orig = LibraryVault.prototype.hashFile; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + expect(leaseHeld).toBe(true); + if (relPath.startsWith('assets/')) scopeKey = 'local:owner-b:1'; + return orig.call(this, relPath); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + expect(leaseReleased).toBe(true); + } finally { + spy.mockRestore(); + scopeKey = 'local:owner-a:1'; + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + }); + + it('disposeGhost 等待在途 release 完成后再切会话', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-drain'); + let disposeDone = false; + let disposeDuringWrite = false; + let pendingDispose: Promise | undefined; + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + pendingDispose = slot.disposeGhost(GHOST_ID).then(() => { disposeDone = true; }); + await new Promise((resolve) => setTimeout(resolve, 20)); + disposeDuringWrite = disposeDone; + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId, released: true }); + expect(disposeDuringWrite).toBe(false); + await pendingDispose; + expect(disposeDone).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('disposeAll 在 tombstone 窗口先置 relocating 并排空,新 release 不得进入', async () => { + const { stagingId, sha, rel, archived } = await commitAndArchive('task-dispose-all'); + let disposeAllDuringWrite = false; + let pendingDispose: Promise | undefined; + const origWrite = LibraryVault.prototype.write; + const spy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('tombstone.json')) { + pendingDispose = slot.disposeAll(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const racing = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + disposeAllDuringWrite = racing.ok === false && (racing as { errorCode?: string }).errorCode === 'ACK_MISMATCH'; + } + return origWrite.call(this, req); + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength('pixel-bytes'), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toEqual({ ok: true, op: 'staging.release', stagingId, released: true }); + expect(disposeAllDuringWrite).toBe(true); + await pendingDispose; + } finally { + spy.mockRestore(); + } + }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts new file mode 100644 index 00000000000..5ed9be6286f --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -0,0 +1,834 @@ +/** + * Host H1 LibraryStagingStore 故障恢复单测。 + * 每条用例执行标题声称的完整序列(失败 → 保留原件 → 去掉故障 → 同实例/新实例恢复), + * 禁止只断言初次失败。tmpdir 合成数据,零 Electron,不读真实 profile/Library。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; + +import { LibraryStagingStore, DEFAULT_LIBRARY_STAGING_LIMITS } from '../libraryStaging.js'; +import { LibraryVault, DEFAULT_LIBRARY_LIMITS } from '../libraryVault.js'; + +const sha256Of = (s: string | Buffer): string => createHash('sha256').update(s).digest('hex'); +const HEX_A = 'a'.repeat(64); +const DEFAULT_READ_CHUNK = DEFAULT_LIBRARY_STAGING_LIMITS.maxChunkBytes; + +describe('LibraryStagingStore 故障恢复', () => { + let tmp: string; + let scope: string | null = 'local:owner-a:1'; + const ghostId = 'mivo-canvas'; + const body = 'pixel-bytes'; + const sha = sha256Of(body); + const recovery = { sceneId: 's1', nodeId: 'n1' }; + + const makeStore = ( + root = path.join(tmp, 'library-staging', ghostId), + extra: { + maxTotalBytes?: number; + maxConcurrentWrites?: number; + maxChunkBytes?: number; + listPageSize?: number; + } = {}, + ): LibraryStagingStore => + new LibraryStagingStore({ + rootDir: root, + ownerScopeKey: 'local:owner-a:1', + ghostId, + captureOwnerScope: () => scope, + createVault: (deps) => new LibraryVault({ + ...deps, + limits: { + ...deps.limits, + ...(extra.listPageSize !== undefined ? { listPageSize: extra.listPageSize } : {}), + }, + }), + getDiskFreeBytes: async () => 1024 ** 4, + limits: { + maxTotalBytes: extra.maxTotalBytes ?? 64, + maxConcurrentWrites: extra.maxConcurrentWrites ?? 2, + reserveBytes: 1, + ...(extra.maxChunkBytes !== undefined ? { maxChunkBytes: extra.maxChunkBytes } : {}), + }, + }); + + beforeEach(async () => { + tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-library-staging-')); + scope = 'local:owner-a:1'; + }); + afterEach(async () => { + vi.restoreAllMocks(); + await fs.promises.rm(tmp, { recursive: true, force: true }); + }); + + async function beginChunk( + store: LibraryStagingStore, + taskId: string, + payload: string | Buffer, + sourceRevision = 'rev-1', + ) { + const buf = typeof payload === 'string' ? Buffer.from(payload) : payload; + const digest = sha256Of(buf); + const begin = await store.begin({ + ghostId, taskId, sourceRevision, + totalBytes: buf.byteLength, sha256: digest, mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(`begin ${taskId}: ${JSON.stringify(begin)}`); + const chunk = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: buf.toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(`chunk ${taskId}: ${JSON.stringify(chunk)}`); + return { stagingId: begin.stagingId, digest, bytes: buf.byteLength }; + } + + async function commitOne( + store = makeStore(), + taskId = 'task-1', + payload: string | Buffer = body, + sourceRevision = 'rev-1', + ) { + const started = await beginChunk(store, taskId, payload, sourceRevision); + const commit = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!commit.ok) throw new Error(`commit ${taskId}: ${JSON.stringify(commit)}`); + return { store, stagingId: started.stagingId, commit, digest: started.digest }; + } + + function blobAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'blob.bin'); + } + function manifestAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'manifest.json'); + } + function tombstoneAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'tombstone.json'); + } + function matchingAck(commit: { sha256: string; bytes: number }) { + return { + ok: true as const, + path: `assets/${commit.sha256.slice(0, 2)}/${commit.sha256}/blob.png`, + sha256: commit.sha256, + bytes: commit.bytes, + libraryIdentity: HEX_A, + libraryGeneration: 0, + }; + } + + async function listAllPublic(store: LibraryStagingStore, limit = 2) { + const items: Array<{ stagingId: string; bytes: number; taskId: string }> = []; + let cursor: string | undefined; + for (;;) { + const page = await store.list({ ghostId, cursor, limit }); + if (!page.ok) throw new Error(`list: ${JSON.stringify(page)}`); + items.push(...page.items.map((item) => ({ + stagingId: item.stagingId, bytes: item.bytes, taskId: item.taskId, + }))); + if (!page.hasMore) { + expect(page.nextCursor).toBeNull(); + return { items, last: page }; + } + expect(typeof page.nextCursor).toBe('string'); + cursor = page.nextCursor ?? undefined; + } + } + + it('commit 后新实例 list/read 可恢复;未提交流不出现', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const live = makeStore(root); + const uploading = await live.begin({ + ghostId, taskId: 'partial', sourceRevision: 'r', + totalBytes: 8, sha256: '0'.repeat(64), mime: 'image/png', recovery, + }); + if (!uploading.ok) throw new Error(JSON.stringify(uploading)); + const { stagingId } = await commitOne(live); + const restored = makeStore(root); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + expect(listed.items[0]?.durable).toBe(true); + expect(listed.items[0]?.recovery).toEqual(recovery); + const read = await restored.read({ ghostId, stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + }); + + it('坏 manifest:list 失败非空,原件保留;修复后同一实例 list/read 成功且无重复', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const { stagingId: firstId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'task-a', 'alpha'); + const { stagingId: secondId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'task-b', 'bravo'); + const originalManifest = await fs.promises.readFile(manifestAbs(root, secondId), 'utf8'); + await fs.promises.writeFile(manifestAbs(root, secondId), '{not json'); + + const same = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await same.list({ ghostId }); + expect(listed).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, firstId))).toBe(true); + expect(fs.existsSync(blobAbs(root, secondId))).toBe(true); + const blocked = await same.begin({ + ghostId, taskId: 'task-x', sourceRevision: 'rev-x', + totalBytes: 1, sha256: '0'.repeat(64), mime: 'image/png', recovery, + }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(blocked.ok).toBe(false); + + await fs.promises.writeFile(manifestAbs(root, secondId), originalManifest); + const recovered = await same.list({ ghostId }); + if (!recovered.ok) throw new Error(`same-instance recover: ${JSON.stringify(recovered)}`); + const ids = recovered.items.map((item) => item.stagingId).sort(); + expect(ids).toEqual([firstId, secondId].sort()); + expect(new Set(ids).size).toBe(2); + const readFirst = await same.read({ ghostId, stagingId: firstId }); + const readSecond = await same.read({ ghostId, stagingId: secondId }); + if (!readFirst.ok || !readSecond.ok) throw new Error('same-instance read failed'); + expect(Buffer.from(readFirst.content, 'base64').toString('utf8')).toBe('alpha'); + expect(Buffer.from(readSecond.content, 'base64').toString('utf8')).toBe('bravo'); + }); + + it('不可读 manifest(目录占位)同样 LIBRARY_UNAVAILABLE,修复后同一实例恢复', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const { stagingId } = await commitOne(makeStore(root)); + const original = await fs.promises.readFile(manifestAbs(root, stagingId), 'utf8'); + await fs.promises.rm(manifestAbs(root, stagingId)); + await fs.promises.mkdir(manifestAbs(root, stagingId)); + + const same = makeStore(root); + expect(await same.list({ ghostId })).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + + await fs.promises.rm(manifestAbs(root, stagingId), { recursive: true, force: true }); + await fs.promises.writeFile(manifestAbs(root, stagingId), original); + const listed = await same.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + const read = await same.read({ ghostId, stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + }); + + it('无 manifest 的 blob 计入额度且不列为 committed,不卡住同 task', async () => { + const root = path.join(tmp, 'library-staging', ghostId); + const orphanId = randomUUID(); + await fs.promises.mkdir(path.join(root, 'tasks', orphanId), { recursive: true }); + await fs.promises.writeFile(path.join(root, 'tasks', orphanId, 'blob.bin'), 'x'.repeat(60)); + const store = makeStore(root); + const begin = await store.begin({ + ghostId, taskId: 'task-1', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(begin).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + expect(fs.existsSync(path.join(root, 'tasks', orphanId, 'blob.bin'))).toBe(true); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items).toEqual([]); + }); + + it('活跃预留+关闭 unlink 失败残片+orphan+committed-pending+durable 合计占额,不得双计活跃 tmp', async () => { + const root = path.join(tmp, 'quota-mix', ghostId); + const orphanId = randomUUID(); + await fs.promises.mkdir(path.join(root, 'tasks', orphanId), { recursive: true }); + await fs.promises.writeFile(path.join(root, 'tasks', orphanId, 'blob.bin'), 'O'.repeat(10)); + const store = makeStore(root, { maxTotalBytes: 50, maxConcurrentWrites: 4 }); + + const durable = await commitOne(store, 'durable-task', 'D'.repeat(10)); + const small = await store.begin({ + ghostId, taskId: 'small-beside-orphan', sourceRevision: 'r', + totalBytes: 8, sha256: sha256Of('s'.repeat(8)), mime: 'image/png', recovery, + }); + if (!small.ok) throw new Error(`small begin beside orphan: ${JSON.stringify(small)}`); + const smallChunk = await store.chunk({ + ghostId, stagingId: small.stagingId, seq: 1, + content: Buffer.from('s'.repeat(8)).toString('base64'), encoding: 'base64', + }); + if (!smallChunk.ok) throw new Error(JSON.stringify(smallChunk)); + + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + const aborted = await store.abort({ ghostId, stagingId: small.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + unlinkSpy.mockRestore(); + const tmpDir = path.join(root, '.cindy-library', 'tmp'); + const leftover = (await fs.promises.readdir(tmpDir)).filter((name) => name !== '.' && name !== '..'); + expect(leftover.length).toBeGreaterThan(0); + const leftoverBytes = leftover.reduce((sum, name) => { + const st = fs.statSync(path.join(tmpDir, name)); + return sum + (st.isFile() ? st.size : 0); + }, 0); + expect(leftoverBytes).toBeGreaterThan(0); + + const active = await store.begin({ + ghostId, taskId: 'still-active', sourceRevision: 'r', + totalBytes: 5, sha256: sha256Of('A'.repeat(5)), mime: 'image/png', recovery, + }); + if (!active.ok) throw new Error(`active reservation: ${JSON.stringify(active)}`); + + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + let pendingId: string | undefined; + try { + const pending = await beginChunk(store, 'pending-task', 'P'.repeat(10)); + pendingId = pending.stagingId; + const failedCommit = await store.commit({ ghostId, stagingId: pending.stagingId }); + expect(failedCommit.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, pending.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, pending.stagingId))).toBe(false); + + // durable 10 + orphan 10 + closed residue >=8 + active 5 + pending 10 >= 43; +8 exceeds 50. + const extra = await store.begin({ + ghostId, taskId: 'extra-over-quota', sourceRevision: 'rev-1', + totalBytes: 8, sha256: sha256Of('E'.repeat(8)), mime: 'image/png', recovery, + }); + expect(extra).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + } finally { + writeSpy.mockRestore(); + } + + if (!pendingId) throw new Error('pending stagingId missing'); + const chunkSpy = vi.spyOn(LibraryVault.prototype, 'writeChunk'); + const beginSpy = vi.spyOn(LibraryVault.prototype, 'writeBegin'); + try { + const retried = await store.commit({ ghostId, stagingId: pendingId }); + if (!retried.ok) throw new Error(`retry commit after manifest fault removed: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + expect(retried.bytes).toBe(10); + expect(chunkSpy).not.toHaveBeenCalled(); + expect(beginSpy).not.toHaveBeenCalled(); + } finally { + chunkSpy.mockRestore(); + beginSpy.mockRestore(); + } + expect(fs.existsSync(manifestAbs(root, pendingId))).toBe(true); + expect(fs.existsSync(blobAbs(root, durable.stagingId))).toBe(true); + expect(fs.existsSync(path.join(root, 'tasks', orphanId, 'blob.bin'))).toBe(true); + }); + + it('abort 真正调用后关闭残片计入额度,不得与活跃预留重复计费', async () => { + const root = path.join(tmp, 'abort-residue', ghostId); + const store = makeStore(root, { maxTotalBytes: 20 }); + const begin = await store.begin({ + ghostId, taskId: 'abort-me', sourceRevision: 'r', + totalBytes: 12, sha256: sha256Of('z'.repeat(12)), mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(JSON.stringify(begin)); + const chunked = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: Buffer.from('z'.repeat(12)).toString('base64'), encoding: 'base64', + }); + if (!chunked.ok) throw new Error(JSON.stringify(chunked)); + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + const aborted = await store.abort({ ghostId, stagingId: begin.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + unlinkSpy.mockRestore(); + const tmpDir = path.join(root, '.cindy-library', 'tmp'); + const leftover = (await fs.promises.readdir(tmpDir)).filter((name) => { + const st = fs.statSync(path.join(tmpDir, name)); + return st.isFile() && st.size > 0; + }); + expect(leftover.length).toBeGreaterThan(0); + + const over = await store.begin({ + ghostId, taskId: 'task-over', sourceRevision: 'rev-1', + totalBytes: 9, sha256: sha256Of('n'.repeat(9)), mime: 'image/png', recovery, + }); + expect(over).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + + const exact = await store.begin({ + ghostId, taskId: 'task-exact', sourceRevision: 'rev-1', + totalBytes: 8, sha256: sha256Of('e'.repeat(8)), mime: 'image/png', recovery, + }); + if (!exact.ok) throw new Error(`no-double-count exact fill: ${JSON.stringify(exact)}`); + const abortExact = await store.abort({ ghostId, stagingId: exact.stagingId }); + expect(abortExact).toMatchObject({ ok: true, aborted: true }); + }); + + it('writeCommit 成功但 manifest 失败后占额;去掉故障后同一 id 提交成功且不再重传字节', async () => { + const root = path.join(tmp, 'pending-retry', ghostId); + const store = makeStore(root, { maxTotalBytes: 40 }); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + const started = await beginChunk(store, 'pending', 'a'.repeat(20)); + const failedCommit = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(failedCommit.ok).toBe(false); + writeSpy.mockRestore(); + + const extra = await store.begin({ + ghostId, taskId: 'extra', sourceRevision: 'rev-1', + totalBytes: 21, sha256: sha256Of('b'.repeat(21)), mime: 'image/png', recovery, + }); + expect(extra).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + + const chunkSpy = vi.spyOn(LibraryVault.prototype, 'writeChunk'); + const beginSpy = vi.spyOn(LibraryVault.prototype, 'writeBegin'); + try { + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!retried.ok) throw new Error(`retry commit: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + expect(chunkSpy).not.toHaveBeenCalled(); + expect(beginSpy).not.toHaveBeenCalled(); + } finally { + chunkSpy.mockRestore(); + beginSpy.mockRestore(); + } + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + }); + + it('任务目录、tasks 父目录、vault 根 fsync 失败均真正命中对应方法:初次不得 durable,去掉故障后同 id 提交成功且新 Store 可 list/read', async () => { + const cases: Array<{ + label: string; + failWhen: (relPath: unknown, stagingId: string) => boolean; + }> = [ + { + label: 'task-dir', + failWhen: (relPath, stagingId) => relPath === `tasks/${stagingId}`, + }, + { + label: 'tasks-parent', + failWhen: (relPath) => relPath === 'tasks', + }, + { + label: 'vault-root', + failWhen: (relPath) => relPath === '', + }, + ]; + + for (const item of cases) { + const root = path.join(tmp, `fsync-${item.label}`, ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const started = await beginChunk(store, `fsync-${item.label}`, body); + const seen: unknown[] = []; + const orig = LibraryVault.prototype.fsyncDir; + const spy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + seen.push(relPath); + if (item.failWhen(relPath, started.stagingId)) { + return { ok: false, errorCode: 'INTERNAL', message: `${item.label} fsync 失败` }; + } + return orig.call(this, relPath); + }); + try { + const commit = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(commit.ok, `${item.label} 初次 commit 应失败`).toBe(false); + if (!commit.ok) expect(commit.errorCode).toBe('INTERNAL'); + expect(commit).not.toHaveProperty('durable'); + expect(seen.some((rel) => item.failWhen(rel, started.stagingId)), `${item.label} 未命中 fsyncDir`).toBe(true); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + const listed = await store.list({ ghostId }); + if (listed.ok) { + expect(listed.items.every((row) => row.stagingId !== started.stagingId)).toBe(true); + } + } finally { + spy.mockRestore(); + } + + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + if (!retried.ok) throw new Error(`${item.label} retry commit: ${JSON.stringify(retried)}`); + expect(retried.durable).toBe(true); + const restored = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`${item.label} new store list: ${JSON.stringify(listed)}`); + expect(listed.items.map((row) => row.stagingId)).toEqual([started.stagingId]); + const read = await restored.read({ ghostId, stagingId: started.stagingId }); + if (!read.ok) throw new Error(`${item.label} new store read: ${JSON.stringify(read)}`); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe(body); + } + }); + + it('release:错误 ACK 保留原件;tombstone fsync 失败保留原件;manifest 删除失败后同实例与新 Store 收敛;损坏 tombstone 不得静默删原件;成功释放精确还额', async () => { + const root = path.join(tmp, 'release', ghostId); + const store = makeStore(root, { maxTotalBytes: 64 }); + const { stagingId, commit } = await commitOne(store); + + const mismatch = await store.release({ + ghostId, stagingId, + ack: { + ok: true, + path: 'assets/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/blob.png', + sha256: '1'.repeat(64), bytes: 1, libraryIdentity: HEX_A, libraryGeneration: 0, + }, + }); + expect(mismatch).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + const still = await store.list({ ghostId }); + if (!still.ok) throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(stagingId); + + const origFsync = LibraryVault.prototype.fsyncDir; + const tombFsync = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (typeof relPath === 'string' && relPath === `tasks/${stagingId}` && fs.existsSync(tombstoneAbs(root, stagingId))) { + return { ok: false, errorCode: 'INTERNAL', message: 'tombstone fsync 失败' }; + } + return origFsync.call(this, relPath); + }); + try { + const blocked = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(blocked.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + } finally { + tombFsync.mockRestore(); + } + + const origDelete = LibraryVault.prototype.delete; + let blobDeleted = false; + const deleteSpy = vi.spyOn(LibraryVault.prototype, 'delete').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json') && blobDeleted) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest delete failed' }; + } + const result = await origDelete.call(this, req); + if (result.ok && typeof req.path === 'string' && req.path.endsWith('blob.bin')) blobDeleted = true; + return result; + }); + try { + const failed = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(failed.ok).toBe(false); + const retrySame = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(retrySame.ok).toBe(false); + const midFault = makeStore(root, { maxTotalBytes: 64 }); + const midListed = await midFault.list({ ghostId }); + expect(midListed.ok).toBe(false); + if (!midListed.ok) expect(['LIBRARY_UNAVAILABLE', 'INTERNAL']).toContain(midListed.errorCode); + const occupied = await store.begin({ + ghostId, taskId: 'quota-while-cleanup-fails', sourceRevision: 'r', + totalBytes: 55, sha256: sha256Of('q'.repeat(55)), mime: 'image/png', recovery, + }); + expect(occupied).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + } finally { + deleteSpy.mockRestore(); + } + + const converged = await store.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(converged).toEqual({ ok: true, stagingId, released: true }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(false); + + const restored = makeStore(root, { maxTotalBytes: 64 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`new store after release: ${JSON.stringify(listed)}`); + expect(listed.items).toEqual([]); + const again = await restored.release({ ghostId, stagingId, ack: matchingAck(commit) }); + expect(again).toMatchObject({ ok: true, released: false }); + + const { stagingId: liveId, commit: liveCommit } = await commitOne(restored, 'task-live', 'keep-me'); + await fs.promises.writeFile(tombstoneAbs(root, liveId), '{not a tombstone'); + const corruptStore = makeStore(root, { maxTotalBytes: 64 }); + const corruptList = await corruptStore.list({ ghostId }); + expect(corruptList).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(root, liveId))).toBe(true); + await fs.promises.rm(tombstoneAbs(root, liveId), { force: true }); + const repaired = await corruptStore.list({ ghostId }); + if (!repaired.ok) throw new Error(`tombstone repaired: ${JSON.stringify(repaired)}`); + expect(repaired.items.map((item) => item.stagingId)).toEqual([liveId]); + const liveRead = await corruptStore.read({ ghostId, stagingId: liveId }); + if (!liveRead.ok) throw new Error(JSON.stringify(liveRead)); + expect(Buffer.from(liveRead.content, 'base64').toString('utf8')).toBe('keep-me'); + + const released = await corruptStore.release({ ghostId, stagingId: liveId, ack: matchingAck(liveCommit) }); + expect(released).toEqual({ ok: true, stagingId: liveId, released: true }); + const afterRelease = await corruptStore.begin({ + ghostId, taskId: 'after-release', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(afterRelease.ok).toBe(true); + }); + + it('有效 tombstone+残留 blob/manifest:新 Store 成功清理后立即归还全部 bytes,不得把已删除 leftover 计入 orphan', async () => { + const root = path.join(tmp, 'tombstone-quota', ghostId); + const maxTotalBytes = 40; + const payload = 'T'.repeat(maxTotalBytes); + const writer = makeStore(root, { maxTotalBytes }); + const { stagingId } = await commitOne(writer, 'crash-release', payload); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(true); + await fs.promises.writeFile( + tombstoneAbs(root, stagingId), + JSON.stringify({ version: 1, stagingId, released: true }), + ); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(true); + expect(fs.existsSync(tombstoneAbs(root, stagingId))).toBe(true); + + const restored = makeStore(root, { maxTotalBytes }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`loadJournal after valid tombstone: ${JSON.stringify(listed)}`); + expect(listed.items).toEqual([]); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(false); + expect(fs.existsSync(manifestAbs(root, stagingId))).toBe(false); + expect(fs.existsSync(tombstoneAbs(root, stagingId))).toBe(false); + + const reclaimed = await restored.begin({ + ghostId, taskId: 'reclaimed-after-tombstone-cleanup', sourceRevision: 'rev-1', + totalBytes: maxTotalBytes, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + expect(reclaimed, `quota not returned after successful tombstone cleanup: ${JSON.stringify(reclaimed)}`).toMatchObject({ ok: true }); + if (!reclaimed.ok) return; + const aborted = await restored.abort({ ghostId, stagingId: reclaimed.stagingId }); + expect(aborted).toMatchObject({ ok: true, aborted: true }); + }); + + it('manifest 写失败后 commitPending blob 已落地:拒 abort 且不调 writeAbort,超额 begin 必须 STAGING_QUOTA,同 id commit 重试成功', async () => { + const root = path.join(tmp, 'commit-pending-abort', ghostId); + const maxTotalBytes = 40; + const store = makeStore(root, { maxTotalBytes }); + const started = await beginChunk(store, 'pending-abort', 'a'.repeat(20)); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + const failedCommit = await store.commit({ ghostId, stagingId: started.stagingId }); + writeSpy.mockRestore(); + expect(failedCommit.ok).toBe(false); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(false); + + const writeAbort = vi.spyOn(LibraryVault.prototype, 'writeAbort'); + const aborted = await store.abort({ ghostId, stagingId: started.stagingId }); + expect(writeAbort).not.toHaveBeenCalled(); + writeAbort.mockRestore(); + expect(aborted).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(false); + + const over = await store.begin({ + ghostId, taskId: 'over-after-pending-abort', sourceRevision: 'rev-1', + totalBytes: 21, sha256: sha256Of('b'.repeat(21)), mime: 'image/png', recovery, + }); + expect(over, `abort dropped undeleted blob from quota: ${JSON.stringify(over)}`).toMatchObject({ + ok: false, errorCode: 'STAGING_QUOTA', + }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(retried).toMatchObject({ ok: true, stagingId: started.stagingId, durable: true, bytes: 20 }); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(true); + }); + + it('abort 不得删除已提交原件;不存在 TTL 清掉 unique durable', async () => { + const root = path.join(tmp, 'no-ttl', ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const { stagingId } = await commitOne(store); + const abortDurable = await store.abort({ ghostId, stagingId }); + expect(abortDurable).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + + const other = await beginChunk(store, 'other', 'other-bytes'); + const abortedOther = await store.abort({ ghostId, stagingId: other.stagingId }); + expect(abortedOther).toMatchObject({ ok: true, aborted: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + expect(fs.existsSync(blobAbs(root, stagingId))).toBe(true); + }); + + it('超过 Vault.listPageSize 的恢复与公开 list 游标:每条 durable 恰好一次且跨页合计字节', async () => { + const pageSize = 2; + expect(pageSize).toBeLessThan(DEFAULT_LIBRARY_LIMITS.listPageSize); + const root = path.join(tmp, 'pages', ghostId); + const writer = makeStore(root, { maxTotalBytes: 4096, listPageSize: pageSize }); + const expected = new Map(); + for (let i = 0; i < pageSize + 3; i += 1) { + const payload = `page-item-${i}`; + const committed = await commitOne(writer, `task-${i}`, payload, `rev-${i}`); + expected.set(committed.stagingId, Buffer.byteLength(payload)); + } + expect(expected.size).toBeGreaterThan(DEFAULT_LIBRARY_LIMITS.listPageSize > pageSize ? pageSize : 0); + expect(expected.size).toBeGreaterThan(pageSize); + + const vaultList = vi.spyOn(LibraryVault.prototype, 'list'); + const restored = makeStore(root, { maxTotalBytes: 4096, listPageSize: pageSize }); + const walked = await listAllPublic(restored, pageSize); + const taskDirPages = vaultList.mock.calls.filter((args) => args[0]?.path === 'tasks').length; + vaultList.mockRestore(); + expect(taskDirPages).toBeGreaterThan(1); + + const seen = walked.items.map((item) => item.stagingId); + expect(seen.sort()).toEqual([...expected.keys()].sort()); + expect(new Set(seen).size).toBe(expected.size); + const totalBytes = walked.items.reduce((sum, item) => sum + item.bytes, 0); + const expectedBytes = [...expected.values()].reduce((sum, n) => sum + n, 0); + expect(totalBytes).toBe(expectedBytes); + expect(walked.items.length).toBeGreaterThan(pageSize); + }); + + it('成功恢复后多次 list/read/chunk 不再对已保留原件全量 hash', async () => { + const root = path.join(tmp, 'no-rehash', ghostId); + const { stagingId } = await commitOne(makeStore(root, { maxTotalBytes: 1024 }), 'kept', 'kept-bytes'); + const origHash = LibraryVault.prototype.hashFile; + const hashSpy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(function (this: LibraryVault, relPath: string) { + return origHash.call(this, relPath); + }); + const restored = makeStore(root, { maxTotalBytes: 1024 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([stagingId]); + const hashesDuringRecovery = hashSpy.mock.calls.filter((args) => args[0] === `tasks/${stagingId}/blob.bin`).length; + expect(hashesDuringRecovery).toBeGreaterThan(0); + hashSpy.mockClear(); + + const listedAgain = await restored.list({ ghostId }); + if (!listedAgain.ok) throw new Error(JSON.stringify(listedAgain)); + const read1 = await restored.read({ ghostId, stagingId }); + const read2 = await restored.read({ ghostId, stagingId, offset: 0, length: 4 }); + if (!read1.ok || !read2.ok) throw new Error('read after recovery failed'); + const extra = await beginChunk(restored, 'extra-after-recovery', 'xy'); + const extraChunk = await restored.chunk({ + ghostId, stagingId: extra.stagingId, seq: 1, + content: Buffer.from('xy').toString('base64'), encoding: 'base64', + }); + expect(extraChunk.ok).toBe(true); + const retainedHashCalls = hashSpy.mock.calls.filter((args) => args[0] === `tasks/${stagingId}/blob.bin`); + expect(retainedHashCalls).toEqual([]); + }); + + it('省略 length 的 >16MiB 原件返回 <=maxChunkBytes 前缀;显式分段正确;非法 offset/length 为 PATH_INVALID', async () => { + const root = path.join(tmp, 'read-16m', ghostId); + const store = makeStore(root, { maxTotalBytes: 32 * 1024 * 1024 }); + const payload = Buffer.alloc(DEFAULT_READ_CHUNK + 64, 7); + payload[0] = 11; + payload[DEFAULT_READ_CHUNK] = 22; + payload[payload.byteLength - 1] = 33; + const digest = sha256Of(payload); + const begin = await store.begin({ + ghostId, taskId: 'big-image', sourceRevision: 'rev-1', + totalBytes: payload.byteLength, sha256: digest, mime: 'image/png', recovery, + }); + if (!begin.ok) throw new Error(JSON.stringify(begin)); + const first = payload.subarray(0, DEFAULT_READ_CHUNK); + const rest = payload.subarray(DEFAULT_READ_CHUNK); + const chunk1 = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 1, + content: first.toString('base64'), encoding: 'base64', + }); + if (!chunk1.ok) throw new Error(JSON.stringify(chunk1)); + const chunk2 = await store.chunk({ + ghostId, stagingId: begin.stagingId, seq: 2, + content: rest.toString('base64'), encoding: 'base64', + }); + if (!chunk2.ok) throw new Error(JSON.stringify(chunk2)); + const commit = await store.commit({ ghostId, stagingId: begin.stagingId }); + if (!commit.ok) throw new Error(JSON.stringify(commit)); + + const omitted = await store.read({ ghostId, stagingId: begin.stagingId }); + if (!omitted.ok) throw new Error(JSON.stringify(omitted)); + expect(omitted.bytes).toBe(DEFAULT_READ_CHUNK); + expect(omitted.bytes).toBeLessThanOrEqual(DEFAULT_READ_CHUNK); + expect(Buffer.from(omitted.content, 'base64').equals(first)).toBe(true); + expect(omitted.sha256).toBe(sha256Of(first)); + + const tail = await store.read({ + ghostId, stagingId: begin.stagingId, offset: DEFAULT_READ_CHUNK, length: 64, + }); + if (!tail.ok) throw new Error(JSON.stringify(tail)); + expect(tail.bytes).toBe(64); + expect(Buffer.from(tail.content, 'base64').equals(rest)).toBe(true); + expect(tail.sha256).toBe(sha256Of(rest)); + + const mid = await store.read({ ghostId, stagingId: begin.stagingId, offset: 1, length: 3 }); + if (!mid.ok) throw new Error(JSON.stringify(mid)); + expect(Buffer.from(mid.content, 'base64').equals(payload.subarray(1, 4))).toBe(true); + + const smallRoot = path.join(tmp, 'read-small', ghostId); + const small = makeStore(smallRoot, { maxTotalBytes: 1024 }); + const { stagingId } = await commitOne(small, 'small-1', 'abcdefghijklmnopqrst'); + const whole = await small.read({ ghostId, stagingId }); + if (!whole.ok) throw new Error(JSON.stringify(whole)); + expect(whole.bytes).toBe(Buffer.byteLength('abcdefghijklmnopqrst')); + + expect(await small.read({ ghostId, stagingId, offset: -1 })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: 1.5 })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, offset: Number.NaN })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: Number.POSITIVE_INFINITY })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, offset: Number.NEGATIVE_INFINITY })).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(await small.read({ ghostId, stagingId, length: DEFAULT_READ_CHUNK + 1 })).toMatchObject({ ok: false, errorCode: 'TOO_LARGE' }); + }, 60_000); + + it('同一 task/revision/metadata 在活跃上传与 commit 后 begin 幂等;末块精确重复接受、冲突重复拒绝', async () => { + const store = makeStore(path.join(tmp, 'idempotent', ghostId), { maxTotalBytes: 1024, maxConcurrentWrites: 2 }); + const payload = 'abcd'; + const digest = sha256Of(payload); + const first = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const againActive = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + expect(againActive).toEqual({ ok: true, stagingId: first.stagingId }); + const other = await store.begin({ + ghostId, taskId: 'other-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: sha256Of('wxyz'), mime: 'image/png', recovery, + }); + expect(other.ok).toBe(true); + + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(chunk).toMatchObject({ ok: true, accepted: 4 }); + const dup = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(dup).toMatchObject({ ok: true, accepted: 4 }); + const conflict = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from('abce').toString('base64'), encoding: 'base64', + }); + expect(conflict).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + const gap = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 3, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + expect(gap).toMatchObject({ ok: false, errorCode: 'STREAM_INVALID' }); + + const commit = await store.commit({ ghostId, stagingId: first.stagingId }); + if (!commit.ok) throw new Error(JSON.stringify(commit)); + const againCommitted = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: digest, mime: 'image/png', recovery, + }); + expect(againCommitted).toEqual({ ok: true, stagingId: first.stagingId }); + const conflictingMeta = await store.begin({ + ghostId, taskId: 'same-task', sourceRevision: 'rev-1', + totalBytes: 4, sha256: sha256Of('abce'), mime: 'image/png', recovery, + }); + expect(conflictingMeta).toMatchObject({ ok: false, errorCode: 'ALREADY_EXISTS' }); + }); + + it('null owner 与切账号后不得写新文件或读旧数据', async () => { + const { store, stagingId } = await commitOne(); + scope = null; + const listed = await store.list({ ghostId }); + expect(listed).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + scope = 'local:owner-b:1'; + const crossed = await store.read({ ghostId, stagingId }); + expect(crossed).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 5069a386c54..a39b37cd2f4 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -4,7 +4,7 @@ * os.tmpdir 临时目录(规则 23:生成物不落仓库工作区),零 Electron。 * symlink 用例带能力探针(Windows 无特权时跳过;POSIX CI 实跑)。 */ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -275,6 +275,73 @@ describe('LibraryVault', () => { // staging 清空。 const tmpEntries = await fs.promises.readdir(path.join(libraryRoot, '.cindy-library', 'tmp')); expect(tmpEntries).toEqual([]); + const dirSync = await vault.fsyncDir('assets'); + expect(dirSync.ok).toBe(true); + if (dirSync.ok) { + if (process.platform === 'win32') expect(dirSync.fsynced).toBe(false); + else expect(dirSync.fsynced).toBe(true); + } + const residue = await vault.tmpResidueBytes(); + expect(residue).toEqual({ ok: true, bytes: 0 }); + }); + + it('fsyncCreatedAncestors 同步新建根的父目录项,只 fsync 根不等于根 entry 已耐久', async () => { + const nestedRoot = path.join(tmpRoot, 'owners', 'a', 'library-staging', 'test-ghost'); + const vault = makeVault({ rootDir: () => nestedRoot }); + const opened = await vault.open(); + expect(opened.ok).toBe(true); + const parent = path.dirname(nestedRoot); + expect(fs.existsSync(parent)).toBe(true); + const synced = new Set(); + const origOpen = fs.promises.open.bind(fs.promises); + const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await origOpen(file, flags, mode); + if (typeof file === 'string' && flags === 'r') { + const origSync = handle.sync.bind(handle); + handle.sync = async () => { + synced.add(path.resolve(file)); + return origSync(); + }; + } + return handle; + }); + try { + const ok = await vault.fsyncCreatedAncestors(); + expect(ok.ok).toBe(true); + if (process.platform === 'win32') { + if (ok.ok) expect(ok.fsynced).toBe(false); + } else { + if (ok.ok) expect(ok.fsynced).toBe(true); + expect(synced.has(path.resolve(parent))).toBe(true); + } + } finally { + spy.mockRestore(); + } + }); + + it('fsyncCreatedAncestors 父目录 fsync 失败则 INTERNAL,不得当耐久', async () => { + const nestedRoot = path.join(tmpRoot, 'owners', 'b', 'library-staging', 'test-ghost'); + const vault = makeVault({ rootDir: () => nestedRoot }); + await vault.open(); + const parent = path.resolve(path.dirname(nestedRoot)); + const origOpen = fs.promises.open.bind(fs.promises); + const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => { + if (typeof file === 'string' && path.resolve(file) === parent && flags === 'r') { + throw Object.assign(new Error('EIO'), { code: 'EIO' }); + } + return origOpen(file, flags, mode); + }); + try { + const failed = await vault.fsyncCreatedAncestors(); + if (process.platform === 'win32') { + expect(failed).toEqual({ ok: true, fsynced: false }); + } else { + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.errorCode).toBe('INTERNAL'); + } + } finally { + spy.mockRestore(); + } }); it('sha256 声明不符 → STREAM_INVALID 且不留目标文件', async () => { @@ -347,6 +414,8 @@ describe('LibraryVault', () => { const flat = await vault.list({ path: 'canvases/c1' }); if (flat.ok) expect(flat.entries.map((e) => e.path)).toEqual(['canvases/c1/state.json']); + const compatible = await vault.list({ recursive: false }); + expect(compatible.ok).toBe(true); }); }); @@ -481,6 +550,11 @@ describe('LibraryVault', () => { expect(r.ok).toBe(false); const d = await vault.delete({ path: 'escape-door/anything' }); expect(d.ok).toBe(false); + const compatible = await vault.list({ recursive: false }); + expect(compatible.ok).toBe(true); + const strict = await vault.list({ recursive: false, strict: true }); + expect(strict.ok).toBe(false); + if (!strict.ok) expect(strict.errorCode).toBe('LIBRARY_UNAVAILABLE'); }); }); @@ -499,6 +573,8 @@ describe('LibraryVault', () => { expect(r.sha256).toBe(sha256Of(body)); expect(r.bytes).toBe(Buffer.byteLength(body)); } + const hashed = await vault.hashFile(rel); + expect(hashed).toEqual({ ok: true, path: rel, bytes: Buffer.byteLength(body), sha256: sha256Of(body) }); }); it('打开后目标 identity 变化 → INTERNAL 且不得返回字节', async () => { diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 8a5d58880f8..91fec9879f4 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -3586,7 +3586,10 @@ const st = await cindy.library({ op: 'status' }); // 只读能力查询:资格审与 op 合法性之后、会话创建之前返回;不打开库、不弹窗 const caps = await cindy.library({ op: 'capabilities' }); // caps = { ok:true, op:'capabilities', -// capabilities:{ version:1, operations:['clipboardWrite','saveAs'] } } +// capabilities:{ version:1, +// operations:['clipboardWrite','saveAs','staging.begin',...], +// staging:{ version:1, maxTaskBytes, maxTotalBytes, +// maxConcurrentWrites, maxChunkBytes, reserveBytes } } } // operations 只表示宿主实现了这些 op,不等于此刻有窗口 / 已授权 / 库可用 // 文件操作(全 Family;写入原子化,大文件走分块流) @@ -3633,6 +3636,18 @@ await cindy.library({ op: 'db.migrate', dbPath: 'canvas.sqlite', targetVersion: { toVersion: 2, sql: ['CREATE TABLE v2 (a TEXT)'] }] }); await cindy.library({ op: 'db.backup', dbPath: 'library.sqlite' }); // 宿主命名空间 await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_check + +// 后台暂存(staging.*):独立于可迁移 Library 根,不是第二媒体库,不返回 imageRef。 +const up = await cindy.library({ + op: 'staging.begin', taskId, sourceRevision, totalBytes, sha256, mime, recovery, +}); +await cindy.library({ op: 'staging.chunk', stagingId: up.stagingId, seq: 1, content: b64, encoding: 'base64' }); +const receipt = await cindy.library({ op: 'staging.commit', stagingId: up.stagingId }); +// receipt.durable === true 才可当跨退出原件。release 带当前 Library ACK 的 bytes(不是 begin 的 totalBytes),且画布已保存后才调用。 +await cindy.library({ + op: 'staging.release', stagingId: up.stagingId, path, sha256, bytes, + libraryIdentity, libraryGeneration, +}); \`\`\` 关键语义(全部由宿主强制): @@ -3649,6 +3664,7 @@ await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_ch open/status 失败),非法请求=\`INVALID_REQUEST\`(含非法/越界 dbPath 与未知 op), 取消=\`CANCELLED\`;成功 open/status 的 \`state:'unavailable'\` 仍用结果体 reason (如 disk-missing),不是失败 reason 枚举;查询/传输层本地分类 \`TIMEOUT\` / \`TRANSPORT_ERROR\`; +- **staging.***:后台暂存,不是媒体库、不弹新 UI。\`staging.read\` 未传 length 默认 16MiB 分片;负数/NaN offset/length 是 \`PATH_INVALID\`。release 必须带当前 Library ACK 的 \`bytes\`(不是 begin 的 \`totalBytes\`),且只在画布保存后调用。父目录 fsync 失败不得 \`durable:true\`。 - **capabilities**:先查 \`{ op:'capabilities' }\`。仅 \`version===1\` 且 \`operations\` 为**全部字符串**的数组才有效;额外字段忽略,未知 operation 忽略, 已知项保留;有效 v1 清单缺少某项才是 unsupported。缺字段、错类型(含数组内混入 diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index adb4fd51789..ffa8a1b0d37 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -321,6 +321,7 @@ import { GhostFsSlot } from './fsSlot.js'; import { GhostLibrarySlot } from './librarySlot.js'; import { LibraryBindingStore, validateLibraryCandidateLocation } from './libraryBinding.js'; import { LibraryVault, statfsFreeBytes, DEFAULT_LIBRARY_LIMITS } from './libraryVault.js'; +import { LibraryStagingStore } from './libraryStaging.js'; import { LibrarySqlService, defaultLibraryDbWorkerPath } from './librarySqlService.js'; import { trashGhostLibrary } from './libraryTrash.js'; import { migrateGhostLibrary } from './libraryMigrate.js'; @@ -1021,8 +1022,9 @@ export async function interruptGhostCallsForAccountBoundary(): Promise { getForgeOidcInstallConfirmBridge()?.cancelAll(); runtimeSingleton?.destroyAll(); resetNodeRuntimeBrokerForAccountBoundary(); - // Library 会话一并作废:关 db worker + 作废 handle——在途写入已在串行链上 - // 归属原 owner 完成或随 vault.invalidate 作废,新 owner 解析到全新根。 + // Drain in-flight staging.release (tombstone/fsync) before tearing Library + // sessions. Owner mutation leases stay held until each call unwinds; waiting + // for idle first would let marker-window teardown race the lease. await getGhostLibrarySlot().disposeAll(); if (libraryExtraDirSync) { await libraryExtraDirSync(null).catch((error) => { @@ -5309,6 +5311,10 @@ export function getGhostLibrarySlot(): GhostLibrarySlot { getGhost: findAvailableGhost, bindingStore, getDefaultRoot: (ghostId) => ownerScopedUserDataPath('libraries', ghostId), + getStagingRoot: (ghostId) => ownerScopedUserDataPath('library-staging', ghostId), + createStagingStore: (deps) => new LibraryStagingStore(deps), + captureMutationOwner: () => captureGhostMutationOwner(), + beginMutation: (expected) => beginGhostMutation(expected as ActiveAppSession | undefined), captureOwnerScope: () => activeOwnerScopeKey(), createVault: (deps) => new LibraryVault(deps), createSqlService: (deps) => new LibrarySqlService(deps), @@ -5526,30 +5532,36 @@ export async function getGhostLibraryOverview(ghostId: string): Promise { if (!isValidGhostId(ghostId)) return { ok: false, message: '非法插件 id' }; - await getGhostLibrarySlot().disposeGhost(ghostId); - const result = await trashGhostLibrary(ghostId, { - // 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层 - // 引导恢复位置,不误删)。 - resolveLibraryRoot: async (id) => { - const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id); - return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id); - }, - trashRoot: () => ownerScopedUserDataPath('libraries-trash'), - removeBinding: async (id) => { - await getGhostLibraryBindingStore().removeBinding(id); - }, - log, - }); - if (result.ok) { - await refreshMivoLibraryExtraDirGrant().catch((error) => { - log.warn('library extraDirs delete sync failed', { - ghostId, - error: error instanceof Error ? error.message : String(error), - }); + const slot = getGhostLibrarySlot(); + slot.setRelocating(ghostId, true); + try { + await slot.disposeGhost(ghostId); + const result = await trashGhostLibrary(ghostId, { + // 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层 + // 引导恢复位置,不误删)。 + resolveLibraryRoot: async (id) => { + const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id); + return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id); + }, + trashRoot: () => ownerScopedUserDataPath('libraries-trash'), + removeBinding: async (id) => { + await getGhostLibraryBindingStore().removeBinding(id); + }, + log, }); - return { ok: true }; + if (result.ok) { + await refreshMivoLibraryExtraDirGrant().catch((error) => { + log.warn('library extraDirs delete sync failed', { + ghostId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return { ok: true }; + } + return { ok: false, message: result.message }; + } finally { + slot.setRelocating(ghostId, false); } - return { ok: false, message: result.message }; } let libraryBindingStoreSingleton: LibraryBindingStore | null = null; @@ -7883,15 +7895,19 @@ export function registerGhostIpc(): void { throwIpcError('INVALID_PARAMS', '参数非法'); } const releaseMutation = beginGhostMutation(); + const slot = getGhostLibrarySlot(); try { + slot.setRelocating(id, true); + await slot.disposeGhost(id); // drain in-flight staging.release before binding changes const set = await getGhostLibraryBindingStore().setBinding(id, candidate, (root) => statfsFreeBytes(root), ); if (!set.ok) return { ok: false as const, message: set.message }; - await getGhostLibrarySlot().disposeGhost(id); // 作废会话,下一请求用新根 + await slot.disposeGhost(id); // 作废会话,下一请求用新根 await refreshMivoLibraryExtraDirGrant(); return { ok: true as const, warnings: set.warnings }; } finally { + slot.setRelocating(id, false); releaseMutation(); } }); @@ -7927,12 +7943,16 @@ export function registerGhostIpc(): void { assertTrustedAppRendererEvent(event); if (typeof id !== 'string' || !isValidGhostId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id'); const releaseMutation = beginGhostMutation(); + const slot = getGhostLibrarySlot(); try { + slot.setRelocating(id, true); + await slot.disposeGhost(id); await getGhostLibraryBindingStore().removeBinding(id); - await getGhostLibrarySlot().disposeGhost(id); + await slot.disposeGhost(id); await refreshMivoLibraryExtraDirGrant(); return { ok: true as const }; } finally { + slot.setRelocating(id, false); releaseMutation(); } }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 99cdcf1bfcf..142e0e7a5f0 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -34,6 +34,13 @@ import { LibraryVault, validateLibraryRelPath, DEFAULT_LIBRARY_LIMITS, type Libr import { LibraryBindingStore, type LibraryLocationResolution } from './libraryBinding.js'; import { LibrarySqlService, type LibrarySqlServiceDeps } from './librarySqlService.js'; import type { LibraryDbResult } from './libraryDbCore.js'; +import { + isGhostLibraryStagingOp, + LibraryStagingStore, + type LibraryStagingAck, + type LibraryStagingDeps, + type LibraryStagingFailure, +} from './libraryStaging.js'; /** 正本相对键:assets//<64-hex>/blob.(不是 .)。 */ const LIBRARY_BLOB_REL_RE = /^assets\/([0-9a-f]{2})\/([0-9a-f]{64})\/blob\.([A-Za-z0-9]+)$/i; @@ -207,6 +214,12 @@ export interface GhostLibrarySlotDeps { ghostId: string, root: string | null, ): Promise; + /** owner-scoped staging 根(生产 = ownerScopedUserDataPath('library-staging', ghostId))。 */ + getStagingRoot?(ghostId: string): string; + createStagingStore?(deps: LibraryStagingDeps): LibraryStagingStore; + /** Capture owner before staging awaits; beginMutation holds the lease across verify+release. */ + captureMutationOwner?(): unknown; + beginMutation?(expected?: unknown): () => void; } const fail = ( @@ -246,6 +259,10 @@ export class GhostLibrarySlot { libraryGeneration: number; libraryIdentity: string; }>(); + /** owner-scoped staging,独立于可迁移 Library 根;按 owner×ghost 隔离,根捕获后不漂移。 */ + private readonly stagingStores = new Map(); + /** In-flight staging.release per ghost. disposeGhost drains these so bind/relocate cannot cut the Library mid-transaction. */ + private readonly stagingReleaseInflight = new Map | null; resolveDrain: (() => void) | null }>(); constructor(private readonly deps: GhostLibrarySlotDeps) {} @@ -255,6 +272,70 @@ export class GhostLibrarySlot { else this.relocating.delete(ghostId); } + private beginStagingRelease(ghostId: string): () => void { + let state = this.stagingReleaseInflight.get(ghostId); + if (!state) { + state = { count: 0, drain: null, resolveDrain: null }; + this.stagingReleaseInflight.set(ghostId, state); + } + if (state.count === 0) { + state.drain = new Promise((resolve) => { + state.resolveDrain = resolve; + }); + } + state.count += 1; + let released = false; + return () => { + if (released) return; + released = true; + const live = this.stagingReleaseInflight.get(ghostId); + if (!live) return; + live.count -= 1; + if (live.count === 0) { + live.resolveDrain?.(); + live.resolveDrain = null; + live.drain = null; + this.stagingReleaseInflight.delete(ghostId); + } + }; + } + + private async waitForStagingReleases(ghostId: string): Promise { + for (;;) { + const state = this.stagingReleaseInflight.get(ghostId); + if (!state || state.count === 0 || !state.drain) return; + await state.drain; + } + } + + private confirmReleaseLibrary( + ghostId: string, + session: GhostLibrarySession, + ack: Extract, + ): LibraryStagingFailure | null { + const live = this.sessions.get(ghostId); + if ( + this.deps.captureOwnerScope() !== session.ownerScopeKey + || !live + || live !== session + || live.identity !== ack.libraryIdentity + || live.generation !== ack.libraryGeneration + ) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }; + } + return null; + } + + /** Relocate/delete/account-boundary gate: reject new release, drain inflight, then teardown. */ + async invalidateGhost(ghostId: string): Promise { + this.setRelocating(ghostId, true); + try { + await this.disposeGhost(ghostId); + } finally { + this.setRelocating(ghostId, false); + } + } + /** 处理一条 library-request(ghost-pipe:send 的 invoke 返回值即本结果)。 */ async handleLibraryRequest(ghostId: string, payload: unknown): Promise { try { @@ -286,9 +367,13 @@ export class GhostLibrarySlot { capabilities: { version: GHOST_LIBRARY_CAPABILITIES_V1.version, operations: [...GHOST_LIBRARY_CAPABILITIES_V1.operations], + staging: { ...GHOST_LIBRARY_CAPABILITIES_V1.staging }, }, }; } + if (isGhostLibraryStagingOp(op)) { + return this.dispatchStaging(ghostId, op, req); + } // 迁移期只读:写类操作在 copying 全程拒绝(读与状态查询照常)。 if (this.relocating.has(ghostId)) { const writeOps: ReadonlySet = new Set([ @@ -501,6 +586,219 @@ export class GhostLibrarySlot { } } + private stagingStoreKey(ownerScopeKey: string, ghostId: string): string { + return `${ownerScopeKey}\0${ghostId}`; + } + + private getOrCreateStagingStore(ghostId: string, ownerScopeKey: string): LibraryStagingStore { + const key = this.stagingStoreKey(ownerScopeKey, ghostId); + const existing = this.stagingStores.get(key); + if (existing) return existing; + const rootDir = this.deps.getStagingRoot + ? this.deps.getStagingRoot(ghostId) + : path.join(this.deps.getDefaultRoot(ghostId), '..', '..', 'library-staging', ghostId); + const create = this.deps.createStagingStore ?? ((deps: LibraryStagingDeps) => new LibraryStagingStore(deps)); + const store = create({ + rootDir, + ownerScopeKey, + ghostId, + captureOwnerScope: () => this.deps.captureOwnerScope(), + createVault: (vaultDeps) => this.deps.createVault(vaultDeps), + getDiskFreeBytes: this.deps.getDiskFreeBytes, + log: this.deps.log, + }); + this.stagingStores.set(key, store); + return store; + } + + private stagingFail(r: LibraryStagingFailure): GhostPipeLibraryResult { + return r.errorCode === 'LIBRARY_UNAVAILABLE' + ? fail(r.errorCode, r.message, 'LIBRARY_UNAVAILABLE') + : { ok: false, errorCode: r.errorCode, message: r.message }; + } + + private async verifyLibraryAck( + ghostId: string, + req: Record, + ): Promise { + const pathRel = typeof req.path === 'string' ? req.path : ''; + const sha256 = typeof req.sha256 === 'string' ? req.sha256 : ''; + const bytes = typeof req.bytes === 'number' ? req.bytes : NaN; + const libraryIdentity = typeof req.libraryIdentity === 'string' ? req.libraryIdentity : ''; + const libraryGeneration = typeof req.libraryGeneration === 'number' ? req.libraryGeneration : NaN; + if (!isLibraryBlobRelPath(pathRel) || !/^[0-9a-f]{64}$/.test(sha256) || !Number.isInteger(bytes) || bytes < 0) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 字段非法,原件已保留' }; + } + const ext = pathRel.split('.').pop() ?? ''; + if (libraryBlobRelPath(sha256, ext) !== pathRel) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 不是 content-addressed 正本路径,原件已保留' }; + } + if (!/^[0-9a-f]{64}$/.test(libraryIdentity) || !Number.isInteger(libraryGeneration) || libraryGeneration < 0) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 非法,原件已保留' }; + } + const scopeKey = this.deps.captureOwnerScope(); + if (scopeKey === null) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '当前没有有效账号,staging 已拒绝' }; + } + const session = await this.getOrCreateSession(ghostId, scopeKey); + if (session.drift !== null) { + return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: `Library 不可用(${session.drift})` }; + } + if (session.identity !== libraryIdentity || session.generation !== libraryGeneration) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 与当前库不一致,原件已保留' }; + } + const opened = await session.vault.open(); + if (this.deps.captureOwnerScope() !== scopeKey) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '账号已切换,staging 操作已取消' }; + } + if (!opened.ok) return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: opened.message }; + if (opened.state === 'unavailable') { + return { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: `Library 不可用(${opened.reason ?? 'io'})` }; + } + const hashed = await session.vault.hashFile(pathRel); + if (this.deps.captureOwnerScope() !== scopeKey) { + return { ok: false, errorCode: 'OWNER_CHANGED', message: '账号已切换,staging 操作已取消' }; + } + if (!hashed.ok) { + return hashed.errorCode === 'LIBRARY_UNAVAILABLE' + ? { ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: hashed.message } + : { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 目标不存在或无法核验,原件已保留' }; + } + if (hashed.sha256 !== sha256 || hashed.bytes !== bytes) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library ACK 与 staging 原件不一致,原件已保留' }; + } + const live = this.sessions.get(ghostId); + if ( + this.relocating.has(ghostId) + || this.deps.captureOwnerScope() !== scopeKey + || !live + || live !== session + || live.identity !== libraryIdentity + || live.generation !== libraryGeneration + ) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }; + } + return { ok: true, path: pathRel, sha256, bytes, libraryIdentity, libraryGeneration }; + } + + private async dispatchStaging( + ghostId: string, + op: string, + req: Record, + ): Promise { + const ownerScopeKey = this.deps.captureOwnerScope(); + if (ownerScopeKey === null) { + return fail('OWNER_CHANGED', '当前没有有效账号,staging 已拒绝'); + } + let expectedOwner: unknown; + try { + expectedOwner = this.deps.captureMutationOwner?.(); + } catch (err) { + return fail('OWNER_CHANGED', err instanceof Error ? err.message : '账号切换中,staging 已拒绝'); + } + let releaseLease: (() => void) | undefined; + if (this.deps.beginMutation) { + try { + releaseLease = this.deps.beginMutation(expectedOwner); + } catch (err) { + return fail('OWNER_CHANGED', err instanceof Error ? err.message : '账号已切换,staging 已拒绝'); + } + } + try { + const store = this.getOrCreateStagingStore(ghostId, ownerScopeKey); + switch (op) { + case 'staging.begin': { + const r = await store.begin({ + ghostId, + taskId: req.taskId, + sourceRevision: req.sourceRevision, + totalBytes: req.totalBytes, + sha256: req.sha256, + mime: req.mime, + recovery: req.recovery, + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.begin', stagingId: r.stagingId }; + } + case 'staging.chunk': { + const r = await store.chunk({ + ghostId, + stagingId: req.stagingId, + seq: req.seq, + content: req.content, + encoding: req.encoding, + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.chunk', accepted: r.accepted }; + } + case 'staging.commit': { + const r = await store.commit({ ghostId, stagingId: req.stagingId }); + if (!r.ok) return this.stagingFail(r); + return { + ok: true, op: 'staging.commit', + stagingId: r.stagingId, taskId: r.taskId, sourceRevision: r.sourceRevision, + sha256: r.sha256, bytes: r.bytes, mime: r.mime, durable: true, + }; + } + case 'staging.list': { + const r = await store.list({ ghostId, cursor: req.cursor, limit: req.limit }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.list', items: r.items, hasMore: r.hasMore, nextCursor: r.nextCursor }; + } + case 'staging.read': { + const r = await store.read({ + ghostId, + stagingId: req.stagingId, + offset: req.offset, + length: req.length, + }); + if (!r.ok) return this.stagingFail(r); + return { + ok: true, op: 'staging.read', + stagingId: r.stagingId, content: r.content, encoding: r.encoding, + bytes: r.bytes, sha256: r.sha256, + }; + } + case 'staging.abort': { + const r = await store.abort({ ghostId, stagingId: req.stagingId }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.abort', aborted: r.aborted }; + } + case 'staging.release': { + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + const releaseDone = this.beginStagingRelease(ghostId); + try { + const ack = await this.verifyLibraryAck(ghostId, req); + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + const session = ack.ok === true ? this.sessions.get(ghostId) : undefined; + const r = await store.release({ + ghostId, + stagingId: req.stagingId, + ack, + confirmLibrary: ack.ok !== true + ? undefined + : () => (session + ? this.confirmReleaseLibrary(ghostId, session, ack) + : { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }), + }); + if (!r.ok) return this.stagingFail(r); + return { ok: true, op: 'staging.release', stagingId: r.stagingId, released: r.released }; + } finally { + releaseDone(); + } + } + default: + return fail('PATH_INVALID', `op 必须是 ${GHOST_LIBRARY_OPS.join(' / ')}`, 'INVALID_REQUEST'); + } + } finally { + releaseLease?.(); + } + } + private async teardownSession(ghostId: string): Promise { const session = this.sessions.get(ghostId); if (!session) return; @@ -518,12 +816,17 @@ export class GhostLibrarySlot { /** 停用/卸载/owner 切换收口:作废全部会话(commit 5 的生命周期接线点)。 */ async disposeGhost(ghostId: string): Promise { + await this.waitForStagingReleases(ghostId); await this.teardownSession(ghostId); } async disposeAll(): Promise { - for (const id of Array.from(this.sessions.keys())) { - await this.teardownSession(id); + const ids = new Set([...this.sessions.keys(), ...this.stagingReleaseInflight.keys()]); + for (const id of ids) this.setRelocating(id, true); + try { + for (const id of ids) await this.disposeGhost(id); + } finally { + for (const id of ids) this.setRelocating(id, false); } } diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts new file mode 100644 index 00000000000..f364e1a1644 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -0,0 +1,889 @@ +/** + * libraryStaging.ts — LibraryVault adapter for owner-scoped upload staging. + * File bytes/fsync/rename/hash go through LibraryVault. This layer only owns + * task identity, hard quota, recovery manifests, tombstones, and release. + */ + +import { randomUUID } from 'node:crypto'; + +import { + LibraryVault, + DEFAULT_LIBRARY_LIMITS, + type LibraryVaultDeps, +} from './libraryVault.js'; + +export type LibraryStagingErrorCode = + | 'PATH_INVALID' + | 'TOO_LARGE' + | 'STAGING_QUOTA' + | 'STAGING_BUSY' + | 'DISK_FULL' + | 'STREAM_INVALID' + | 'NOT_FOUND' + | 'ALREADY_EXISTS' + | 'OWNER_CHANGED' + | 'ACK_MISMATCH' + | 'LIBRARY_UNAVAILABLE' + | 'INTERNAL'; + +export type LibraryStagingFailure = { ok: false; errorCode: LibraryStagingErrorCode; message: string }; +export type LibraryStagingSuccess = { ok: true } & T; +export type LibraryStagingResult = LibraryStagingSuccess | LibraryStagingFailure; + +export interface LibraryStagingLimits { + maxTaskBytes: number; + maxTotalBytes: number; + maxConcurrentWrites: number; + maxChunkBytes: number; + reserveBytes: number; + maxRecoveryMetadataBytes: number; + defaultListLimit: number; + maxListLimit: number; +} + +export const DEFAULT_LIBRARY_STAGING_LIMITS: LibraryStagingLimits = { + maxTaskBytes: 8 * 1024 * 1024 * 1024, + maxTotalBytes: 8 * 1024 * 1024 * 1024, + maxConcurrentWrites: 4, + maxChunkBytes: 16 * 1024 * 1024, + reserveBytes: 1024 * 1024 * 1024, + maxRecoveryMetadataBytes: 64 * 1024, + defaultListLimit: 100, + maxListLimit: 500, +}; + +export const GHOST_LIBRARY_STAGING_OPS = [ + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', +] as const; +export type GhostLibraryStagingOp = (typeof GHOST_LIBRARY_STAGING_OPS)[number]; + +export interface LibraryStagingReceipt { + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; +} + +export interface LibraryStagingListItem extends LibraryStagingReceipt { + recovery: Record; +} + +export type LibraryStagingAck = + | { + ok: true; + path: string; + sha256: string; + bytes: number; + libraryIdentity: string; + libraryGeneration: number; + } + | LibraryStagingFailure; + +export interface LibraryStagingDeps { + rootDir: string; + ownerScopeKey: string; + ghostId: string; + captureOwnerScope(): string | null; + createVault?(deps: LibraryVaultDeps): LibraryVault; + getDiskFreeBytes?(root: string): Promise; + log?: LibraryVaultDeps['log']; + limits?: Partial; +} + +const HEX64 = /^[0-9a-f]{64}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const TASK_ID_MAX = 128; +const MIME_MAX = 256; + +const fail = (errorCode: LibraryStagingErrorCode, message: string): LibraryStagingFailure => ({ + ok: false, errorCode, message, +}); + +export function isGhostLibraryStagingOp(op: string): op is GhostLibraryStagingOp { + return (GHOST_LIBRARY_STAGING_OPS as readonly string[]).includes(op); +} + +function vaultFail(r: { errorCode: string; message: string }): LibraryStagingFailure { + const code = r.errorCode as LibraryStagingErrorCode; + const allowed: LibraryStagingErrorCode[] = [ + 'PATH_INVALID', 'TOO_LARGE', 'DISK_FULL', 'STREAM_INVALID', 'NOT_FOUND', + 'ALREADY_EXISTS', 'LIBRARY_UNAVAILABLE', 'INTERNAL', + ]; + return fail(allowed.includes(code) ? code : 'INTERNAL', r.message); +} + +function parseBoundedString(value: unknown, field: string, max: number): string | LibraryStagingFailure { + if (typeof value !== 'string' || value.length === 0 || value.length > max) { + return fail('PATH_INVALID', `${field} 必须是 1..${max} 字符`); + } + return value; +} + +function parseSha256(value: unknown): string | LibraryStagingFailure { + if (typeof value !== 'string' || !HEX64.test(value)) return fail('PATH_INVALID', 'sha256 必须是 64 位小写十六进制'); + return value; +} + +function parseStagingId(value: unknown): string | LibraryStagingFailure { + if (typeof value !== 'string' || !UUID.test(value)) return fail('PATH_INVALID', 'stagingId 必须是 UUID'); + return value; +} + +function parseRecovery(value: unknown, maxBytes: number): LibraryStagingResult<{ recovery: Record }> { + if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) { + return fail('PATH_INVALID', 'recovery 必须是 JSON 对象'); + } + let serialized: string; + try { + serialized = JSON.stringify(value); + } catch { + return fail('PATH_INVALID', 'recovery 不是合法 JSON'); + } + if (Buffer.byteLength(serialized, 'utf8') > maxBytes) { + return fail('TOO_LARGE', `recovery 超上限(${maxBytes} 字节)`); + } + return { ok: true, recovery: JSON.parse(serialized) as Record }; +} + +function decodeStrictBase64(content: unknown, maxBytes: number): Buffer | LibraryStagingFailure { + if (typeof content !== 'string') return fail('PATH_INVALID', 'content 必须是 base64 字符串'); + const compact = content.replace(/[\r\n]/g, ''); + if (compact.length === 0) return Buffer.alloc(0); + const maxChars = Math.floor((maxBytes * 4) / 3) + 8; + if (compact.length > maxChars) return fail('TOO_LARGE', `单块超上限(${maxBytes} 字节)`); + if (compact.length % 4 !== 0) return fail('PATH_INVALID', 'content 不是合法 base64'); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return fail('PATH_INVALID', 'content 不是合法 base64'); + const decoded = Buffer.from(compact, 'base64'); + if (decoded.toString('base64') !== compact) return fail('PATH_INVALID', 'content 不是合法 base64'); + if (decoded.byteLength > maxBytes) return fail('TOO_LARGE', `单块超上限(${maxBytes} 字节)`); + return decoded; +} + +function taskKey(taskId: string, sourceRevision: string): string { + return `${taskId}\0${sourceRevision}`; +} + +function sameRecovery(a: Record, b: Record): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function blobPath(id: string): string { + return `tasks/${id}/blob.bin`; +} +function manifestPath(id: string): string { + return `tasks/${id}/manifest.json`; +} +function tombstonePath(id: string): string { + return `tasks/${id}/tombstone.json`; +} + +interface UploadRecord { + stagingId: string; + streamId: string; + taskId: string; + sourceRevision: string; + totalBytes: number; + sha256Declared: string; + mime: string; + recovery: Record; + nextSeq: number; + lastChunk: Buffer | null; + /** writeCommit succeeded; keep mapping until manifest+dirsync durable. */ + commitPending: boolean; +} + +interface DurableRecord { + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + recovery: Record; +} + +interface DurableManifest { + version: 1; + stagingId: string; + ghostId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + recovery: Record; + durable: true; +} + +function receiptOf(record: DurableRecord): LibraryStagingReceipt { + return { + stagingId: record.stagingId, + taskId: record.taskId, + sourceRevision: record.sourceRevision, + sha256: record.sha256, + bytes: record.bytes, + mime: record.mime, + durable: true, + }; +} + +function parseManifest(raw: string, stagingId: string, ghostId: string): DurableManifest | LibraryStagingFailure { + if (Buffer.byteLength(raw, 'utf8') > 256 * 1024) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 过大'); + let parsed: DurableManifest; + try { + parsed = JSON.parse(raw) as DurableManifest; + } catch { + return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); + } + if ( + parsed?.version !== 1 + || parsed.durable !== true + || parsed.stagingId !== stagingId + || parsed.ghostId !== ghostId + || typeof parsed.taskId !== 'string' || parsed.taskId.length === 0 || parsed.taskId.length > TASK_ID_MAX + || typeof parsed.sourceRevision !== 'string' || parsed.sourceRevision.length === 0 || parsed.sourceRevision.length > TASK_ID_MAX + || typeof parsed.sha256 !== 'string' || !HEX64.test(parsed.sha256) + || typeof parsed.bytes !== 'number' || !Number.isInteger(parsed.bytes) || parsed.bytes < 0 + || typeof parsed.mime !== 'string' || parsed.mime.length === 0 || parsed.mime.length > MIME_MAX + || typeof parsed.recovery !== 'object' || parsed.recovery === null || Array.isArray(parsed.recovery) + ) { + return fail('LIBRARY_UNAVAILABLE', 'staging manifest 字段非法'); + } + return parsed; +} + +export class LibraryStagingStore { + private readonly limits: LibraryStagingLimits; + private readonly ownerScopeKey: string; + private readonly ghostId: string; + private readonly vault: LibraryVault; + private readonly uploads = new Map(); + private readonly byTask = new Map(); + private durables = new Map(); + private durableBytes = 0; + private orphanBlobBytes = 0; + private closedTmpBytes = 0; + private chain: Promise = Promise.resolve(); + private opened = false; + private journalReady = false; + private closedTmpStale = false; + + constructor(private readonly deps: LibraryStagingDeps) { + this.limits = { ...DEFAULT_LIBRARY_STAGING_LIMITS, ...(deps.limits ?? {}) }; + this.ownerScopeKey = deps.ownerScopeKey; + this.ghostId = deps.ghostId; + const capturedRoot = deps.rootDir; + const createVault = deps.createVault ?? ((vaultDeps: LibraryVaultDeps) => new LibraryVault(vaultDeps)); + this.vault = createVault({ + rootDir: () => capturedRoot, + ghostId: deps.ghostId, + getDiskFreeBytes: deps.getDiskFreeBytes, + log: deps.log, + limits: { + writeMaxBytes: this.limits.maxChunkBytes, + readMaxBytes: this.limits.maxChunkBytes, + streamMaxTotalBytes: this.limits.maxTaskBytes, + diskReserveBytes: this.limits.reserveBytes, + softLimitBytes: this.limits.maxTotalBytes, + }, + onStreamClosed: (streamId) => { + for (const [id, upload] of this.uploads) { + if (upload.streamId !== streamId || upload.commitPending) continue; + this.uploads.delete(id); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + this.closedTmpStale = true; + } + }, + }); + } + + private runSerialized(fn: () => Promise): Promise { + const next = this.chain.then(fn, fn); + this.chain = next.catch(() => {}); + return next; + } + + private requireOwner(): LibraryStagingFailure | null { + const live = this.deps.captureOwnerScope(); + if (live === null || live !== this.ownerScopeKey) { + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } + return null; + } + + private async ensureOpen(): Promise { + const opened = await this.vault.open(); + if (!opened.ok) return vaultFail(opened); + if (opened.state === 'unavailable') { + return fail('LIBRARY_UNAVAILABLE', `staging 不可用(${opened.reason ?? 'io'})`); + } + this.opened = true; + return null; + } + + private async listAll(rel: string): Promise }>> { + const entries: Array<{ path: string; kind: 'file' | 'dir'; bytes: number }> = []; + let cursor: string | null = null; + for (;;) { + const page = await this.vault.list({ + path: rel, + recursive: false, + cursor, + limit: DEFAULT_LIBRARY_LIMITS.listPageSize, + strict: true, + }); + if (!page.ok) { + if (page.errorCode === 'NOT_FOUND' && entries.length === 0) { + return { ok: true, entries: [] }; + } + return vaultFail(page); + } + entries.push(...page.entries.map((item) => ({ path: item.path, kind: item.kind, bytes: item.bytes }))); + if (!page.hasMore || page.nextCursor === null) break; + cursor = page.nextCursor; + } + return { ok: true, entries }; + } + + private async loadJournal(): Promise { + if (this.journalReady) return null; + const openFail = await this.ensureOpen(); + if (openFail) return openFail; + const residue = await this.vault.tmpResidueBytes(); + if (!residue.ok) return vaultFail(residue); + const listed = await this.listAll('tasks'); + if (!listed.ok) return listed; + const next = new Map(); + let durableBytes = 0; + let orphanBlobBytes = 0; + for (const entry of listed.entries) { + if (entry.kind !== 'dir' || !UUID.test(entry.path.slice('tasks/'.length))) continue; + const stagingId = entry.path.slice('tasks/'.length); + const inner = await this.listAll(entry.path); + if (!inner.ok) return inner; + const names = new Set(inner.entries.map((item) => item.path.split('/').pop())); + if (names.has('tombstone.json')) { + const marker = await this.readTombstone(stagingId); + if (!marker.ok) return marker; + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) { + // Fail closed without installing a partial journal. Retry after repair + // must not see a half-loaded durables map. Do not count the pre-delete + // leftover size as orphan quota: cleanup already removed those bytes. + return cleaned; + } + continue; + } + if (!names.has('manifest.json')) { + const blob = inner.entries.find((item) => item.path.endsWith('/blob.bin') && item.kind === 'file'); + if (blob) orphanBlobBytes += blob.bytes; + continue; + } + const raw = await this.vault.read({ path: manifestPath(stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); + const parsed = parseManifest(raw.content, stagingId, this.ghostId); + if ('errorCode' in parsed) return parsed; + const hashed = await this.vault.hashFile(blobPath(stagingId)); + if (!hashed.ok) return fail('LIBRARY_UNAVAILABLE', 'staging 原件缺失或不可读'); + if (hashed.sha256 !== parsed.sha256 || hashed.bytes !== parsed.bytes) { + return fail('LIBRARY_UNAVAILABLE', 'staging 原件与 manifest 不一致'); + } + next.set(stagingId, { + stagingId, + taskId: parsed.taskId, + sourceRevision: parsed.sourceRevision, + sha256: parsed.sha256, + bytes: parsed.bytes, + mime: parsed.mime, + recovery: parsed.recovery, + }); + durableBytes += parsed.bytes; + } + this.durables = next; + this.durableBytes = durableBytes; + this.orphanBlobBytes = orphanBlobBytes; + this.closedTmpBytes = residue.bytes; + for (const record of next.values()) { + this.byTask.set(taskKey(record.taskId, record.sourceRevision), record.stagingId); + } + for (const upload of this.uploads.values()) { + this.byTask.set(taskKey(upload.taskId, upload.sourceRevision), upload.stagingId); + } + this.journalReady = true; + return null; + } + + /** In-memory uploads already reserve their declared size; do not also add vault stream bytes. */ + private trackedUploadBytes(): number { + let total = 0; + for (const upload of this.uploads.values()) total += upload.totalBytes; + return total; + } + + private quotaBytes(): number { + return this.durableBytes + + this.orphanBlobBytes + + this.closedTmpBytes + + this.trackedUploadBytes(); + } + + private async requireReady(): Promise { + const denied = this.requireOwner(); + if (denied) return denied; + const loaded = await this.loadJournal(); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (loaded) return loaded; + if (this.closedTmpStale) { + const residue = await this.refreshClosedTmp(); + if (residue) return residue; + this.closedTmpStale = false; + } + return null; + } + + private async refreshClosedTmp(): Promise { + const residue = await this.vault.tmpResidueBytes(); + if (!residue.ok) return vaultFail(residue); + this.closedTmpBytes = residue.bytes; + return null; + } + + /** New tasks/ is not durable until the parent tasks dir, vault root, and any newly created root ancestors are fsynced. */ + private async fsyncDurablePath(stagingId: string): Promise { + for (const rel of [`tasks/${stagingId}`, 'tasks', ''] as const) { + const synced = await this.vault.fsyncDir(rel); + if (!synced.ok) return vaultFail(synced); + if (process.platform !== 'win32' && synced.fsynced !== true) { + return fail('INTERNAL', '目录 fsync 失败'); + } + } + const ancestors = await this.vault.fsyncCreatedAncestors(); + if (!ancestors.ok) return vaultFail(ancestors); + if (process.platform !== 'win32' && ancestors.fsynced !== true) { + return fail('INTERNAL', '根目录项 fsync 失败'); + } + return null; + } + + private async readTombstone(stagingId: string): Promise> { + const raw = await this.vault.read({ path: tombstonePath(stagingId), encoding: 'utf8' }); + if (!raw.ok) { + return raw.errorCode === 'NOT_FOUND' + ? fail('NOT_FOUND', 'stagingId 无效') + : fail('LIBRARY_UNAVAILABLE', 'staging tombstone 不可读'); + } + let parsed: { version?: unknown; stagingId?: unknown; released?: unknown }; + try { + parsed = JSON.parse(raw.content) as { version?: unknown; stagingId?: unknown; released?: unknown }; + } catch { + return fail('LIBRARY_UNAVAILABLE', 'staging tombstone 不可读'); + } + if (parsed.version !== 1 || parsed.stagingId !== stagingId || parsed.released !== true) { + return fail('LIBRARY_UNAVAILABLE', 'staging tombstone 字段非法'); + } + return { ok: true, stagingId }; + } + + private findByTask(taskId: string, sourceRevision: string): DurableRecord | UploadRecord | undefined { + const id = this.byTask.get(taskKey(taskId, sourceRevision)); + if (!id) return undefined; + return this.durables.get(id) ?? this.uploads.get(id); + } + + private async finishReleaseUnlocked(stagingId: string): Promise { + const blob = await this.vault.delete({ path: blobPath(stagingId) }); + if (!blob.ok && blob.errorCode !== 'NOT_FOUND') return vaultFail(blob); + const manifest = await this.vault.delete({ path: manifestPath(stagingId) }); + if (!manifest.ok && manifest.errorCode !== 'NOT_FOUND') return vaultFail(manifest); + await this.vault.delete({ path: tombstonePath(stagingId) }).catch(() => {}); + return null; + } + + async begin(req: { + ghostId: string; + taskId: unknown; + sourceRevision: unknown; + totalBytes: unknown; + sha256: unknown; + mime: unknown; + recovery: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const taskId = parseBoundedString(req.taskId, 'taskId', TASK_ID_MAX); + if (typeof taskId !== 'string') return taskId; + const sourceRevision = parseBoundedString(req.sourceRevision, 'sourceRevision', TASK_ID_MAX); + if (typeof sourceRevision !== 'string') return sourceRevision; + const mime = parseBoundedString(req.mime, 'mime', MIME_MAX); + if (typeof mime !== 'string') return mime; + const sha256 = parseSha256(req.sha256); + if (typeof sha256 !== 'string') return sha256; + if (typeof req.totalBytes !== 'number' || !Number.isInteger(req.totalBytes) || req.totalBytes < 0) { + return fail('PATH_INVALID', 'totalBytes 必须是非负整数'); + } + if (req.totalBytes > this.limits.maxTaskBytes) { + return fail('TOO_LARGE', `分块流总大小超上限(${this.limits.maxTaskBytes} 字节)`); + } + const parsedRecovery = parseRecovery(req.recovery, this.limits.maxRecoveryMetadataBytes); + if (!parsedRecovery.ok) return parsedRecovery; + const existing = this.findByTask(taskId, sourceRevision); + if (existing) { + const same = 'durable' in existing === false + && 'streamId' in existing + && existing.mime === mime + && existing.totalBytes === req.totalBytes + && existing.sha256Declared === sha256 + && sameRecovery(existing.recovery, parsedRecovery.recovery); + const sameDurable = this.durables.get((existing as DurableRecord).stagingId) + && (existing as DurableRecord).mime === mime + && (existing as DurableRecord).bytes === req.totalBytes + && (existing as DurableRecord).sha256 === sha256 + && sameRecovery((existing as DurableRecord).recovery, parsedRecovery.recovery); + if (same || sameDurable) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, stagingId: existing.stagingId }; + } + return fail('ALREADY_EXISTS', '同一 task/revision 已有不同元数据的原件'); + } + if (this.uploads.size >= this.limits.maxConcurrentWrites) { + return fail('STAGING_BUSY', '并发上传已达上限,请稍后重试'); + } + if (this.quotaBytes() + req.totalBytes > this.limits.maxTotalBytes) { + return fail('STAGING_QUOTA', 'staging 总容量不足,请在确认归档后释放再试'); + } + const stagingId = randomUUID(); + const begin = await this.vault.writeBegin({ + path: blobPath(stagingId), + totalBytes: req.totalBytes, + sha256, + }); + if (this.requireOwner()) { + if (begin.ok) await this.vault.writeAbort({ streamId: begin.streamId }).catch(() => {}); + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } + if (!begin.ok) return vaultFail(begin); + this.uploads.set(stagingId, { + stagingId, + streamId: begin.streamId, + taskId, + sourceRevision, + totalBytes: req.totalBytes, + sha256Declared: sha256, + mime, + recovery: parsedRecovery.recovery, + nextSeq: 1, + lastChunk: null, + commitPending: false, + }); + this.byTask.set(taskKey(taskId, sourceRevision), stagingId); + return { ok: true as const, stagingId }; + }); + } + + async chunk(req: { + ghostId: string; + stagingId: unknown; + seq: unknown; + content: unknown; + encoding?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (req.encoding !== undefined && req.encoding !== 'base64') { + return fail('PATH_INVALID', 'encoding 只支持 "base64"'); + } + const upload = this.uploads.get(stagingId); + if (!upload) { + if (this.durables.has(stagingId)) return fail('STREAM_INVALID', '已提交的原件不能再写分块'); + return fail('NOT_FOUND', 'stagingId 无效'); + } + if (typeof req.seq !== 'number' || !Number.isInteger(req.seq) || req.seq < 1) { + return fail('STREAM_INVALID', 'seq 必须从 1 起连续'); + } + const decoded = decodeStrictBase64(req.content, this.limits.maxChunkBytes); + if (!Buffer.isBuffer(decoded)) return decoded; + if (req.seq === upload.nextSeq - 1 && upload.lastChunk && upload.lastChunk.equals(decoded)) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, accepted: decoded.byteLength }; + } + const chunk = await this.vault.writeChunk({ + streamId: upload.streamId, + seq: req.seq, + content: decoded.toString('base64'), + encoding: 'base64', + }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!chunk.ok) return vaultFail(chunk); + upload.nextSeq = req.seq + 1; + upload.lastChunk = decoded; + return { ok: true as const, accepted: chunk.accepted }; + }); + } + + async commit(req: { ghostId: string; stagingId: unknown }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + const durable = this.durables.get(stagingId); + if (durable) { + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { ok: true as const, ...receiptOf(durable) }; + } + const upload = this.uploads.get(stagingId); + if (!upload) return fail('NOT_FOUND', 'stagingId 无效'); + if (!upload.commitPending) { + upload.commitPending = true; + const committed = await this.vault.writeCommit({ streamId: upload.streamId }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!committed.ok) { + this.uploads.delete(stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + const residue = await this.refreshClosedTmp(); + const leftover = await this.vault.stat({ path: blobPath(stagingId) }); + if (leftover.ok && leftover.kind === 'file') this.orphanBlobBytes += leftover.bytes; + if (residue) return residue; + return vaultFail(committed); + } + } + const hashed = await this.vault.hashFile(blobPath(stagingId)); + if (!hashed.ok || hashed.sha256 !== upload.sha256Declared || hashed.bytes !== upload.totalBytes) { + return fail('STREAM_INVALID', 'sha256 校验失败(声明值与实际字节不一致)'); + } + const blobSync = await this.fsyncDurablePath(stagingId); + if (blobSync) return blobSync; + const manifest: DurableManifest = { + version: 1, + stagingId, + ghostId: this.ghostId, + taskId: upload.taskId, + sourceRevision: upload.sourceRevision, + sha256: hashed.sha256, + bytes: hashed.bytes, + mime: upload.mime, + recovery: upload.recovery, + durable: true, + }; + const written = await this.vault.write({ + path: manifestPath(stagingId), + content: JSON.stringify(manifest), + ifNotExists: true, + }); + if (!written.ok) return vaultFail(written); + const journalSync = await this.fsyncDurablePath(stagingId); + if (journalSync) { + await this.vault.delete({ path: manifestPath(stagingId) }).catch(() => {}); + return journalSync; + } + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + const record: DurableRecord = { + stagingId, + taskId: upload.taskId, + sourceRevision: upload.sourceRevision, + sha256: hashed.sha256, + bytes: hashed.bytes, + mime: upload.mime, + recovery: upload.recovery, + }; + this.uploads.delete(stagingId); + this.durables.set(stagingId, record); + this.durableBytes += hashed.bytes; + return { ok: true as const, ...receiptOf(record) }; + }); + } + + async list(req: { + ghostId: string; + cursor?: unknown; + limit?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const limit = req.limit === undefined + ? this.limits.defaultListLimit + : (typeof req.limit === 'number' && Number.isInteger(req.limit) && req.limit >= 1 && req.limit <= this.limits.maxListLimit + ? req.limit + : null); + if (limit === null) return fail('PATH_INVALID', `limit 必须是 1..${this.limits.maxListLimit}`); + if (req.cursor !== undefined && (typeof req.cursor !== 'string' || req.cursor.length === 0)) { + return fail('PATH_INVALID', 'cursor 必须是非空字符串'); + } + const committed = [...this.durables.values()].sort((a, b) => a.stagingId.localeCompare(b.stagingId)); + let start = 0; + if (typeof req.cursor === 'string') { + const cursor = req.cursor; + start = committed.findIndex((record) => record.stagingId > cursor); + if (start < 0) start = committed.length; + } + const slice = committed.slice(start, start + limit); + const hasMore = start + slice.length < committed.length; + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + return { + ok: true as const, + items: slice.map((record) => ({ ...receiptOf(record), recovery: record.recovery })), + hasMore, + nextCursor: hasMore ? slice[slice.length - 1]!.stagingId : null, + }; + }); + } + + async read(req: { + ghostId: string; + stagingId: unknown; + offset?: unknown; + length?: unknown; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (!this.durables.has(stagingId)) return fail('NOT_FOUND', 'stagingId 无效'); + const offset = req.offset === undefined ? 0 : req.offset; + const length = req.length === undefined ? this.limits.maxChunkBytes : req.length; + if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { + return fail('PATH_INVALID', 'offset 必须是非负整数'); + } + if (typeof length !== 'number' || !Number.isInteger(length) || length < 0) { + return fail('PATH_INVALID', 'length 必须是非负整数'); + } + if (length > this.limits.maxChunkBytes) { + return fail('TOO_LARGE', `读取长度超上限(${this.limits.maxChunkBytes} 字节)`); + } + const read = await this.vault.read({ + path: blobPath(stagingId), + encoding: 'base64', + offset, + length, + }); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!read.ok) return vaultFail(read); + return { + ok: true as const, + stagingId, + content: read.content, + encoding: 'base64' as const, + bytes: read.bytes, + sha256: read.sha256, + }; + }); + } + + async abort(req: { ghostId: string; stagingId: unknown }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + const upload = this.uploads.get(stagingId); + if (!upload) { + if (this.durables.has(stagingId)) return fail('STREAM_INVALID', '已提交的原件不能 abort'); + return { ok: true as const, aborted: false }; + } + if (upload.commitPending) { + return fail('STREAM_INVALID', '已提交的原件不能 abort'); + } + const aborted = await this.vault.writeAbort({ streamId: upload.streamId }); + this.uploads.delete(stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + const residue = await this.refreshClosedTmp(); + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + if (!aborted.ok) return vaultFail(aborted); + if (residue) return residue; + return { ok: true as const, aborted: aborted.aborted }; + }); + } + + async release(req: { + ghostId: string; + stagingId: unknown; + ack: LibraryStagingAck; + /** Sync recheck of the current Library session/epoch/migrating gate. */ + confirmLibrary?: () => LibraryStagingFailure | null; + }): Promise> { + return this.runSerialized(async () => { + const ready = await this.requireReady(); + if (ready) return ready; + if (req.ghostId !== this.ghostId) return fail('PATH_INVALID', 'ghostId 与当前 staging 根不一致'); + const stagingId = parseStagingId(req.stagingId); + if (typeof stagingId !== 'string') return stagingId; + if (req.ack.ok !== true) return req.ack; + const confirm = (): LibraryStagingFailure | null => { + const owner = this.requireOwner(); + if (owner) return owner; + return req.confirmLibrary?.() ?? null; + }; + const rollbackTombstone = async (): Promise => { + const deleted = await this.vault.delete({ path: tombstonePath(stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + return null; + }; + const record = this.durables.get(stagingId); + if (!record) { + const blocked = confirm(); + if (blocked) return blocked; + const tomb = await this.readTombstone(stagingId); + const blockedAfter = confirm(); + if (blockedAfter) return blockedAfter; + if (!tomb.ok) { + return tomb.errorCode === 'NOT_FOUND' + ? { ok: true as const, stagingId, released: false } + : tomb; + } + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) return cleaned; + return { ok: true as const, stagingId, released: false }; + } + if (req.ack.sha256 !== record.sha256 || req.ack.bytes !== record.bytes) { + return fail('ACK_MISMATCH', 'Library ACK 与 staging 原件不一致,原件已保留'); + } + const blocked = confirm(); + if (blocked) return blocked; + const stone = await this.vault.write({ + path: tombstonePath(stagingId), + content: JSON.stringify({ version: 1, stagingId, released: true }), + ifNotExists: true, + }); + const blockedAfterWrite = confirm(); + if (blockedAfterWrite) { + return await rollbackTombstone() ?? blockedAfterWrite; + } + if (!stone.ok && stone.errorCode !== 'ALREADY_EXISTS') return vaultFail(stone); + const markerSync = await this.fsyncDurablePath(stagingId); + if (markerSync) { + return await rollbackTombstone() ?? markerSync; + } + const blockedAfterSync = confirm(); + if (blockedAfterSync) { + return await rollbackTombstone() ?? blockedAfterSync; + } + const cleaned = await this.finishReleaseUnlocked(stagingId); + if (cleaned) return cleaned; + this.durables.delete(stagingId); + this.byTask.delete(taskKey(record.taskId, record.sourceRevision)); + this.durableBytes = Math.max(0, this.durableBytes - record.bytes); + return { ok: true as const, stagingId, released: true }; + }); + } +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 7856fdb1f18..82d912ac5a6 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -298,6 +298,8 @@ export class LibraryVault { private opened = false; private invalidated = false; + /** Absolute parent dirs created by recursive mkdir of a missing root; fsync these before claiming durable. */ + private createdAncestorDirs: string[] = []; private state: LibraryState = 'ready'; private unavailableReason: string | null = null; private readonlyReason: string | null = null; @@ -341,6 +343,80 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } + /** mkdir -p that records newly created ancestor dirs (deepest first) so their parent entries can be fsynced. */ + private async mkdirRecordingCreated(absDir: string): Promise { + const target = path.resolve(absDir); + const created: string[] = []; + let cursor = target; + const missing: string[] = []; + for (;;) { + try { + const st = await fs.promises.lstat(cursor); + if (st.isSymbolicLink() || !st.isDirectory()) { + throw Object.assign(new Error('mkdir target is not a directory'), { code: 'ENOTDIR' }); + } + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + missing.push(cursor); + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + } + for (const dir of missing.reverse()) { + await fs.promises.mkdir(dir); + created.push(dir); + } + return created; + } + + private async fsyncAbsDir(absDir: string): Promise> { + const st = await this.lstatTarget(absDir); + if (!st) return fail('NOT_FOUND', '目录不存在'); + if (st.isSymbolicLink() || !st.isDirectory()) { + return fail('PATH_INVALID', 'fsyncDir 目标必须是普通目录'); + } + if (process.platform === 'win32') { + return { ok: true as const, fsynced: false }; + } + try { + const fh = await fs.promises.open(absDir, 'r'); + try { + await fh.sync(); + } finally { + await fh.close(); + } + return { ok: true as const, fsynced: true }; + } catch (err) { + return this.tmpFailure(err, '目录 fsync 失败'); + } + } + + /** + * Fsync parents of directories created when open() first minted this root. + * Syncing the root inode is not the same as syncing the parent directory entry. + * Windows still reports fsynced:false after the same path checks. + */ + async fsyncCreatedAncestors(): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + if (this.createdAncestorDirs.length === 0) { + return { ok: true as const, fsynced: true }; + } + let anyUnsynced = false; + const parents = new Set(); + for (const created of this.createdAncestorDirs) { + parents.add(path.dirname(created)); + } + for (const parent of parents) { + const synced = await this.fsyncAbsDir(parent); + if (!synced.ok) return synced; + if (synced.fsynced !== true) anyUnsynced = true; + } + if (process.platform !== 'win32' && !anyUnsynced) this.createdAncestorDirs = []; + return { ok: true as const, fsynced: !anyUnsynced }; + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -353,7 +429,7 @@ export class LibraryVault { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } try { - await fs.promises.mkdir(this.root, { recursive: true }); + this.createdAncestorDirs = await this.mkdirRecordingCreated(this.root); await fs.promises.mkdir(this.tmpDir, { recursive: true }); await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } catch (err) { @@ -907,6 +983,69 @@ export class LibraryVault { }); } + /** Active writeBegin reservations (declared totalBytes). Staging uses this for hard 8GiB accounting. */ + pendingStreamReservationBytes(): number { + let total = 0; + for (const stream of this.streams.values()) total += stream.totalBytes; + return total; + } + + /** Leftover .cindy-library/tmp files. ENOENT = 0; permission/corrupt/symlink fail closed. */ + async tmpResidueBytes(): Promise> { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(this.tmpDir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: true as const, bytes: 0 }; + } + return this.tmpFailure(err, 'staging 残片目录不可读'); + } + const activeTmp = new Set(Array.from(this.streams.values()).map((stream) => stream.tmpAbs)); + let bytes = 0; + for (const entry of entries) { + const full = path.join(this.tmpDir, entry.name); + if (activeTmp.has(full)) continue; + let st: fs.Stats; + try { + st = await fs.promises.lstat(full); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + return this.tmpFailure(err, 'staging 残片不可读'); + } + if (st.isSymbolicLink()) { + return fail('LIBRARY_UNAVAILABLE', 'staging 残片含符号链接'); + } + if (st.isFile()) bytes += st.size; + } + return { ok: true as const, bytes }; + } + + /** + * Fsync a library-relative directory after durable rename. + * Reuses resolveTarget identity (symlink/root guard). POSIX failure is not durable. + * Windows cannot open a directory fd — returns fsynced:false after the same path checks. + */ + async fsyncDir(relPath: unknown = ''): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + const rel = relPath === '' || relPath === undefined || relPath === null ? '' : String(relPath); + let target: string; + if (rel === '') { + try { + target = await fs.promises.realpath(this.root); + } catch { + return fail('LIBRARY_UNAVAILABLE', 'Library 根目录不可访问'); + } + } else { + const reason = validateLibraryRelPath(rel, this.limits); + if (reason) return fail('PATH_INVALID', reason); + const resolved = await this.resolveTarget(rel); + if (!('target' in resolved)) return resolved; + target = resolved.target; + } + return this.fsyncAbsDir(target); + } + async writeAbort(req: { streamId: unknown }): Promise> { const stream = typeof req.streamId === 'string' ? this.streams.get(req.streamId) : undefined; if (!stream) return { ok: true as const, aborted: false }; // 幂等 @@ -974,7 +1113,7 @@ export class LibraryVault { }); } - async list(req: { path?: unknown; recursive?: unknown; cursor?: unknown; limit?: unknown }): Promise; hasMore: boolean; nextCursor: string | null }>> { + async list(req: { path?: unknown; recursive?: unknown; cursor?: unknown; limit?: unknown; strict?: unknown }): Promise; hasMore: boolean; nextCursor: string | null }>> { if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); const sub = req.path === undefined || req.path === '' ? '' : String(req.path); if (sub !== '') { @@ -985,6 +1124,7 @@ export class LibraryVault { const listRoot = sub === '' ? base : path.join(base, ...sub.split('/')); if (!isInsideDir(base, listRoot)) return fail('PATH_INVALID', 'path 越界'); const recursive = req.recursive === true; + const strict = req.strict === true; const limit = Math.min( typeof req.limit === 'number' && Number.isInteger(req.limit) && req.limit > 0 ? req.limit : this.limits.listPageSize, this.limits.listPageSize, @@ -1022,10 +1162,17 @@ export class LibraryVault { let st: fs.Stats; try { st = await fs.promises.lstat(full); - } catch { + } catch (err) { + if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + return this.tmpFailure(err, '目录条目不可读'); + } continue; } const rel = path.relative(base, full).split(path.sep).join('/'); + if (st.isSymbolicLink()) { + if (strict) return fail('LIBRARY_UNAVAILABLE', '目录含符号链接'); + continue; + } if (st.isFile()) { if (!push(rel, 'file', st.size, Math.round(st.mtimeMs))) break; } else if (st.isDirectory()) { @@ -1281,6 +1428,44 @@ export class LibraryVault { * 相对路径 → 已存在普通文件的绝对路径。路径纪律与 read 同源;任何校验 * 不过(越界/symlink/不存在/是目录)返回 null,调用方统一折叠 404。 */ + /** Stream the whole file for hash+size; never slurps 8GiB into memory. */ + async hashFile(relPath: string): Promise> { + if (!this.opened) return fail('LIBRARY_UNAVAILABLE', 'Library 未打开(先调用 open)'); + const reason = validateLibraryRelPath(relPath, this.limits); + if (reason) return fail('PATH_INVALID', reason); + const resolved = await this.resolveTarget(relPath); + if (!('target' in resolved)) return resolved; + const expected = await this.lstatIdentityForRead(resolved.target); + if (!expected) return fail('NOT_FOUND', `文件不存在:${relPath}`); + if (!expected.isFile()) return fail('PATH_INVALID', `不是文件:${relPath}`); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0); + let handle: LibraryReadHandle | null = null; + try { + handle = await this.openReadHandle(resolved.target, flags); + const opened = await handle.stat(); + if (!opened.isFile()) return fail('PATH_INVALID', `不是文件:${relPath}`); + if (!isSameLibraryFileObject(expected, opened)) { + return fail('INTERNAL', '读取身份校验失败(目标 identity 不一致)'); + } + const size = Number(opened.size); + const hash = crypto.createHash('sha256'); + const buf = Buffer.alloc(Math.min(1024 * 1024, Math.max(size, 1))); + let pos = 0; + while (pos < size) { + const { bytesRead } = await handle.read(buf, 0, buf.length, pos); + if (bytesRead === 0) break; + hash.update(buf.subarray(0, bytesRead)); + pos += bytesRead; + } + if (pos !== size) return fail('INTERNAL', '读取不完整'); + return { ok: true as const, path: relPath, bytes: size, sha256: hash.digest('hex') }; + } catch (err) { + return this.tmpFailure(err, '读取失败(主机 IO 错误)'); + } finally { + if (handle) await handle.close().catch(() => {}); + } + } + async resolveExistingFile(relPath: string): Promise { const reason = validateLibraryRelPath(relPath, this.limits); if (reason) return null; diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 09e188383d8..e3bd2e6720e 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -8290,6 +8290,13 @@ export const GHOST_LIBRARY_OPS = [ 'reveal', 'saveAs', 'clipboardWrite', + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', ] as const; export type GhostLibraryOp = (typeof GHOST_LIBRARY_OPS)[number]; @@ -8297,9 +8304,30 @@ export type GhostLibraryOp = (typeof GHOST_LIBRARY_OPS)[number]; export const GHOST_LIBRARY_CAPABILITY_OPERATIONS = ['clipboardWrite', 'saveAs'] as const; export type GhostLibraryCapabilityOperation = (typeof GHOST_LIBRARY_CAPABILITY_OPERATIONS)[number]; +export const GHOST_LIBRARY_STAGING_OPERATIONS = [ + 'staging.begin', + 'staging.chunk', + 'staging.commit', + 'staging.list', + 'staging.read', + 'staging.release', + 'staging.abort', +] as const; +export type GhostLibraryStagingOperation = (typeof GHOST_LIBRARY_STAGING_OPERATIONS)[number]; + +export const GHOST_LIBRARY_STAGING_LIMITS_V1 = { + version: 1 as const, + maxTaskBytes: 8 * 1024 * 1024 * 1024, + maxTotalBytes: 8 * 1024 * 1024 * 1024, + maxConcurrentWrites: 4, + maxChunkBytes: 16 * 1024 * 1024, + reserveBytes: 1024 * 1024 * 1024, +}; + export const GHOST_LIBRARY_CAPABILITIES_V1 = { version: 1 as const, - operations: GHOST_LIBRARY_CAPABILITY_OPERATIONS, + operations: [...GHOST_LIBRARY_CAPABILITY_OPERATIONS, ...GHOST_LIBRARY_STAGING_OPERATIONS] as const, + staging: GHOST_LIBRARY_STAGING_LIMITS_V1, }; /** 宿主实际操作的稳定失败类别;TIMEOUT / TRANSPORT_ERROR 由插件查询层本地分类,不从 message 猜测。 */ @@ -8376,6 +8404,16 @@ export interface GhostPipeLibraryRequest { length?: number; /** saveAs: 另存为建议文件名(仅 basename)。 */ name?: string; + /** staging: 插件任务身份 / 源版本 / MIME / 恢复元数据。 */ + taskId?: string; + sourceRevision?: string; + mime?: string; + recovery?: Record; + stagingId?: string; + /** staging.release: Library ACK 字节数(不是 begin 的 totalBytes)。 */ + bytes?: number; + libraryIdentity?: string; + libraryGeneration?: number; } /** @@ -8456,9 +8494,57 @@ export type GhostPipeLibraryResult = op: 'capabilities'; capabilities: { version: 1; - operations: GhostLibraryCapabilityOperation[]; + operations: ReadonlyArray; + staging?: { + version: 1; + maxTaskBytes: number; + maxTotalBytes: number; + maxConcurrentWrites: number; + maxChunkBytes: number; + reserveBytes: number; + }; }; } + | { ok: true; op: 'staging.begin'; stagingId: string } + | { ok: true; op: 'staging.chunk'; accepted: number } + | { + ok: true; + op: 'staging.commit'; + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; + } + | { + ok: true; + op: 'staging.list'; + items: Array<{ + stagingId: string; + taskId: string; + sourceRevision: string; + sha256: string; + bytes: number; + mime: string; + durable: true; + recovery: Record; + }>; + hasMore: boolean; + nextCursor: string | null; + } + | { + ok: true; + op: 'staging.read'; + stagingId: string; + content: string; + encoding: 'base64'; + bytes: number; + sha256: string; + } + | { ok: true; op: 'staging.release'; stagingId: string; released: boolean } + | { ok: true; op: 'staging.abort'; aborted: boolean } | { ok: false; errorCode: string; message: string; reason?: GhostLibraryErrorReason }; /** Library 概览(ghosts:library-overview IPC 载荷;设置页插件详情消费)。 */ diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index 404a875fb75..ddd113aa59c 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -21,6 +21,7 @@ | per-plugin worker 入口 | `apps/desktop/src/main/cindy-brain/libraryDbWorker.ts` | | 主进程 RPC 服务(发送前语句门 / dispose 收口) | `apps/desktop/src/main/cindy-brain/librarySqlService.ts` | | 协议分派(资格审 / binding 根解析 / owner scope 复核) | `apps/desktop/src/main/cindy-brain/librarySlot.ts` | +| owner-scoped staging(独立于可迁移 Library 根) | `apps/desktop/src/main/cindy-brain/libraryStaging.ts` | | 随时迁移状态机 | `apps/desktop/src/main/cindy-brain/libraryMigrate.ts` | | 回收站删除通道 | `apps/desktop/src/main/cindy-brain/libraryTrash.ts` | | 管子协议类型(`library-request`) | `apps/desktop/src/shared/ghost.ts`(`GhostPipeLibraryRequest/Result`) | @@ -34,7 +35,8 @@ owners// ├── libraries// # 系统管理默认根 ├── libraries-binding.json # 自定义位置持久 binding(原子写) -└── libraries-trash/-/ # 删除通道的 30 天回收站 +├── libraries-trash/-/ # 删除通道的 30 天回收站 +└── library-staging// # H1 上传 staging(不可随 Library 根迁移) ``` 自定义根 = `<用户所选父目录>/`(binding 记录 realpath 快照 + 文件 @@ -108,8 +110,13 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 13. **只读操作能力合同(capabilities)**:`{op:'capabilities'}` 在资格审与 op 合法性 校验之后、会话创建之前返回,不捕获 owner、不解析库根、不 open vault、不弹窗、 不碰剪贴板、不泄漏 owner 或绝对库路径。成功形态固定为 - `{ok:true, op:'capabilities', capabilities:{version:1, operations:['clipboardWrite','saveAs']}}`。 + `{ok:true, op:'capabilities', capabilities:{version:1, operations:[...clipboardWrite/saveAs, staging.*], staging:{version:1,...limits}}}`。 `operations` 只表达**实现支持**,不等于此刻有窗口、已授权或库可用。 + staging 操作与 capabilities 在 Library open/root/authorizedReadonly 门之前分派, + 仍验当前 owner 与已启用 library 能力;只有 `staging.release` 核验当前 Library ACK。 + `disposeGhost` / `disposeAll` 先置 `relocating` 再排空该 ghost 在途 `staging.release`(tombstone/fsync 期间 Library 会话保持稳定),新的 release 在闸上拒绝;bind/unbind/relocate/delete 都先置 relocating 再 dispose,不把 owner mutation lease 当迁库锁。首次 mint staging 根时,耐久还要 fsync 新建根在其父目录中的 entry,只 fsync 根 inode 不算。Windows 仍报 `fsynced:false`。 + staging 根按 owner×ghost 捕获后不漂移;坏/不可读 manifest 返回 `LIBRARY_UNAVAILABLE`,不得报空或释放对应空间。 + 恢复与 release 走流式 hash,禁止 `readFile` 整文件入内存。null owner 拒。 消费规则:仅 `version===1` 且 `operations` 为字符串数组才有效;额外字段忽略,未知 operation 忽略,已知项保留;有效 v1 清单缺少某项才是 unsupported;缺字段、错类型、 `version` 非 1、或旧宿主 unknown-op 一律 unknown。旧插件无需重装或重授权。 From 5c2a71798d1cab619b3f415c868fcd8e738b62e2 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 04:43:02 +0800 Subject: [PATCH 04/23] fix(desktop): recover staging commit from retained manifest and intent Same-instance retry after a durable manifest write plus failed journal fsync no longer dies on ifNotExists ALREADY_EXISTS. Matching identity is adopted; conflicting identity is rejected without overwrite. New originals write intent.json before blob rename. A new Store recovers only from that trusted identity after hash/bytes checks, then installs manifest+dirsync. Incomplete or conflicting intent fails closed and keeps the source. Historical orphans without intent stay billed in isolation and are not TTL-deleted. Signed-off-by: PraiseZhu --- .../__tests__/libraryStaging.test.ts | 157 ++++++++ .../src/main/cindy-brain/libraryStaging.ts | 335 ++++++++++++++---- docs/dev-rules/plugin-library-storage.md | 1 + 3 files changed, 424 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts index 5ed9be6286f..128395c0c5a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -105,6 +105,9 @@ describe('LibraryStagingStore 故障恢复', () => { function tombstoneAbs(root: string, stagingId: string): string { return path.join(root, 'tasks', stagingId, 'tombstone.json'); } + function intentAbs(root: string, stagingId: string): string { + return path.join(root, 'tasks', stagingId, 'intent.json'); + } function matchingAck(commit: { sha256: string; bytes: number }) { return { ok: true as const, @@ -831,4 +834,158 @@ describe('LibraryStagingStore 故障恢复', () => { const crossed = await store.read({ ghostId, stagingId }); expect(crossed).toMatchObject({ ok: false, errorCode: 'OWNER_CHANGED' }); }); + + it('manifest 已落盘且 journalSync/delete 失败后,同实例重试读回身份并 durable,冲突 manifest 拒绝覆盖', async () => { + const root = path.join(tmp, 'retained-manifest', ghostId); + const store = makeStore(root, { maxTotalBytes: 1024 }); + const started = await beginChunk(store, 'retained', body); + expect(fs.existsSync(intentAbs(root, started.stagingId))).toBe(true); + const origWrite = LibraryVault.prototype.write; + const origFsync = LibraryVault.prototype.fsyncDir; + const origDelete = LibraryVault.prototype.delete; + let failJournal = false; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + const result = await origWrite.call(this, req); + if (typeof req.path === 'string' && req.path.endsWith('manifest.json') && result.ok) failJournal = true; + return result; + }); + const fsyncSpy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (failJournal) return { ok: false, errorCode: 'INTERNAL', message: 'journal fsync 失败' }; + return origFsync.call(this, relPath); + }); + const deleteSpy = vi.spyOn(LibraryVault.prototype, 'delete').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest delete failed' }; + } + return origDelete.call(this, req); + }); + try { + const failed = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.errorCode).toBe('INTERNAL'); + expect(fs.existsSync(manifestAbs(root, started.stagingId))).toBe(true); + expect(fs.existsSync(blobAbs(root, started.stagingId))).toBe(true); + } finally { + writeSpy.mockRestore(); + fsyncSpy.mockRestore(); + deleteSpy.mockRestore(); + } + const retried = await store.commit({ ghostId, stagingId: started.stagingId }); + expect(retried).toMatchObject({ ok: true, stagingId: started.stagingId, durable: true, bytes: Buffer.byteLength(body) }); + const listed = await store.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + + const conflictRoot = path.join(tmp, 'conflict-manifest', ghostId); + const conflictStore = makeStore(conflictRoot, { maxTotalBytes: 1024 }); + const other = await beginChunk(conflictStore, 'conflict', body); + const origFsync2 = LibraryVault.prototype.fsyncDir; + const plant = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (relPath === `tasks/${other.stagingId}` && !fs.existsSync(manifestAbs(conflictRoot, other.stagingId))) { + const r = await origFsync2.call(this, relPath); + await fs.promises.writeFile(manifestAbs(conflictRoot, other.stagingId), JSON.stringify({ + version: 1, durable: true, intent: false, + stagingId: other.stagingId, ghostId, ownerScopeKey: 'local:owner-a:1', + taskId: 'not-this-task', sourceRevision: 'rev-x', + sha256: other.digest, bytes: other.bytes, mime: 'image/png', recovery, + })); + return r; + } + return origFsync2.call(this, relPath); + }); + try { + const conflicted = await conflictStore.commit({ ghostId, stagingId: other.stagingId }); + expect(conflicted).toMatchObject({ ok: false, errorCode: 'ALREADY_EXISTS' }); + const onDisk = JSON.parse(await fs.promises.readFile(manifestAbs(conflictRoot, other.stagingId), 'utf8')) as { taskId: string }; + expect(onDisk.taskId).toBe('not-this-task'); + expect(fs.existsSync(blobAbs(conflictRoot, other.stagingId))).toBe(true); + } finally { + plant.mockRestore(); + } + }); + + it('intent 在 blob 就位前 fsync 失败则回滚;intent+blob 重启后恢复;冲突/不完整 fail-closed 保留源;release 清 intent;无配额泄漏', async () => { + const fsyncRoot = path.join(tmp, 'intent-fsync', ghostId); + const fsyncStore = makeStore(fsyncRoot, { maxTotalBytes: 1024 }); + const origFsync = LibraryVault.prototype.fsyncDir; + const fsyncSpy = vi.spyOn(LibraryVault.prototype, 'fsyncDir').mockImplementation(async function (this: LibraryVault, relPath?: unknown) { + if (relPath === 'tasks' || relPath === '') { + return { ok: false, errorCode: 'INTERNAL', message: 'intent parent fsync 失败' }; + } + return origFsync.call(this, relPath); + }); + try { + const began = await fsyncStore.begin({ + ghostId, taskId: 'intent-fsync', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery, + }); + expect(began.ok).toBe(false); + if (!began.ok) expect(began.errorCode).toBe('INTERNAL'); + } finally { + fsyncSpy.mockRestore(); + } + const taskDirs = fs.existsSync(path.join(fsyncRoot, 'tasks')) + ? await fs.promises.readdir(path.join(fsyncRoot, 'tasks')) + : []; + for (const id of taskDirs) { + expect(fs.existsSync(intentAbs(fsyncRoot, id))).toBe(false); + expect(fs.existsSync(blobAbs(fsyncRoot, id))).toBe(false); + } + + const recoverRoot = path.join(tmp, 'intent-recover', ghostId); + const live = makeStore(recoverRoot, { maxTotalBytes: 20 }); + const started = await beginChunk(live, 'intent-crash', 'x'.repeat(20)); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(true); + const origWrite = LibraryVault.prototype.write; + const writeSpy = vi.spyOn(LibraryVault.prototype, 'write').mockImplementation(async function (this: LibraryVault, req) { + if (typeof req.path === 'string' && req.path.endsWith('manifest.json')) { + return { ok: false, errorCode: 'INTERNAL', message: 'manifest write failed' }; + } + return origWrite.call(this, req); + }); + try { + const failed = await live.commit({ ghostId, stagingId: started.stagingId }); + expect(failed.ok).toBe(false); + expect(fs.existsSync(blobAbs(recoverRoot, started.stagingId))).toBe(true); + expect(fs.existsSync(manifestAbs(recoverRoot, started.stagingId))).toBe(false); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(true); + } finally { + writeSpy.mockRestore(); + } + const restored = makeStore(recoverRoot, { maxTotalBytes: 20 }); + const listed = await restored.list({ ghostId }); + if (!listed.ok) throw new Error(`intent recover list: ${JSON.stringify(listed)}`); + expect(listed.items.map((item) => item.stagingId)).toEqual([started.stagingId]); + expect(listed.items[0]?.durable).toBe(true); + expect(fs.existsSync(manifestAbs(recoverRoot, started.stagingId))).toBe(true); + const read = await restored.read({ ghostId, stagingId: started.stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(Buffer.from(read.content, 'base64').toString('utf8')).toBe('x'.repeat(20)); + const leaked = await restored.begin({ + ghostId, taskId: 'should-quota', sourceRevision: 'rev-1', + totalBytes: 1, sha256: sha256Of('y'), mime: 'image/png', recovery, + }); + expect(leaked).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + const released = await restored.release({ + ghostId, stagingId: started.stagingId, ack: matchingAck({ sha256: started.digest, bytes: 20 }), + }); + expect(released).toEqual({ ok: true, stagingId: started.stagingId, released: true }); + expect(fs.existsSync(intentAbs(recoverRoot, started.stagingId))).toBe(false); + expect(fs.existsSync(blobAbs(recoverRoot, started.stagingId))).toBe(false); + + const conflictId = randomUUID(); + const conflictRoot = path.join(tmp, 'intent-conflict', ghostId); + await fs.promises.mkdir(path.join(conflictRoot, 'tasks', conflictId), { recursive: true }); + await fs.promises.writeFile(path.join(conflictRoot, 'tasks', conflictId, 'blob.bin'), body); + await fs.promises.writeFile(path.join(conflictRoot, 'tasks', conflictId, 'intent.json'), JSON.stringify({ + version: 1, intent: true, + stagingId: conflictId, ghostId, ownerScopeKey: 'local:owner-a:1', + taskId: 'guessed', sourceRevision: 'rev-1', + sha256: '0'.repeat(64), bytes: Buffer.byteLength(body), mime: 'image/png', recovery, + })); + const conflicted = makeStore(conflictRoot, { maxTotalBytes: 1024 }); + const conflictList = await conflicted.list({ ghostId }); + expect(conflictList).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(blobAbs(conflictRoot, conflictId))).toBe(true); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index f364e1a1644..c9ade779fd7 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -179,6 +179,9 @@ function sameRecovery(a: Record, b: Record): b function blobPath(id: string): string { return `tasks/${id}/blob.bin`; } +function intentPath(id: string): string { + return `tasks/${id}/intent.json`; +} function manifestPath(id: string): string { return `tasks/${id}/manifest.json`; } @@ -211,19 +214,28 @@ interface DurableRecord { recovery: Record; } -interface DurableManifest { - version: 1; +interface TaskIdentity { stagingId: string; ghostId: string; + ownerScopeKey: string; taskId: string; sourceRevision: string; sha256: string; bytes: number; mime: string; recovery: Record; +} + +interface DurableManifest extends TaskIdentity { + version: 1; durable: true; } +interface IntentMarker extends TaskIdentity { + version: 1; + intent: true; +} + function receiptOf(record: DurableRecord): LibraryStagingReceipt { return { stagingId: record.stagingId, @@ -236,19 +248,18 @@ function receiptOf(record: DurableRecord): LibraryStagingReceipt { }; } -function parseManifest(raw: string, stagingId: string, ghostId: string): DurableManifest | LibraryStagingFailure { - if (Buffer.byteLength(raw, 'utf8') > 256 * 1024) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 过大'); - let parsed: DurableManifest; - try { - parsed = JSON.parse(raw) as DurableManifest; - } catch { - return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); - } +function parseTaskIdentity( + parsed: Record, + stagingId: string, + ghostId: string, + ownerScopeKey: string, + kind: 'manifest' | 'intent', +): TaskIdentity | LibraryStagingFailure { + const label = kind === 'manifest' ? 'staging manifest' : 'staging intent'; if ( - parsed?.version !== 1 - || parsed.durable !== true - || parsed.stagingId !== stagingId + parsed.stagingId !== stagingId || parsed.ghostId !== ghostId + || parsed.ownerScopeKey !== ownerScopeKey || typeof parsed.taskId !== 'string' || parsed.taskId.length === 0 || parsed.taskId.length > TASK_ID_MAX || typeof parsed.sourceRevision !== 'string' || parsed.sourceRevision.length === 0 || parsed.sourceRevision.length > TASK_ID_MAX || typeof parsed.sha256 !== 'string' || !HEX64.test(parsed.sha256) @@ -256,9 +267,76 @@ function parseManifest(raw: string, stagingId: string, ghostId: string): Durable || typeof parsed.mime !== 'string' || parsed.mime.length === 0 || parsed.mime.length > MIME_MAX || typeof parsed.recovery !== 'object' || parsed.recovery === null || Array.isArray(parsed.recovery) ) { + return fail('LIBRARY_UNAVAILABLE', `${label} 字段非法`); + } + return { + stagingId, + ghostId, + ownerScopeKey, + taskId: parsed.taskId, + sourceRevision: parsed.sourceRevision, + sha256: parsed.sha256, + bytes: parsed.bytes, + mime: parsed.mime, + recovery: parsed.recovery as Record, + }; +} + +function parseJsonObject(raw: string, label: string): { ok: true; value: Record } | LibraryStagingFailure { + if (Buffer.byteLength(raw, 'utf8') > 256 * 1024) return fail('LIBRARY_UNAVAILABLE', `${label} 过大`); + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return fail('LIBRARY_UNAVAILABLE', `${label} 不可读`); + } + return { ok: true, value: parsed as Record }; + } catch { + return fail('LIBRARY_UNAVAILABLE', `${label} 不可读`); + } +} + +function parseManifest( + raw: string, + stagingId: string, + ghostId: string, + ownerScopeKey: string, +): DurableManifest | LibraryStagingFailure { + const parsed = parseJsonObject(raw, 'staging manifest'); + if (!parsed.ok) return parsed; + if (parsed.value.version !== 1 || parsed.value.durable !== true || parsed.value.intent === true) { return fail('LIBRARY_UNAVAILABLE', 'staging manifest 字段非法'); } - return parsed; + const identity = parseTaskIdentity(parsed.value, stagingId, ghostId, ownerScopeKey, 'manifest'); + if ('errorCode' in identity) return identity; + return { ...identity, version: 1, durable: true }; +} + +function parseIntent( + raw: string, + stagingId: string, + ghostId: string, + ownerScopeKey: string, +): IntentMarker | LibraryStagingFailure { + const parsed = parseJsonObject(raw, 'staging intent'); + if (!parsed.ok) return parsed; + if (parsed.value.version !== 1 || parsed.value.intent !== true || parsed.value.durable === true) { + return fail('LIBRARY_UNAVAILABLE', 'staging intent 字段非法'); + } + const identity = parseTaskIdentity(parsed.value, stagingId, ghostId, ownerScopeKey, 'intent'); + if ('errorCode' in identity) return identity; + return { ...identity, version: 1, intent: true }; +} + +function identityMatches(actual: TaskIdentity, expected: TaskIdentity): boolean { + return actual.stagingId === expected.stagingId + && actual.ghostId === expected.ghostId + && actual.ownerScopeKey === expected.ownerScopeKey + && actual.taskId === expected.taskId + && actual.sourceRevision === expected.sourceRevision + && actual.sha256 === expected.sha256 + && actual.bytes === expected.bytes + && actual.mime === expected.mime + && sameRecovery(actual.recovery, expected.recovery); } export class LibraryStagingStore { @@ -383,30 +461,22 @@ export class LibraryStagingStore { } continue; } - if (!names.has('manifest.json')) { - const blob = inner.entries.find((item) => item.path.endsWith('/blob.bin') && item.kind === 'file'); - if (blob) orphanBlobBytes += blob.bytes; + if (names.has('manifest.json')) { + const recovered = await this.recoverDurableFromDisk(stagingId, names.has('intent.json')); + if (!recovered.ok) return recovered; + next.set(stagingId, recovered.record); + durableBytes += recovered.record.bytes; continue; } - const raw = await this.vault.read({ path: manifestPath(stagingId), encoding: 'utf8' }); - if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); - const parsed = parseManifest(raw.content, stagingId, this.ghostId); - if ('errorCode' in parsed) return parsed; - const hashed = await this.vault.hashFile(blobPath(stagingId)); - if (!hashed.ok) return fail('LIBRARY_UNAVAILABLE', 'staging 原件缺失或不可读'); - if (hashed.sha256 !== parsed.sha256 || hashed.bytes !== parsed.bytes) { - return fail('LIBRARY_UNAVAILABLE', 'staging 原件与 manifest 不一致'); + if (names.has('intent.json') && names.has('blob.bin')) { + const recovered = await this.recoverDurableFromIntent(stagingId); + if (!recovered.ok) return recovered; + next.set(stagingId, recovered.record); + durableBytes += recovered.record.bytes; + continue; } - next.set(stagingId, { - stagingId, - taskId: parsed.taskId, - sourceRevision: parsed.sourceRevision, - sha256: parsed.sha256, - bytes: parsed.bytes, - mime: parsed.mime, - recovery: parsed.recovery, - }); - durableBytes += parsed.bytes; + const blob = inner.entries.find((item) => item.path.endsWith('/blob.bin') && item.kind === 'file'); + if (blob) orphanBlobBytes += blob.bytes; } this.durables = next; this.durableBytes = durableBytes; @@ -499,11 +569,144 @@ export class LibraryStagingStore { return this.durables.get(id) ?? this.uploads.get(id); } + private durableFromIdentity(identity: TaskIdentity): DurableRecord { + return { + stagingId: identity.stagingId, + taskId: identity.taskId, + sourceRevision: identity.sourceRevision, + sha256: identity.sha256, + bytes: identity.bytes, + mime: identity.mime, + recovery: identity.recovery, + }; + } + + private expectedIdentity(upload: UploadRecord, hashed: { sha256: string; bytes: number }): TaskIdentity { + return { + stagingId: upload.stagingId, + ghostId: this.ghostId, + ownerScopeKey: this.ownerScopeKey, + taskId: upload.taskId, + sourceRevision: upload.sourceRevision, + sha256: hashed.sha256, + bytes: hashed.bytes, + mime: upload.mime, + recovery: upload.recovery, + }; + } + + private async hashMatchesIdentity(identity: TaskIdentity): Promise> { + const hashed = await this.vault.hashFile(blobPath(identity.stagingId)); + if (!hashed.ok) return fail('LIBRARY_UNAVAILABLE', 'staging 原件缺失或不可读'); + if (hashed.sha256 !== identity.sha256 || hashed.bytes !== identity.bytes) { + return fail('LIBRARY_UNAVAILABLE', 'staging 原件与声明身份不一致'); + } + return { ok: true, record: this.durableFromIdentity(identity) }; + } + + private async recoverDurableFromDisk( + stagingId: string, + hasIntent: boolean, + ): Promise> { + const raw = await this.vault.read({ path: manifestPath(stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读'); + const parsed = parseManifest(raw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in parsed) return parsed; + if (hasIntent) { + const intentRaw = await this.vault.read({ path: intentPath(stagingId), encoding: 'utf8' }); + if (!intentRaw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const intent = parseIntent(intentRaw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in intent) return intent; + if (!identityMatches(parsed, intent)) { + return fail('LIBRARY_UNAVAILABLE', 'staging intent 与 manifest 冲突'); + } + } + return this.hashMatchesIdentity(parsed); + } + + private async recoverDurableFromIntent(stagingId: string): Promise> { + const raw = await this.vault.read({ path: intentPath(stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const intent = parseIntent(raw.content, stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in intent) return intent; + const matched = await this.hashMatchesIdentity(intent); + if (!matched.ok) return matched; + const promoted = await this.writeManifestUnlocked(intent); + if (promoted) return promoted; + return { ok: true, record: matched.record }; + } + + private async writeIntentUnlocked(identity: TaskIdentity): Promise { + const marker: IntentMarker = { ...identity, version: 1, intent: true }; + const written = await this.vault.write({ + path: intentPath(identity.stagingId), + content: JSON.stringify(marker), + ifNotExists: true, + }); + if (!written.ok && written.errorCode !== 'ALREADY_EXISTS') return vaultFail(written); + if (!written.ok) { + const raw = await this.vault.read({ path: intentPath(identity.stagingId), encoding: 'utf8' }); + if (!raw.ok) return fail('LIBRARY_UNAVAILABLE', 'staging intent 不可读'); + const existing = parseIntent(raw.content, identity.stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in existing) return existing; + if (!identityMatches(existing, identity)) { + return fail('ALREADY_EXISTS', 'staging intent 与当前任务身份冲突'); + } + } + const synced = await this.fsyncDurablePath(identity.stagingId); + if (synced) { + const deleted = await this.vault.delete({ path: intentPath(identity.stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + return synced; + } + return null; + } + + private async writeManifestUnlocked(identity: TaskIdentity): Promise { + const manifest: DurableManifest = { ...identity, version: 1, durable: true }; + const written = await this.vault.write({ + path: manifestPath(identity.stagingId), + content: JSON.stringify(manifest), + ifNotExists: true, + }); + if (!written.ok && written.errorCode !== 'ALREADY_EXISTS') return vaultFail(written); + let created = written.ok; + if (!written.ok) { + const adopted = await this.adoptExistingManifest(identity); + if (adopted.error) return adopted.error; + created = false; + } + const journalSync = await this.fsyncDurablePath(identity.stagingId); + if (journalSync) { + if (created) { + const deleted = await this.vault.delete({ path: manifestPath(identity.stagingId) }); + if (!deleted.ok && deleted.errorCode !== 'NOT_FOUND') return vaultFail(deleted); + } + return journalSync; + } + return null; + } + + private async adoptExistingManifest( + expected: TaskIdentity, + ): Promise<{ error: LibraryStagingFailure | null }> { + const raw = await this.vault.read({ path: manifestPath(expected.stagingId), encoding: 'utf8' }); + if (!raw.ok) return { error: fail('LIBRARY_UNAVAILABLE', 'staging manifest 不可读') }; + const parsed = parseManifest(raw.content, expected.stagingId, this.ghostId, this.ownerScopeKey); + if ('errorCode' in parsed) return { error: parsed }; + if (!identityMatches(parsed, expected)) { + return { error: fail('ALREADY_EXISTS', '已有 manifest 与当前任务身份冲突') }; + } + return { error: null }; + } + private async finishReleaseUnlocked(stagingId: string): Promise { const blob = await this.vault.delete({ path: blobPath(stagingId) }); if (!blob.ok && blob.errorCode !== 'NOT_FOUND') return vaultFail(blob); const manifest = await this.vault.delete({ path: manifestPath(stagingId) }); if (!manifest.ok && manifest.errorCode !== 'NOT_FOUND') return vaultFail(manifest); + const intent = await this.vault.delete({ path: intentPath(stagingId) }); + if (!intent.ok && intent.errorCode !== 'NOT_FOUND') return vaultFail(intent); await this.vault.delete({ path: tombstonePath(stagingId) }).catch(() => {}); return null; } @@ -563,6 +766,23 @@ export class LibraryStagingStore { return fail('STAGING_QUOTA', 'staging 总容量不足,请在确认归档后释放再试'); } const stagingId = randomUUID(); + const identity: TaskIdentity = { + stagingId, + ghostId: this.ghostId, + ownerScopeKey: this.ownerScopeKey, + taskId, + sourceRevision, + sha256, + bytes: req.totalBytes, + mime, + recovery: parsedRecovery.recovery, + }; + const intentFail = await this.writeIntentUnlocked(identity); + if (intentFail) return intentFail; + if (this.requireOwner()) { + await this.vault.delete({ path: intentPath(stagingId) }).catch(() => {}); + return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); + } const begin = await this.vault.writeBegin({ path: blobPath(stagingId), totalBytes: req.totalBytes, @@ -572,7 +792,10 @@ export class LibraryStagingStore { if (begin.ok) await this.vault.writeAbort({ streamId: begin.streamId }).catch(() => {}); return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); } - if (!begin.ok) return vaultFail(begin); + if (!begin.ok) { + await this.vault.delete({ path: intentPath(stagingId) }).catch(() => {}); + return vaultFail(begin); + } this.uploads.set(stagingId, { stagingId, streamId: begin.streamId, @@ -669,42 +892,14 @@ export class LibraryStagingStore { } const blobSync = await this.fsyncDurablePath(stagingId); if (blobSync) return blobSync; - const manifest: DurableManifest = { - version: 1, - stagingId, - ghostId: this.ghostId, - taskId: upload.taskId, - sourceRevision: upload.sourceRevision, - sha256: hashed.sha256, - bytes: hashed.bytes, - mime: upload.mime, - recovery: upload.recovery, - durable: true, - }; - const written = await this.vault.write({ - path: manifestPath(stagingId), - content: JSON.stringify(manifest), - ifNotExists: true, - }); - if (!written.ok) return vaultFail(written); - const journalSync = await this.fsyncDurablePath(stagingId); - if (journalSync) { - await this.vault.delete({ path: manifestPath(stagingId) }).catch(() => {}); - return journalSync; - } - if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); - const record: DurableRecord = { - stagingId, - taskId: upload.taskId, - sourceRevision: upload.sourceRevision, - sha256: hashed.sha256, - bytes: hashed.bytes, - mime: upload.mime, - recovery: upload.recovery, - }; + const identity = this.expectedIdentity(upload, hashed); + const written = await this.writeManifestUnlocked(identity); + if (written) return written; + const record = this.durableFromIdentity(identity); this.uploads.delete(stagingId); this.durables.set(stagingId, record); this.durableBytes += hashed.bytes; + if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); return { ok: true as const, ...receiptOf(record) }; }); } @@ -807,6 +1002,8 @@ export class LibraryStagingStore { const aborted = await this.vault.writeAbort({ streamId: upload.streamId }); this.uploads.delete(stagingId); this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + const intentDeleted = await this.vault.delete({ path: intentPath(stagingId) }); + if (!intentDeleted.ok && intentDeleted.errorCode !== 'NOT_FOUND') return vaultFail(intentDeleted); const residue = await this.refreshClosedTmp(); if (this.requireOwner()) return fail('OWNER_CHANGED', '账号已切换,staging 操作已取消'); if (!aborted.ok) return vaultFail(aborted); diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index ddd113aa59c..918bd4b1ea4 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -116,6 +116,7 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 仍验当前 owner 与已启用 library 能力;只有 `staging.release` 核验当前 Library ACK。 `disposeGhost` / `disposeAll` 先置 `relocating` 再排空该 ghost 在途 `staging.release`(tombstone/fsync 期间 Library 会话保持稳定),新的 release 在闸上拒绝;bind/unbind/relocate/delete 都先置 relocating 再 dispose,不把 owner mutation lease 当迁库锁。首次 mint staging 根时,耐久还要 fsync 新建根在其父目录中的 entry,只 fsync 根 inode 不算。Windows 仍报 `fsynced:false`。 staging 根按 owner×ghost 捕获后不漂移;坏/不可读 manifest 返回 `LIBRARY_UNAVAILABLE`,不得报空或释放对应空间。 + 新原件在 blob 就位前经 Vault 写下 `intent.json`(owner/ghost/id/task/revision/hash/bytes/mime/recovery);崩溃后 new Store 只从这份可信 intent 校验 bytes/hash 再补 `manifest.json`+dirfsync,不从 blob 猜归属。不完整或冲突 fail-closed 并保留源。本 PR 新原件必有 intent;历史无 intent 的 orphan blob 只隔离计费,不 TTL 删除、不声称可恢复。原件只在 Library ACK 且调用者已保存画布后释放。 恢复与 release 走流式 hash,禁止 `readFile` 整文件入内存。null owner 拒。 消费规则:仅 `version===1` 且 `operations` 为字符串数组才有效;额外字段忽略,未知 operation 忽略,已知项保留;有效 v1 清单缺少某项才是 unsupported;缺字段、错类型、 From f255e0db1e8b889491d5ed22690d19825009dafd Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 17:12:44 +0800 Subject: [PATCH 05/23] fix(desktop): fail closed when a custom Library parent vanishes A stale custom resolveLibraryRoot could still reach vault.open, which recursively created the missing user parent and reported ready empty. Custom open now refuses to recreate a vanished parent (disk-missing); default first-time create still mkdir. keep files remain in the renamed directory and are not treated as deleted. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 30 +++++++++++++ .../__tests__/libraryVault.test.ts | 25 +++++++++++ .../src/main/cindy-brain/librarySlot.ts | 29 +++++++++++- .../src/main/cindy-brain/libraryVault.ts | 44 ++++++++++++++++++- docs/dev-rules/plugin-library-storage.md | 8 ++-- 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 26a3cb2391d..7e8d3937429 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -431,6 +431,36 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); }); + it('delayed resolveLibraryRoot: stale custom after parent rename is disk-missing without recreating empty library', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null) { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 5069a386c54..c74180ef552 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -123,6 +123,31 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => { + const parent = path.join(tmpRoot, 'picked'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const first = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const opened = await first.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.rename(parent, `${parent}.parked`); + const second = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const missing = await second.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(`${parent}.parked`, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + + it('default 缺失根仍可首次创建', async () => { + const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); + const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(missing, '.cindy-library', 'meta.json'))).toBe(true); + }); + it('meta 损坏 → unavailable(corrupt),绝不静默重建空库', async () => { await fs.promises.mkdir(path.join(libraryRoot, '.cindy-library'), { recursive: true }); await fs.promises.writeFile(path.join(libraryRoot, '.cindy-library', 'meta.json'), '{not json'); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 99cdcf1bfcf..ec546ff8da5 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -314,7 +314,9 @@ export class GhostLibrarySlot { await this.teardownSession(ghostId); session = undefined; } - const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); + const resolution = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); if (session && !this.sessionMatchesResolution(session, resolution)) { await this.teardownSession(ghostId); session = undefined; @@ -367,6 +369,23 @@ export class GhostLibrarySlot { } } + /** Stale custom resolution after the user parent vanished is disk-missing; do not open/mkdir. */ + private async confirmLiveCustomRoot( + resolution: LibraryLocationResolution, + ): Promise { + if (resolution.kind !== 'custom' || resolution.root === null) return resolution; + const parent = path.dirname(resolution.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + return resolution; + } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ private sessionMatchesResolution( session: GhostLibrarySession, @@ -612,6 +631,14 @@ export class GhostLibrarySlot { case 'open': { const r = await vault.open(); if (!r.ok) return vaultFail(r); + if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { + session.drift = r.reason; + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason: r.reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } this.extraDirOpenerGhostId = ghostId; await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); const body = { diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 7856fdb1f18..61f7185e2e9 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -136,7 +136,7 @@ export interface LibraryVaultDeps { * 兜底——比假装知道更诚实。 */ getDiskFreeBytes?(root: string): Promise; - /** 位置类别(仅透传给 status;binding 层提供,默认系统管理位置)。 */ + /** 位置类别。custom 根的用户父目录消失时 open 必须 fail-closed,不得 recursive mkdir 空库。 */ locationKind?: 'default' | 'custom'; log?: { info: (msg: string, meta?: Record) => void; @@ -341,6 +341,26 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } + /** Custom roots must not recreate a vanished user-selected parent. keep files stay in the renamed-away directory. */ + private customRootUnavailable(): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { + this.state = 'unavailable'; + this.unavailableReason = 'disk-missing'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + + private async missingCustomParent(): Promise | null> { + const parent = path.dirname(this.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable(); + return null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable(); + throw err; + } + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -353,10 +373,30 @@ export class LibraryVault { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } try { - await fs.promises.mkdir(this.root, { recursive: true }); + if ((this.deps.locationKind ?? 'default') === 'custom') { + const missing = await this.missingCustomParent(); + if (missing) return missing; + try { + await fs.promises.mkdir(this.root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return this.customRootUnavailable(); + } + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) { + return this.customRootUnavailable(); + } + } + } else { + await fs.promises.mkdir(this.root, { recursive: true }); + } await fs.promises.mkdir(this.tmpDir, { recursive: true }); await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } catch (err) { + if ((this.deps.locationKind ?? 'default') === 'custom' && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return this.customRootUnavailable(); + } this.state = 'unavailable'; this.unavailableReason = 'permission'; this.deps.log?.warn('library open: cannot create root', { error: err instanceof Error ? err.message : String(err) }); diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index 404a875fb75..e0b8c46f003 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -46,9 +46,11 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 1. **不可用 ≠ 空**:meta 损坏 → `unavailable(corrupt)`;binding 漂移 → `binding-moved` / `disk-missing`。宿主不自动重建、不清空、不回退写默认根、 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 - binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库;同一磁盘 - 对象归位后既有 `open`/`status` 恢复,删后重建的同路径是 `binding-moved` - 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 + binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库。custom vault.open + 不得 recursive mkdir 已消失的用户父目录(父目录不在 → `disk-missing`);仅在父目录仍在时 + 允许创建 `/` 子目录。合法 default 首次创建仍可 mkdir。被 rename 走的 + 原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` 恢复,删后重建的 + 同路径是 `binding-moved` 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From f43ab2a52ba7cbea6d7aeab9f422874b94583088 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 17:59:53 +0800 Subject: [PATCH 06/23] fix(desktop): reject replaced custom parents and revoke extraDir on drift Stale resolve plus a same-path new inode is binding-moved at the mkdir boundary. disk-missing after a granted extraDir actually revokes the slot. Auto-open failures latch drift so status-only restore works. Keep files stay in the renamed-away directory. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 85 +++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 44 ++++++++-- .../src/main/cindy-brain/libraryVault.ts | 44 +++++++--- docs/dev-rules/plugin-library-storage.md | 9 +- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 7e8d3937429..80181145d7e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -461,6 +461,91 @@ describe('GhostLibrarySlot', () => { expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); }); + it('stale resolve 后 rename+同路径新 inode:不得建空库或授权错误根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null && fs.existsSync(candidate)) { + await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate, { recursive: true }); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(after.authorizedReadonly).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.authorizedReadonly).toBe(true); + const grantedRoot = syncAgentReadonlyExtraDir.mock.calls.find((call) => call[0] === GHOST_ID && call[1] !== null)?.[1]; + expect(typeof grantedRoot).toBe('string'); + const parked = `${candidate}.parked`; + const vault = createVault.mock.results.at(-1)?.value as LibraryVault; + const orig = vault.open.bind(vault); + vault.open = async () => { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + const raced = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!raced.ok || raced.op !== 'open') throw new Error(JSON.stringify(raced)); + expect(raced.state).toBe('unavailable'); + expect(raced.reason).toBe('disk-missing'); + expect(raced.authorizedReadonly).toBe(false); + const nullGrants = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID && call[1] === null); + expect(nullGrants.length).toBeGreaterThan(0); + }); + + it('auto-open 失败后同盘归位只 status 须恢复', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + const parked = `${candidate}.parked`; + let vanishOnOpen = true; + createVault.mockImplementation((deps) => { + const vault = new LibraryVault(deps); + const orig = vault.open.bind(vault); + vault.open = async () => { + if (vanishOnOpen && fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + return vault; + }); + await slot.disposeAll(); + const statusMissing = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusMissing.ok || statusMissing.op !== 'status') throw new Error(JSON.stringify(statusMissing)); + expect(statusMissing.state).toBe('unavailable'); + expect(statusMissing.reason).toBe('disk-missing'); + vanishOnOpen = false; + await fs.promises.rename(parked, candidate); + const statusRestored = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusRestored.ok || statusRestored.op !== 'status') throw new Error(JSON.stringify(statusRestored)); + expect(statusRestored.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index ec546ff8da5..5e3b36a4f25 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -327,10 +327,16 @@ export class GhostLibrarySlot { // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 // extraDirs 只在显式 open 时挂,status / 首次任意请求不得抢槽。 if (session.drift === null) { - await session.vault.open(); - // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned - // 标记(best-effort,失败不影响使用)。 - if (session.vault.getMeta()?.orphaned) { + const opened = await session.vault.open(); + if ( + opened.ok + && opened.state === 'unavailable' + && (opened.reason === 'disk-missing' || opened.reason === 'binding-moved') + ) { + await this.latchCustomUnavailable(session, ghostId, opened.reason); + } else if (session.vault.getMeta()?.orphaned) { + // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned + // 标记(best-effort,失败不影响使用)。 await session.vault.clearOrphaned().catch(() => {}); } } else if (this.extraDirGrant?.ghostId === ghostId) { @@ -369,7 +375,7 @@ export class GhostLibrarySlot { } } - /** Stale custom resolution after the user parent vanished is disk-missing; do not open/mkdir. */ + /** Stale custom resolution after the user parent vanished or was replaced must not open/mkdir. */ private async confirmLiveCustomRoot( resolution: LibraryLocationResolution, ): Promise { @@ -380,12 +386,35 @@ export class GhostLibrarySlot { if (st.isSymbolicLink() || !st.isDirectory()) { return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; } + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + if (real !== resolution.record.realPathAtGrant) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } + const identity = resolution.record.identity; + if (identity && identity.ino !== 0 && (st.dev !== identity.dev || st.ino !== identity.ino)) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } } catch { return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; } return resolution; } + private async latchCustomUnavailable( + session: GhostLibrarySession, + ghostId: string, + reason: 'disk-missing' | 'binding-moved', + ): Promise { + session.drift = reason; + if (this.extraDirOpenerGhostId === ghostId) this.extraDirOpenerGhostId = null; + await this.syncAgentReadonlyExtraDir(ghostId, null); + } + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ private sessionMatchesResolution( session: GhostLibrarySession, @@ -417,6 +446,9 @@ export class GhostLibrarySlot { ghostId, getDiskFreeBytes: this.deps.getDiskFreeBytes, locationKind: resolution.kind, + customParentGrant: resolution.kind === 'custom' && resolution.root !== null + ? { realPathAtGrant: resolution.record.realPathAtGrant, identity: resolution.record.identity } + : undefined, log: this.deps.log, }); const sql = this.deps.createSqlService({ @@ -632,7 +664,7 @@ export class GhostLibrarySlot { const r = await vault.open(); if (!r.ok) return vaultFail(r); if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { - session.drift = r.reason; + await this.latchCustomUnavailable(session, ghostId, r.reason); const drifted = { ok: true as const, op: 'open' as const, state: 'unavailable' as const, reason: r.reason, usedBytes: 0, fileCount: 0, location: session.locationKind, diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 61f7185e2e9..53b99edee17 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -138,6 +138,11 @@ export interface LibraryVaultDeps { getDiskFreeBytes?(root: string): Promise; /** 位置类别。custom 根的用户父目录消失时 open 必须 fail-closed,不得 recursive mkdir 空库。 */ locationKind?: 'default' | 'custom'; + /** Custom parent identity from binding; compared at mkdir so a replaced inode cannot mint an empty library. */ + customParentGrant?: { + realPathAtGrant: string; + identity: { dev: number; ino: number } | null; + }; log?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; @@ -341,22 +346,39 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } - /** Custom roots must not recreate a vanished user-selected parent. keep files stay in the renamed-away directory. */ - private customRootUnavailable(): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { + /** Custom roots must not recreate a vanished or replaced user-selected parent. keep files stay in the renamed-away directory. */ + private customRootUnavailable( + reason: 'disk-missing' | 'binding-moved' = 'disk-missing', + ): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { this.state = 'unavailable'; - this.unavailableReason = 'disk-missing'; + this.unavailableReason = reason; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - private async missingCustomParent(): Promise | null> { + private async inspectCustomParent(): Promise | null> { const parent = path.dirname(this.root); + const grant = this.deps.customParentGrant; try { const st = await fs.promises.lstat(parent); - if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable(); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return this.customRootUnavailable('disk-missing'); + } + if (grant && real !== grant.realPathAtGrant) return this.customRootUnavailable('binding-moved'); + if ( + grant?.identity + && grant.identity.ino !== 0 + && (st.dev !== grant.identity.dev || st.ino !== grant.identity.ino) + ) { + return this.customRootUnavailable('binding-moved'); + } return null; } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable(); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); throw err; } } @@ -374,20 +396,22 @@ export class LibraryVault { } try { if ((this.deps.locationKind ?? 'default') === 'custom') { - const missing = await this.missingCustomParent(); - if (missing) return missing; + const before = await this.inspectCustomParent(); + if (before) return before; try { await fs.promises.mkdir(this.root); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return this.customRootUnavailable(); + return this.customRootUnavailable('disk-missing'); } if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; const st = await fs.promises.lstat(this.root); if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable(); + return this.customRootUnavailable('disk-missing'); } } + const after = await this.inspectCustomParent(); + if (after) return after; } else { await fs.promises.mkdir(this.root, { recursive: true }); } diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index e0b8c46f003..c2ece161e05 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -48,9 +48,12 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库。custom vault.open 不得 recursive mkdir 已消失的用户父目录(父目录不在 → `disk-missing`);仅在父目录仍在时 - 允许创建 `/` 子目录。合法 default 首次创建仍可 mkdir。被 rename 走的 - 原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` 恢复,删后重建的 - 同路径是 `binding-moved` 不是原盘回归。插件侧同样语义写进了 FORGE_GUIDE。 + 允许创建 `/` 子目录。建根边界须复核 binding 的 realpath/dev/ino:同路径新对象是 + `binding-moved`,不得初始化空库或把 extraDir 授权到错误根。已挂 extraDir 后若 open 进入 drift,必须 + 实际撤 grant。自动 open 失败要把 drift 记进 session,同盘归位后仅 `status` 也须恢复。合法 default + 首次创建仍可 mkdir。被 rename 走的原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` + 恢复,删后重建的同路径是 `binding-moved` 不是原盘回归。Windows st_ino=0 检不出同路径重建,已知限制。 + 插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 From e5df252ef4ccf7b53ee214275175543c152ca3f6 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 18:43:55 +0800 Subject: [PATCH 07/23] fix(desktop): do not recursive-mkdir custom Library parent after inspect Custom skeleton mkdir is non-recursive so a vanished user parent cannot be rebuilt as an empty library. Default roots still create recursively. Keep files stay in the renamed-away directory. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 38 ++++++++++++++++++ .../__tests__/libraryVault.test.ts | 39 ++++++++++++++++++- .../src/main/cindy-brain/libraryVault.ts | 21 +++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 80181145d7e..949938fd7ee 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -490,6 +490,44 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); }); + it('最后一次 inspect 后骨架 mkdir 前父目录被移走: disk-missing, keep 留 parked, 不授权空根', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const parked = `${candidate}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + let injected = false; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!injected && dest.includes(`${path.sep}.cindy-library`)) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return realMkdir(target, options); + }); + let after: Awaited>; + try { + after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + } finally { + mkdirSpy.mockRestore(); + } + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index c74180ef552..b21f263645d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -4,7 +4,7 @@ * os.tmpdir 临时目录(规则 23:生成物不落仓库工作区),零 Electron。 * symlink 用例带能力探针(Windows 无特权时跳过;POSIX CI 实跑)。 */ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -140,6 +140,43 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(`${parent}.parked`, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('custom 最后一次 inspect 后、骨架 mkdir 前父目录被移走:不得 recursive 重建空库', async () => { + const parent = path.join(tmpRoot, 'picked-411'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + let injected = false; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!injected && dest.includes(`${path.sep}.cindy-library`)) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + } + return realMkdir(target, options); + }); + const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + let missing: Awaited>; + try { + missing = await raced.open(); + } finally { + mkdirSpy.mockRestore(); + } + expect(injected).toBe(true); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 53b99edee17..589a8f47df5 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -383,6 +383,19 @@ export class LibraryVault { } } + /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ + private async mkdirCustomSkeleton(): Promise | null> { + for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { + try { + await fs.promises.mkdir(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + } + return null; + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -412,11 +425,15 @@ export class LibraryVault { } const after = await this.inspectCustomParent(); if (after) return after; + const skeleton = await this.mkdirCustomSkeleton(); + if (skeleton) return skeleton; + const afterSkeleton = await this.inspectCustomParent(); + if (afterSkeleton) return afterSkeleton; } else { await fs.promises.mkdir(this.root, { recursive: true }); + await fs.promises.mkdir(this.tmpDir, { recursive: true }); + await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } - await fs.promises.mkdir(this.tmpDir, { recursive: true }); - await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); } catch (err) { if ((this.deps.locationKind ?? 'default') === 'custom' && (err as NodeJS.ErrnoException).code === 'ENOENT') { return this.customRootUnavailable(); From 08627b366d1a70e8b2d8511eef03069ef5ba1d6c Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:02:40 +0800 Subject: [PATCH 08/23] fix(desktop): bind custom Library mkdir to held parent identity After the last successful inspect, hold the parent directory and compare path identity after mkdir(root). A same-path new inode fails closed as binding-moved; an empty root created on the replacement is rolled back and keep files stay parked. Node has no mkdirat; this is not atomic. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 ++++++++++ .../__tests__/libraryVault.test.ts | 47 +++++++++++ .../src/main/cindy-brain/libraryVault.ts | 82 +++++++++++++++---- 3 files changed, 159 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 949938fd7ee..b1586dca252 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -528,6 +528,51 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); }); + it('最后一次成功 inspect 后换成同路径新 inode:不得 mkdir/meta/授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + await slot.disposeAll(); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + const isGhostRoot = (dest: string): boolean => + path.basename(dest) === GHOST_ID && !dest.includes(`${path.sep}.cindy-library`); + const realMkdir = fs.promises.mkdir.bind(fs.promises); + const observed = { injected: false, mkdirOnReplacement: false }; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!observed.injected && isGhostRoot(dest)) { + observed.injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + await realMkdir(candidate); + } + const result = await realMkdir(target, options); + if (observed.injected && isGhostRoot(dest)) observed.mkdirOnReplacement = fs.existsSync(custom); + return result; + }); + let after: Awaited>; + try { + after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + } finally { + mkdirSpy.mockRestore(); + } + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(observed.injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(custom); + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index b21f263645d..29bc7de4f11 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -177,6 +177,53 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('最后一次成功 inspect 后、mkdir(root) 前换成同路径新 inode:不得在替换目录创建/写 meta', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-379'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + const realMkdir = fs.promises.mkdir.bind(fs.promises); + const observed = { injected: false, mkdirOnReplacement: false, mkdirTarget: '' }; + const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (!observed.injected && dest === custom) { + observed.injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + await realMkdir(parent); + observed.mkdirTarget = dest; + } + const result = await realMkdir(target, options); + if (observed.injected && dest === custom) { + observed.mkdirOnReplacement = fs.existsSync(custom); + } + return result; + }); + const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + let opened: Awaited>; + try { + opened = await raced.open(); + } finally { + mkdirSpy.mockRestore(); + } + const replacementRoot = fs.existsSync(custom); + const replacementMeta = fs.existsSync(path.join(custom, '.cindy-library', 'meta.json')); + expect(observed.injected).toBe(true); + expect(opened).toMatchObject({ ok: true, state: 'unavailable' }); + expect(['binding-moved', 'disk-missing']).toContain(opened.ok ? opened.reason : ''); + expect(replacementRoot).toBe(false); + expect(replacementMeta).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 589a8f47df5..a8deaf9e60d 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -369,10 +369,12 @@ export class LibraryVault { return this.customRootUnavailable('disk-missing'); } if (grant && real !== grant.realPathAtGrant) return this.customRootUnavailable('binding-moved'); + const stAfter = await fs.promises.lstat(parent); + if (stAfter.isSymbolicLink() || !stAfter.isDirectory()) return this.customRootUnavailable('disk-missing'); if ( grant?.identity && grant.identity.ino !== 0 - && (st.dev !== grant.identity.dev || st.ino !== grant.identity.ino) + && (stAfter.dev !== grant.identity.dev || stAfter.ino !== grant.identity.ino) ) { return this.customRootUnavailable('binding-moved'); } @@ -383,6 +385,37 @@ export class LibraryVault { } } + /** Path vs held parent inode vs grant. Not an extra inspect loop and not an atomic mkdirat. */ + private async assertHeldCustomParent(held: { + dev: number; + ino: number; + }): Promise | null> { + const parent = path.dirname(this.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + if (held.ino !== 0 && (st.dev !== held.dev || st.ino !== held.ino)) { + return this.customRootUnavailable('binding-moved'); + } + return this.inspectCustomParent(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + } + + /** Best-effort: drop an empty root we just created on a replaced parent. */ + private async rollbackEmptyCustomRoot(): Promise { + try { + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) return; + const names = await fs.promises.readdir(this.root); + if (names.length === 0) await fs.promises.rmdir(this.root); + } catch { + /* keep files stay in the renamed-away directory */ + } + } + /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ private async mkdirCustomSkeleton(): Promise | null> { for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { @@ -411,24 +444,43 @@ export class LibraryVault { if ((this.deps.locationKind ?? 'default') === 'custom') { const before = await this.inspectCustomParent(); if (before) return before; + let parentHandle: fs.promises.FileHandle | null = null; + let createdRoot = false; try { - await fs.promises.mkdir(this.root); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return this.customRootUnavailable('disk-missing'); + try { + parentHandle = await fs.promises.open(path.dirname(this.root), fs.constants.O_RDONLY); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + const held = await parentHandle.stat(); + if (!held.isDirectory()) return this.customRootUnavailable('disk-missing'); + const heldId = { dev: held.dev, ino: held.ino }; + const heldBefore = await this.assertHeldCustomParent(heldId); + if (heldBefore) return heldBefore; + try { + await fs.promises.mkdir(this.root); + createdRoot = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + const st = await fs.promises.lstat(this.root); + if (st.isSymbolicLink() || !st.isDirectory()) { + return this.customRootUnavailable('disk-missing'); + } } - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable('disk-missing'); + const afterRoot = await this.assertHeldCustomParent(heldId); + if (afterRoot) { + if (createdRoot) await this.rollbackEmptyCustomRoot(); + return afterRoot; } + const skeleton = await this.mkdirCustomSkeleton(); + if (skeleton) return skeleton; + const afterSkeleton = await this.assertHeldCustomParent(heldId); + if (afterSkeleton) return afterSkeleton; + } finally { + await parentHandle?.close().catch(() => undefined); } - const after = await this.inspectCustomParent(); - if (after) return after; - const skeleton = await this.mkdirCustomSkeleton(); - if (skeleton) return skeleton; - const afterSkeleton = await this.inspectCustomParent(); - if (afterSkeleton) return afterSkeleton; } else { await fs.promises.mkdir(this.root, { recursive: true }); await fs.promises.mkdir(this.tmpDir, { recursive: true }); From 35ced6648445143cb3f82d95af06d914caf7efd0 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:27:13 +0800 Subject: [PATCH 09/23] fix(desktop): create custom Library trees via held parent fd Darwin mkdirat/openat (SYS_mkdirat=475) and Linux /proc/self/fd keep ghostId/meta on the granted parent object. Missing helper fail-closed before mutation; no path mkdir fallback. Windows custom init unsupported. Default roots still mkdir recursively. Keep files stay parked. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 64 +++-- .../__tests__/libraryVault.test.ts | 75 +++--- .../src/main/cindy-brain/libraryDirFd.ts | 219 ++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 93 ++++---- 4 files changed, 322 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/main/cindy-brain/libraryDirFd.ts diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index b1586dca252..dff26b6c350 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -21,6 +21,7 @@ import { import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; +import { initCustomLibraryTree } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; import { @@ -498,23 +499,20 @@ describe('GhostLibrarySlot', () => { expect(open.state).toBe('ready'); const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); expect(keep.ok).toBe(true); + await slot.disposeAll(); const parked = `${candidate}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); let injected = false; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!injected && dest.includes(`${path.sep}.cindy-library`)) { - injected = true; - if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); - } - return realMkdir(target, options); - }); - let after: Awaited>; - try { - after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); - } finally { - mkdirSpy.mockRestore(); - } + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return initCustomLibraryTree(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); expect(injected).toBe(true); expect(after.state).toBe('unavailable'); @@ -540,29 +538,21 @@ describe('GhostLibrarySlot', () => { await slot.disposeAll(); const parked = `${candidate}.parked`; const custom = path.join(candidate, GHOST_ID); - const isGhostRoot = (dest: string): boolean => - path.basename(dest) === GHOST_ID && !dest.includes(`${path.sep}.cindy-library`); - const realMkdir = fs.promises.mkdir.bind(fs.promises); - const observed = { injected: false, mkdirOnReplacement: false }; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!observed.injected && isGhostRoot(dest)) { - observed.injected = true; - if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); - await realMkdir(candidate); - } - const result = await realMkdir(target, options); - if (observed.injected && isGhostRoot(dest)) observed.mkdirOnReplacement = fs.existsSync(custom); - return result; - }); - let after: Awaited>; - try { - after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); - } finally { - mkdirSpy.mockRestore(); - } + let injected = false; + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate); + } + return initCustomLibraryTree(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); - expect(observed.injected).toBe(true); + expect(injected).toBe(true); expect(after.state).toBe('unavailable'); expect(after.reason).toBe('binding-moved'); expect(after.authorizedReadonly).toBe(false); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 29bc7de4f11..ce8cfba35c1 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,6 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; +import { initCustomLibraryTree } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -153,23 +154,20 @@ describe('LibraryVault', () => { const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); const parked = `${parent}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); let injected = false; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!injected && dest.includes(`${path.sep}.cindy-library`)) { - injected = true; - if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); - } - return realMkdir(target, options); + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + } + return initCustomLibraryTree(req); + }, }); - const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); - let missing: Awaited>; - try { - missing = await raced.open(); - } finally { - mkdirSpy.mockRestore(); - } + const missing = await raced.open(); expect(injected).toBe(true); expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); expect(fs.existsSync(parent)).toBe(false); @@ -191,36 +189,25 @@ describe('LibraryVault', () => { const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); const parked = `${parent}.parked`; - const realMkdir = fs.promises.mkdir.bind(fs.promises); - const observed = { injected: false, mkdirOnReplacement: false, mkdirTarget: '' }; - const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async (target, options) => { - const dest = String(target); - if (!observed.injected && dest === custom) { - observed.injected = true; - if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); - await realMkdir(parent); - observed.mkdirTarget = dest; - } - const result = await realMkdir(target, options); - if (observed.injected && dest === custom) { - observed.mkdirOnReplacement = fs.existsSync(custom); - } - return result; + let injected = false; + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + initCustomTree: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + await fs.promises.mkdir(parent); + } + return initCustomLibraryTree(req); + }, }); - const raced = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); - let opened: Awaited>; - try { - opened = await raced.open(); - } finally { - mkdirSpy.mockRestore(); - } - const replacementRoot = fs.existsSync(custom); - const replacementMeta = fs.existsSync(path.join(custom, '.cindy-library', 'meta.json')); - expect(observed.injected).toBe(true); - expect(opened).toMatchObject({ ok: true, state: 'unavailable' }); - expect(['binding-moved', 'disk-missing']).toContain(opened.ok ? opened.reason : ''); - expect(replacementRoot).toBe(false); - expect(replacementMeta).toBe(false); + const opened = await raced.open(); + expect(injected).toBe(true); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'binding-moved' }); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts new file mode 100644 index 00000000000..0ef9970bd04 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -0,0 +1,219 @@ +/** + * Custom Library first-create: mkdir/open/meta stay on a held parent directory + * fd. Darwin uses a fixed /usr/bin/perl mkdirat/openat helper (SYS_mkdirat=475, + * SYS_openat=463 from MacOSX.sdk sys/syscall.h). Linux uses /proc/self/fd. + * Windows and missing helpers fail closed before any mutation. No path mkdir + * fallback. Segments are fixed/validated names, never concatenated user paths. + */ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; + +export type CustomTreeInitResult = + | { ok: true; createdMeta: boolean } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' }; + +const HELPER_TIMEOUT_MS = 15_000; +const SYS_OPENAT = 463; +const SYS_MKDIRAT = 475; +const SYS_FSYNC = 95; + +function validSegment(segment: string): boolean { + return ( + segment.length > 0 && + segment.length <= 255 && + segment !== '.' && + segment !== '..' && + !segment.includes('\0') && + !segment.includes('/') && + !segment.includes('\\') + ); +} + +const DARWIN_INIT_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_WRONLY O_CREAT O_EXCL O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(write close); +use Errno qw(EEXIST); + +use constant SYS_openat => ${SYS_OPENAT}; +use constant SYS_mkdirat => ${SYS_MKDIRAT}; +use constant SYS_fsync => ${SYS_FSYNC}; + +sub fail_closed { exit 1; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub mkdirat_seg { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $mode = 0700; + my $rc = syscall(SYS_mkdirat, $parent + 0, $seg, $mode); + if (!defined($rc) || $rc < 0) { + fail_closed() unless $! == EEXIST; + } +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + fail_closed() if !defined($fd) || $fd < 0; + return $fd; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $meta = $ENV{CINDY_LIBRARY_META_JSON} // ''; +fail_closed() unless $meta =~ /^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/; + +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +mkdirat_seg($parent, $ghost); +my $root = openat_dir($parent, $ghost); +mkdirat_seg($root, '.cindy-library'); +my $meta_dir = openat_dir($root, '.cindy-library'); +mkdirat_seg($meta_dir, 'tmp'); +mkdirat_seg($meta_dir, 'backups'); + +my $flags = O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW; +my $meta_name = 'meta.json'; +my $meta_mode = 0600; +my $mfd = syscall(SYS_openat, $meta_dir + 0, $meta_name, $flags, $meta_mode); +my $created = 0; +if (defined($mfd) && $mfd >= 0) { + $created = 1; + my $w = POSIX::write($mfd, $meta, length($meta)); + fail_closed() unless defined($w) && $w == length($meta); + syscall(SYS_fsync, $mfd); + POSIX::close($mfd); +} else { + fail_closed() unless $! == EEXIST; +} + +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT ($created ? 'created' : 'exists'); +`; + +function runDarwinInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_INIT_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { CINDY_LIBRARY_META_JSON: metaJson }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 0 && (text === 'created' || text === 'exists')) { + finish({ ok: true, createdMeta: text === 'created' }); + return; + } + finish({ ok: false, code: 'IO' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxInit(parentFd: number, ghostId: string, metaJson: string): CustomTreeInitResult { + const opened: number[] = []; + try { + const mkdirAt = (dirFd: number, name: string): void => { + try { + fs.mkdirSync(`/proc/self/fd/${dirFd}/${name}`, { recursive: false }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + }; + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + const st = fs.fstatSync(fd); + if (!st.isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + mkdirAt(parentFd, ghostId); + const rootFd = openDirAt(parentFd, ghostId); + mkdirAt(rootFd, '.cindy-library'); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + mkdirAt(metaDirFd, 'tmp'); + mkdirAt(metaDirFd, 'backups'); + let createdMeta = false; + try { + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, flags, 0o600); + opened.push(metaFd); + fs.writeSync(metaFd, metaJson); + fs.fsyncSync(metaFd); + createdMeta = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + return { ok: true, createdMeta }; + } catch { + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close helper fds */ + } + } + } +} + +export async function initCustomLibraryTree(req: { + parentFd: number; + ghostId: string; + metaJson: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (!/^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/.test(req.metaJson)) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); + return { ok: false, code: 'UNSUPPORTED' }; +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index a8deaf9e60d..898e991f4a9 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -27,6 +27,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isSafeGhostRelativePath } from '../../shared/ghost.js'; +import { initCustomLibraryTree, type CustomTreeInitResult } from './libraryDirFd.js'; /** Library 操作的结构化错误码(fs 槽只有人话 message 的缺口在这里补上)。 */ export type LibraryErrorCode = @@ -157,6 +158,12 @@ export interface LibraryVaultDeps { * 读路径打开注入点,仅单测。生产缺省走 O_RDONLY|O_NOFOLLOW,失败不得回落裸 open。 */ openForRead?(absPath: string, flags: number): Promise; + /** Custom first-create via held parent fd. Tests may inject; production uses libraryDirFd. */ + initCustomTree?(req: { + parentFd: number; + ghostId: string; + metaJson: string; + }): Promise; } /** Windows 保留设备名(与 fsSlot/dirDeposit 同口径;目录名撞上同样出事)。 */ @@ -385,7 +392,7 @@ export class LibraryVault { } } - /** Path vs held parent inode vs grant. Not an extra inspect loop and not an atomic mkdirat. */ + /** Path vs held parent inode vs grant after fd-relative create. */ private async assertHeldCustomParent(held: { dev: number; ino: number; @@ -404,31 +411,6 @@ export class LibraryVault { } } - /** Best-effort: drop an empty root we just created on a replaced parent. */ - private async rollbackEmptyCustomRoot(): Promise { - try { - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) return; - const names = await fs.promises.readdir(this.root); - if (names.length === 0) await fs.promises.rmdir(this.root); - } catch { - /* keep files stay in the renamed-away directory */ - } - } - - /** Custom skeleton never uses recursive mkdir: that would rebuild a vanished user parent. */ - private async mkdirCustomSkeleton(): Promise | null> { - for (const dir of [this.metaDir, this.tmpDir, path.join(this.metaDir, 'backups')]) { - try { - await fs.promises.mkdir(dir); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - } - } - return null; - } - /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -445,10 +427,20 @@ export class LibraryVault { const before = await this.inspectCustomParent(); if (before) return before; let parentHandle: fs.promises.FileHandle | null = null; - let createdRoot = false; try { + const parent = path.dirname(this.root); + const dirSeg = path.basename(this.root); + const metaGhost = this.deps.ghostId || dirSeg; + const metaJson = JSON.stringify({ + version: 1, + ghostId: metaGhost, + createdAt: this.now(), + }); + let openFlags = fs.constants.O_RDONLY; + if (fs.constants.O_DIRECTORY) openFlags |= fs.constants.O_DIRECTORY; + if (fs.constants.O_NOFOLLOW) openFlags |= fs.constants.O_NOFOLLOW; try { - parentHandle = await fs.promises.open(path.dirname(this.root), fs.constants.O_RDONLY); + parentHandle = await fs.promises.open(parent, openFlags); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); throw err; @@ -458,28 +450,27 @@ export class LibraryVault { const heldId = { dev: held.dev, ino: held.ino }; const heldBefore = await this.assertHeldCustomParent(heldId); if (heldBefore) return heldBefore; - try { - await fs.promises.mkdir(this.root); - createdRoot = true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - const st = await fs.promises.lstat(this.root); - if (st.isSymbolicLink() || !st.isDirectory()) { - return this.customRootUnavailable('disk-missing'); - } - } - const afterRoot = await this.assertHeldCustomParent(heldId); - if (afterRoot) { - if (createdRoot) await this.rollbackEmptyCustomRoot(); - return afterRoot; + const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + metaJson, + }); + if (!tree.ok) { + this.state = 'unavailable'; + this.unavailableReason = tree.code === 'IO' ? 'permission' : 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - const skeleton = await this.mkdirCustomSkeleton(); - if (skeleton) return skeleton; - const afterSkeleton = await this.assertHeldCustomParent(heldId); - if (afterSkeleton) return afterSkeleton; + const afterTree = await this.assertHeldCustomParent(heldId); + if (afterTree) return afterTree; } finally { - await parentHandle?.close().catch(() => undefined); + if (parentHandle) { + try { + await parentHandle.close(); + } catch { + /* still close */ + } + } } } else { await fs.promises.mkdir(this.root, { recursive: true }); @@ -510,6 +501,12 @@ export class LibraryVault { this.meta = parsed; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + if ((this.deps.locationKind ?? 'default') === 'custom') { + this.opened = true; + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } this.meta = { version: 1, ghostId: this.deps.ghostId ?? '', createdAt: this.now() }; const w = await this.writeMetaUnlocked(); if (w) return w; From 5e19e9e43a4a222622cb72c843cdffe8ebcb401b Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 19:54:13 +0800 Subject: [PATCH 10/23] fix(desktop): skip custom first-open path usage persist and sweep After the held parent fd is closed, custom open does not write, rename, or unlink under this.root. Sweep unlink and persistUsage are skipped; read-only usage scan remains. Default roots unchanged. Later library writes still persist. Isolated D: no replacement usage.json. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 ++++++++++++++ .../__tests__/libraryVault.test.ts | 58 +++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 24 ++++++-- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index dff26b6c350..2cc525c772e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -563,6 +563,51 @@ describe('GhostLibrarySlot', () => { expect(extraRoots.at(-1) ?? 'none').not.toBe(custom); }); + it('D: helper 后 tmp readdir 换根不得写 usage.json 且拒授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}${GHOST_ID}${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(candidate)) await origRename(candidate, parked); + await origMkdir(candidate); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library', 'tmp'), { recursive: true }); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: GHOST_ID, createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + } + return origReaddir(target, options); + }); + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + })); + const opened = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + readdirSpy.mockRestore(); + if (!opened.ok || opened.op !== 'open') throw new Error(JSON.stringify(opened)); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened.authorizedReadonly).toBe(false); + } + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index ce8cfba35c1..46b199fc15d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -211,6 +211,64 @@ describe('LibraryVault', () => { expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); }); + it('D: initCustomLibraryTree 后 sweep readdir 换根不得写 replacement usage.json', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-D'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(parent); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const parked = `${parent}.parked`; + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}mivo-canvas${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(parent)) await origRename(parent, parked); + await origMkdir(parent); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library')); + await origMkdir(path.join(custom, '.cindy-library', 'tmp')); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: 'mivo-canvas', createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + await origWriteFile(path.join(custom, '.cindy-library', 'tmp', 'old.tmp'), 'stale'); + } + return origReaddir(target, options); + }); + const vault = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + ghostId: 'mivo-canvas', + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + }); + const opened = await vault.open(); + readdirSpy.mockRestore(); + expect(opened.ok).toBe(true); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened).toMatchObject({ state: 'unavailable' }); + } else { + expect(opened).toMatchObject({ state: 'ready' }); + expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'usage.json'))).toBe(false); + } + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 898e991f4a9..dc7fb2b0074 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -463,6 +463,15 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; + if (tree.createdMeta) { + const parsed = JSON.parse(metaJson) as LibraryMeta; + if ( + typeof parsed === 'object' && parsed !== null && parsed.version === 1 && + typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' + ) { + this.meta = parsed; + } + } } finally { if (parentHandle) { try { @@ -486,9 +495,11 @@ export class LibraryVault { this.deps.log?.warn('library open: cannot create root', { error: err instanceof Error ? err.message : String(err) }); return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - await this.sweepStaleTmp(); + const customOpen = (this.deps.locationKind ?? 'default') === 'custom'; + if (!customOpen) await this.sweepStaleTmp(); - // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。 + // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。custom 首次 open 用 held-fd 已写的 meta,不再 path 写。 + if (!this.meta) { try { const raw = await fs.promises.readFile(this.metaFile, 'utf8'); const parsed = JSON.parse(raw) as LibraryMeta; @@ -501,7 +512,7 @@ export class LibraryVault { this.meta = parsed; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - if ((this.deps.locationKind ?? 'default') === 'custom') { + if (customOpen) { this.opened = true; this.state = 'unavailable'; this.unavailableReason = 'permission'; @@ -518,9 +529,11 @@ export class LibraryVault { return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } } + } - // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。 + // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。custom 首次 open 只读扫描,不 persist/unlink。 let ledger: UsageLedger | null = null; + if (!(customOpen && this.meta)) { try { const raw = JSON.parse(await fs.promises.readFile(this.usageFile, 'utf8')) as UsageLedger; if (typeof raw === 'object' && raw !== null && typeof raw.files === 'number' && typeof raw.bytes === 'number') { @@ -529,6 +542,7 @@ export class LibraryVault { } catch { /* 损坏/缺失 → 重扫 */ } + } if (!ledger) { const scanned = await this.scanUsageUnlocked(); if (scanned.tripped) { @@ -536,7 +550,7 @@ export class LibraryVault { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; } else { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; - await this.persistUsageUnlocked(); + if (!customOpen) await this.persistUsageUnlocked(); } } else { this.usage = ledger; From 0f372affada4604eb2cf45783ef50f793f4aa595 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:25:02 +0800 Subject: [PATCH 11/23] fix(desktop): existing custom open, session teardown, usage ledger Open an already-initialized custom library from the held parent fd without mkdir. Missing structure on platforms without create stays fail-closed. Teardown only the captured session. Reuse a valid usage ledger; scan only when it is missing or corrupt. Custom tmp is not path-unlinked. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 45 +++- .../__tests__/libraryVault.test.ts | 73 +++++- .../src/main/cindy-brain/libraryDirFd.ts | 221 ++++++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 38 ++- .../src/main/cindy-brain/libraryVault.ts | 58 +++-- 5 files changed, 403 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 2cc525c772e..fd4bffff583 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -21,7 +21,7 @@ import { import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; -import { initCustomLibraryTree } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; import { @@ -504,12 +504,12 @@ describe('GhostLibrarySlot', () => { let injected = false; createVault.mockImplementation((d) => new LibraryVault({ ...d, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, })); const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -541,13 +541,13 @@ describe('GhostLibrarySlot', () => { let injected = false; createVault.mockImplementation((d) => new LibraryVault({ ...d, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); await fs.promises.mkdir(candidate); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, })); const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); @@ -608,6 +608,41 @@ describe('GhostLibrarySlot', () => { } }); + it('旧 session teardown 只删自己捕获的引用,不踩并发新建的 session', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const first = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!first.ok || first.op !== 'open') throw new Error(JSON.stringify(first)); + expect(first.state).toBe('ready'); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + let n = 0; + const origResolve = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementation(async (id: string) => { + const i = ++n; + const res = await origResolve(id); + if (i >= 2) await gate; + return res; + }); + const other = path.join(tmp, 'picked-concurrent'); + await fs.promises.mkdir(other); + const pendingA = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + await Promise.resolve(); + await bindingStore.setBinding(GHOST_ID, other); + const pendingB = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + release(); + const [a, b] = await Promise.all([pendingA, pendingB]); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + const later = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + expect(later.ok).toBe(true); + if (later.ok && later.op === 'status') { + expect(later.state === 'ready' || later.state === 'unavailable').toBe(true); + } + }); + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 46b199fc15d..aa51ef5b7d2 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -159,12 +159,12 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, }); const missing = await raced.open(); @@ -194,13 +194,13 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, - initCustomTree: async (req) => { + openExistingCustom: async (req) => { if (!injected) { injected = true; if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); await fs.promises.mkdir(parent); } - return initCustomLibraryTree(req); + return openExistingCustomLibrary(req); }, }); const opened = await raced.open(); @@ -269,6 +269,69 @@ describe('LibraryVault', () => { } }); + it('已有 custom 再 open 走 existing,不调用 create helper', async () => { + const parent = path.join(tmpRoot, 'picked-exist'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const init = vi.fn(async () => ({ ok: false as const, code: 'UNSUPPORTED' as const })); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + initCustomTree: init, + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(init).not.toHaveBeenCalled(); + }); + + it('existing UNSUPPORTED 且无完整结构: permission 且不 mkdir', async () => { + const parent = path.join(tmpRoot, 'picked-win'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + openExistingCustom: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + initCustomTree: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); + expect(fs.existsSync(custom)).toBe(false); + }); + + it('合法 usage.json 只读复用,不因 custom open 丢账本', async () => { + const parent = path.join(tmpRoot, 'picked-ledger'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const w = await first.write({ path: 'keep.txt', content: 'abcdef' }); + expect(w.ok).toBe(true); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await second.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0ef9970bd04..4343ea13315 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -12,6 +12,12 @@ export type CustomTreeInitResult = | { ok: true; createdMeta: boolean } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' }; +export type CustomExistingMeta = { version: 1; ghostId: string; createdAt: number }; +export type CustomExistingUsage = { files: number; bytes: number; updatedAt: number; mutations: number }; +export type CustomExistingResult = + | { ok: true; meta: CustomExistingMeta; usage: CustomExistingUsage | null } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' | 'MISSING' | 'CORRUPT' }; + const HELPER_TIMEOUT_MS = 15_000; const SYS_OPENAT = 463; const SYS_MKDIRAT = 475; @@ -217,3 +223,218 @@ export async function initCustomLibraryTree(req: { if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); return { ok: false, code: 'UNSUPPORTED' }; } + +const DARWIN_OPEN_EXISTING_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(read close); + +use constant SYS_openat => ${SYS_OPENAT}; + +sub fail_closed { exit 1; } +sub missing { print STDOUT 'MISSING'; exit 0; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + missing() if !defined($fd) || $fd < 0; + return $fd; +} + +sub openat_file { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + return undef if !defined($fd) || $fd < 0; + return $fd; +} + +sub read_all { + my ($fd) = @_; + my $buf = ''; + while (1) { + my $chunk = ''; + my $n = POSIX::read($fd, $chunk, 8192); + last if !defined($n) || $n == 0; + $buf .= $chunk; + } + return $buf; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +my $root = openat_dir($parent, $ghost); +my $meta_dir = openat_dir($root, '.cindy-library'); +openat_dir($meta_dir, 'tmp'); +openat_dir($meta_dir, 'backups'); +my $mfd = openat_file($meta_dir, 'meta.json'); +missing() unless defined $mfd; +my $meta_raw = read_all($mfd); +POSIX::close($mfd); +my $ufd = openat_file($meta_dir, 'usage.json'); +my $usage_raw = ''; +if (defined $ufd) { + $usage_raw = read_all($ufd); + POSIX::close($ufd); +} +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT "OK\n$meta_raw\n"; +print STDOUT $usage_raw; +`; + +function parseExistingStdout(text: string): CustomExistingResult { + if (text === 'MISSING') return { ok: false, code: 'MISSING' }; + if (!text.startsWith('OK\n')) return { ok: false, code: 'IO' }; + const rest = text.slice(3); + const nl = rest.indexOf('\n'); + const metaRaw = nl === -1 ? rest : rest.slice(0, nl); + const usageRaw = nl === -1 ? '' : rest.slice(nl + 1); + let meta: CustomExistingMeta; + try { + const parsed = JSON.parse(metaRaw) as CustomExistingMeta; + if ( + typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || + typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' + ) { + return { ok: false, code: 'CORRUPT' }; + } + meta = parsed; + } catch { + return { ok: false, code: 'CORRUPT' }; + } + let usage: CustomExistingUsage | null = null; + if (usageRaw.trim()) { + try { + const parsed = JSON.parse(usageRaw) as CustomExistingUsage; + if ( + typeof parsed === 'object' && parsed !== null && + typeof parsed.files === 'number' && typeof parsed.bytes === 'number' + ) { + usage = { + files: parsed.files, + bytes: parsed.bytes, + updatedAt: parsed.updatedAt ?? 0, + mutations: parsed.mutations ?? 0, + }; + } + } catch { + usage = null; + } + } + return { ok: true, meta, usage }; +} + +function runDarwinOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_OPEN_EXISTING_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { PATH: '/usr/bin:/bin' }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxOpenExisting(parentFd: number, ghostId: string): CustomExistingResult { + const opened: number[] = []; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + if (!fs.fstatSync(fd).isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + openDirAt(metaDirFd, 'tmp'); + openDirAt(metaDirFd, 'backups'); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(metaFd); + const metaRaw = fs.readFileSync(metaFd, 'utf8'); + let usageRaw = ''; + try { + const usageFd = fs.openSync(`/proc/self/fd/${metaDirFd}/usage.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(usageFd); + usageRaw = fs.readFileSync(usageFd, 'utf8'); + } catch { + usageRaw = ''; + } + return parseExistingStdout(`OK\n${metaRaw}\n${usageRaw}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +/** Read an already-initialized custom library from the held parent fd. Never mkdir. */ +export async function openExistingCustomLibrary(req: { + parentFd: number; + ghostId: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'linux') return linuxOpenExisting(req.parentFd, req.ghostId); + return { ok: false, code: 'UNSUPPORTED' }; +} diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 5e3b36a4f25..42fe6360978 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -310,16 +310,27 @@ export class GhostLibrarySlot { private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise { let session = this.sessions.get(ghostId); + const capturedScope = session; if (session && session.ownerScopeKey !== scopeKey) { - await this.teardownSession(ghostId); - session = undefined; + await this.teardownSession(ghostId, capturedScope); + session = this.sessions.get(ghostId); + if (session === capturedScope) session = undefined; } const resolution = await this.confirmLiveCustomRoot( await this.deps.bindingStore.resolveLibraryRoot(ghostId), ); + session = this.sessions.get(ghostId) ?? session; + if (session && session.ownerScopeKey !== scopeKey) { + const staleScope = session; + await this.teardownSession(ghostId, staleScope); + session = this.sessions.get(ghostId); + if (session === staleScope) session = undefined; + } if (session && !this.sessionMatchesResolution(session, resolution)) { - await this.teardownSession(ghostId); - session = undefined; + const staleRoot = session; + await this.teardownSession(ghostId, staleRoot); + session = this.sessions.get(ghostId); + if (session === staleRoot) session = undefined; } if (!session) { session = this.createSession(ghostId, resolution, scopeKey); @@ -552,9 +563,10 @@ export class GhostLibrarySlot { } } - private async teardownSession(ghostId: string): Promise { + private async teardownSession(ghostId: string, expected?: GhostLibrarySession): Promise { const session = this.sessions.get(ghostId); if (!session) return; + if (expected && session !== expected) return; this.sessions.delete(ghostId); for (const [streamId, epoch] of this.writeEpochByStream) { if (epoch.ghostId === ghostId) this.writeEpochByStream.delete(streamId); @@ -671,6 +683,22 @@ export class GhostLibrarySlot { }; return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; } + if (session.locationKind === 'custom') { + const live = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); + if (live.kind !== 'custom' || live.root === null) { + const reason = live.kind === 'custom' && live.root === null && live.drift === 'binding-moved' + ? 'binding-moved' + : 'disk-missing'; + await this.latchCustomUnavailable(session, ghostId, reason); + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } + } this.extraDirOpenerGhostId = ghostId; await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); const body = { diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index dc7fb2b0074..fb4d34fd66b 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -27,7 +27,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isSafeGhostRelativePath } from '../../shared/ghost.js'; -import { initCustomLibraryTree, type CustomTreeInitResult } from './libraryDirFd.js'; +import { + initCustomLibraryTree, + openExistingCustomLibrary, + type CustomExistingUsage, + type CustomTreeInitResult, +} from './libraryDirFd.js'; /** Library 操作的结构化错误码(fs 槽只有人话 message 的缺口在这里补上)。 */ export type LibraryErrorCode = @@ -164,6 +169,10 @@ export interface LibraryVaultDeps { ghostId: string; metaJson: string; }): Promise; + openExistingCustom?(req: { + parentFd: number; + ghostId: string; + }): Promise; } /** Windows 保留设备名(与 fsSlot/dirDeposit 同口径;目录名撞上同样出事)。 */ @@ -422,6 +431,7 @@ export class LibraryVault { if (this.invalidated) { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } + let customUsage: UsageLedger | null = null; try { if ((this.deps.locationKind ?? 'default') === 'custom') { const before = await this.inspectCustomParent(); @@ -450,28 +460,42 @@ export class LibraryVault { const heldId = { dev: held.dev, ino: held.ino }; const heldBefore = await this.assertHeldCustomParent(heldId); if (heldBefore) return heldBefore; - const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + const existing = await (this.deps.openExistingCustom ?? openExistingCustomLibrary)({ parentFd: parentHandle.fd, ghostId: dirSeg, - metaJson, }); - if (!tree.ok) { + if (existing.ok) { + this.meta = existing.meta; + if (existing.usage) customUsage = existing.usage; + } else if (existing.code === 'MISSING') { + const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + metaJson, + }); + if (!tree.ok) { + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + if (tree.createdMeta) { + const parsed = JSON.parse(metaJson) as LibraryMeta; + if ( + typeof parsed === 'object' && parsed !== null && parsed.version === 1 && + typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' + ) { + this.meta = parsed; + } + } + } else { this.state = 'unavailable'; - this.unavailableReason = tree.code === 'IO' ? 'permission' : 'permission'; + this.unavailableReason = 'permission'; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; - if (tree.createdMeta) { - const parsed = JSON.parse(metaJson) as LibraryMeta; - if ( - typeof parsed === 'object' && parsed !== null && parsed.version === 1 && - typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' - ) { - this.meta = parsed; - } - } } finally { if (parentHandle) { try { @@ -531,9 +555,9 @@ export class LibraryVault { } } - // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。custom 首次 open 只读扫描,不 persist/unlink。 - let ledger: UsageLedger | null = null; - if (!(customOpen && this.meta)) { + // 用量:合法账本只读复用;坏/缺才 scan。custom 首次 open 不 persist/unlink。 + let ledger: UsageLedger | null = customUsage; + if (!ledger) { try { const raw = JSON.parse(await fs.promises.readFile(this.usageFile, 'utf8')) as UsageLedger; if (typeof raw === 'object' && raw !== null && typeof raw.files === 'number' && typeof raw.bytes === 'number') { From 0cf02ab07830df8ca584b1985199cfa929cc655f Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:34:18 +0800 Subject: [PATCH 12/23] fix(desktop): Windows existing custom open via FILE_OPEN ReadFile Existing libraries open from the inherited parent handle with NtCreateFile FILE_OPEN+ReadFile. FileIndex 0 and missing helpers fail closed. Create stays unsupported on Windows. Linux may unlink only uuid.tmp/.stream staging leftovers via /proc/self/fd. No path mkdir fallback. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 28 +- .../src/main/cindy-brain/libraryDirFd.ts | 312 ++++++++++++++++++ .../src/main/cindy-brain/libraryVault.ts | 9 +- 3 files changed, 347 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index aa51ef5b7d2..792dfec752e 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -332,6 +332,32 @@ describe('LibraryVault', () => { expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); }); + it('可证 staging 名只匹配 uuid.tmp/stream,不匹配 old.tmp 或原件', () => { + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.tmp')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.stream')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('old.tmp')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('keep.txt')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); + }); + + it('Windows 新建 custom 仍 unsupported,不 mkdir', async () => { + if (process.platform !== 'win32') return; + const parent = path.join(tmpRoot, 'picked-win-new'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); + expect(fs.existsSync(custom)).toBe(false); + }); + it('default 缺失根仍可首次创建', async () => { const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 4343ea13315..f2a2c1117ff 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -7,6 +7,7 @@ */ import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; +import * as path from 'node:path'; export type CustomTreeInitResult = | { ok: true; createdMeta: boolean } @@ -436,5 +437,316 @@ export async function openExistingCustomLibrary(req: { } if (process.platform === 'darwin') return runDarwinOpenExisting(req.parentFd, req.ghostId); if (process.platform === 'linux') return linuxOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'win32') return runWindowsOpenExisting(req.parentFd, req.ghostId); return { ok: false, code: 'UNSUPPORTED' }; } + +function windowsPowerShellPath(): string | null { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (!systemRoot || !path.win32.isAbsolute(systemRoot)) return null; + const executable = path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + try { + return fs.statSync(executable).isFile() ? executable : null; + } catch { + return null; + } +} + +/** Staging leftovers from atomicWrite (`uuid.tmp`) and streams (`uuid.stream`). Not unique originals. */ +export const PROVABLE_STAGING_NAME = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(tmp|stream)$/i; + +/** Linux only: unlinkat-equivalent via /proc/self/fd. Unknown names kept. */ +export function sweepProvableStagingOnLinux( + parentFd: number, + ghostId: string, + activeStreamIds: ReadonlySet, + nowMs: number, + maxAgeMs: number, +): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { + if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; + if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { + return { ok: false, code: 'IO' }; + } + const opened: number[] = []; + let unlinked = 0; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + const tmpFd = openDirAt(metaDirFd, 'tmp'); + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`); + const cutoff = nowMs - maxAgeMs; + for (const name of names) { + if (!PROVABLE_STAGING_NAME.test(name)) continue; + if (name.endsWith('.stream') && activeStreamIds.has(name.slice(0, -'.stream'.length))) continue; + const target = `/proc/self/fd/${tmpFd}/${name}`; + try { + const st = fs.statSync(target); + if (!st.isFile() || st.mtimeMs >= cutoff) continue; + fs.unlinkSync(target); + unlinked += 1; + } catch { + /* keep on error */ + } + } + return { ok: true, unlinked }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +const WINDOWS_EXISTING_OPEN_SCRIPT = String.raw` +$utf8 = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $utf8 +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class CindyLibraryExistingOpen { + private const uint FILE_READ_DATA = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_SHARE_READ_WRITE = 0x00000003; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_ATTRIBUTES { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + [StructLayout(LayoutKind.Sequential)] + private struct IO_STATUS_BLOCK { + public IntPtr Status; + public IntPtr Information; + } + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ATTRIBUTE_TAG_INFO { + public uint FileAttributes; + public uint ReparseTag; + } + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle, out BY_HANDLE_FILE_INFORMATION info); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandleEx(IntPtr handle, int infoClass, out FILE_ATTRIBUTE_TAG_INFO info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool ReadFile(IntPtr hFile, byte[] buffer, uint toRead, out uint read, IntPtr overlapped); + [DllImport("ntdll.dll")] static extern int NtCreateFile(out SafeFileHandle fileHandle, uint desiredAccess, ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize, uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions, IntPtr eaBuffer, uint eaLength); + + private static bool ValidSegment(string segment) { + if (String.IsNullOrEmpty(segment) || segment.Length > 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private static SafeFileHandle OpenRelative(IntPtr root, string name, bool directory, bool readData) { + if (!ValidSegment(name)) return null; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + SafeFileHandle opened; + uint access = (readData ? FILE_READ_DATA : 0) | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, 0, + directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL, FILE_OPEN, options, IntPtr.Zero, 0); + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) opened.Dispose(); + return null; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || + (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); + return null; + } + return opened; + } catch { + return null; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + private static string ReadUtf8(IntPtr handle) { + var chunks = new List(); + byte[] buffer = new byte[4096]; + uint read; + while (ReadFile(handle, buffer, (uint)buffer.Length, out read, IntPtr.Zero) && read > 0) { + for (int i = 0; i < read; i++) chunks.Add(buffer[i]); + if (chunks.Count > 1048576) return null; + } + return Encoding.UTF8.GetString(chunks.ToArray()); + } + + public static int Run(string ghostId) { + if (!ValidSegment(ghostId)) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + var opened = new List(); + try { + SafeFileHandle ghost = OpenRelative(parent, ghostId, true, false); + if (ghost == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(ghost); + SafeFileHandle metaDir = OpenRelative(ghost.DangerousGetHandle(), ".cindy-library", true, false); + if (metaDir == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(metaDir); + SafeFileHandle tmp = OpenRelative(metaDir.DangerousGetHandle(), "tmp", true, false); + if (tmp == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(tmp); + SafeFileHandle backups = OpenRelative(metaDir.DangerousGetHandle(), "backups", true, false); + if (backups == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(backups); + SafeFileHandle meta = OpenRelative(metaDir.DangerousGetHandle(), "meta.json", false, true); + if (meta == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(meta); + string metaRaw = ReadUtf8(meta.DangerousGetHandle()); + if (metaRaw == null) return 1; + string usageRaw = ""; + SafeFileHandle usage = OpenRelative(metaDir.DangerousGetHandle(), "usage.json", false, true); + if (usage != null) { + opened.Add(usage); + usageRaw = ReadUtf8(usage.DangerousGetHandle()) ?? ""; + } + Console.Out.Write("OK\n" + metaRaw + "\n" + usageRaw); + return 0; + } finally { + for (int i = opened.Count - 1; i >= 0; i--) opened[i].Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $code = [CindyLibraryExistingOpen]::Run([string]$ghost) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_EXISTING_OPEN_COMMAND = Buffer.from(WINDOWS_EXISTING_OPEN_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_EXISTING_OPEN_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, CINDY_LIBRARY_GHOST_ID: ghostId }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index fb4d34fd66b..fccac77055f 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,7 +30,7 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, - type CustomExistingUsage, + sweepProvableStagingOnLinux, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -496,6 +496,13 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; + sweepProvableStagingOnLinux( + parentHandle.fd, + dirSeg, + new Set(this.streams.keys()), + this.now(), + this.limits.tmpMaxAgeMs, + ); } finally { if (parentHandle) { try { From 11cb431ce5fc84b65955438f6cc105fb8a5619e8 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 20:40:37 +0800 Subject: [PATCH 13/23] fix(desktop): parse existing-open helper JSON as complete values Pretty-printed and compact meta/usage payloads both parse. Malformed JSON or failed field checks stay CORRUPT. Do not split on the first newline of the helper stdout. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 49 ++++++++++- .../src/main/cindy-brain/libraryDirFd.ts | 85 ++++++++++++++----- .../src/main/cindy-brain/libraryVault.ts | 2 +- 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 792dfec752e..8a37741c2e1 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -19,7 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; -import { initCustomLibraryTree, openExistingCustomLibrary, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; +import { initCustomLibraryTree, openExistingCustomLibrary, parseExistingStdout, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -332,6 +332,53 @@ describe('LibraryVault', () => { expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); }); + it('existing-open payload: pretty 与 compact 合法 meta 都读, malformed 仍 CORRUPT', () => { + const compact = 'OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{"files":2,"bytes":10,"updatedAt":1,"mutations":0}'; + const prettyMeta = JSON.stringify({ version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, null, 2); + const prettyUsage = JSON.stringify({ files: 2, bytes: 10, updatedAt: 1, mutations: 0 }, null, 2); + const pretty = `OK\n${prettyMeta}\n${prettyUsage}`; + expect(parseExistingStdout(compact)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout(pretty)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ + ok: true, usage: null, + }); + expect(parseExistingStdout('OK\n{not json')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":2,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{nope')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('MISSING')).toMatchObject({ ok: false, code: 'MISSING' }); + }); + + it('pretty-printed 落盘 meta 再 open 仍 ready,不降校验', async () => { + const parent = path.join(tmpRoot, 'picked-pretty'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const metaPath = path.join(custom, '.cindy-library', 'meta.json'); + const compact = JSON.parse(await fs.promises.readFile(metaPath, 'utf8')); + await fs.promises.writeFile(metaPath, JSON.stringify(compact, null, 2)); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.writeFile(metaPath, '{not json'); + const third = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await third.open()).toMatchObject({ ok: true, state: 'unavailable', reason: 'corrupt' }); + }); + it('可证 staging 名只匹配 uuid.tmp/stream,不匹配 old.tmp 或原件', () => { expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.tmp')).toBe(true); expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.stream')).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index f2a2c1117ff..0378eb330e6 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -303,16 +303,57 @@ print STDOUT "OK\n$meta_raw\n"; print STDOUT $usage_raw; `; -function parseExistingStdout(text: string): CustomExistingResult { - if (text === 'MISSING') return { ok: false, code: 'MISSING' }; - if (!text.startsWith('OK\n')) return { ok: false, code: 'IO' }; - const rest = text.slice(3); - const nl = rest.indexOf('\n'); - const metaRaw = nl === -1 ? rest : rest.slice(0, nl); - const usageRaw = nl === -1 ? '' : rest.slice(nl + 1); +function extractJsonValue(source: string, from: number): { json: string; end: number } | null { + let i = from; + while (i < source.length && (source[i] === ' ' || source[i] === '\n' || source[i] === '\r' || source[i] === '\t')) i += 1; + if (i >= source.length || (source[i] !== '{' && source[i] !== '[')) return null; + const start = i; + let depth = 0; + let inStr = false; + let esc = false; + for (; i < source.length; i += 1) { + const c = source[i]; + if (inStr) { + if (esc) { + esc = false; + continue; + } + if (c === '\\') { + esc = true; + continue; + } + if (c === '"') inStr = false; + continue; + } + if (c === '"') { + inStr = true; + continue; + } + if (c === '{' || c === '[') depth += 1; + if (c === '}' || c === ']') { + depth -= 1; + if (depth === 0) return { json: source.slice(start, i + 1), end: i + 1 }; + } + } + return null; +} + +/** Existing-open helper payload: `OK` + complete JSON value(s), or `MISSING`. Pretty or compact. */ +export function parseExistingStdout(text: string): CustomExistingResult { + const body = text.replace(/^\uFEFF/, ''); + const trimmed = body.replace(/^\s+/, ''); + if (trimmed === 'MISSING' || trimmed.startsWith('MISSING\n') || trimmed.startsWith('MISSING\r\n')) { + return { ok: false, code: 'MISSING' }; + } + if (!trimmed.startsWith('OK')) return { ok: false, code: 'IO' }; + let rest = trimmed.slice(2); + if (rest.startsWith('\r\n')) rest = rest.slice(2); + else if (rest.startsWith('\n')) rest = rest.slice(1); + const metaTok = extractJsonValue(rest, 0); + if (!metaTok) return { ok: false, code: 'CORRUPT' }; let meta: CustomExistingMeta; try { - const parsed = JSON.parse(metaRaw) as CustomExistingMeta; + const parsed = JSON.parse(metaTok.json) as CustomExistingMeta; if ( typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' @@ -323,24 +364,30 @@ function parseExistingStdout(text: string): CustomExistingResult { } catch { return { ok: false, code: 'CORRUPT' }; } + const afterMeta = rest.slice(metaTok.end); + const usageTok = extractJsonValue(afterMeta, 0); let usage: CustomExistingUsage | null = null; - if (usageRaw.trim()) { + if (usageTok) { try { - const parsed = JSON.parse(usageRaw) as CustomExistingUsage; + const parsed = JSON.parse(usageTok.json) as CustomExistingUsage; if ( - typeof parsed === 'object' && parsed !== null && - typeof parsed.files === 'number' && typeof parsed.bytes === 'number' + typeof parsed !== 'object' || parsed === null || + typeof parsed.files !== 'number' || typeof parsed.bytes !== 'number' ) { - usage = { - files: parsed.files, - bytes: parsed.bytes, - updatedAt: parsed.updatedAt ?? 0, - mutations: parsed.mutations ?? 0, - }; + return { ok: false, code: 'CORRUPT' }; } + usage = { + files: parsed.files, + bytes: parsed.bytes, + updatedAt: parsed.updatedAt ?? 0, + mutations: parsed.mutations ?? 0, + }; } catch { - usage = null; + return { ok: false, code: 'CORRUPT' }; } + if (afterMeta.slice(usageTok.end).trim() !== '') return { ok: false, code: 'CORRUPT' }; + } else if (afterMeta.trim() !== '') { + return { ok: false, code: 'CORRUPT' }; } return { ok: true, meta, usage }; } diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index fccac77055f..c6fd4464411 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -490,7 +490,7 @@ export class LibraryVault { } } else { this.state = 'unavailable'; - this.unavailableReason = 'permission'; + this.unavailableReason = existing.code === 'CORRUPT' ? 'corrupt' : 'permission'; this.opened = true; return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } From 8cfa9d6fb822bf430beeb8b1d494120a81537f61 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:12:15 +0800 Subject: [PATCH 14/23] fix(desktop): do not delete custom Library uuid tmp/stream on open Age and an empty streams map cannot prove a fsynced .tmp/.stream is garbage after a crash before rename. Open no longer unlinks them. P2_tmp stays unresolved. Unknown names are kept. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/libraryDirFd.ts | 32 ++++++------------- .../src/main/cindy-brain/libraryVault.ts | 8 ----- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0378eb330e6..0bf1f98ab07 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -509,20 +509,20 @@ function windowsPowerShellPath(): string | null { export const PROVABLE_STAGING_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(tmp|stream)$/i; -/** Linux only: unlinkat-equivalent via /proc/self/fd. Unknown names kept. */ -export function sweepProvableStagingOnLinux( +/** + * Age + empty streams Map cannot prove uuid.tmp/.stream are garbage: + * atomicWrite/stream may have fsynced a complete unique payload before rename. + * Diagnostic list only. No unlink. P2_tmp UNRESOLVED. + */ +export function listProvableStagingOnLinux( parentFd: number, ghostId: string, - activeStreamIds: ReadonlySet, - nowMs: number, - maxAgeMs: number, -): { ok: true; unlinked: number } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { +): { ok: true; names: string[] } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { return { ok: false, code: 'IO' }; } const opened: number[] = []; - let unlinked = 0; try { const openDirAt = (dirFd: number, name: string): number => { let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; @@ -534,22 +534,8 @@ export function sweepProvableStagingOnLinux( const rootFd = openDirAt(parentFd, ghostId); const metaDirFd = openDirAt(rootFd, '.cindy-library'); const tmpFd = openDirAt(metaDirFd, 'tmp'); - const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`); - const cutoff = nowMs - maxAgeMs; - for (const name of names) { - if (!PROVABLE_STAGING_NAME.test(name)) continue; - if (name.endsWith('.stream') && activeStreamIds.has(name.slice(0, -'.stream'.length))) continue; - const target = `/proc/self/fd/${tmpFd}/${name}`; - try { - const st = fs.statSync(target); - if (!st.isFile() || st.mtimeMs >= cutoff) continue; - fs.unlinkSync(target); - unlinked += 1; - } catch { - /* keep on error */ - } - } - return { ok: true, unlinked }; + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`).filter((name) => PROVABLE_STAGING_NAME.test(name)); + return { ok: true, names }; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index c6fd4464411..03d856425ac 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -30,7 +30,6 @@ import { isSafeGhostRelativePath } from '../../shared/ghost.js'; import { initCustomLibraryTree, openExistingCustomLibrary, - sweepProvableStagingOnLinux, type CustomTreeInitResult, } from './libraryDirFd.js'; @@ -496,13 +495,6 @@ export class LibraryVault { } const afterTree = await this.assertHeldCustomParent(heldId); if (afterTree) return afterTree; - sweepProvableStagingOnLinux( - parentHandle.fd, - dirSeg, - new Set(this.streams.keys()), - this.now(), - this.limits.tmpMaxAgeMs, - ); } finally { if (parentHandle) { try { From cb55ea81e4eaafea86b68db8c6949b3aba890e33 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:46:45 +0800 Subject: [PATCH 15/23] feat(desktop): Windows custom Library first-create via NtCreateFile Create dirs with FILE_OPEN_IF and meta.json with FILE_CREATE from the inherited parent handle. No path mkdir, no rollback rmdir, no new native dependency. Existing FILE_OPEN stays read-only. Missing helper still fails closed before mutation. Signed-off-by: PraiseZhu --- .../__tests__/libraryVault.test.ts | 6 +- .../src/main/cindy-brain/libraryDirFd.ts | 254 +++++++++++++++++- 2 files changed, 255 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 8a37741c2e1..55ce4afe88a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -387,7 +387,7 @@ describe('LibraryVault', () => { expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); }); - it('Windows 新建 custom 仍 unsupported,不 mkdir', async () => { + it('Windows 新建 custom 走稳定 parent handle 首建 ready', async () => { if (process.platform !== 'win32') return; const parent = path.join(tmpRoot, 'picked-win-new'); await fs.promises.mkdir(parent); @@ -401,8 +401,8 @@ describe('LibraryVault', () => { rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', }); const opened = await vault.open(); - expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); - expect(fs.existsSync(custom)).toBe(false); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(true); }); it('default 缺失根仍可首次创建', async () => { diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 0bf1f98ab07..02b607d341c 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -2,8 +2,10 @@ * Custom Library first-create: mkdir/open/meta stay on a held parent directory * fd. Darwin uses a fixed /usr/bin/perl mkdirat/openat helper (SYS_mkdirat=475, * SYS_openat=463 from MacOSX.sdk sys/syscall.h). Linux uses /proc/self/fd. - * Windows and missing helpers fail closed before any mutation. No path mkdir - * fallback. Segments are fixed/validated names, never concatenated user paths. + * Windows uses the same PowerShell NtCreateFile helper: FILE_OPEN_IF dirs and + * FILE_CREATE meta from the inherited parent handle. Missing helpers fail + * closed before any mutation. No path mkdir fallback. Segments are + * fixed/validated names, never concatenated user paths. */ import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; @@ -222,6 +224,7 @@ export async function initCustomLibraryTree(req: { } if (process.platform === 'darwin') return runDarwinInit(req.parentFd, req.ghostId, req.metaJson); if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'win32') return runWindowsInit(req.parentFd, req.ghostId, req.metaJson); return { ok: false, code: 'UNSUPPORTED' }; } @@ -783,3 +786,250 @@ function runWindowsOpenExisting(parentFd: number, ghostId: string): Promise 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private static SafeFileHandle CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { + if (!ValidSegment(name)) return null; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + SafeFileHandle opened; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + uint fileAttributes = directory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; + uint share = directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL; + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, fileAttributes, + share, disposition, options, IntPtr.Zero, 0); + if (status == STATUS_OBJECT_NAME_COLLISION) { + if (opened != null) opened.Dispose(); + return null; + } + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) opened.Dispose(); + return null; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || + (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); + return null; + } + return opened; + } catch { + return null; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + public static int Run(string ghostId, string metaJson) { + if (!ValidSegment(ghostId)) return 2; + if (metaJson == null || !Regex.IsMatch(metaJson, "^\\{\"version\":1,\"ghostId\":\"[A-Za-z0-9._-]{1,128}\",\"createdAt\":[0-9]{1,16}\\}$")) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + uint dirAccess = FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + SafeFileHandle ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); + if (ghost == null) return 1; + try { + SafeFileHandle metaDir = CreateRelative(ghost.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); + if (metaDir == null) return 1; + try { + SafeFileHandle tmp = CreateRelative(metaDir.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); + if (tmp == null) return 1; + tmp.Dispose(); + SafeFileHandle backups = CreateRelative(metaDir.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); + if (backups == null) return 1; + backups.Dispose(); + uint fileAccess = FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + SafeFileHandle meta = CreateRelative(metaDir.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); + if (meta == null) { + Console.Out.Write("exists"); + return 0; + } + try { + byte[] bytes = Encoding.UTF8.GetBytes(metaJson); + uint written; + if (!WriteFile(meta.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; + if (!FlushFileBuffers(meta.DangerousGetHandle())) return 1; + Console.Out.Write("created"); + return 0; + } finally { + meta.Dispose(); + } + } finally { + metaDir.Dispose(); + } + } finally { + ghost.Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $meta = $env:CINDY_LIBRARY_META_JSON + $code = [CindyLibraryInit]::Run([string]$ghost, [string]$meta) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_INIT_COMMAND = Buffer.from(WINDOWS_INIT_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_INIT_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + CINDY_LIBRARY_GHOST_ID: ghostId, + CINDY_LIBRARY_META_JSON: metaJson, + }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0 || (text !== 'created' && text !== 'exists')) { + finish({ ok: false, code: 'IO' }); + return; + } + finish({ ok: true, createdMeta: text === 'created' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} From 132dc9824dd7244b67cd796715254a542c3f2e31 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 21:47:24 +0800 Subject: [PATCH 16/23] test(maker-core): time durable Subagent system-deny waitFor Record list/resolver/control monotonic marks and dump them on waitFor failure so Linux CI can discriminate missed 500ms poll vs unpublished system-deny. Keep the original assertion and timeout. Mac is syntax sanity only. Signed-off-by: PraiseZhu --- .../__tests__/pi-startsession-cleanup.test.ts | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts index 11ca0d28685..ed4bf0ed981 100644 --- a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts +++ b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts @@ -1665,22 +1665,59 @@ describe('PiAgent.startSession failure cleanup (mocked pi process)', () => { }); it('preserves system denial when a durable Subagent approval resolver fails', async () => { + const t0 = performance.now(); + const events: Array<{ t: number; k: string; n?: number }> = []; + const mark = (k: string, n?: number): void => { + events.push({ t: Math.round(performance.now() - t0), k, ...(n === undefined ? {} : { n }) }); + }; const run = pendingSubagentRun({ toolName: 'write', input: { path: 'a.txt' }, }, {}, 'input'); - vi.spyOn(piSubagentRuns, 'listPiSubagentRuns').mockResolvedValue([run]); - const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockResolvedValue(1); - const handle = await new PiAgent(buildDeps()).startSession(opts()); - handle.setInteractionResolver(vi.fn(async () => { throw new Error('resolver failed'); })); - - await vi.waitFor(() => expect(control).toHaveBeenCalledWith( - expect.any(String), - run.taskId, - 'approval', - expect.objectContaining({ value: 'system-deny' }), - )); - await handle.close(); + const list = vi.spyOn(piSubagentRuns, 'listPiSubagentRuns').mockImplementation(async () => { + mark('list', list.mock.calls.length); + return [run]; + }); + const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockImplementation(async () => { + mark('control', control.mock.calls.length + 1); + return 1; + }); + const resolver = vi.fn(async () => { + mark('resolver-throw'); + throw new Error('resolver failed'); + }); + let handle: Awaited> | undefined; + try { + handle = await new PiAgent(buildDeps()).startSession(opts()); + mark('startSession-returned'); + mark('resolver-install-start'); + handle.setInteractionResolver(resolver); + mark('resolver-install-end'); + mark('waitFor-start'); + try { + await vi.waitFor(() => expect(control).toHaveBeenCalledWith( + expect.any(String), + run.taskId, + 'approval', + expect.objectContaining({ value: 'system-deny' }), + )); + mark('waitFor-pass'); + } catch (err) { + mark('waitFor-fail', control.mock.calls.length); + console.error('[pi-startsession-cleanup.diag]', JSON.stringify({ + host: `${process.platform} ${process.version}`, + notLinuxProof: process.platform !== 'linux', + events, + listCalls: list.mock.calls.length, + controlCalls: control.mock.calls.length, + resolverCalls: resolver.mock.calls.length, + })); + throw err; + } + } finally { + await handle?.close(); + mark('handle-closed'); + } }); it('never answers durable Subagent approvals owned by another runtime', async () => { From 94f36c0e2917ab75d5e88cb1fdebf4c3453a5ff3 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 22:00:53 +0800 Subject: [PATCH 17/23] fix(desktop): distinguish Windows meta exists from reparse collision FILE_CREATE collision is exists only after FILE_OPEN of a regular file. Reparse and unknown collisions fail closed. CreateRelative always releases handles it still owns, including the catch path. Signed-off-by: PraiseZhu --- .../src/main/cindy-brain/libraryDirFd.ts | 105 +++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts index 02b607d341c..cd6691bb883 100644 --- a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -807,6 +807,7 @@ public static class CindyLibraryInit { private const uint SYNCHRONIZE = 0x00100000; private const uint FILE_SHARE_READ_WRITE = 0x00000003; private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_OPEN = 0x00000001; private const uint FILE_CREATE = 0x00000002; private const uint FILE_OPEN_IF = 0x00000003; private const uint FILE_DIRECTORY_FILE = 0x00000001; @@ -870,10 +871,18 @@ public static class CindyLibraryInit { return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; } - private static SafeFileHandle CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { - if (!ValidSegment(name)) return null; + private enum RelativeStatus { Ok, Collision, Reparse, Failed } + private struct RelativeOpen { + public SafeFileHandle Handle; + public RelativeStatus Status; + } + + private static RelativeOpen CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { + var failed = new RelativeOpen { Handle = null, Status = RelativeStatus.Failed }; + if (!ValidSegment(name)) return failed; IntPtr nameBuffer = IntPtr.Zero; IntPtr unicodePointer = IntPtr.Zero; + SafeFileHandle opened = null; try { nameBuffer = Marshal.StringToHGlobalUni(name); var unicode = new UNICODE_STRING { @@ -892,7 +901,6 @@ public static class CindyLibraryInit { SecurityQualityOfService = IntPtr.Zero }; IO_STATUS_BLOCK statusBlock; - SafeFileHandle opened; uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); uint fileAttributes = directory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; @@ -900,22 +908,36 @@ public static class CindyLibraryInit { int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, fileAttributes, share, disposition, options, IntPtr.Zero, 0); if (status == STATUS_OBJECT_NAME_COLLISION) { - if (opened != null) opened.Dispose(); - return null; + if (opened != null) { opened.Dispose(); opened = null; } + return new RelativeOpen { Handle = null, Status = RelativeStatus.Collision }; } if (status < 0 || opened == null || opened.IsInvalid) { - if (opened != null) opened.Dispose(); - return null; + if (opened != null) { opened.Dispose(); opened = null; } + return failed; } FILE_ATTRIBUTE_TAG_INFO tag; - if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || - (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { - opened.Dispose(); - return null; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { + opened.Dispose(); opened = null; + return failed; } - return opened; + if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); opened = null; + return new RelativeOpen { Handle = null, Status = RelativeStatus.Reparse }; + } + if (directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + opened.Dispose(); opened = null; + return failed; + } + if (!directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + opened.Dispose(); opened = null; + return failed; + } + var ok = new RelativeOpen { Handle = opened, Status = RelativeStatus.Ok }; + opened = null; + return ok; } catch { - return null; + if (opened != null) opened.Dispose(); + return failed; } finally { if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); @@ -931,39 +953,44 @@ public static class CindyLibraryInit { if (!GetFileInformationByHandle(parent, out parentInfo) || (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; uint dirAccess = FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE; - SafeFileHandle ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); - if (ghost == null) return 1; + RelativeOpen ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); + if (ghost.Status != RelativeStatus.Ok || ghost.Handle == null) return 1; try { - SafeFileHandle metaDir = CreateRelative(ghost.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); - if (metaDir == null) return 1; + RelativeOpen metaDir = CreateRelative(ghost.Handle.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); + if (metaDir.Status != RelativeStatus.Ok || metaDir.Handle == null) return 1; try { - SafeFileHandle tmp = CreateRelative(metaDir.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); - if (tmp == null) return 1; - tmp.Dispose(); - SafeFileHandle backups = CreateRelative(metaDir.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); - if (backups == null) return 1; - backups.Dispose(); + RelativeOpen tmp = CreateRelative(metaDir.Handle.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); + if (tmp.Status != RelativeStatus.Ok || tmp.Handle == null) return 1; + tmp.Handle.Dispose(); + RelativeOpen backups = CreateRelative(metaDir.Handle.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); + if (backups.Status != RelativeStatus.Ok || backups.Handle == null) return 1; + backups.Handle.Dispose(); uint fileAccess = FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE; - SafeFileHandle meta = CreateRelative(metaDir.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); - if (meta == null) { - Console.Out.Write("exists"); - return 0; - } - try { - byte[] bytes = Encoding.UTF8.GetBytes(metaJson); - uint written; - if (!WriteFile(meta.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; - if (!FlushFileBuffers(meta.DangerousGetHandle())) return 1; - Console.Out.Write("created"); - return 0; - } finally { - meta.Dispose(); + RelativeOpen meta = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); + if (meta.Status == RelativeStatus.Ok && meta.Handle != null) { + try { + byte[] bytes = Encoding.UTF8.GetBytes(metaJson); + uint written; + if (!WriteFile(meta.Handle.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; + if (!FlushFileBuffers(meta.Handle.DangerousGetHandle())) return 1; + Console.Out.Write("created"); + return 0; + } finally { + meta.Handle.Dispose(); + } } + if (meta.Handle != null) meta.Handle.Dispose(); + if (meta.Status != RelativeStatus.Collision) return 1; + RelativeOpen existing = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_OPEN, FILE_READ_ATTRIBUTES | SYNCHRONIZE); + if (existing.Status != RelativeStatus.Ok || existing.Handle == null) return 1; + existing.Handle.Dispose(); + Console.Out.Write("exists"); + return 0; } finally { - metaDir.Dispose(); + metaDir.Handle.Dispose(); } } finally { - ghost.Dispose(); + ghost.Handle.Dispose(); } } } From 58efb97f8b8f9e62de003b977838315548952500 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 17:44:18 +0800 Subject: [PATCH 18/23] fix(desktop): sweep idle staging uploads and count concurrent disk reserve Idle incomplete uploads now yield BUSY/QUOTA slots after timeout. Concurrent begin reservations are checked against reserveBytes. Durables and commitPending originals are never TTL-deleted. Signed-off-by: PraiseZhu --- .../__tests__/libraryStaging.test.ts | 72 ++++++++++++++++++- .../src/main/cindy-brain/libraryStaging.ts | 39 ++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts index 128395c0c5a..88a86746977 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -30,7 +30,12 @@ describe('LibraryStagingStore 故障恢复', () => { maxTotalBytes?: number; maxConcurrentWrites?: number; maxChunkBytes?: number; + maxTaskBytes?: number; + reserveBytes?: number; + streamIdleTimeoutMs?: number; listPageSize?: number; + now?: () => number; + getDiskFreeBytes?: () => Promise; } = {}, ): LibraryStagingStore => new LibraryStagingStore({ @@ -45,12 +50,15 @@ describe('LibraryStagingStore 故障恢复', () => { ...(extra.listPageSize !== undefined ? { listPageSize: extra.listPageSize } : {}), }, }), - getDiskFreeBytes: async () => 1024 ** 4, + getDiskFreeBytes: extra.getDiskFreeBytes ?? (async () => 1024 ** 4), + now: extra.now, limits: { maxTotalBytes: extra.maxTotalBytes ?? 64, maxConcurrentWrites: extra.maxConcurrentWrites ?? 2, - reserveBytes: 1, + reserveBytes: extra.reserveBytes ?? 1, ...(extra.maxChunkBytes !== undefined ? { maxChunkBytes: extra.maxChunkBytes } : {}), + ...(extra.maxTaskBytes !== undefined ? { maxTaskBytes: extra.maxTaskBytes } : {}), + ...(extra.streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs: extra.streamIdleTimeoutMs } : {}), }, }); @@ -988,4 +996,64 @@ describe('LibraryStagingStore 故障恢复', () => { expect(conflictList).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); expect(fs.existsSync(blobAbs(conflictRoot, conflictId))).toBe(true); }); + + it('闲置未完成上传超时后让出并发槽,不扫 commitPending/durable', async () => { + let now = 1_000; + const store = makeStore(path.join(tmp, 'idle-busy', ghostId), { + maxConcurrentWrites: 1, + maxTotalBytes: 1024, + streamIdleTimeoutMs: 10, + now: () => now, + }); + const first = await store.begin({ + ghostId, taskId: 'idle-one', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('idle'), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const busy = await store.begin({ + ghostId, taskId: 'blocked', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('next'), mime: 'image/png', recovery, + }); + expect(busy).toMatchObject({ ok: false, errorCode: 'STAGING_BUSY' }); + now = 1_020; + const second = await store.begin({ + ghostId, taskId: 'after-idle', sourceRevision: 'r', + totalBytes: 4, sha256: sha256Of('next'), mime: 'image/png', recovery, + }); + if (!second.ok) throw new Error(JSON.stringify(second)); + expect(second.stagingId).not.toBe(first.stagingId); + expect(fs.existsSync(intentAbs(path.join(tmp, 'idle-busy', ghostId), first.stagingId))).toBe(false); + expect(await store.abort({ ghostId, stagingId: second.stagingId })).toMatchObject({ ok: true, aborted: true }); + + const durable = await commitOne(store, 'keep-durable', 'keep'); + now = 2_000; + const stillListed = await store.list({ ghostId }); + if (!stillListed.ok) throw new Error(JSON.stringify(stillListed)); + expect(stillListed.items.map((item) => item.stagingId)).toContain(durable.stagingId); + }); + + it('并发预留计入磁盘保留水位,不删已落地 commitPending 原件', async () => { + const gib = 1024 ** 3; + const store = makeStore(path.join(tmp, 'reserve', ghostId), { + maxConcurrentWrites: 2, + maxTotalBytes: 8 * gib, + maxTaskBytes: 4 * gib, + reserveBytes: gib, + getDiskFreeBytes: async () => 6 * gib, + }); + const hashA = 'a'.repeat(64); + const hashB = 'b'.repeat(64); + const first = await store.begin({ + ghostId, taskId: 'reserve-a', sourceRevision: 'r', + totalBytes: 3 * gib, sha256: hashA, mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const second = await store.begin({ + ghostId, taskId: 'reserve-b', sourceRevision: 'r', + totalBytes: 3 * gib, sha256: hashB, mime: 'image/png', recovery, + }); + expect(second).toMatchObject({ ok: false, errorCode: 'DISK_FULL' }); + const abortFirst = await store.abort({ ghostId, stagingId: first.stagingId }); + expect(abortFirst).toMatchObject({ ok: true, aborted: true }); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index c9ade779fd7..1a2b13d76c7 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -36,6 +36,8 @@ export interface LibraryStagingLimits { maxConcurrentWrites: number; maxChunkBytes: number; reserveBytes: number; + /** Incomplete upload idle timeout; durables/commitPending are never swept. */ + streamIdleTimeoutMs: number; maxRecoveryMetadataBytes: number; defaultListLimit: number; maxListLimit: number; @@ -47,6 +49,7 @@ export const DEFAULT_LIBRARY_STAGING_LIMITS: LibraryStagingLimits = { maxConcurrentWrites: 4, maxChunkBytes: 16 * 1024 * 1024, reserveBytes: 1024 * 1024 * 1024, + streamIdleTimeoutMs: DEFAULT_LIBRARY_LIMITS.streamIdleTimeoutMs, maxRecoveryMetadataBytes: 64 * 1024, defaultListLimit: 100, maxListLimit: 500, @@ -97,6 +100,7 @@ export interface LibraryStagingDeps { getDiskFreeBytes?(root: string): Promise; log?: LibraryVaultDeps['log']; limits?: Partial; + now?(): number; } const HEX64 = /^[0-9a-f]{64}$/; @@ -200,6 +204,7 @@ interface UploadRecord { recovery: Record; nextSeq: number; lastChunk: Buffer | null; + lastAt: number; /** writeCommit succeeded; keep mapping until manifest+dirsync durable. */ commitPending: boolean; } @@ -343,6 +348,7 @@ export class LibraryStagingStore { private readonly limits: LibraryStagingLimits; private readonly ownerScopeKey: string; private readonly ghostId: string; + private readonly now: () => number; private readonly vault: LibraryVault; private readonly uploads = new Map(); private readonly byTask = new Map(); @@ -359,6 +365,7 @@ export class LibraryStagingStore { this.limits = { ...DEFAULT_LIBRARY_STAGING_LIMITS, ...(deps.limits ?? {}) }; this.ownerScopeKey = deps.ownerScopeKey; this.ghostId = deps.ghostId; + this.now = deps.now ?? Date.now; const capturedRoot = deps.rootDir; const createVault = deps.createVault ?? ((vaultDeps: LibraryVaultDeps) => new LibraryVault(vaultDeps)); this.vault = createVault({ @@ -506,6 +513,33 @@ export class LibraryStagingStore { + this.trackedUploadBytes(); } + /** Idle incomplete uploads only. Durables and commitPending originals are never TTL-deleted. */ + private async sweepIdleUploadsUnlocked(): Promise { + const cutoff = this.now() - this.limits.streamIdleTimeoutMs; + for (const upload of [...this.uploads.values()]) { + if (upload.commitPending || upload.lastAt >= cutoff) continue; + await this.vault.writeAbort({ streamId: upload.streamId }).catch(() => {}); + this.uploads.delete(upload.stagingId); + this.byTask.delete(taskKey(upload.taskId, upload.sourceRevision)); + await this.vault.delete({ path: intentPath(upload.stagingId) }).catch(() => {}); + this.closedTmpStale = true; + } + } + + private async checkDiskReserveUnlocked(extraBytes: number): Promise { + if (!this.deps.getDiskFreeBytes) return null; + let free: number | null = null; + try { + free = await this.deps.getDiskFreeBytes(this.deps.rootDir); + } catch { + return null; + } + if (free !== null && free - this.trackedUploadBytes() - extraBytes < this.limits.reserveBytes) { + return fail('DISK_FULL', `磁盘剩余空间低于保留水位(${this.limits.reserveBytes} 字节);请清理磁盘或确认归档后释放`); + } + return null; + } + private async requireReady(): Promise { const denied = this.requireOwner(); if (denied) return denied; @@ -759,12 +793,15 @@ export class LibraryStagingStore { } return fail('ALREADY_EXISTS', '同一 task/revision 已有不同元数据的原件'); } + await this.sweepIdleUploadsUnlocked(); if (this.uploads.size >= this.limits.maxConcurrentWrites) { return fail('STAGING_BUSY', '并发上传已达上限,请稍后重试'); } if (this.quotaBytes() + req.totalBytes > this.limits.maxTotalBytes) { return fail('STAGING_QUOTA', 'staging 总容量不足,请在确认归档后释放再试'); } + const disk = await this.checkDiskReserveUnlocked(req.totalBytes); + if (disk) return disk; const stagingId = randomUUID(); const identity: TaskIdentity = { stagingId, @@ -807,6 +844,7 @@ export class LibraryStagingStore { recovery: parsedRecovery.recovery, nextSeq: 1, lastChunk: null, + lastAt: this.now(), commitPending: false, }); this.byTask.set(taskKey(taskId, sourceRevision), stagingId); @@ -854,6 +892,7 @@ export class LibraryStagingStore { if (!chunk.ok) return vaultFail(chunk); upload.nextSeq = req.seq + 1; upload.lastChunk = decoded; + upload.lastAt = this.now(); return { ok: true as const, accepted: chunk.accepted }; }); } From 6445e75671eed381ed5ec655c50eb054d21c67ef Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sun, 20 Sep 2026 18:09:00 +0800 Subject: [PATCH 19/23] fix(desktop): count idle tmp residue and unwritten disk reserve Refresh closed tmp bytes after idle abort before quota admission. Disk reserve subtracts only unwritten stream remainder; commitPending does not double-count landed blobs. Durables stay off TTL. Signed-off-by: PraiseZhu --- .../__tests__/libraryStaging.test.ts | 63 +++++++++++++++++++ .../src/main/cindy-brain/libraryStaging.ts | 20 +++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts index 88a86746977..11dc65c3f2d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -1056,4 +1056,67 @@ describe('LibraryStagingStore 故障恢复', () => { const abortFirst = await store.abort({ ghostId, stagingId: first.stagingId }); expect(abortFirst).toMatchObject({ ok: true, aborted: true }); }); + + it('闲置清扫 unlink 失败后本次 begin 仍计入残片额度', async () => { + let now = 1_000; + const root = path.join(tmp, 'idle-residue', ghostId); + const store = makeStore(root, { + maxConcurrentWrites: 2, + maxTotalBytes: 60, + streamIdleTimeoutMs: 10, + now: () => now, + }); + const payload = 'x'.repeat(50); + const first = await store.begin({ + ghostId, taskId: 'idle-residue', sourceRevision: 'r', + totalBytes: payload.length, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(JSON.stringify(chunk)); + const realUnlink = fs.promises.unlink.bind(fs.promises); + const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockImplementation(async (target, ...rest) => { + if (String(target).includes(`${path.sep}.cindy-library${path.sep}tmp${path.sep}`)) { + throw Object.assign(new Error('EACCES unlink tmp'), { code: 'EACCES' }); + } + return realUnlink(target, ...rest); + }); + now = 1_020; + const second = await store.begin({ + ghostId, taskId: 'after-residue', sourceRevision: 'r', + totalBytes: 20, sha256: sha256Of('y'.repeat(20)), mime: 'image/png', recovery, + }); + unlinkSpy.mockRestore(); + expect(second).toMatchObject({ ok: false, errorCode: 'STAGING_QUOTA' }); + }); + + it('磁盘保留只扣未写入剩余;commitPending 不再重复占预留', async () => { + const store = makeStore(path.join(tmp, 'unwritten-reserve', ghostId), { + maxConcurrentWrites: 2, + maxTotalBytes: 10_000, + maxTaskBytes: 10_000, + maxChunkBytes: 10_000, + reserveBytes: 10, + getDiskFreeBytes: async () => 1000, + }); + const payload = 'z'.repeat(80); + const first = await store.begin({ + ghostId, taskId: 'partial', sourceRevision: 'r', + totalBytes: payload.length, sha256: sha256Of(payload), mime: 'image/png', recovery, + }); + if (!first.ok) throw new Error(JSON.stringify(first)); + const chunk = await store.chunk({ + ghostId, stagingId: first.stagingId, seq: 1, + content: Buffer.from(payload).toString('base64'), encoding: 'base64', + }); + if (!chunk.ok) throw new Error(JSON.stringify(chunk)); + const next = await store.begin({ + ghostId, taskId: 'fits-remaining', sourceRevision: 'r', + totalBytes: 960, sha256: sha256Of('n'.repeat(960)), mime: 'image/png', recovery, + }); + expect(next.ok).toBe(true); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index 1a2b13d76c7..ce7164a23bc 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -205,6 +205,7 @@ interface UploadRecord { nextSeq: number; lastChunk: Buffer | null; lastAt: number; + written: number; /** writeCommit succeeded; keep mapping until manifest+dirsync durable. */ commitPending: boolean; } @@ -526,6 +527,16 @@ export class LibraryStagingStore { } } + /** Bytes declared but not yet on disk. commitPending blobs are already landed. */ + private unwrittenReservationBytes(): number { + let total = 0; + for (const upload of this.uploads.values()) { + if (upload.commitPending) continue; + total += Math.max(0, upload.totalBytes - upload.written); + } + return total; + } + private async checkDiskReserveUnlocked(extraBytes: number): Promise { if (!this.deps.getDiskFreeBytes) return null; let free: number | null = null; @@ -534,7 +545,7 @@ export class LibraryStagingStore { } catch { return null; } - if (free !== null && free - this.trackedUploadBytes() - extraBytes < this.limits.reserveBytes) { + if (free !== null && free - this.unwrittenReservationBytes() - extraBytes < this.limits.reserveBytes) { return fail('DISK_FULL', `磁盘剩余空间低于保留水位(${this.limits.reserveBytes} 字节);请清理磁盘或确认归档后释放`); } return null; @@ -794,6 +805,11 @@ export class LibraryStagingStore { return fail('ALREADY_EXISTS', '同一 task/revision 已有不同元数据的原件'); } await this.sweepIdleUploadsUnlocked(); + if (this.closedTmpStale) { + const residue = await this.refreshClosedTmp(); + if (residue) return residue; + this.closedTmpStale = false; + } if (this.uploads.size >= this.limits.maxConcurrentWrites) { return fail('STAGING_BUSY', '并发上传已达上限,请稍后重试'); } @@ -845,6 +861,7 @@ export class LibraryStagingStore { nextSeq: 1, lastChunk: null, lastAt: this.now(), + written: 0, commitPending: false, }); this.byTask.set(taskKey(taskId, sourceRevision), stagingId); @@ -893,6 +910,7 @@ export class LibraryStagingStore { upload.nextSeq = req.seq + 1; upload.lastChunk = decoded; upload.lastAt = this.now(); + upload.written += decoded.byteLength; return { ok: true as const, accepted: chunk.accepted }; }); } From 7faa6c91aca9fc983bc65a082ada2d78a27b1bb1 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 02:03:00 +0800 Subject: [PATCH 20/23] fix(desktop): recover staging durables across owner generation Restart resets app-session generation so live ownerScopeKey (local:id:2 vs local:id:0) no longer matches the on-disk manifest. Treat generation as a process lease, not durable owner identity. Signed-off-by: PraiseZhu --- .../__tests__/libraryStaging.test.ts | 31 +++++++++++++++++-- .../src/main/cindy-brain/libraryStaging.ts | 12 ++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts index 11dc65c3f2d..9c045c8b9f4 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts @@ -36,13 +36,14 @@ describe('LibraryStagingStore 故障恢复', () => { listPageSize?: number; now?: () => number; getDiskFreeBytes?: () => Promise; + ownerScopeKey?: string; } = {}, ): LibraryStagingStore => new LibraryStagingStore({ rootDir: root, - ownerScopeKey: 'local:owner-a:1', + ownerScopeKey: extra.ownerScopeKey ?? 'local:owner-a:1', ghostId, - captureOwnerScope: () => scope, + captureOwnerScope: () => extra.ownerScopeKey ?? scope, createVault: (deps) => new LibraryVault({ ...deps, limits: { @@ -1119,4 +1120,30 @@ describe('LibraryStagingStore 故障恢复', () => { }); expect(next.ok).toBe(true); }); + + it('同 owner 进程重启 generation 变化后仍恢复磁盘 durable,不报 manifest 字段非法', async () => { + const root = path.join(tmp, 'gen-restart', ghostId); + const first = makeStore(root, { ownerScopeKey: 'local:local-v1:2', maxTotalBytes: 1024 }); + const committed = await commitOne(first, 'restart-task', body); + const onDisk = JSON.parse(await fs.promises.readFile(manifestAbs(root, committed.stagingId), 'utf8')) as { + ownerScopeKey: string; + version: number; + durable: boolean; + }; + expect(onDisk).toMatchObject({ ownerScopeKey: 'local:local-v1:2', version: 1, durable: true }); + expect(fs.existsSync(blobAbs(root, committed.stagingId))).toBe(true); + + const restarted = makeStore(root, { ownerScopeKey: 'local:local-v1:0', maxTotalBytes: 1024 }); + const listed = await restarted.list({ ghostId }); + if (!listed.ok) throw new Error(JSON.stringify(listed)); + expect(listed.items.map((item) => item.stagingId)).toEqual([committed.stagingId]); + const read = await restarted.read({ ghostId, stagingId: committed.stagingId }); + if (!read.ok) throw new Error(JSON.stringify(read)); + expect(read.sha256).toBe(committed.digest); + + const otherOwner = makeStore(root, { ownerScopeKey: 'local:owner-b:0', maxTotalBytes: 1024 }); + expect(await otherOwner.list({ ghostId })).toMatchObject({ + ok: false, errorCode: 'LIBRARY_UNAVAILABLE', message: 'staging manifest 字段非法', + }); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index ce7164a23bc..006834b2a8f 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -254,6 +254,16 @@ function receiptOf(record: DurableRecord): LibraryStagingReceipt { }; } +/** Disk identity ignores process-lifetime generation (`local:id:2` ≡ `local:id:0`). */ +function durableOwnerScopeKey(scopeKey: string): string { + const parsed = /^(local|cloud|signed-out):(.+):(\d+)$/.exec(scopeKey); + return parsed ? `${parsed[1]}:${parsed[2]}` : scopeKey; +} + +function sameDurableOwner(persisted: unknown, live: string): boolean { + return typeof persisted === 'string' && durableOwnerScopeKey(persisted) === durableOwnerScopeKey(live); +} + function parseTaskIdentity( parsed: Record, stagingId: string, @@ -265,7 +275,7 @@ function parseTaskIdentity( if ( parsed.stagingId !== stagingId || parsed.ghostId !== ghostId - || parsed.ownerScopeKey !== ownerScopeKey + || !sameDurableOwner(parsed.ownerScopeKey, ownerScopeKey) || typeof parsed.taskId !== 'string' || parsed.taskId.length === 0 || parsed.taskId.length > TASK_ID_MAX || typeof parsed.sourceRevision !== 'string' || parsed.sourceRevision.length === 0 || parsed.sourceRevision.length > TASK_ID_MAX || typeof parsed.sha256 !== 'string' || !HEX64.test(parsed.sha256) From 85c8d734856adedef0fc4983dec1b1a061bb789b Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 11:06:15 +0800 Subject: [PATCH 21/23] fix(desktop): fail closed on custom ghost loss and staging.release races Greptile P1: vanished custom ghost dirs must not rebuild an empty library on a live vault; staging.release re-hashes the unique original and blocks concurrent library mutates until the staging original is gone. P2: disposeAll now invalidates staging stores, uploads, and vaults. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 115 ++++++++++++++++++ .../__tests__/libraryVault.test.ts | 15 +++ .../src/main/cindy-brain/librarySlot.ts | 41 +++++-- .../src/main/cindy-brain/libraryStaging.ts | 29 +++-- .../src/main/cindy-brain/libraryVault.ts | 5 + 5 files changed, 189 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index dbe9c25efb1..e255f2983c4 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -1914,4 +1914,119 @@ describe('GhostLibrarySlot', () => { spy.mockRestore(); } }); + + it('staging.release 首次 hash 后正本被删: ACK_MISMATCH 且保留 staging 原件', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-hash-delete', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const orig = LibraryVault.prototype.hashFile; + let seen = 0; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + const hashed = await orig.call(this, relPath); + seen += 1; + if (seen === 1 && relPath === rel) { + await fs.promises.rm(path.join(defaultRootBase, GHOST_ID, rel), { force: true }); + } + return hashed; + }); + try { + const released = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + expect(released).toMatchObject({ ok: false, errorCode: 'ACK_MISMATCH' }); + } finally { + spy.mockRestore(); + } + const still = await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.list' }); + if (!still.ok || still.op !== 'staging.list') throw new Error(JSON.stringify(still)); + expect(still.items.map((item) => item.stagingId)).toContain(begin.stagingId); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(true); + }); + + it('staging.release 窗口内并发 delete 被 LIBRARY_READONLY 挡住', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-release-mutex', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.chunk', stagingId: begin.stagingId, seq: 1, + content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + await slot.handleLibraryRequest(GHOST_ID, { op: 'staging.commit', stagingId: begin.stagingId }); + const archived = await slot.handleLibraryRequest(GHOST_ID, { + op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', + }); + if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + let resume!: () => void; + const held = new Promise((resolve) => { resume = resolve; }); + let entered!: () => void; + const started = new Promise((resolve) => { entered = resolve; }); + const orig = LibraryVault.prototype.hashFile; + const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { + if (relPath === rel) { + entered(); + await held; + } + return orig.call(this, relPath); + }); + try { + const releaseP = slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.release', + stagingId: begin.stagingId, + path: rel, + sha256: sha, + bytes: Buffer.byteLength(body), + libraryIdentity: archived.libraryIdentity, + libraryGeneration: archived.libraryGeneration, + }); + await started; + const deleted = await slot.handleLibraryRequest(GHOST_ID, { op: 'delete', path: rel }); + expect(deleted).toMatchObject({ ok: false, errorCode: 'LIBRARY_READONLY' }); + resume(); + const released = await releaseP; + expect(released).toEqual({ + ok: true, op: 'staging.release', stagingId: begin.stagingId, released: true, + }); + } finally { + spy.mockRestore(); + } + }); + + it('disposeAll 释放 stagingStores', async () => { + const body = 'pixel-bytes'; + const sha = createHash('sha256').update(body).digest('hex'); + const begin = await slot.handleLibraryRequest(GHOST_ID, { + op: 'staging.begin', taskId: 'task-dispose-stores', sourceRevision: 'rev-1', + totalBytes: Buffer.byteLength(body), sha256: sha, mime: 'image/png', recovery: { n: 1 }, + }); + if (!begin.ok || begin.op !== 'staging.begin') throw new Error(JSON.stringify(begin)); + const stores = (slot as unknown as { stagingStores: Map }).stagingStores; + expect(stores.size).toBeGreaterThan(0); + await slot.disposeAll(); + expect(stores.size).toBe(0); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 576247043d6..241ddcbd85b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -124,6 +124,21 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 已 open 后 ghost 子目录消失: 再 open 报 disk-missing 且不重建空库', async () => { + const parent = path.join(tmpRoot, 'picked-ghost-gone'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const vault = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + expect(await vault.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.rename(custom, `${custom}.parked`); + const missing = await vault.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(`${custom}.parked`, 'keep.txt'))).toBe(true); + }); + it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => { const parent = path.join(tmpRoot, 'picked'); const custom = path.join(parent, 'mivo-canvas'); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 922c0c8e37f..dacb8485bc3 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -308,11 +308,11 @@ export class GhostLibrarySlot { } } - private confirmReleaseLibrary( + private async confirmReleaseLibrary( ghostId: string, session: GhostLibrarySession, ack: Extract, - ): LibraryStagingFailure | null { + ): Promise { const live = this.sessions.get(ghostId); if ( this.deps.captureOwnerScope() !== session.ownerScopeKey @@ -323,6 +323,10 @@ export class GhostLibrarySlot { ) { return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library epoch 已变化,原件已保留' }; } + const hashed = await session.vault.hashFile(ack.path); + if (!hashed.ok || hashed.sha256 !== ack.sha256 || hashed.bytes !== ack.bytes) { + return { ok: false, errorCode: 'ACK_MISMATCH', message: 'Library 正本已变化,原件已保留' }; + } return null; } @@ -375,16 +379,22 @@ export class GhostLibrarySlot { return this.dispatchStaging(ghostId, op, req); } // 迁移期只读:写类操作在 copying 全程拒绝(读与状态查询照常)。 + const writeOps: ReadonlySet = new Set([ + 'write', 'writeBegin', 'writeChunk', 'writeCommit', 'writeAbort', + 'mkdir', 'delete', 'rename', + 'db.open', 'db.exec', 'db.batch', 'db.migrate', 'db.backup', + ]); if (this.relocating.has(ghostId)) { - const writeOps: ReadonlySet = new Set([ - 'write', 'writeBegin', 'writeChunk', 'writeCommit', 'writeAbort', - 'mkdir', 'delete', 'rename', - 'db.open', 'db.exec', 'db.batch', 'db.migrate', 'db.backup', - ]); if (writeOps.has(op)) { return fail('LIBRARY_READONLY', 'Library 正在迁移到新位置,写入已暂停;请稍后重试'); } } + // staging.release 核验窗口:并发删/改/写会把已 hash 的正本换掉, + // 不能在此窗口放行 mutating ops。 + const releasing = this.stagingReleaseInflight.get(ghostId); + if (releasing && releasing.count > 0 && writeOps.has(op)) { + return fail('LIBRARY_READONLY', 'Library 正本正在核验归档,写入已暂停;请稍后重试'); + } // 会话获取/作废:owner scope 变了(切换在途或已切),旧会话的根与连接 // 一并作废——绝不把上个 owner 的库当成本 owner 的库继续用。 @@ -881,18 +891,35 @@ export class GhostLibrarySlot { async disposeGhost(ghostId: string): Promise { await this.waitForStagingReleases(ghostId); await this.teardownSession(ghostId); + await this.disposeStagingStores(ghostId); } async disposeAll(): Promise { const ids = new Set([...this.sessions.keys(), ...this.stagingReleaseInflight.keys()]); + for (const key of this.stagingStores.keys()) { + const ghostId = key.split('\0')[1]; + if (ghostId) ids.add(ghostId); + } for (const id of ids) this.setRelocating(id, true); try { for (const id of ids) await this.disposeGhost(id); + await this.disposeStagingStores(); } finally { for (const id of ids) this.setRelocating(id, false); } } + private async disposeStagingStores(ghostId?: string): Promise { + const entries = [...this.stagingStores.entries()].filter(([key]) => { + if (!ghostId) return true; + return key.split('\0')[1] === ghostId; + }); + for (const [key, store] of entries) { + this.stagingStores.delete(key); + await store.dispose().catch(() => {}); + } + } + /** * 慢 IO / 系统对话框之后再核一次:停用、切账号、disposeAll 都会让这次 * 请求作废。reveal 的 resolveExistingFile 与 saveAs 的 copyFile 都不走 diff --git a/apps/desktop/src/main/cindy-brain/libraryStaging.ts b/apps/desktop/src/main/cindy-brain/libraryStaging.ts index 006834b2a8f..774310c1736 100644 --- a/apps/desktop/src/main/cindy-brain/libraryStaging.ts +++ b/apps/desktop/src/main/cindy-brain/libraryStaging.ts @@ -1079,12 +1079,23 @@ export class LibraryStagingStore { }); } + async dispose(): Promise { + await this.runSerialized(async () => { + for (const upload of [...this.uploads.values()]) { + await this.vault.writeAbort({ streamId: upload.streamId }).catch(() => {}); + } + this.uploads.clear(); + this.byTask.clear(); + await this.vault.invalidate().catch(() => {}); + }); + } + async release(req: { ghostId: string; stagingId: unknown; ack: LibraryStagingAck; - /** Sync recheck of the current Library session/epoch/migrating gate. */ - confirmLibrary?: () => LibraryStagingFailure | null; + /** Recheck Library session/epoch and re-hash the unique original before irreversible staging delete. */ + confirmLibrary?: () => LibraryStagingFailure | null | Promise; }): Promise> { return this.runSerialized(async () => { const ready = await this.requireReady(); @@ -1093,10 +1104,10 @@ export class LibraryStagingStore { const stagingId = parseStagingId(req.stagingId); if (typeof stagingId !== 'string') return stagingId; if (req.ack.ok !== true) return req.ack; - const confirm = (): LibraryStagingFailure | null => { + const confirm = async (): Promise => { const owner = this.requireOwner(); if (owner) return owner; - return req.confirmLibrary?.() ?? null; + return (await req.confirmLibrary?.()) ?? null; }; const rollbackTombstone = async (): Promise => { const deleted = await this.vault.delete({ path: tombstonePath(stagingId) }); @@ -1105,10 +1116,10 @@ export class LibraryStagingStore { }; const record = this.durables.get(stagingId); if (!record) { - const blocked = confirm(); + const blocked = await confirm(); if (blocked) return blocked; const tomb = await this.readTombstone(stagingId); - const blockedAfter = confirm(); + const blockedAfter = await confirm(); if (blockedAfter) return blockedAfter; if (!tomb.ok) { return tomb.errorCode === 'NOT_FOUND' @@ -1122,14 +1133,14 @@ export class LibraryStagingStore { if (req.ack.sha256 !== record.sha256 || req.ack.bytes !== record.bytes) { return fail('ACK_MISMATCH', 'Library ACK 与 staging 原件不一致,原件已保留'); } - const blocked = confirm(); + const blocked = await confirm(); if (blocked) return blocked; const stone = await this.vault.write({ path: tombstonePath(stagingId), content: JSON.stringify({ version: 1, stagingId, released: true }), ifNotExists: true, }); - const blockedAfterWrite = confirm(); + const blockedAfterWrite = await confirm(); if (blockedAfterWrite) { return await rollbackTombstone() ?? blockedAfterWrite; } @@ -1138,7 +1149,7 @@ export class LibraryStagingStore { if (markerSync) { return await rollbackTombstone() ?? markerSync; } - const blockedAfterSync = confirm(); + const blockedAfterSync = await confirm(); if (blockedAfterSync) { return await rollbackTombstone() ?? blockedAfterSync; } diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index d754003287a..dce822d4b5c 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -543,6 +543,11 @@ export class LibraryVault { this.meta = existing.meta; if (existing.usage) customUsage = existing.usage; } else if (existing.code === 'MISSING') { + // Same vault already had a live custom tree: ghost dir vanished. + // Do not mkdir a new empty library over that loss. + if (this.meta) { + return this.customRootUnavailable('disk-missing'); + } const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ parentFd: parentHandle.fd, ghostId: dirSeg, From 22d9b70379a726f68eff824bffc0516e5e875d2c Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 11:20:39 +0800 Subject: [PATCH 22/23] fix(desktop): persist custom libraryReady so vanished ghost dirs stay fail-closed Binding records first-create vs already-built trees without bumping generation. Restart after `/` disappears now returns disk-missing instead of initializing an empty replacement library. Signed-off-by: PraiseZhu --- .../__tests__/libraryBinding.test.ts | 4 ++++ .../cindy-brain/__tests__/librarySlot.test.ts | 22 +++++++++++++++++++ .../__tests__/libraryVault.test.ts | 14 ++++++++++++ .../src/main/cindy-brain/libraryBinding.ts | 17 ++++++++++++++ .../src/main/cindy-brain/librarySlot.ts | 16 ++++++++++---- .../src/main/cindy-brain/libraryVault.ts | 8 ++++--- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts index 396ed5aebda..9fadc3e5c9b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryBinding.test.ts @@ -49,6 +49,10 @@ describe('LibraryBindingStore', () => { expect(set.ok).toBe(true); if (!set.ok) return; expect(set.record.generation).toBe(1); + expect(set.record.libraryReady).toBe(false); + await store.markLibraryReady(GHOST_ID); + expect((await store.getBinding(GHOST_ID))?.libraryReady).toBe(true); + expect((await store.getBinding(GHOST_ID))?.generation).toBe(1); const after = await store.resolveLibraryRoot(GHOST_ID); expect(after.kind).toBe('custom'); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index e255f2983c4..3e291d61e7d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -442,6 +442,28 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); }); + it('custom 已 ready 后只丢 ghost 子目录: dispose 后再 open 不得重建空库', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + expect((await bindingStore.getBinding(GHOST_ID))?.libraryReady).toBe(true); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + await fs.promises.rename(customRoot, `${customRoot}.parked`); + await slot.disposeAll(); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(fs.existsSync(path.join(customRoot, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(`${customRoot}.parked`, 'keep.txt'))).toBe(true); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + }); + it('delayed resolveLibraryRoot: stale custom after parent rename is disk-missing without recreating empty library', async () => { const bound = await bindingStore.setBinding(GHOST_ID, candidate); expect(bound.ok).toBe(true); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 241ddcbd85b..3743b0b3c08 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -124,6 +124,20 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 已建过(allowCustomInit=false)且新 vault: ghost 子目录 MISSING 不得空库重建', async () => { + const parent = path.join(tmpRoot, 'picked-no-init'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(parent, { recursive: true }); + const vault = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + allowCustomInit: false, + }); + const missing = await vault.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + }); + it('custom 已 open 后 ghost 子目录消失: 再 open 报 disk-missing 且不重建空库', async () => { const parent = path.join(tmpRoot, 'picked-ghost-gone'); const custom = path.join(parent, 'mivo-canvas'); diff --git a/apps/desktop/src/main/cindy-brain/libraryBinding.ts b/apps/desktop/src/main/cindy-brain/libraryBinding.ts index f043f3cd108..f1528f531e8 100644 --- a/apps/desktop/src/main/cindy-brain/libraryBinding.ts +++ b/apps/desktop/src/main/cindy-brain/libraryBinding.ts @@ -33,6 +33,11 @@ export interface LibraryBindingRecord { grantedAt: number; /** 每次重新绑定递增;迁移切换时原子写入。 */ generation: number; + /** + * false = 授权后尚未成功建出 `/`。缺省/true = 已经建过, + * ghost 子目录 MISSING 时不得空库重建。旧文件无此字段按已建过处理。 + */ + libraryReady?: boolean; } export interface LibraryBindingFileData { @@ -263,6 +268,7 @@ export class LibraryBindingStore { identity, grantedAt: this.now, generation: (prev?.generation ?? 0) + 1, + libraryReady: false, }; data.bindings[ghostId] = record; await this.writeData(data); @@ -286,6 +292,17 @@ export class LibraryBindingStore { return this.readData().then((d) => d.bindings[ghostId] ?? null); } + /** First successful custom open: persist ready without bumping generation. */ + async markLibraryReady(ghostId: string): Promise { + await this.runSerialized(async () => { + const data = await this.readData(); + const rec = data.bindings[ghostId]; + if (!rec || rec.libraryReady === true) return; + data.bindings[ghostId] = { ...rec, libraryReady: true }; + await this.writeData(data); + }); + } + /** * 解析库根:无 binding → 系统默认;有 binding → 漂移检测(realpath 重解 + * identity 比对)。漂移时返回 root:null,上层必须进入 unavailable 状态并 diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index dacb8485bc3..8df0ccec9e6 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -440,10 +440,15 @@ export class GhostLibrarySlot { && (opened.reason === 'disk-missing' || opened.reason === 'binding-moved') ) { await this.latchCustomUnavailable(session, ghostId, opened.reason); - } else if (session.vault.getMeta()?.orphaned) { - // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned - // 标记(best-effort,失败不影响使用)。 - await session.vault.clearOrphaned().catch(() => {}); + } else { + if (opened.ok && opened.state === 'ready' && resolution.kind === 'custom' && resolution.root !== null) { + await this.deps.bindingStore.markLibraryReady(ghostId).catch(() => {}); + } + if (session.vault.getMeta()?.orphaned) { + // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned + // 标记(best-effort,失败不影响使用)。 + await session.vault.clearOrphaned().catch(() => {}); + } } } else if (this.extraDirGrant?.ghostId === ghostId) { await this.syncAgentReadonlyExtraDir(ghostId, null); @@ -555,6 +560,9 @@ export class GhostLibrarySlot { customParentGrant: resolution.kind === 'custom' && resolution.root !== null ? { realPathAtGrant: resolution.record.realPathAtGrant, identity: resolution.record.identity } : undefined, + allowCustomInit: resolution.kind === 'custom' && resolution.root !== null + ? resolution.record.libraryReady === false + : undefined, log: this.deps.log, }); const sql = this.deps.createSqlService({ diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index dce822d4b5c..fd2c57f39f2 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -148,6 +148,8 @@ export interface LibraryVaultDeps { realPathAtGrant: string; identity: { dev: number; ino: number } | null; }; + /** false = binding already created this ghost tree; MISSING must not init an empty replacement. */ + allowCustomInit?: boolean; log?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; @@ -543,9 +545,9 @@ export class LibraryVault { this.meta = existing.meta; if (existing.usage) customUsage = existing.usage; } else if (existing.code === 'MISSING') { - // Same vault already had a live custom tree: ghost dir vanished. - // Do not mkdir a new empty library over that loss. - if (this.meta) { + // Same vault already had a live custom tree, or binding says this + // ghost dir was created before: do not mkdir an empty replacement. + if (this.meta || this.deps.allowCustomInit === false) { return this.customRootUnavailable('disk-missing'); } const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ From 6576e4d119fd6ba57a7db853c0ab9cb92b2408d4 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Mon, 21 Sep 2026 11:27:34 +0800 Subject: [PATCH 23/23] fix(desktop): serialize Library mutates with staging.release Greptile P1 r4058467558 needs a real per-ghost mutex, not only a second hash. Mutating Library ops and staging.release now share one chain so hash-then-delete cannot overlap write/delete/rename. Relocating releases still fail closed without taking the lock. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 16 ++++++--- .../src/main/cindy-brain/librarySlot.ts | 33 ++++++++++++------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 3e291d61e7d..1f9e95ee349 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -1985,7 +1985,7 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(true); }); - it('staging.release 窗口内并发 delete 被 LIBRARY_READONLY 挡住', async () => { + it('staging.release 与并发 delete 互斥:核验结束前正本仍在,release 成功后再删', async () => { const body = 'pixel-bytes'; const sha = createHash('sha256').update(body).digest('hex'); const rel = `assets/${sha.slice(0, 2)}/${sha}/blob.png`; @@ -2003,13 +2003,16 @@ describe('GhostLibrarySlot', () => { op: 'write', path: rel, content: Buffer.from(body).toString('base64'), encoding: 'base64', }); if (!archived.ok || archived.op !== 'write') throw new Error(JSON.stringify(archived)); + const canonical = path.join(defaultRootBase, GHOST_ID, rel); let resume!: () => void; const held = new Promise((resolve) => { resume = resolve; }); let entered!: () => void; const started = new Promise((resolve) => { entered = resolve; }); const orig = LibraryVault.prototype.hashFile; + let heldOnce = false; const spy = vi.spyOn(LibraryVault.prototype, 'hashFile').mockImplementation(async function (this: LibraryVault, relPath: string) { - if (relPath === rel) { + if (relPath === rel && !heldOnce) { + heldOnce = true; entered(); await held; } @@ -2026,13 +2029,18 @@ describe('GhostLibrarySlot', () => { libraryGeneration: archived.libraryGeneration, }); await started; - const deleted = await slot.handleLibraryRequest(GHOST_ID, { op: 'delete', path: rel }); - expect(deleted).toMatchObject({ ok: false, errorCode: 'LIBRARY_READONLY' }); + const deleteP = slot.handleLibraryRequest(GHOST_ID, { op: 'delete', path: rel }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fs.existsSync(canonical)).toBe(true); resume(); const released = await releaseP; expect(released).toEqual({ ok: true, op: 'staging.release', stagingId: begin.stagingId, released: true, }); + const deleted = await deleteP; + expect(deleted).toMatchObject({ ok: true, op: 'delete' }); + expect(fs.existsSync(canonical)).toBe(false); + expect(fs.existsSync(path.join(tmp, 'library-staging', GHOST_ID, 'tasks', begin.stagingId, 'blob.bin'))).toBe(false); } finally { spy.mockRestore(); } diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 8df0ccec9e6..bf4ac2e0867 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -263,6 +263,8 @@ export class GhostLibrarySlot { private readonly stagingStores = new Map(); /** In-flight staging.release per ghost. disposeGhost drains these so bind/relocate cannot cut the Library mid-transaction. */ private readonly stagingReleaseInflight = new Map | null; resolveDrain: (() => void) | null }>(); + /** Mutating Library ops and staging.release share one chain per ghost so hash-then-delete cannot race a concurrent write/delete/rename. */ + private readonly ghostExclusive = new Map>(); constructor(private readonly deps: GhostLibrarySlotDeps) {} @@ -308,6 +310,13 @@ export class GhostLibrarySlot { } } + private runGhostExclusive(ghostId: string, fn: () => Promise): Promise { + const prev = this.ghostExclusive.get(ghostId) ?? Promise.resolve(); + const next = prev.then(fn, fn); + this.ghostExclusive.set(ghostId, next.then(() => undefined, () => undefined)); + return next; + } + private async confirmReleaseLibrary( ghostId: string, session: GhostLibrarySession, @@ -376,6 +385,12 @@ export class GhostLibrarySlot { }; } if (isGhostLibraryStagingOp(op)) { + if (op === 'staging.release') { + if (this.relocating.has(ghostId)) { + return fail('ACK_MISMATCH', 'Library 正在迁移到新位置,原件已保留'); + } + return this.runGhostExclusive(ghostId, () => this.dispatchStaging(ghostId, op, req)); + } return this.dispatchStaging(ghostId, op, req); } // 迁移期只读:写类操作在 copying 全程拒绝(读与状态查询照常)。 @@ -389,18 +404,14 @@ export class GhostLibrarySlot { return fail('LIBRARY_READONLY', 'Library 正在迁移到新位置,写入已暂停;请稍后重试'); } } - // staging.release 核验窗口:并发删/改/写会把已 hash 的正本换掉, - // 不能在此窗口放行 mutating ops。 - const releasing = this.stagingReleaseInflight.get(ghostId); - if (releasing && releasing.count > 0 && writeOps.has(op)) { - return fail('LIBRARY_READONLY', 'Library 正本正在核验归档,写入已暂停;请稍后重试'); - } - // 会话获取/作废:owner scope 变了(切换在途或已切),旧会话的根与连接 - // 一并作废——绝不把上个 owner 的库当成本 owner 的库继续用。 - const scopeKey = this.deps.captureOwnerScope(); - const session = await this.getOrCreateSession(ghostId, scopeKey); - return this.runOp(ghostId, session, op, req); + const runSessionOp = async (): Promise => { + const scopeKey = this.deps.captureOwnerScope(); + const session = await this.getOrCreateSession(ghostId, scopeKey); + return this.runOp(ghostId, session, op, req); + }; + if (writeOps.has(op)) return this.runGhostExclusive(ghostId, runSessionOp); + return runSessionOp(); } private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise {