Skip to content

[Bug] Windows: daemon crash loop with EPERM on fsync during credential migration -> cannot connect to daemon (127.0.0.1:19523) #437

Description

@baoyu0

Summary

On Windows, LinkCode 0.20.0 enters a permanent daemon crash loop during the one-shot "move credentials out of config.json into the secret vault" migration. The desktop app then reports it cannot connect to the daemon at http://127.0.0.1:19523.

Environment

  • LinkCode desktop 0.20.0 (also present since the migration feature shipped)
  • Windows 11 x64, NTFS, no AV beyond Windows Defender
  • App installed at %LOCALAPPDATA%\Programs\LinkCode

Symptom

  • UI: "无法连接到 daemon (http://127.0.0.1:19523)" / cannot connect to daemon; the daemon never listens on 19523.
  • %APPDATA%\LinkCode\logs\main.log shows the daemon exiting with code 1, restarting ~6 times, then giving up:
[info]  {"operation":"config.load","msg":"Moving credentials out of config.json into the secret vault"}
[info]  {"level":60,"err":{"type":"Error","message":"EPERM: operation not permitted, fsync",
         "stack":"Error: EPERM: operation not permitted, fsync
           at fsyncSync (node:fs:1285:11)
           at fsyncPath (.../out/daemon/index.mjs:158447:5)
           at writeConfigFields (.../out/daemon/index.mjs:158466:5)
           at persistCustomMcpSnapshot (.../out/daemon/index.mjs:158424:5)
           at saveConfigSnapshot (.../out/daemon/index.mjs:158396:3)
           at loadConfig (.../out/daemon/index.mjs:158262:5)"},"msg":"Daemon failed"}
[warn]  [linkcode/desktop] daemon exited (code 1); restarting
...
[error] [linkcode/desktop] daemon keeps exiting (last code 1); giving up

Root cause

fsyncPath() opens the file read-only and then fsyncs it:

function fsyncPath(path) {
  const descriptor = openSync(path, "r");   // <-- read-only handle
  try {
    fsyncSync(descriptor);
  } finally {
    closeSync(descriptor);
  }
}

On Windows, fsync on a read-only handle fails (ERROR_ACCESS_DENIED mapped to EPERM). I verified this locally: fsync on a handle opened with "r" fails deterministically, while fsync on a handle opened with "r+"/"w" succeeds on the same files and directory. The directory fsync in writeConfigFields is already guarded with if (process.platform !== "win32"), but the temp-file fsync (fsyncPath(temporaryPath)) is not.

Trigger chain: accounts stored with inline credentials → loadConfig runs the migration → saveConfigSnapshot writes the vault, then writeConfigFields writes config.json → fsyncPath(temp) throws EPERM → daemon exits before renameSync → config.json still contains the inline credential → next startup runs the migration again → infinite crash loop.

Workaround (for affected users)

Complete the migration by hand:

  1. Quit LinkCode.
  2. Back up %USERPROFILE%\.linkcode\config.json and secrets.json.
  3. In config.json, remove the key/token from each account's credential but keep the credential object shape, e.g. "credential": {"type": "api-key"} (the daemon re-attaches the secret from the vault by account id).
  4. Restart LinkCode.

Suggested fix

  • Open the fsync descriptor with write access (openSync(path, "r+")), or fsync the temp file's own write descriptor before renameSync, or skip fsyncPath on win32 (the rename-based write is already durable enough for this use case).
  • Optionally: make the migration tolerant of a failed fsync (treat EPERM on win32 as non-fatal) so the vault/config state stays consistent and the daemon still starts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions