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

fix(webworker): restore session and workspace writes
This commit is contained in:
imccyu
2026-09-09 19:21:07 +08:00
committed by GitHub
12 changed files with 108 additions and 23 deletions
@@ -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
@@ -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
@@ -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
View File
@@ -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,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
@@ -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.
@@ -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 URLboot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。
@@ -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)
@@ -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)) {
@@ -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
}
/**
@@ -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)
@@ -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({