fix(sync): harden sync CLI, IPC, and UI paths from CodeRabbit review

- reject option-like tokens as flag values (--snapshot --force wrote a
  file named --force); --flag=-value still works
- PowerShell remote quoting uses single-quoted literals so $() in a
  quoted path cannot expand
- sync-hosts.json written via temp file + rename; a crash mid-write
  truncated it and the reader's corrupt-fallback dropped every host
- cancelled sync child escalates SIGTERM -> SIGKILL after 5s grace
- NDJSON progress events validated field-by-field before casting
- snapshot filenames include milliseconds to avoid same-second overwrite
- syncAutoScheduler.stop() wired into will-quit cleanup
- sync --ui exclusivity also rejects --make-temp/--remove-temp/--json
- document --sync-window in app help; group --make-temp/--remove-temp
  under modes in sync usage
This commit is contained in:
2026-07-12 02:10:04 -07:00
parent 25cca8ce24
commit c9f85473bb
19 changed files with 257 additions and 27 deletions
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -47,7 +48,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 33);
assert.equal(calls.length, 34);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -68,6 +69,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.stopSyncAutoScheduler();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -19,6 +19,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -113,6 +114,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
@@ -173,6 +175,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
stopSyncAutoScheduler: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
+24
View File
@@ -2,6 +2,9 @@ import { spawn as nodeSpawn } from 'node:child_process';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
/** How long a cancelled sync child gets to exit on SIGTERM before SIGKILL. */
const CANCEL_GRACE_MS = 5000;
export interface SyncLauncherChildLike {
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
stderr: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
@@ -114,14 +117,35 @@ export function runSyncLauncher(options: {
});
});
// A sync child blocked on an ssh password prompt ignores SIGTERM, so escalate
// to SIGKILL if it is still alive after a grace period. The timer is cleared
// on close so a cancelled run cannot hold the process open.
let killTimer: ReturnType<typeof setTimeout> | null = null;
const clearKillTimer = (): void => {
if (killTimer === null) return;
clearTimeout(killTimer);
killTimer = null;
};
child.on('close', clearKillTimer);
return {
cancel: () => {
if (cancelled) return;
cancelled = true;
try {
child.kill('SIGTERM');
} catch {
// process may already be gone
}
killTimer = setTimeout(() => {
killTimer = null;
try {
child.kill('SIGKILL');
} catch {
// process may already be gone
}
}, CANCEL_GRACE_MS);
killTimer.unref?.();
},
done,
};
+1 -1
View File
@@ -21,7 +21,7 @@ function makeTestRig(root: string) {
const windowStub = {
isDestroyed: () => false,
webContents: {
send: (channel: string, payload: unknown) => sent.push({ channel, payload }),
send: (channel: string, payload?: unknown) => sent.push({ channel, payload }),
},
};
+4 -2
View File
@@ -58,9 +58,11 @@ interface ActiveRun {
resultSeen: boolean;
}
function formatSnapshotName(nowMs: number): string {
// Milliseconds are part of the stamp so two snapshots taken in the same second
// do not land on the same path and silently overwrite each other.
export function formatSnapshotName(nowMs: number): string {
const iso = new Date(nowMs).toISOString();
const stamp = iso.slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
const stamp = iso.slice(0, 23).replace(/[-:.]/g, '').replace('T', '-');
return `immersion-${stamp}.sqlite`;
}