fix(gui): close attachment durability gaps
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
@@ -78,6 +78,25 @@ async function syncDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one private directory tree and persist every newly published ancestor.
|
||||
* @param path - absolute directory to create.
|
||||
*/
|
||||
async function ensureDurableDirectory(path: string): Promise<void> {
|
||||
const target = resolve(path)
|
||||
const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 })
|
||||
await chmod(target, 0o700)
|
||||
if (firstCreated === undefined) return
|
||||
|
||||
const highestCreated = resolve(firstCreated)
|
||||
let created = target
|
||||
while (true) {
|
||||
await syncDirectory(dirname(created))
|
||||
if (created === highestCreated) return
|
||||
created = dirname(created)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and verify immutable image bytes below a versioned attachment root.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
@@ -91,10 +110,8 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
await mkdir(bucket, { recursive: true, mode: 0o700 })
|
||||
await mkdir(staging, { recursive: true, mode: 0o700 })
|
||||
await chmod(bucket, 0o700)
|
||||
await chmod(staging, 0o700)
|
||||
await ensureDurableDirectory(bucket)
|
||||
await ensureDurableDirectory(staging)
|
||||
const temporary = join(staging, randomUUID())
|
||||
const target = objectPath(root, sha256)
|
||||
let handle
|
||||
@@ -112,11 +129,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
const existing = new Uint8Array(await readFile(target))
|
||||
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
// The synced file becomes durable only once its directory entries are: sync
|
||||
// the bucket (the new object entry) and its parent (the possibly new bucket
|
||||
// entry) before this reference can reach a session checkpoint. The dedup
|
||||
// path syncs too — the earlier save that created the entry may have crashed
|
||||
// before its own directory sync.
|
||||
// Persist the target entry and close a concurrent bucket-creation window
|
||||
// before the reference can reach a session checkpoint. The dedup path
|
||||
// repeats both syncs because it may observe another writer's link before
|
||||
// that writer reaches its own durability boundary.
|
||||
await syncDirectory(bucket)
|
||||
await syncDirectory(join(root, 'objects'))
|
||||
await unlink(temporary)
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
|
||||
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
|
||||
return actual.open(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const PNG = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
@@ -33,6 +47,27 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('local attachment store', () => {
|
||||
it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => {
|
||||
const storageRoot = await root()
|
||||
const base = join(storageRoot, '..', '..')
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const objects = join(storageRoot, 'objects')
|
||||
const bucket = join(objects, sha256.slice(0, 2))
|
||||
fsControl.syncedDirectories.length = 0
|
||||
|
||||
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
expect(fsControl.syncedDirectories).toEqual([
|
||||
objects,
|
||||
storageRoot,
|
||||
join(storageRoot, '..'),
|
||||
base,
|
||||
storageRoot,
|
||||
bucket,
|
||||
objects,
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes one private content-addressed object and deduplicates equal bytes', async () => {
|
||||
const storageRoot = await root()
|
||||
const first = await saveImageFile(storageRoot, {
|
||||
|
||||
Reference in New Issue
Block a user