fix(desktop): retry Windows settings file replacement
This commit is contained in:
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import updaterPkg from 'electron-updater';
|
import updaterPkg from 'electron-updater';
|
||||||
import { ElectronSshManager } from './ssh-manager.mjs';
|
import { ElectronSshManager } from './ssh-manager.mjs';
|
||||||
|
import { replaceFileWithRetry } from './windows-file-replace.mjs';
|
||||||
import { createTrayController } from './tray.mjs';
|
import { createTrayController } from './tray.mjs';
|
||||||
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
|
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
|
||||||
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
|
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
|
||||||
@@ -563,7 +564,7 @@ const writeJsonFile = async (filePath, data) => {
|
|||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||||
if (process.platform !== 'win32') await fsp.chmod(tmp, 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);
|
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
import { replaceFileWithRetry } from './windows-file-replace.mjs';
|
||||||
|
|
||||||
const LOCAL_HOST_ID = 'local';
|
const LOCAL_HOST_ID = 'local';
|
||||||
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
|
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
|
||||||
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
|
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)}`;
|
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
|
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
|
||||||
await fsp.rename(tmp, settingsFilePath);
|
await replaceFileWithRetry(tmp, settingsFilePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fsp.rm(tmp, { force: true }).catch(() => {});
|
await fsp.rm(tmp, { force: true }).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user