From 0a9c77461777598f8adf67d242044b91aaeb73ca Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:25:43 +0800 Subject: [PATCH 1/5] docs(webworker): document bigint handle identity --- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 2 +- packages/experimental/webworker-runtime/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 1a3a5a0997..c06d04c923 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/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: d50a7c43db99ff7417cdb39aeb6caae2070125a1 +README.zh.md: 29b92ee280a3d7780218741620655b98d651498c diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 2327851be6..d50a7c43db 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/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, and `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file; `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` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index afd1bef17c..29b92ee280 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/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 相同;`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` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 From a7bd71e0245d64a4603e6d71fd08a73e0db23ab2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:26:00 +0800 Subject: [PATCH 2/5] fix(webworker): preserve bigint file handle identity --- apps/web/tests/preview-boot.e2e.ts | 15 +++++++++++++-- .../node/builtin_modules/implemented/fs.ts | 8 ++++++-- .../webworker-runtime/src/storage/memory.ts | 19 +++++++++++++++++-- .../webworker-runtime/src/storage/types.ts | 7 ++++++- .../webworker-runtime/tests/node/fs.spec.ts | 7 +++++++ .../tests/storage/memory-vfs.spec.ts | 11 +++++++++++ 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 7422f902e1..ca04c720c2 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/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 { 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 = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise @@ -352,6 +355,9 @@ async function bootPreview(origin: string, browser: Browser): Promise { 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 { 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) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index b2f4462a50..66676be09e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/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 write(data: string | Uint8Array): Promise<{ bytesWritten: number }> read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }> - stat(): Promise + stat(options?: VfsStatOptions): Promise truncate(length?: number): Promise sync(): Promise datasync(): Promise @@ -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) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 24d12a688e..3a14c62b49 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/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 => { current('ftruncate').truncate(length) }, - stat: async (): Promise => current('fstat').stat(), + stat: async (options?: VfsStatOptions): Promise => options?.bigint === true + ? current('fstat').statBigInt() + : current('fstat').stat(), sync: async (): Promise => { current('fsync'); await this.flush() }, datasync: async (): Promise => { current('fdatasync'); await this.flush() }, close: async (): Promise => { 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 { return { - stat: async (): Promise => this.plainStats(target), + stat: async (options?: VfsStatOptions): Promise => this.statSync(target, options), sync: async (): Promise => { await this.flush() }, datasync: async (): Promise => { await this.flush() }, close: async (): Promise => {}, diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index f1482e07d6..616d24cf27 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -119,7 +119,7 @@ export interface VfsFileHandle { writeFile(data: string | Uint8Array): Promise readFile(options?: VfsReadOptions): Promise truncate(length?: number): Promise - stat(): Promise + stat(options?: VfsStatOptions): Promise sync(): Promise datasync(): Promise close(): Promise @@ -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 } /** diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index ce9e6fd916..cb30f33dc2 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/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) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index b80987cd4c..521342f19d 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/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({ From 950474605f9995e675a71ba64618021d2d0108cb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:52:14 +0800 Subject: [PATCH 3/5] docs(webworker): record file handle compatibility --- .../2026-08-31-cross-process-session-write-lease.i18n.yaml | 4 ++-- .../feature/2026-08-31-cross-process-session-write-lease.md | 2 +- .../2026-08-31-cross-process-session-write-lease.zh.md | 2 +- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 2 +- packages/experimental/webworker-runtime/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml index 71a7ed9371..0451d6edcb 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md index ef8ebe2de6..bf02ce3edd 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md index 8a79b6d532..b287ab7d6c 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md +++ b/.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 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index c06d04c923..2a908c9a11 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/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: d50a7c43db99ff7417cdb39aeb6caae2070125a1 -README.zh.md: 29b92ee280a3d7780218741620655b98d651498c +README.md: 09c79e969165d341371bd85708a01f2655f3d24d +README.zh.md: 16267df48ad09945f10be1c353723322ea6006eb diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index d50a7c43db..09c79e9691 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/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, and `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file; `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` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 29b92ee280..16267df48a 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/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 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同;`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` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 From 60a3d0fbc2cb549b0c800b29d020130eb83abe29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:54:53 +0800 Subject: [PATCH 4/5] fix(webworker): support file handle chmod --- apps/web/tests/preview-boot.e2e.ts | 11 ++++++++-- .../node/builtin_modules/implemented/fs.ts | 10 ++++++++- .../webworker-runtime/src/storage/memory.ts | 22 +++++++++++++------ .../webworker-runtime/src/storage/types.ts | 3 +++ .../webworker-runtime/tests/node/fs.spec.ts | 4 ++++ .../tests/storage/memory-vfs.spec.ts | 4 +++- 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index ca04c720c2..0819e0e8ce 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -355,7 +355,12 @@ async function bootPreview(origin: string, browser: Browser): Promise { 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', { + // 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 }> }>( @@ -390,7 +395,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { - renamedTitle: renamed.title, + renamedTitle: secondRename.title, + renameAdvanced: secondRename.seq > firstRename.seq, skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } @@ -399,6 +405,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { seededSessionTitle: SHOWCASE_TITLE, }) expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE) + expect(exercised.renameAdvanced).toBe(true) expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 66676be09e..359e008492 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/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,6 +402,7 @@ export interface FileHandle { writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise write(data: string | Uint8Array): Promise<{ bytesWritten: number }> read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }> + chmod(mode: number | string): Promise stat(options?: VfsStatOptions): Promise truncate(length?: number): Promise sync(): Promise @@ -441,6 +445,10 @@ export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileH bytesRead: readSync(fd, buffer, offset, length, position), buffer, }), + 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 diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 3a14c62b49..0486dc84ab 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -426,6 +426,16 @@ 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) @@ -659,6 +669,7 @@ export class MemoryVfs implements Vfs { truncate: async (length = 0): Promise => { current('ftruncate').truncate(length) }, + chmod: async (mode: number): Promise => { current('fchmod').chmod(mode) }, stat: async (options?: VfsStatOptions): Promise => options?.bigint === true ? current('fstat').statBigInt() : current('fstat').stat(), @@ -705,6 +716,7 @@ 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), } @@ -717,8 +729,9 @@ 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 { + private handleTail(target: string): Pick { return { + chmod: async (mode: number): Promise => { this.chmodSync(target, mode) }, stat: async (options?: VfsStatOptions): Promise => this.statSync(target, options), sync: async (): Promise => { await this.flush() }, datasync: async (): Promise => { await this.flush() }, @@ -851,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)) { diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index 616d24cf27..085901b12f 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -119,6 +119,7 @@ export interface VfsFileHandle { writeFile(data: string | Uint8Array): Promise readFile(options?: VfsReadOptions): Promise truncate(length?: number): Promise + chmod(mode: number): Promise stat(options?: VfsStatOptions): Promise sync(): Promise datasync(): Promise @@ -152,6 +153,8 @@ 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. diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index cb30f33dc2..fc5ed8e8c5 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts @@ -212,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) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 521342f19d..5816a2abfa 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -206,11 +206,13 @@ describe('mutation publication', () => { 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 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() }) From d7ecdcf02cbb1ff233c4704c6a4bffc61a577c17 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:20:36 +0800 Subject: [PATCH 5/5] fix(lint): parenthesize file handle callback --- packages/experimental/webworker-runtime/src/storage/memory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 0486dc84ab..f7f8fc7a76 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -716,7 +716,7 @@ export class MemoryVfs implements Vfs { if (!access.writable) fail('EINVAL', 'ftruncate', target) this.truncateFile(node, length) }, - chmod: mode => { this.chmodFile(node, mode) }, + chmod: (mode) => { this.chmodFile(node, mode) }, stat: () => this.fileStats(node), statBigInt: () => this.fileBigIntStats(node), }