Browse Source

Merge pull request #3871 from deepseek-harness/worktree-fixworkerflock

fix(webworker): restore session and workspace writes
imccyu 2 weeks ago
parent
commit
fbfc8123e3

+ 2 - 2
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md
-2026-08-31-cross-process-session-write-lease.md: ef8ebe2de6b231075dee31a9184bcc0a5b323011
-2026-08-31-cross-process-session-write-lease.zh.md: 8a79b6d5327c7bd5d0ca12ac435140bc0951e993
+2026-08-31-cross-process-session-write-lease.md: bf02ce3eddfb5bcfe2a7d6bd6a6701c47d5f28a4
+2026-08-31-cross-process-session-write-lease.zh.md: b287ab7d6cb4202745fd1112bcb313cb261d674e

+ 1 - 1
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md

@@ -10,7 +10,7 @@ The JSONL backend's write-handle claim excluded a second writer only inside one
 
 ## Decision
 
-`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success: it is single-process, so the in-process write claim already excludes every writer.
+`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success because its in-process write claim excludes every writer. Its `node:fs` replacement still reports BigInt device and inode identity from `FileHandle.stat({ bigint: true })`, matching path `stat` while the path names that file, because the lease retains the inode-replacement check after the stubbed acquisition.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md

@@ -10,7 +10,7 @@ JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写
 
 ## Decision
 
-`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。
+`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功,因为进程内写认领已排除所有写入方。它的 `node:fs` 替代实现仍从 `FileHandle.stat({ bigint: true })` 报告 BigInt device 与 inode 身份,并在该路径仍指向所打开文件时与路径 `stat` 一致,因为租约在存根式加锁后仍保留 inode 替换检查。
 
 ## Alternatives considered
 

+ 20 - 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,14 @@ 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
       }
+      // Keep the fixture title stable for later UI assertions; increasing seqs
+      // prove that the cold Session acquired its write lease and appended.
+      const firstRename = await remote<{ title: string; seq: number }>('session/rename', {
+        request: { sessionId: seededSessionId, title: seededSessionTitle },
+      })
+      const secondRename = await remote<{ title: string; seq: number }>('session/rename', {
+        request: { sessionId: seededSessionId, title: seededSessionTitle },
+      })
       const skills = await remote<{ skills: Array<{ name: string }> }>(
         'skills/list', { request: { sessionId } },
       )
@@ -384,10 +395,17 @@ 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: secondRename.title,
+        renameAdvanced: secondRename.seq > firstRename.seq,
         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.renameAdvanced).toBe(true)
     expect(exercised.skillCount).toBeGreaterThan(0)
     expect(exercised.credentialConfigured).toBe(true)
 

+ 2 - 2
packages/experimental/webworker-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
-README.md: 2327851be62bc4e09e8678cd8d0c7b07663d1bdd
-README.zh.md: afd1bef17c8e921b4a6e97bd224deaa0f94e9486
+README.md: 09c79e969165d341371bd85708a01f2655f3d24d
+README.zh.md: 16267df48ad09945f10be1c353723322ea6006eb

+ 1 - 1
packages/experimental/webworker-runtime/README.md

@@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated
 Three artifacts from one tsdown pipeline:
 
 - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
-- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount.
+- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount.
 - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
 - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload.
 

+ 1 - 1
packages/experimental/webworker-runtime/README.zh.md

@@ -26,7 +26,7 @@ kind: "package-library"
 一条 tsdown 管线出三个产物:
 
 - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
-- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。
+- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。
 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。
 

+ 15 - 3
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts

@@ -36,6 +36,9 @@ const encodingOf = (options: EncodingOption): BufferEncoding | undefined => {
   return options.encoding ?? undefined
 }
 
+const numericMode = (mode: number | string): number =>
+  typeof mode === 'string' ? Number.parseInt(mode, 8) : mode
+
 const bytesOf = (path: string): Uint8Array => vfs().readFileSync(path) as Uint8Array
 
 /** Share the VFS bytes rather than copying them. */
