Skip to content

fix: session persistence is completely broken on Windows (EPERM on directory fsync) - #82

Open
rexdotsh wants to merge 2 commits into
anomalyco:mainfrom
rexdotsh:fix/windows-directory-fsync
Open

rexdotsh wants to merge 2 commits into
anomalyco:mainfrom
rexdotsh:fix/windows-directory-fsync

Conversation

@rexdotsh

@rexdotsh rexdotsh commented Sep 8, 2026

Copy link
Copy Markdown

Browser Control is currently unusable on Windows

Every relay-backed command that creates or updates a session (execute, session new, session adopt, …) fails on Windows with:

Could not write Browser Control session catalog at C:\Users\<user>\.browser-control\relays\19989\sessions.json
  cause: Error: EPERM: operation not permitted, fsync
         syscall: fsync

The relay starts and the extension connects fine, but the first session write fails, so nothing useful can run. This affects 0.7.0 and, as far as I can tell, every release since the durable session catalog was introduced.

Cause

SessionCatalog.save() calls sync() on an opened directory handle after atomically replacing sessions.json. Windows cannot fsync a directory handle: libuv implements fsync with FlushFileBuffers, which requires write access, and Node opens the directory read-only. The result is EPERM on every save.

Confirmed on Windows:

  • sessions.json is writable; the user's ACL grants Full Control.
  • File-level fsync() on the temporary file succeeds.
  • The failure is specifically the directory-handle sync().

Minimal reproduction (no Browser Control involved)

The same sequence save() runs — write temp file, fsync it, rename into place, fsync the directory — fails at the last step on Windows:

import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"

const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bc-fsync-"))
const tmp = path.join(dir, "sessions.json.tmp")
const file = path.join(dir, "sessions.json")

const fh = await fs.open(tmp, "wx", 0o600)
await fh.writeFile('{"version":1,"sessions":[]}\n', "utf8")
await fh.sync()
await fh.close()
console.log("file fsync:      ok")

await fs.rename(tmp, file)
console.log("rename:          ok")

const dh = await fs.open(dir, "r")
try {
  await dh.sync()
  console.log("directory fsync: ok")
} catch (error) {
  console.log(`directory fsync: FAILED  ${error.code}: ${error.message}`)
} finally {
  await dh.close()
}
console.log(`platform: ${process.platform}  node: ${process.version}`)
> node f.js
file fsync:      ok
rename:          ok
directory fsync: FAILED  EPERM: EPERM: operation not permitted, fsync
platform: win32  node: v24.0.1

The same script prints directory fsync: ok on Linux (node v24.13.0).

Fix

Attempt the directory sync everywhere, and treat only the codes that mean "this platform or filesystem cannot fsync a directory handle" as non-fatal: EPERM (Windows), EINVAL and ENOTSUP (some Linux filesystems and network mounts). Any other error from the directory sync (EIO, EBADF, …) still fails the save.

The temporary-file sync() before the atomic rename is untouched, so on platforms that cannot fsync a directory the save still gets the strongest durability guarantee available. Platforms that support directory fsync keep the exact behavior documented in PLAN.md (updated to note the exception). This is the same approach SQLite and Postgres take for directory fsync.

No platform check and no API change: SessionCatalog's constructor is unchanged.

Verification

Tested on Windows against the installed 0.7.0 by applying the equivalent guard to dist/cli.js. Before the patch every execute failed with the error above; after it:

browser-control serve
browser-control execute "await page.goto('https://example.com'); return { title: await page.title(), url: page.url() }"

succeeds.

Tests

  • One case per tolerated code (EPERM, EINVAL, ENOTSUP) injects the error on the directory handle's sync() and asserts the save succeeds, the catalog round-trips, and both the file and directory handles are synced and closed exactly once.
  • A new case injects EIO on the directory sync and asserts the save still fails with the existing error envelope.
  • The existing directory-sync failure-injection case (plain Error, no code) is unchanged and still rejects.

pnpm typecheck, pnpm test (799 passing), pnpm check:unused, and pnpm check:locals are green.

SessionCatalog.save() calls sync() on an opened directory handle after
atomically replacing sessions.json. On Windows, Node returns EPERM when
syncing a directory handle, so session persistence failed even though the
file itself was writable and file-level fsync succeeded:

    Error: EPERM: operation not permitted, fsync
    syscall: fsync

Keep the temporary-file sync before the atomic rename and skip only the
directory sync on win32. Platforms that support directory fsync keep the
existing durability behavior.

Adds a regression test that injects the Windows EPERM on the directory
handle and confirms the save succeeds on win32 (and still fails elsewhere).
@rexdotsh rexdotsh changed the title fix: skip directory fsync on Windows in SessionCatalog.save() fix: session persistence is completely broken on Windows (EPERM on directory fsync) Sep 8, 2026
…platform

Attempt the directory sync everywhere and treat only EPERM, EINVAL, and
ENOTSUP as "this platform or filesystem cannot fsync a directory handle".
Any other error still fails the save. This fixes Windows without a platform
check, covers the same failure on filesystems that reject directory fsync,
and drops the injected-platform constructor parameter.

Regression tests inject each tolerated code on the directory handle and
assert the save succeeds and both handles are synced and closed once, plus
a case asserting EIO from directory sync still fails the save.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant