Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
};
92 changes: 92 additions & 0 deletions apps/browser-demos/test/opfs-directory-fsync.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(worker: Worker, expectedType: string): Promise<T> =>
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" });
});
26 changes: 20 additions & 6 deletions crates/kernel/src/syscalls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -13820,6 +13818,7 @@ mod tests {
symlink_targets: std::collections::HashMap<Vec<u8>, Vec<u8>>,
lstat_paths: Vec<Vec<u8>>,
statfs_by_path: std::collections::HashMap<Vec<u8>, WasmStatfs>,
fsync_calls: Vec<i64>,
/// 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)>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/browser-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/posix-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
10 changes: 8 additions & 2 deletions host/src/vfs/opfs-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,13 +589,19 @@ async function handleFtruncate(): Promise<void> {
async function handleFsync(): Promise<void> {
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) {
Expand Down
45 changes: 45 additions & 0 deletions host/test/vfs/directory-fsync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
26 changes: 26 additions & 0 deletions tests/sortix/os-test-local/basic/unistd/fsync-directory.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#include <sys/stat.h>

#include <fcntl.h>
#include <unistd.h>

#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;
}
Loading