From 09f75b75e1c8b255e1c56407301c5e7e41594773 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 14:44:03 +0300 Subject: [PATCH] fix(desktop): retry Windows settings file replacement --- packages/electron/main.mjs | 3 +- packages/electron/ssh-manager.mjs | 4 +- packages/electron/windows-file-replace.mjs | 28 +++++++ .../electron/windows-file-replace.test.mjs | 81 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 packages/electron/windows-file-replace.mjs create mode 100644 packages/electron/windows-file-replace.test.mjs diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index c18d9ffa..9ecbead7 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; +import { replaceFileWithRetry } from './windows-file-replace.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs'; @@ -563,7 +564,7 @@ const writeJsonFile = async (filePath, data) => { try { await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600); - await fsp.rename(tmp, filePath); + await replaceFileWithRetry(tmp, filePath); if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); } catch (error) { await fsp.rm(tmp, { force: true }).catch(() => {}); diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 5f390a19..e0da931f 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -5,6 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; +import { replaceFileWithRetry } from './windows-file-replace.mjs'; + const LOCAL_HOST_ID = 'local'; const DEFAULT_CONNECTION_TIMEOUT_SEC = 60; const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'; @@ -81,7 +83,7 @@ const writeJsonRoot = async (settingsFilePath, root) => { const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); - await fsp.rename(tmp, settingsFilePath); + await replaceFileWithRetry(tmp, settingsFilePath); } catch (error) { await fsp.rm(tmp, { force: true }).catch(() => {}); throw error; diff --git a/packages/electron/windows-file-replace.mjs b/packages/electron/windows-file-replace.mjs new file mode 100644 index 00000000..b43ad904 --- /dev/null +++ b/packages/electron/windows-file-replace.mjs @@ -0,0 +1,28 @@ +import fsp from 'node:fs/promises'; + +const WINDOWS_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1_000, 1_000]; + +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const isTransientWindowsFileError = (error, platform) => { + if (platform !== 'win32') return false; + const code = error?.code; + return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'; +}; + +export const replaceFileWithRetry = async (source, target, options = {}) => { + const platform = options.platform ?? process.platform; + const rename = options.rename ?? fsp.rename; + const wait = options.wait ?? sleep; + + for (let attempt = 0; ; attempt += 1) { + try { + await rename(source, target); + return; + } catch (error) { + const delay = WINDOWS_RETRY_DELAYS_MS[attempt]; + if (delay === undefined || !isTransientWindowsFileError(error, platform)) throw error; + await wait(delay); + } + } +}; diff --git a/packages/electron/windows-file-replace.test.mjs b/packages/electron/windows-file-replace.test.mjs new file mode 100644 index 00000000..a860037d --- /dev/null +++ b/packages/electron/windows-file-replace.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { replaceFileWithRetry } from './windows-file-replace.mjs'; + +const fileError = (code = 'EPERM') => Object.assign(new Error(code), { code }); + +test('retries transient Windows rename failures until replacement succeeds', async () => { + const delays = []; + let attempts = 0; + + await replaceFileWithRetry('settings.tmp', 'settings.json', { + platform: 'win32', + rename: async () => { + attempts += 1; + if (attempts < 4) throw fileError(); + }, + wait: async (delay) => delays.push(delay), + }); + + assert.equal(attempts, 4); + assert.deepEqual(delays, [50, 100, 200]); +}); + +test('does not retry rename errors that are not transient Windows locks', async () => { + let attempts = 0; + const error = fileError('ENOENT'); + + await assert.rejects( + replaceFileWithRetry('settings.tmp', 'settings.json', { + platform: 'win32', + rename: async () => { + attempts += 1; + throw error; + }, + wait: async () => assert.fail('unexpected wait'), + }), + error, + ); + + assert.equal(attempts, 1); +}); + +test('does not retry transient error codes outside Windows', async () => { + let attempts = 0; + const error = fileError(); + + await assert.rejects( + replaceFileWithRetry('settings.tmp', 'settings.json', { + platform: 'linux', + rename: async () => { + attempts += 1; + throw error; + }, + wait: async () => assert.fail('unexpected wait'), + }), + error, + ); + + assert.equal(attempts, 1); +}); + +test('returns the final Windows lock error after the retry window', async () => { + const delays = []; + let attempts = 0; + + await assert.rejects( + replaceFileWithRetry('settings.tmp', 'settings.json', { + platform: 'win32', + rename: async () => { + attempts += 1; + throw fileError(); + }, + wait: async (delay) => delays.push(delay), + }), + { code: 'EPERM' }, + ); + + assert.equal(attempts, 8); + assert.deepEqual(delays, [50, 100, 200, 400, 800, 1_000, 1_000]); +});