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:
+72
-44
@@ -295,14 +295,23 @@ const sshManager = new ElectronSshManager({
|
|||||||
const readJsonFile = (filePath) => {
|
const readJsonFile = (filePath) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeJsonFile = async (filePath, data) => {
|
const writeJsonFile = async (filePath, data) => {
|
||||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
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 = () => {
|
const readSettingsRoot = () => {
|
||||||
@@ -310,6 +319,24 @@ const readSettingsRoot = () => {
|
|||||||
return root && typeof root === 'object' && !Array.isArray(root) ? root : {};
|
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 writeSettingsRoot = async (root) => writeJsonFile(settingsFilePath(), root);
|
||||||
|
|
||||||
const normalizeHostUrl = (raw) => {
|
const normalizeHostUrl = (raw) => {
|
||||||
@@ -350,28 +377,28 @@ const readDesktopHostsConfig = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const writeDesktopHostsConfig = async (config) => {
|
const writeDesktopHostsConfig = async (config) => {
|
||||||
const root = readSettingsRoot();
|
await mutateSettingsRoot((root) => {
|
||||||
root.desktopHosts = Array.isArray(config?.hosts)
|
root.desktopHosts = Array.isArray(config?.hosts)
|
||||||
? config.hosts
|
? config.hosts
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
||||||
const url = sanitizeHostUrlForStorage(entry?.url);
|
const url = sanitizeHostUrlForStorage(entry?.url);
|
||||||
if (!id || id === LOCAL_HOST_ID || !url) return null;
|
if (!id || id === LOCAL_HOST_ID || !url) return null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url,
|
label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url,
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim()
|
root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim()
|
||||||
? config.defaultHostId.trim()
|
? config.defaultHostId.trim()
|
||||||
: null;
|
: null;
|
||||||
if (typeof config?.initialHostChoiceCompleted === 'boolean') {
|
if (typeof config?.initialHostChoiceCompleted === 'boolean') {
|
||||||
root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted;
|
root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted;
|
||||||
}
|
}
|
||||||
await writeSettingsRoot(root);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const readWindowState = () => {
|
const readWindowState = () => {
|
||||||
@@ -384,16 +411,16 @@ const writeWindowState = async (browserWindow) => {
|
|||||||
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
|
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
|
||||||
|
|
||||||
const bounds = browserWindow.getBounds();
|
const bounds = browserWindow.getBounds();
|
||||||
const root = readSettingsRoot();
|
await mutateSettingsRoot((root) => {
|
||||||
root.desktopWindowState = {
|
root.desktopWindowState = {
|
||||||
x: bounds.x,
|
x: bounds.x,
|
||||||
y: bounds.y,
|
y: bounds.y,
|
||||||
width: Math.max(bounds.width, MIN_WINDOW_WIDTH),
|
width: Math.max(bounds.width, MIN_WINDOW_WIDTH),
|
||||||
height: Math.max(bounds.height, MIN_WINDOW_HEIGHT),
|
height: Math.max(bounds.height, MIN_WINDOW_HEIGHT),
|
||||||
maximized: browserWindow.isMaximized(),
|
maximized: browserWindow.isMaximized(),
|
||||||
fullscreen: browserWindow.isFullScreen(),
|
fullscreen: browserWindow.isFullScreen(),
|
||||||
};
|
};
|
||||||
await writeSettingsRoot(root);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const debounceWindowStatePersist = (browserWindow, immediate = false) => {
|
const debounceWindowStatePersist = (browserWindow, immediate = false) => {
|
||||||
@@ -735,9 +762,9 @@ const spawnLocalServer = async () => {
|
|||||||
state.serverHandle = handle;
|
state.serverHandle = handle;
|
||||||
state.sidecarUrl = url;
|
state.sidecarUrl = url;
|
||||||
|
|
||||||
const root = readSettingsRoot();
|
await mutateSettingsRoot((root) => {
|
||||||
root.desktopLocalPort = port;
|
root.desktopLocalPort = port;
|
||||||
await writeSettingsRoot(root);
|
});
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
@@ -1445,10 +1472,11 @@ const readDesktopSshInstances = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const writeDesktopSshInstances = async (config) => {
|
const writeDesktopSshInstances = async (config) => {
|
||||||
const root = readSettingsRoot();
|
const nextInstances = Array.isArray(config?.instances) ? config.instances : [];
|
||||||
root.desktopSshInstances = Array.isArray(config?.instances) ? config.instances : [];
|
await mutateSettingsRoot((root) => {
|
||||||
await writeSettingsRoot(root);
|
root.desktopSshInstances = nextInstances;
|
||||||
return { instances: root.desktopSshInstances };
|
});
|
||||||
|
return { instances: nextInstances };
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateHostUrlForSshInstance = async (id, label, localUrl) => {
|
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
|
// Tauri build used NSVisualEffectView via Tauri plugin, Electron has
|
||||||
// no equivalent for our titleBarStyle:'hidden' setup. Persist the
|
// no equivalent for our titleBarStyle:'hidden' setup. Persist the
|
||||||
// disabled state so settings UI reflects it; args.enabled is ignored.
|
// disabled state so settings UI reflects it; args.enabled is ignored.
|
||||||
const root = readSettingsRoot();
|
await mutateSettingsRoot((root) => {
|
||||||
root.desktopVibrancy = false;
|
root.desktopVibrancy = false;
|
||||||
await writeSettingsRoot(root);
|
});
|
||||||
return { enabled: false, requiresRestart: false };
|
return { enabled: false, requiresRestart: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ const readJsonRoot = (settingsFilePath) => {
|
|||||||
|
|
||||||
const writeJsonRoot = async (settingsFilePath, root) => {
|
const writeJsonRoot = async (settingsFilePath, root) => {
|
||||||
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
|
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;
|
const defaultTrue = () => true;
|
||||||
|
|||||||
@@ -655,6 +655,25 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const current = get();
|
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 projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
|
||||||
const activeChanged = current.activeProjectId !== incomingActive;
|
const activeChanged = current.activeProjectId !== incomingActive;
|
||||||
|
|
||||||
|
|||||||
@@ -440,7 +440,13 @@ export const createSettingsRuntime = (deps) => {
|
|||||||
const writeSettingsToDisk = async (settings) => {
|
const writeSettingsToDisk = async (settings) => {
|
||||||
try {
|
try {
|
||||||
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to write settings file:', error);
|
console.warn('Failed to write settings file:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user