diff --git a/apps/browser-demos/test/fixtures/opfs-directory-fsync-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-directory-fsync-client-worker.ts new file mode 100644 index 000000000..f61db2921 --- /dev/null +++ b/apps/browser-demos/test/fixtures/opfs-directory-fsync-client-worker.ts @@ -0,0 +1,41 @@ +import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; + +const O_RDONLY = 0; +const O_DIRECTORY = 0x010000; + +self.onmessage = ( + event: MessageEvent<{ buffer: SharedArrayBuffer; path: string }>, +) => { + const { buffer, path } = event.data; + const fs = OpfsFileSystem.create(buffer); + let fd = -1; + + try { + fs.mkdir(path, 0o700); + fd = fs.open(path, O_RDONLY | O_DIRECTORY, 0); + fs.fsync(fd); + fs.close(fd); + fd = -1; + fs.rmdir(path); + self.postMessage({ type: "result" }); + } catch (error) { + if (fd >= 0) { + try { + fs.close(fd); + } catch { + // Preserve the original failure. + } + } + try { + fs.rmdir(path); + } catch { + // Preserve the original failure. + } + self.postMessage({ + type: "error", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + self.close(); + } +}; diff --git a/apps/browser-demos/test/opfs-directory-fsync.spec.ts b/apps/browser-demos/test/opfs-directory-fsync.spec.ts new file mode 100644 index 000000000..c3460ce93 --- /dev/null +++ b/apps/browser-demos/test/opfs-directory-fsync.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const proxyWorkerPath = resolve( + __dirname, + "../../../host/src/vfs/opfs-worker.ts", +); +const clientWorkerPath = resolve( + __dirname, + "fixtures/opfs-directory-fsync-client-worker.ts", +); + +test("OPFS accepts fsync on an open directory", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "OPFS sync access handles are Chromium-only here", + ); + expect(baseURL).toBeTruthy(); + + const proxyWorkerUrl = new URL(`/@fs/${proxyWorkerPath}`, baseURL).href; + const clientWorkerUrl = new URL(`/@fs/${clientWorkerPath}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ proxyWorkerUrl, clientWorkerUrl }) => { + const buffer = new SharedArrayBuffer(4 * 1024 * 1024); + const proxy = new Worker(proxyWorkerUrl, { type: "module" }); + const client = new Worker(clientWorkerUrl, { type: "module" }); + + const receive = (worker: Worker, expectedType: string): Promise => + new Promise((resolvePromise, reject) => { + const timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${expectedType}`)), + 15_000, + ); + worker.addEventListener( + "message", + (event) => { + if (event.data?.type === "error") { + clearTimeout(timeout); + reject(new Error(event.data.error)); + return; + } + if (event.data?.type === expectedType) { + clearTimeout(timeout); + resolvePromise(event.data as T); + } + }, + { once: false }, + ); + worker.addEventListener( + "error", + (event) => { + clearTimeout(timeout); + reject( + new Error( + `${expectedType}: ${event.message || "worker module failed to load"} ` + + `(${event.filename}:${event.lineno}:${event.colno})`, + ), + ); + }, + { once: true }, + ); + }); + + try { + const ready = receive<{ type: "ready" }>(proxy, "ready"); + proxy.postMessage({ type: "init", buffer }); + await ready; + + const pending = receive<{ type: "result" }>(client, "result"); + client.postMessage({ + buffer, + path: `/kandelo-opfs-directory-fsync-${crypto.randomUUID()}`, + }); + return await pending; + } finally { + client.terminate(); + proxy.terminate(); + } + }, + { proxyWorkerUrl, clientWorkerUrl }, + ); + + expect(result).toEqual({ type: "result" }); +}); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 1057d4587..cdd1e9ece 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -12709,12 +12709,10 @@ pub fn sys_fsync(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( let ofd_idx = entry.ofd_ref.0; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - // Must be a regular file - if ofd.file_type != FileType::Regular { - return Err(Errno::EINVAL); + match ofd.file_type { + FileType::Regular | FileType::Directory => host.host_fsync(ofd.host_handle), + _ => Err(Errno::EINVAL), } - - host.host_fsync(ofd.host_handle) } /// truncate -- truncate a file to a specified length (path-based). @@ -13820,6 +13818,7 @@ mod tests { symlink_targets: std::collections::HashMap, Vec>, lstat_paths: Vec>, statfs_by_path: std::collections::HashMap, WasmStatfs>, + fsync_calls: Vec, /// Recorded `(pid, bo_id, addr, len)` for every `gbm_bo_bind` call so /// the DRI mmap path can be asserted against. gbm_bo_bind_calls: Vec<(i32, u32, usize, usize)>, @@ -13871,6 +13870,7 @@ mod tests { symlink_targets: std::collections::HashMap::new(), lstat_paths: Vec::new(), statfs_by_path: std::collections::HashMap::new(), + fsync_calls: Vec::new(), gbm_bo_bind_calls: Vec::new(), gbm_bo_unbind_calls: Vec::new(), gl_unbind_calls: Vec::new(), @@ -14215,7 +14215,8 @@ mod tests { Ok(()) } - fn host_fsync(&mut self, _handle: i64) -> Result<(), Errno> { + fn host_fsync(&mut self, handle: i64) -> Result<(), Errno> { + self.fsync_calls.push(handle); Ok(()) } @@ -20694,6 +20695,19 @@ mod tests { .unwrap(); let result = sys_fsync(&mut proc, &mut host, fd); assert!(result.is_ok()); + assert_eq!(host.fsync_calls, vec![100]); + } + + #[test] + fn test_fsync_directory_delegates_to_host() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); + + let result = sys_fsync(&mut proc, &mut host, fd); + + assert!(result.is_ok()); + assert_eq!(host.fsync_calls, vec![100]); } #[test] diff --git a/docs/browser-support.md b/docs/browser-support.md index 057f5c4d6..9b3f21752 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -115,7 +115,7 @@ pipe pair. ### Filesystem - `MemoryFileSystem` — SharedArrayBuffer-based VFS shared between main thread and kernel worker -- `OpfsFileSystem` — Origin Private File System for browser persistence. Its current stat metadata has no stable inode identity, so regular-file `MAP_SHARED` returns `ENOTSUP` instead of using unsafe pathname identity; `MAP_PRIVATE` is unaffected. +- `OpfsFileSystem` — Origin Private File System for browser persistence. Regular-file `fsync()` calls the browser's file-handle `flush()` operation. Directory `fsync()` succeeds after already-completed directory operations because the File System API exposes no directory flush primitive; it is not an additional crash-durability barrier. Its current stat metadata has no stable inode identity, so regular-file `MAP_SHARED` returns `ENOTSUP` instead of using unsafe pathname identity; `MAP_PRIVATE` is unaffected. - `DeviceFileSystem` — `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx` - Stable-identity regular files can be shared across process memories through the host mapping cache, but updates become visible at syscall boundaries rather than immediately on direct loads/stores. Cross-process futex waits/wakes remain unsupported; see [architecture.md](architecture.md#shared-mapping-coherence). diff --git a/docs/posix-status.md b/docs/posix-status.md index 1f31926b6..9e5c20cc9 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -56,7 +56,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `writev()` | Full | Gather write. Enforces aggregate count and RLIMIT_FSIZE once across the full iovec operation, including host scratch-buffer decomposition, then stops on a short underlying write. | | `fstat()` | Partial | Host-delegated for regular files. Pipe returns S_IFIFO | 0o600. Full struct stat populated. | | `ftruncate()` | Partial | Host-delegated for regular files, with in-kernel memfd support. Requires write access, validates length >= 0, rejects non-regular fds, and enforces RLIMIT_FSIZE before changing either backing. | -| `fsync()` | Partial | Host-delegated for regular files. Rejects non-regular fds (pipes, sockets). | +| `fsync()` | Partial | Host-delegated for regular files and directories. Node-backed directories use the native durability barrier; memory-backed filesystems have no queued writes. Browser OPFS flushes regular-file access handles, but its API exposes no separate directory durability barrier. Rejects pipes and sockets. | | `fdatasync()` | Partial | Alias for fsync(). No metadata distinction in Wasm environment. | | `truncate()` | Partial | Path-based. Opens file O_WRONLY, calls ftruncate, closes. | | `fchmod()` | Partial | Regular files and directories update VFS metadata. Rejects pipes/sockets. Node host-backed files never receive native mode changes after creation. | diff --git a/host/src/vfs/opfs-worker.ts b/host/src/vfs/opfs-worker.ts index 67d98c963..038fea2a3 100644 --- a/host/src/vfs/opfs-worker.ts +++ b/host/src/vfs/opfs-worker.ts @@ -589,13 +589,19 @@ async function handleFtruncate(): Promise { async function handleFsync(): Promise { const handle = channel.getArg(0); const entry = fileHandles.get(handle); - if (!entry || !entry.handle) { + if (!entry) { channel.notifyError(EBADF); return; } try { - entry.handle.flush(); + if (entry.handle) { + entry.handle.flush(); + } + // The File System API exposes flush() for file access handles but no + // equivalent durability barrier for directories. Directory mutations are + // already complete before their OPFS promises resolve, so there is no + // additional browser operation to issue for an O_DIRECTORY handle. channel.result = 0; channel.notifyComplete(); } catch (err) { diff --git a/host/test/vfs/directory-fsync.test.ts b/host/test/vfs/directory-fsync.test.ts new file mode 100644 index 000000000..d82cd0eb4 --- /dev/null +++ b/host/test/vfs/directory-fsync.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HostFileSystem } from "../../src/vfs/host-fs"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; + +const O_RDONLY = 0; +const O_DIRECTORY = 0o200000; + +describe("directory fsync", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses the native durability barrier for Node-backed directories", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-directory-fsync-")); + roots.push(root); + mkdirSync(join(root, "journal")); + const fs = new HostFileSystem(root); + const fd = fs.open("/journal", O_RDONLY | O_DIRECTORY, 0); + + try { + expect(() => fs.fsync(fd)).not.toThrow(); + } finally { + fs.close(fd); + } + }); + + it("accepts directory fsync when memory writes are already synchronous", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + fs.mkdir("/journal", 0o700); + const fd = fs.open("/journal", O_RDONLY | O_DIRECTORY, 0); + + try { + expect(() => fs.fsync(fd)).not.toThrow(); + } finally { + fs.close(fd); + } + }); +}); diff --git a/tests/sortix/os-test-local/basic/unistd/fsync-directory.c b/tests/sortix/os-test-local/basic/unistd/fsync-directory.c new file mode 100644 index 000000000..04fce4d88 --- /dev/null +++ b/tests/sortix/os-test-local/basic/unistd/fsync-directory.c @@ -0,0 +1,26 @@ +#include + +#include +#include + +#include "../basic.h" + +int main(void) +{ + const char path[] = "fsync-directory.tmp"; + if ( mkdir(path, 0700) < 0 ) + err(1, "mkdir"); + + int fd = open(path, O_RDONLY | O_DIRECTORY); + if ( fd < 0 ) + err(1, "open"); + + if ( fsync(fd) < 0 ) + err(1, "fsync"); + + if ( close(fd) < 0 ) + err(1, "close"); + if ( rmdir(path) < 0 ) + err(1, "rmdir"); + return 0; +}