[Bug]: fsync(dir) on Windows always throws EPERM, so every atomic write warns "durability not confirmed" and real ACL regressions get masked

Author: mowenQWQCreated Sep 17, 2026Updated Sep 17, 2026

This issue was translated automatically.

Environment

  • Cherry Studio 2.0.14 (packaged build)
  • Windows 11 Home China x64

Symptom

The log is polluted by a repeating warn on every atomic file write:

{"module":"utils/file/fs","level":"warn",
 "message":"fsync(dir) failed after atomic rename; durability not confirmed",
 "err":{"code":"EPERM","errno":-4048,"syscall":"fsync"}}

In the current day's log this alone appears 9 times across two rotation files. The warn is noise — every file save triggers it.

Root cause

src/main/utils/file/fs.ts implements the POSIX atomic-write dance (tmp + fsync + rename + fsync(dir)). The directory-fsync step is meant to be tolerated on Windows, but the errno classification is wrong for Windows:

/** Only codes that mean "this FS semantically rejects directory fsync"
 *  qualify — EINVAL / EISDIR / ENOTSUP all come from Windows, FUSE, or
 *  network mounts ... EPERM / EACCES intentionally do NOT qualify ... */
export function shouldSilenceFsyncDirError(code: string | undefined): boolean {
  return code === 'EINVAL' || code === 'EISDIR' || code === 'ENOTSUP'
}

On Windows, fsync() on a directory handle does not return EINVAL/EISDIR/ENOTSUP — it returns EPERM (EINVAL is what you get calling fsync on a directory fd on some POSIX systems; Windows FlushFileBuffers on a directory handle → ERROR_ACCESS_DENIED → Node maps to EPERM). So the one errno the code intentionally wants to surface (thinking it means an ACL regression) is exactly the one Windows produces on every single directory fsync.

Local repro (Windows, any Node ≥ 12)

const fs = require('fs');
(async () => {
  const fh = await fs.promises.open(process.argv[2], 'r');
  await fh.sync();               // ← throws on Windows
})().catch(e => { console.log(e.code); });   // EPERM

Run against a directory path → prints EPERM on Windows; a file handle fsyncs fine (OK). Verified locally on 2.0.14 + Win11.

Why it matters

  1. Noise: every atomic write (conversation save, DB, settings, …) warn-logs on Windows — the log grows useless warnings and the "durability not confirmed" signal is flooded.
  2. Masks the real signal: the whole point of not silencing EPERM was to catch ACL drifts (sandbox containment shift, AV blocking). On Windows that detector can never fire because normal dir-fsync is always EPERM — a genuine ACL regression would be indistinguishable from routine noise.

Suggested fix

  • Add EPERM (and arguably EACCES) to the silenced set when the failed syscall is a directory fsync on Windows — or gate on process.platform === 'win32':
    export function shouldSilenceFsyncDirError(code, platform = process.platform): boolean {
      return code === 'EINVAL' || code === 'EISDIR' || code === 'ENOTSUP'
        || (platform === 'win32' && (code === 'EPERM' || code === 'EACCES'))
    }
    
  • Update the comment so the Windows behavior (dir-fsync = EPERM) is documented, not assumed to be EINVAL-family.
  • Optionally downgrade to a single one-time warn (or debug) for the known-Windows case so the log stays clean while keeping an explicit escape hatch for the ACL-regression story on non-Windows.

Chinese Summary: On Windows, executing fsync() on a directory handle always returns EPERM (FlushFileBufferERROR_ACCESS_DENIED → Node maps to EPERM), but the code's silence list only contains EINVAL/EISDIR/ENOTSUP and reserves EPERM for "ACL drift detection". The result is that every atomic write falsely reports fsync(dir) failed; durability not confirmed (appeared 9 times in today's log), while real ACL regressions are drowned out by noise. Verified locally: directory fsync → EPERM, file fsync → OK. Suggestion: add EPERM/EACCES to the silence list on Windows platform (preserving ACL signals on non-Windows).


