diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0e6555eb..54213991 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -295,14 +295,23 @@ const sshManager = new ElectronSshManager({ const readJsonFile = (filePath) => { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { + } catch (error) { + if (error && error.code === 'ENOENT') return {}; + // Parse errors can happen if a concurrent writer just truncated the file + // and hasn't finished writing yet. Log loudly so we notice, then return + // {} as before. Writes are atomic (tmp + rename) so this race is rare. + log.warn?.('[electron] failed to read JSON file', filePath, error); return {}; } }; const writeJsonFile = async (filePath, data) => { await fsp.mkdir(path.dirname(filePath), { recursive: true }); - await fsp.writeFile(filePath, JSON.stringify(data, null, 2)); + // Atomic: write to a temp file then rename. Readers never see a partial + // JSON file that could parse-error and get coerced to {}. + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await fsp.writeFile(tmp, JSON.stringify(data, null, 2)); + await fsp.rename(tmp, filePath); }; const readSettingsRoot = () => { @@ -310,6 +319,24 @@ const readSettingsRoot = () => { return root && typeof root === 'object' && !Array.isArray(root) ? root : {}; }; +// Serializes read-modify-write of the settings file within this process. +// Multiple call sites (spawnLocalServer, writeDesktopHostsConfig, theme +// preference saves, ssh manager imports, etc.) would otherwise have their +// RMW pairs interleave across awaits, letting one writer's stale copy +// overwrite another writer's just-persisted changes. +let settingsMutationChain = Promise.resolve(); +const mutateSettingsRoot = (mutator) => { + const next = settingsMutationChain.then(async () => { + const current = readSettingsRoot(); + const result = await mutator(current); + const nextRoot = result ?? current; + await writeJsonFile(settingsFilePath(), nextRoot); + }); + // Keep the chain alive even if one mutator throws. + settingsMutationChain = next.catch(() => {}); + return next; +}; + const writeSettingsRoot = async (root) => writeJsonFile(settingsFilePath(), root); const normalizeHostUrl = (raw) => { @@ -350,28 +377,28 @@ const readDesktopHostsConfig = () => { }; const writeDesktopHostsConfig = async (config) => { - const root = readSettingsRoot(); - root.desktopHosts = Array.isArray(config?.hosts) - ? config.hosts - .map((entry) => { - const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; - const url = sanitizeHostUrlForStorage(entry?.url); - if (!id || id === LOCAL_HOST_ID || !url) return null; - return { - id, - label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, - url, - }; - }) - .filter(Boolean) - : []; - root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim() - ? config.defaultHostId.trim() - : null; - if (typeof config?.initialHostChoiceCompleted === 'boolean') { - root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted; - } - await writeSettingsRoot(root); + await mutateSettingsRoot((root) => { + root.desktopHosts = Array.isArray(config?.hosts) + ? config.hosts + .map((entry) => { + const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; + const url = sanitizeHostUrlForStorage(entry?.url); + if (!id || id === LOCAL_HOST_ID || !url) return null; + return { + id, + label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, + url, + }; + }) + .filter(Boolean) + : []; + root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim() + ? config.defaultHostId.trim() + : null; + if (typeof config?.initialHostChoiceCompleted === 'boolean') { + root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted; + } + }); }; const readWindowState = () => { @@ -384,16 +411,16 @@ const writeWindowState = async (browserWindow) => { if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return; const bounds = browserWindow.getBounds(); - const root = readSettingsRoot(); - root.desktopWindowState = { - x: bounds.x, - y: bounds.y, - width: Math.max(bounds.width, MIN_WINDOW_WIDTH), - height: Math.max(bounds.height, MIN_WINDOW_HEIGHT), - maximized: browserWindow.isMaximized(), - fullscreen: browserWindow.isFullScreen(), - }; - await writeSettingsRoot(root); + await mutateSettingsRoot((root) => { + root.desktopWindowState = { + x: bounds.x, + y: bounds.y, + width: Math.max(bounds.width, MIN_WINDOW_WIDTH), + height: Math.max(bounds.height, MIN_WINDOW_HEIGHT), + maximized: browserWindow.isMaximized(), + fullscreen: browserWindow.isFullScreen(), + }; + }); }; const debounceWindowStatePersist = (browserWindow, immediate = false) => { @@ -735,9 +762,9 @@ const spawnLocalServer = async () => { state.serverHandle = handle; state.sidecarUrl = url; - const root = readSettingsRoot(); - root.desktopLocalPort = port; - await writeSettingsRoot(root); + await mutateSettingsRoot((root) => { + root.desktopLocalPort = port; + }); return url; }; @@ -1445,10 +1472,11 @@ const readDesktopSshInstances = () => { }; const writeDesktopSshInstances = async (config) => { - const root = readSettingsRoot(); - root.desktopSshInstances = Array.isArray(config?.instances) ? config.instances : []; - await writeSettingsRoot(root); - return { instances: root.desktopSshInstances }; + const nextInstances = Array.isArray(config?.instances) ? config.instances : []; + await mutateSettingsRoot((root) => { + root.desktopSshInstances = nextInstances; + }); + return { instances: nextInstances }; }; const updateHostUrlForSshInstance = async (id, label, localUrl) => { @@ -1783,9 +1811,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { // Tauri build used NSVisualEffectView via Tauri plugin, Electron has // no equivalent for our titleBarStyle:'hidden' setup. Persist the // disabled state so settings UI reflects it; args.enabled is ignored. - const root = readSettingsRoot(); - root.desktopVibrancy = false; - await writeSettingsRoot(root); + await mutateSettingsRoot((root) => { + root.desktopVibrancy = false; + }); return { enabled: false, requiresRestart: false }; } diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index ed64093b..e21ab19a 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -72,7 +72,12 @@ const readJsonRoot = (settingsFilePath) => { const writeJsonRoot = async (settingsFilePath, root) => { await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true }); - await fsp.writeFile(settingsFilePath, JSON.stringify(root, null, 2)); + // Atomic write: concurrent readers (main.mjs, web server) would otherwise + // see partial JSON and readJsonRoot()'s catch would silently coerce to {}, + // causing the next read-modify-write to wipe the entire settings file. + const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); + await fsp.rename(tmp, settingsFilePath); }; const defaultTrue = () => true; diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index cd079f88..5d30153e 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -655,6 +655,25 @@ export const useProjectsStore = create()( : null; const current = get(); + + // Race guard: settings load can return empty projects during app + // rebuild/reinstall or an incomplete settings read. Don't clobber + // a populated cache with empty — the sidebar would go blank and + // localStorage would be overwritten, losing the list entirely. + if (incomingProjects.length === 0 && current.projects.length > 0) { + if (incomingActive !== current.activeProjectId) { + // Active project may still be valid within the cached list. + const activeExists = incomingActive + ? current.projects.some((project) => project.id === incomingActive) + : true; + if (activeExists) { + set({ activeProjectId: incomingActive }); + cacheProjects(current.projects, incomingActive); + } + } + return; + } + const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects); const activeChanged = current.activeProjectId !== incomingActive; diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index e132b254..b7e6afcc 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -440,7 +440,13 @@ export const createSettingsRuntime = (deps) => { const writeSettingsToDisk = async (settings) => { try { await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8'); + // Atomic write: Electron main and ssh-manager read this file via plain + // readFile + JSON.parse and silently coerce parse errors to {}. A + // partial read during a non-atomic writeFile would make their next + // read-modify-write wipe the settings file. + const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8'); + await fsPromises.rename(tmp, SETTINGS_FILE_PATH); } catch (error) { console.warn('Failed to write settings file:', error); throw error;