@@ -186,7 +189,7 @@ export function stat(
  * @param mode - new permission bits (`0o777` mask), numeric or Node's octal string form.
  */
 export function chmodSync(path: PathArg, mode: number | string): void {
-  vfs().chmodSync(asPath(path), typeof mode === 'string' ? Number.parseInt(mode, 8) : mode)
+  vfs().chmodSync(asPath(path), numericMode(mode))
 }
 
 /**
@@ -399,7 +402,8 @@ 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>
+  chmod(mode: number | string): Promise<void>
+  stat(options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats>
   truncate(length?: number): Promise<void>
   sync(): Promise<void>
   datasync(): Promise<void>
@@ -441,7 +445,15 @@ 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(),
+    chmod: async (mode: number | string) => {
+      if (directory) chmodSync(target, mode)
+      else descriptor('fchmod').file.chmod(numericMode(mode))
+    },
+    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)

+ 32 - 9
packages/experimental/webworker-runtime/src/storage/memory.ts

@@ -426,11 +426,33 @@ export class MemoryVfs implements Vfs {
     this.replaceFile(node, resize(node.bytes, length))
   }
 
+  /** Change one file identity's permission bits and notify every linked path. */
+  private chmodFile(node: FileNode, mode: number): void {
+    node.mode = mode & 0o777
+    if (typeof node.paths === 'string') {
+      this.publish({ kind: 'chmod', path: node.paths, mode: node.mode })
+    } else if (node.paths !== undefined) {
+      for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode })
+    }
+  }
+
   /** @returns Plain stats for an open file, including after its last name is removed. */
   private fileStats(node: FileNode): VfsStats {
     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 +669,10 @@ export class MemoryVfs implements Vfs {
       truncate: async (length = 0): Promise<void> => {
         current('ftruncate').truncate(length)
       },
-      stat: async (): Promise<VfsStats> => current('fstat').stat(),
+      chmod: async (mode: number): Promise<void> => { current('fchmod').chmod(mode) },
+      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 },
@@ -691,7 +716,9 @@ export class MemoryVfs implements Vfs {
         if (!access.writable) fail('EINVAL', 'ftruncate', target)
         this.truncateFile(node, length)
       },
+      chmod: (mode) => { this.chmodFile(node, mode) },
       stat: () => this.fileStats(node),
+      statBigInt: () => this.fileBigIntStats(node),
     }
   }
 
@@ -702,9 +729,10 @@ export class MemoryVfs implements Vfs {
    * @param target - Normalized path the handle was opened on.
    * @returns Metadata plus the no-op durability and release calls.
    */
-  private handleTail(target: string): Pick<VfsFileHandle, 'stat' | 'sync' | 'datasync' | 'close'> {
+  private handleTail(target: string): Pick<VfsFileHandle, 'chmod' | 'stat' | 'sync' | 'datasync' | 'close'> {
     return {
-      stat: async (): Promise<VfsStats> => this.plainStats(target),
+      chmod: async (mode: number): Promise<void> => { this.chmodSync(target, mode) },
+      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> => {},
@@ -836,12 +864,7 @@ export class MemoryVfs implements Vfs {
     const target = this.key(path)
     const node = this.files.get(target)
     if (node !== undefined) {
-      node.mode = mode & 0o777
-      if (typeof node.paths === 'string') {
-        this.publish({ kind: 'chmod', path: node.paths, mode: node.mode })
-      } else if (node.paths !== undefined) {
-        for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode })
-      }
+      this.chmodFile(node, mode)
       return
     }
     if (this.directories.has(target)) {

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

@@ -119,7 +119,8 @@ export interface VfsFileHandle {
   writeFile(data: string | Uint8Array): Promise<void>
   readFile(options?: VfsReadOptions): Promise<string | Uint8Array>
   truncate(length?: number): Promise<void>
-  stat(): Promise<VfsStats>
+  chmod(mode: number): Promise<void>
+  stat(options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats>
   sync(): Promise<void>
   datasync(): Promise<void>
   close(): Promise<void>
@@ -152,11 +153,18 @@ export interface VfsOpenFile {
    * @param length - Target byte length.
    */
   truncate(length: number): void
+  /** Change permission bits on the opened file identity. */
+  chmod(mode: number): void
   /**
    * Read metadata from the opened file identity.
    * @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
 }
 
 /**

+ 11 - 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)
@@ -205,6 +212,10 @@ fs.renameSync('/dsh/secrets.tmp', '/dsh/secrets.yaml')
 check('a wx write with mode 600 stats as 600 after rename', plainMode('/dsh/secrets.yaml'), 0o600)
 fs.writeFileSync('/dsh/secrets.yaml', 'k: w\n')
 check('a rewrite keeps the creation bits', plainMode('/dsh/secrets.yaml'), 0o600)
+const chmodHandle = await fsp.open('/dsh/secrets.yaml', 'r+')
+await chmodHandle.chmod(0o640)
+await chmodHandle.close()
+check('FileHandle.chmod updates the opened file', plainMode('/dsh/secrets.yaml'), 0o640)
 fs.chmodSync('/dsh/secrets.yaml', 0o640)
 check('chmod reads back exactly what was set', plainMode('/dsh/secrets.yaml'), 0o640)
 await fsp.chmod('/dsh/secrets.yaml', 0o600)

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

@@ -203,6 +203,19 @@ 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', 'w')
+    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.chmod(0o600)
+    expect((vfs.statSync('/dsh/session.lock') as VfsStats).mode & 0o777).toBe(0o600)
+    await handle.close()
+  })
+
   it('decomposes a directory rename into replayable destination state', () => {
     const recorded: VfsMutation[] = []
     const vfs = new MemoryVfs({