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:
- Quit LinkCode.
- Back up
%USERPROFILE%\.linkcode\config.json and secrets.json.
- 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).
- 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.
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
%LOCALAPPDATA%\Programs\LinkCodeSymptom
%APPDATA%\LinkCode\logs\main.logshows the daemon exiting with code 1, restarting ~6 times, then giving up:Root cause
fsyncPath()opens the file read-only and then fsyncs it:On Windows,
fsyncon a read-only handle fails (ERROR_ACCESS_DENIEDmapped toEPERM). 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 inwriteConfigFieldsis already guarded withif (process.platform !== "win32"), but the temp-file fsync (fsyncPath(temporaryPath)) is not.Trigger chain: accounts stored with inline credentials →
loadConfigruns the migration →saveConfigSnapshotwrites the vault, thenwriteConfigFieldswrites config.json →fsyncPath(temp)throws EPERM → daemon exits beforerenameSync→ 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:
%USERPROFILE%\.linkcode\config.jsonandsecrets.json.config.json, remove thekey/tokenfrom each account'scredentialbut keep thecredentialobject shape, e.g."credential": {"type": "api-key"}(the daemon re-attaches the secret from the vault by account id).Suggested fix
openSync(path, "r+")), or fsync the temp file's own write descriptor beforerenameSync, or skipfsyncPathon win32 (the rename-based write is already durable enough for this use case).