소스 검색

fix(webworker): preserve bigint file handle identity

imccyu 3 일 전
부모
커밋
a7bd71e024

+ 13 - 2
apps/web/tests/preview-boot.e2e.ts

@@ -34,7 +34,10 @@ import {
   IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION,
   type PreviewFixtureManifest,
 } from '@deepseek-ai/dsh-experimental-webworker-runtime'
-import { buildVfsExampleFiles } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
+import {
+  VFS_EXAMPLE_SESSION_IDS,
+  buildVfsExampleFiles,
+} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
 import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
 import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
 
@@ -317,7 +320,7 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
     await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
       .waitFor({ timeout: 30_000 })
 
-    const exercised = await page.evaluate(async () => {
+    const exercised = await page.evaluate(async ({ seededSessionId, seededSessionTitle }) => {
       type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
       interface PreviewTransport {
         fetch(input: string, init: RequestInit): Promise<Response>
@@ -352,6 +355,9 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
         if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`)
         return body.result.value
       }
+      const renamed = await remote<{ title: string }>('session/rename', {
+        request: { sessionId: seededSessionId, title: seededSessionTitle },
+      })
       const skills = await remote<{ skills: Array<{ name: string }> }>(
         'skills/list', { request: { sessionId } },
       )
@@ -384,10 +390,15 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
       await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' })
       await new Promise((resolve) => { setTimeout(resolve, 250) })
       return {
+        renamedTitle: renamed.title,
         skillCount: skills.skills.length,
         credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured,
       }
+    }, {
+      seededSessionId: VFS_EXAMPLE_SESSION_IDS.main,
+      seededSessionTitle: SHOWCASE_TITLE,
     })
+    expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE)
     expect(exercised.skillCount).toBeGreaterThan(0)
     expect(exercised.credentialConfigured).toBe(true)
 

+ 6 - 2
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts

@@ -399,7 +399,7 @@ export interface FileHandle {
   writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>
   write(data: string | Uint8Array): Promise<{ bytesWritten: number }>
   read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }>
-  stat(): Promise<VfsStats>
+  stat(options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats>
   truncate(length?: number): Promise<void>
   sync(): Promise<void>
   datasync(): Promise<void>
@@ -441,7 +441,11 @@ export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileH
       bytesRead: readSync(fd, buffer, offset, length, position),
       buffer,
     }),
-    stat: async () => directory ? statSync(target) as VfsStats : descriptor('fstat').file.stat(),
+    stat: async (options?: VfsStatOptions) => directory
+      ? statSync(target, options)
+      : options?.bigint === true
+        ? descriptor('fstat').file.statBigInt()
+        : descriptor('fstat').file.stat(),
     truncate: async (length = 0) => {
       if (directory) writeFileSync(target, new Uint8Array(length))
       else descriptor('ftruncate').file.truncate(length)

+ 17 - 2
packages/experimental/webworker-runtime/src/storage/memory.ts

@@ -431,6 +431,18 @@ export class MemoryVfs implements Vfs {
     return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode)
   }
 
+  /** @returns BigInt stats for an open file, including its device and inode identity. */
+  private fileBigIntStats(node: FileNode): VfsBigIntStats {
+    return bigIntStatsOf(
+      node.bytes.length,
+      node.mtimeMs,
+      false,
+      this.identityOfFile(node),
+      node.mode,
+      this.fileLinkCount(node),
+    )
+  }
+
   /** Forget removed directory identities, so recreated paths report new ones. */
   private forgetIdentity(target: string): void {
     this.identities.delete(target)
@@ -647,7 +659,9 @@ export class MemoryVfs implements Vfs {
       truncate: async (length = 0): Promise<void> => {
         current('ftruncate').truncate(length)
       },
-      stat: async (): Promise<VfsStats> => current('fstat').stat(),
+      stat: async (options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => options?.bigint === true
+        ? current('fstat').statBigInt()
+        : current('fstat').stat(),
       sync: async (): Promise<void> => { current('fsync'); await this.flush() },
       datasync: async (): Promise<void> => { current('fdatasync'); await this.flush() },
       close: async (): Promise<void> => { closed = true },
@@ -692,6 +706,7 @@ export class MemoryVfs implements Vfs {
         this.truncateFile(node, length)
       },
       stat: () => this.fileStats(node),
+      statBigInt: () => this.fileBigIntStats(node),
     }
   }
 
@@ -704,7 +719,7 @@ export class MemoryVfs implements Vfs {
    */
   private handleTail(target: string): Pick<VfsFileHandle, 'stat' | 'sync' | 'datasync' | 'close'> {
     return {
-      stat: async (): Promise<VfsStats> => this.plainStats(target),
+      stat: async (options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(target, options),
       sync: async (): Promise<void> => { await this.flush() },
       datasync: async (): Promise<void> => { await this.flush() },
       close: async (): Promise<void> => {},

+ 6 - 1
packages/experimental/webworker-runtime/src/storage/types.ts

@@ -119,7 +119,7 @@ export interface VfsFileHandle {
   writeFile(data: string | Uint8Array): Promise<void>
   readFile(options?: VfsReadOptions): Promise<string | Uint8Array>
   truncate(length?: number): Promise<void>
-  stat(): Promise<VfsStats>
+  stat(options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats>
   sync(): Promise<void>
   datasync(): Promise<void>
   close(): Promise<void>
@@ -157,6 +157,11 @@ export interface VfsOpenFile {
    * @returns Current file metadata, including after rename or unlink.
    */
   stat(): VfsStats
+  /**
+   * Read BigInt metadata from the opened file identity.
+   * @returns Current file metadata, including device and inode identity.
+   */
+  statBigInt(): VfsBigIntStats
 }
 
 /**

+ 7 - 0
packages/experimental/webworker-runtime/tests/node/fs.spec.ts

@@ -115,6 +115,13 @@ check('rmSync removes', fs.existsSync('/dsh/renamed.txt'), false)
 fs.writeFileSync('/dsh/log-handle.jsonl', 'header\n')
 const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a')
 check('append handle sees the existing size', (await appendHandle.stat()).size, 7)
+const appendHandleStats = await appendHandle.stat({ bigint: true }) as VfsBigIntStats
+const appendPathStats = await fsp.stat('/dsh/log-handle.jsonl', { bigint: true }) as VfsBigIntStats
+check('bigint handle stat matches the path identity', [
+  typeof appendHandleStats.ino,
+  appendHandleStats.ino === appendPathStats.ino,
+  appendHandleStats.dev === appendPathStats.dev,
+], ['bigint', true, true])
 await appendHandle.writeFile('batch-1\n')
 await appendHandle.sync()
 check('handle.sync flushes the active VFS', flushes, 1)

+ 11 - 0
packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts

@@ -203,6 +203,17 @@ describe('mutation publication', () => {
     expect(new TextDecoder().decode(descriptor.read(0, descriptor.stat().size))).toBe('detached')
   })
 
+  it('reports the path identity through a BigInt file handle stat', async () => {
+    const vfs = new MemoryVfs()
+    vfs.seed('/dsh/session.lock', '')
+    const handle = vfs.open('/dsh/session.lock', 'r')
+    const held = await handle.stat({ bigint: true }) as VfsBigIntStats
+    const current = vfs.statSync('/dsh/session.lock', { bigint: true }) as VfsBigIntStats
+
+    expect([held.dev, held.ino]).toEqual([current.dev, current.ino])
+    await handle.close()
+  })
+
   it('decomposes a directory rename into replayable destination state', () => {
     const recorded: VfsMutation[] = []
     const vfs = new MemoryVfs({