fix: stop settings.json from being wiped on launch

Electron main, ssh-manager, and the embedded web server all write the
same settings.json. readJsonFile/readJsonRoot silently coerced any read
failure (including mid-write parse errors) to {}, and writes were plain
fs.writeFile. A partial read during a concurrent write let the reader's
next read-modify-write overwrite the whole file with only the field it
just set — wiping projects, desktopDefaultHostId, and more. Next launch
showed the welcome chooser because defaultHostId was gone, and the
sidebar was empty because projects were gone.

- Switch all writers to atomic tmp+rename so readers never see partial
  JSON.
- Add mutateSettingsRoot() in Electron main to serialize read-modify-
  write pairs across its own call sites (hosts config, window state,
  desktop port, ssh instances, vibrancy).
- Keep read-on-error returning {} to avoid crashing startup callers,
  but log loudly now so we can catch it if it ever happens again.
- useProjectsStore: don't clobber a populated cache with empty incoming
  settings. If settings ever do come back empty, the sidebar stays
  intact until a real, non-empty sync lands.
This commit is contained in:
Bohdan Triapitsyn
2026-04-20 22:28:32 +03:00
parent f1b84e3291
commit 70fd6aaacc
4 changed files with 104 additions and 46 deletions
+72 -44
View File
@@ -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 };
}
+6 -1
View File
@@ -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;
@@ -655,6 +655,25 @@ export const useProjectsStore = create<ProjectsStore>()(
: 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;
@@ -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;