Original Content

Environment

  • Cherry Studio 2.0.14 (packaged build)
  • Windows 11 Home China x64

Symptom

The log is polluted by a repeating warn on every atomic file write:

{"module":"utils/file/fs","level":"warn",
 "message":"fsync(dir) failed after atomic rename; durability not confirmed",
 "err":{"code":"EPERM","errno":-4048,"syscall":"fsync"}}

In the current day's log this alone appears 9 times across two rotation files. The warn is noise — every file save triggers it.

Root cause

src/main/utils/file/fs.ts implements the POSIX atomic-write dance (tmp + fsync + rename + fsync(dir)). The directory-fsync step is meant to be tolerated on Windows, but the errno classification is wrong for Windows:

/** Only codes that mean "this FS semantically rejects directory fsync"
 *  qualify — EINVAL / EISDIR / ENOTSUP all come from Windows, FUSE, or
 *  network mounts ... EPERM / EACCES intentionally do NOT qualify ... */
export function shouldSilenceFsyncDirError(code: string | undefined): boolean {
  return code === 'EINVAL' || code === 'EISDIR' || code === 'ENOTSUP'
}

On Windows, fsync() on a directory handle does not return EINVAL/EISDIR/ENOTSUP — it returns EPERM (EINVAL is what you get calling fsync on a directory fd on some POSIX systems; Windows FlushFileBuffers on a directory handle → ERROR_ACCESS_DENIED → Node maps to EPERM). So the one errno the code intentionally wants to surface (thinking it means an ACL regression) is exactly the one Windows produces on every single directory fsync.

Local repro (Windows, any Node ≥ 12)

const fs = require('fs');
(async () => {
  const fh = await fs.promises.open(process.argv[2], 'r');
  await fh.sync();               // ← throws on Windows
})().catch(e => { console.log(e.code); });   // EPERM

Run against a directory path → prints EPERM on Windows; a file handle fsyncs fine (OK). Verified locally on 2.0.14 + Win11.

Why it matters

  1. Noise: every atomic write (conversation save, DB, settings, …) warn-logs on Windows — the log grows useless warnings and the "durability not confirmed" signal is flooded.
  2. Masks the real signal: the whole point of not silencing EPERM was to catch ACL drifts (sandbox containment shift, AV blocking). On Windows that detector can never fire because normal dir-fsync is always EPERM — a genuine ACL regression would be indistinguishable from routine noise.

Suggested fix

  • Add EPERM (and arguably EACCES) to the silenced set when the failed syscall is a directory fsync on Windows — or gate on process.platform === 'win32':
    export function shouldSilenceFsyncDirError(code, platform = process.platform): boolean {
      return code === 'EINVAL' || code === 'EISDIR' || code === 'ENOTSUP'
        || (platform === 'win32' && (code === 'EPERM' || code === 'EACCES'))
    }
    
  • Update the comment so the Windows behavior (dir-fsync = EPERM) is documented, not assumed to be EINVAL-family.
  • Optionally downgrade to a single one-time warn (or debug) for the known-Windows case so the log stays clean while keeping an explicit escape hatch for the ACL-regression story on non-Windows.

中文摘要:Windows 上对目录句柄执行 fsync() 恒返回 EPERMFlushFileBufferERROR_ACCESS_DENIED → Node 映射为 EPERM),而代码的静默名单只含 EINVAL/EISDIR/ENOTSUP 并把 EPERM 留给"ACL 漂移检测"。结果是每次原子写都误报 fsync(dir) failed; durability not confirmed(当日日志出现 9 次),同时真实 ACL 回归被噪音淹没。本机已实测:目录 fsync → EPERM,文件 fsync → OK。建议把 Windows 平台的 EPERM/EACCES 加入静默名单(保留非 Windows 的 ACL 信号)。