mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -351,7 +351,18 @@ export function parseCliPrograms(
|
|||||||
const makeTemp = options.makeTemp === true;
|
const makeTemp = options.makeTemp === true;
|
||||||
const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : '';
|
const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : '';
|
||||||
if (options.ui === true) {
|
if (options.ui === true) {
|
||||||
if (host || snapshot || merge || push || pull || check || options.force === true) {
|
if (
|
||||||
|
host ||
|
||||||
|
snapshot ||
|
||||||
|
merge ||
|
||||||
|
push ||
|
||||||
|
pull ||
|
||||||
|
check ||
|
||||||
|
makeTemp ||
|
||||||
|
removeTemp ||
|
||||||
|
options.json === true ||
|
||||||
|
options.force === true
|
||||||
|
) {
|
||||||
throw new Error('Sync --ui cannot be combined with other sync options.');
|
throw new Error('Sync --ui cannot be combined with other sync options.');
|
||||||
}
|
}
|
||||||
syncUiTriggered = true;
|
syncUiTriggered = true;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ ${B}Jellyfin${R}
|
|||||||
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
|
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
|
||||||
|
|
||||||
${B}Stats sync${R}
|
${B}Stats sync${R}
|
||||||
|
--sync-window Open the stats sync window
|
||||||
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
||||||
${D}run --sync-cli --help for details)${R}
|
${D}run --sync-cli --help for details)${R}
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,20 @@ test('parseSyncCliTokens mirrors launcher sync validation', () => {
|
|||||||
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
|
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
|
||||||
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
|
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSyncCliTokens rejects an option-like token where a value is required', () => {
|
||||||
|
// Without this guard `--snapshot --force` writes a snapshot to a file literally
|
||||||
|
// named "--force" and silently drops the flag.
|
||||||
|
assert.deepEqual(parseSyncCliTokens(['sync', '--snapshot', '--force']), {
|
||||||
|
kind: 'error',
|
||||||
|
message: 'Missing value for --snapshot.',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseSyncCliTokens(['sync', '--remove-temp', '--force']), {
|
||||||
|
kind: 'error',
|
||||||
|
message: 'Missing value for --remove-temp.',
|
||||||
|
});
|
||||||
|
// The `--flag=<value>` form still accepts values that begin with "-".
|
||||||
|
const parsed = parseSyncCliTokens(['sync', '--snapshot=-weird-name.sqlite']);
|
||||||
|
assert.equal(parsed.kind, 'run');
|
||||||
|
assert.equal(parsed.kind === 'run' && parsed.args.syncSnapshotPath, '-weird-name.sqlite');
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const value = rest[i + 1];
|
const value = rest[i + 1];
|
||||||
if (value === undefined) {
|
// An option-like token is never a value: `--snapshot --force` must fail
|
||||||
|
// loudly instead of writing a snapshot to a file named "--force". Paths
|
||||||
|
// that really do start with "-" can still be passed as `--snapshot=-x`.
|
||||||
|
if (value === undefined || value.startsWith('-')) {
|
||||||
return { kind: 'error', message: `Missing value for ${token}.` };
|
return { kind: 'error', message: `Missing value for ${token}.` };
|
||||||
}
|
}
|
||||||
assignValue(value);
|
assignValue(value);
|
||||||
@@ -144,6 +147,8 @@ export function syncCliUsage(): string {
|
|||||||
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
|
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
|
||||||
' --snapshot <file> Write a consistent snapshot of the local stats database',
|
' --snapshot <file> Write a consistent snapshot of the local stats database',
|
||||||
' --merge <file> Merge a snapshot database file into the local stats database',
|
' --merge <file> Merge a snapshot database file into the local stats database',
|
||||||
|
' --make-temp Create a sync temp directory and print its path (used over SSH)',
|
||||||
|
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
|
||||||
'',
|
'',
|
||||||
'Options:',
|
'Options:',
|
||||||
' --push Only merge local stats into the SSH host',
|
' --push Only merge local stats into the SSH host',
|
||||||
@@ -153,8 +158,6 @@ export function syncCliUsage(): string {
|
|||||||
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
||||||
' -f, --force Skip the running-app safety check',
|
' -f, --force Skip the running-app safety check',
|
||||||
' --json Emit machine-readable NDJSON progress output',
|
' --json Emit machine-readable NDJSON progress output',
|
||||||
' --make-temp Create a sync temp directory and print its path (used over SSH)',
|
|
||||||
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
|
|
||||||
' --log-level <level> Log level',
|
' --log-level <level> Log level',
|
||||||
' --help Show this help',
|
' --help Show this help',
|
||||||
' --version Show the SubMiner version',
|
' --version Show the SubMiner version',
|
||||||
|
|||||||
@@ -160,3 +160,17 @@ test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values',
|
|||||||
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
|
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
|
||||||
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
|
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('quoteForRemoteShell does not let PowerShell expand a quoted value', () => {
|
||||||
|
// PowerShell expands $(...) and $var inside double quotes, so a single-quoted
|
||||||
|
// literal is the only safe form; '' is the escape for an embedded quote.
|
||||||
|
assert.equal(
|
||||||
|
quoteForRemoteShell('windows-powershell', 'C:/tmp/$(calc.exe)'),
|
||||||
|
`'C:/tmp/$(calc.exe)'`,
|
||||||
|
);
|
||||||
|
assert.equal(quoteForRemoteShell('windows-powershell', "C:/tmp/it's"), `'C:/tmp/it''s'`);
|
||||||
|
assert.equal(
|
||||||
|
quoteForRemoteShell('windows-powershell', 'C:/Users/First Last/Temp/subminer-sync-ab'),
|
||||||
|
`'C:/Users/First Last/Temp/subminer-sync-ab'`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,15 +100,21 @@ export function detectRemoteShellFlavor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quote one argument for the detected remote shell. Windows shells have no
|
* Quote one argument for the detected remote shell. PowerShell expands $(...)
|
||||||
* safe single-quote escaping (cmd treats ' literally; PowerShell and cmd
|
* and $var inside double quotes, so it gets a single-quoted literal ('' escapes
|
||||||
* disagree on embedded double quotes), so quote-containing values are
|
* a quote). cmd.exe has no single-quote form and treats ' literally, so it keeps
|
||||||
* rejected instead of escaped — sync only ever quotes paths it composed.
|
* double quotes and rejects values carrying a double quote of their own.
|
||||||
*/
|
*/
|
||||||
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
|
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
|
||||||
if (flavor === 'posix') return shellQuote(value);
|
if (flavor === 'posix') return shellQuote(value);
|
||||||
if (value.includes('"') || /[\r\n]/.test(value)) {
|
if (/[\r\n]/.test(value)) {
|
||||||
throw new Error(`Refusing to quote a value with quotes or newlines for a Windows shell: ${value}`);
|
throw new Error(`Refusing to quote a value with newlines for a Windows shell: ${value}`);
|
||||||
|
}
|
||||||
|
if (flavor === 'windows-powershell') {
|
||||||
|
return `'${value.replaceAll("'", "''")}'`;
|
||||||
|
}
|
||||||
|
if (value.includes('"')) {
|
||||||
|
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
|
||||||
}
|
}
|
||||||
return `"${value}"`;
|
return `"${value}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3951,6 +3951,7 @@ const {
|
|||||||
annotationSubtitleWsService.stop();
|
annotationSubtitleWsService.stop();
|
||||||
},
|
},
|
||||||
stopTexthookerService: () => texthookerService.stop(),
|
stopTexthookerService: () => texthookerService.stop(),
|
||||||
|
stopSyncAutoScheduler: () => syncAutoScheduler.stop(),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
|
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||||
calls.push('clear-windows-visible-overlay-poll'),
|
calls.push('clear-windows-visible-overlay-poll'),
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||||
@@ -47,7 +48,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 33);
|
assert.equal(calls.length, 34);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||||
@@ -68,6 +69,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
stopSyncAutoScheduler: () => {},
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||||
destroyMainOverlayWindow: () => {},
|
destroyMainOverlayWindow: () => {},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
|
stopSyncAutoScheduler: () => void;
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||||
destroyMainOverlayWindow: () => void;
|
destroyMainOverlayWindow: () => void;
|
||||||
@@ -41,6 +42,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.unregisterAllGlobalShortcuts();
|
deps.unregisterAllGlobalShortcuts();
|
||||||
deps.stopSubtitleWebsocket();
|
deps.stopSubtitleWebsocket();
|
||||||
deps.stopTexthookerService();
|
deps.stopTexthookerService();
|
||||||
|
deps.stopSyncAutoScheduler();
|
||||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||||
deps.destroyMainOverlayWindow();
|
deps.destroyMainOverlayWindow();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
|
stopSyncAutoScheduler: () => calls.push('stop-sync-auto-scheduler'),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||||
@@ -113,6 +114,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
stopSyncAutoScheduler: () => {},
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||||
getMainOverlayWindow: () => ({
|
getMainOverlayWindow: () => ({
|
||||||
@@ -173,6 +175,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
},
|
},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
stopSyncAutoScheduler: () => {},
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||||
getMainOverlayWindow: () => null,
|
getMainOverlayWindow: () => null,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
|
stopSyncAutoScheduler: () => void;
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||||
@@ -73,6 +74,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||||
|
stopSyncAutoScheduler: () => deps.stopSyncAutoScheduler(),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
stopSyncAutoScheduler: () => {},
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||||
getMainOverlayWindow: () => null,
|
getMainOverlayWindow: () => null,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|||||||
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
|
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||||
import { SYNC_CLI_FLAG } from '../../core/services/stats-sync/cli-args';
|
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 {
|
export interface SyncLauncherChildLike {
|
||||||
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
stdout: { on(event: 'data', listener: (chunk: Buffer | string) => void): unknown } | null;
|
||||||
stderr: { 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 {
|
return {
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
|
if (cancelled) return;
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
try {
|
try {
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
} catch {
|
} catch {
|
||||||
// process may already be gone
|
// 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,
|
done,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function makeTestRig(root: string) {
|
|||||||
const windowStub = {
|
const windowStub = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
webContents: {
|
webContents: {
|
||||||
send: (channel: string, payload: unknown) => sent.push({ channel, payload }),
|
send: (channel: string, payload?: unknown) => sent.push({ channel, payload }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ interface ActiveRun {
|
|||||||
resultSeen: boolean;
|
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 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`;
|
return `immersion-${stamp}.sqlite`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,22 @@ test('parseSyncProgressLine parses known event types', () => {
|
|||||||
const summaryLine = JSON.stringify({
|
const summaryLine = JSON.stringify({
|
||||||
type: 'merge-summary',
|
type: 'merge-summary',
|
||||||
target: 'local',
|
target: 'local',
|
||||||
summary: { sessionsMerged: 2 },
|
summary: {
|
||||||
|
sessionsMerged: 2,
|
||||||
|
sessionsAlreadyPresent: 0,
|
||||||
|
activeSessionsSkipped: 0,
|
||||||
|
animeAdded: 0,
|
||||||
|
videosAdded: 0,
|
||||||
|
wordsAdded: 0,
|
||||||
|
kanjiAdded: 0,
|
||||||
|
subtitleLinesAdded: 0,
|
||||||
|
telemetryRowsAdded: 0,
|
||||||
|
eventsAdded: 0,
|
||||||
|
excludedWordsAdded: 0,
|
||||||
|
dailyRollupsCopied: 0,
|
||||||
|
monthlyRollupsCopied: 0,
|
||||||
|
rollupGroupsRecomputed: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const parsed = parseSyncProgressLine(summaryLine);
|
const parsed = parseSyncProgressLine(summaryLine);
|
||||||
assert.equal(parsed?.type, 'merge-summary');
|
assert.equal(parsed?.type, 'merge-summary');
|
||||||
@@ -28,3 +43,25 @@ test('parseSyncProgressLine rejects non-events and garbage', () => {
|
|||||||
assert.equal(parseSyncProgressLine(''), null);
|
assert.equal(parseSyncProgressLine(''), null);
|
||||||
assert.equal(parseSyncProgressLine('42'), null);
|
assert.equal(parseSyncProgressLine('42'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSyncProgressLine rejects events missing required fields', () => {
|
||||||
|
// A half-formed event used to be cast straight through; the UI reads
|
||||||
|
// `.ok` / `.summary` directly and would misreport a failed sync as done.
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"result"}'), null);
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"result","ok":"yes","error":null}'), null);
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"merge-summary","target":"local"}'), null);
|
||||||
|
assert.equal(
|
||||||
|
parseSyncProgressLine('{"type":"merge-summary","target":"nowhere","summary":{}}'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"stage","stage":"bogus","message":"x"}'), null);
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"snapshot-created"}'), null);
|
||||||
|
assert.equal(parseSyncProgressLine('{"type":"check-result","host":"h","sshOk":true}'), null);
|
||||||
|
|
||||||
|
// Well-formed events still parse.
|
||||||
|
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||||
|
type: 'result',
|
||||||
|
ok: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,15 +43,51 @@ export type SyncProgressEvent =
|
|||||||
}
|
}
|
||||||
| { type: 'result'; ok: boolean; error: string | null };
|
| { type: 'result'; ok: boolean; error: string | null };
|
||||||
|
|
||||||
const EVENT_TYPES = new Set([
|
const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
|
||||||
'stage',
|
'sessionsMerged',
|
||||||
'merge-summary',
|
'sessionsAlreadyPresent',
|
||||||
'remote-output',
|
'activeSessionsSkipped',
|
||||||
'snapshot-created',
|
'animeAdded',
|
||||||
'check-result',
|
'videosAdded',
|
||||||
'result',
|
'wordsAdded',
|
||||||
]);
|
'kanjiAdded',
|
||||||
|
'subtitleLinesAdded',
|
||||||
|
'telemetryRowsAdded',
|
||||||
|
'eventsAdded',
|
||||||
|
'excludedWordsAdded',
|
||||||
|
'dailyRollupsCopied',
|
||||||
|
'monthlyRollupsCopied',
|
||||||
|
'rollupGroupsRecomputed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGES: readonly SyncStage[] = [
|
||||||
|
'snapshot-local',
|
||||||
|
'snapshot-remote',
|
||||||
|
'download',
|
||||||
|
'upload',
|
||||||
|
'merge-local',
|
||||||
|
'merge-remote',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNullableString(value: unknown): value is string | null {
|
||||||
|
return value === null || typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMergeSummary(value: unknown): value is SyncMergeSummary {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return SUMMARY_KEYS.every((key) => typeof record[key] === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events cross a process boundary (the sync child's stdout), so each variant
|
||||||
|
* is validated field-by-field: a half-formed `result` or `merge-summary` would
|
||||||
|
* otherwise be cast straight into the UI, which reads `.ok`/`.summary` directly.
|
||||||
|
*/
|
||||||
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed.startsWith('{')) return null;
|
if (!trimmed.startsWith('{')) return null;
|
||||||
@@ -62,7 +98,35 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
const type = (parsed as { type?: unknown }).type;
|
const event = parsed as Record<string, unknown>;
|
||||||
if (typeof type !== 'string' || !EVENT_TYPES.has(type)) return null;
|
|
||||||
return parsed as SyncProgressEvent;
|
switch (event.type) {
|
||||||
|
case 'stage':
|
||||||
|
return STAGES.includes(event.stage as SyncStage) && isString(event.message)
|
||||||
|
? (parsed as SyncProgressEvent)
|
||||||
|
: null;
|
||||||
|
case 'merge-summary':
|
||||||
|
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
|
||||||
|
? (parsed as SyncProgressEvent)
|
||||||
|
: null;
|
||||||
|
case 'remote-output':
|
||||||
|
return isString(event.text) ? (parsed as SyncProgressEvent) : null;
|
||||||
|
case 'snapshot-created':
|
||||||
|
return isString(event.path) ? (parsed as SyncProgressEvent) : null;
|
||||||
|
case 'check-result':
|
||||||
|
return isString(event.host) &&
|
||||||
|
typeof event.sshOk === 'boolean' &&
|
||||||
|
typeof event.ok === 'boolean' &&
|
||||||
|
isNullableString(event.remoteCommand) &&
|
||||||
|
isNullableString(event.remoteVersion) &&
|
||||||
|
isNullableString(event.error)
|
||||||
|
? (parsed as SyncProgressEvent)
|
||||||
|
: null;
|
||||||
|
case 'result':
|
||||||
|
return typeof event.ok === 'boolean' && isNullableString(event.error)
|
||||||
|
? (parsed as SyncProgressEvent)
|
||||||
|
: null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,3 +180,25 @@ test('writeSyncHostsState creates parent directories', () => {
|
|||||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writeSyncHostsState leaves the previous file intact when the write fails', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const filePath = getSyncHostsPath(root);
|
||||||
|
let state = createDefaultSyncHostsState();
|
||||||
|
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||||
|
writeSyncHostsState(filePath, state);
|
||||||
|
|
||||||
|
// A crash mid-write must not truncate the live file: readSyncHostsState
|
||||||
|
// falls back to defaults on a corrupt file, which would drop every host.
|
||||||
|
let next = upsertSyncHost(state, { host: 'user@desktop' }, 43);
|
||||||
|
assert.throws(() =>
|
||||||
|
writeSyncHostsState(filePath, next, {
|
||||||
|
renameSync: () => {
|
||||||
|
throw new Error('disk full');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||||
|
assert.equal(fs.existsSync(`${filePath}.tmp`), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -180,16 +180,34 @@ export function readSyncHostsState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Written via a sibling temp file + rename so an interrupted write cannot leave
|
||||||
|
// a truncated sync-hosts.json behind (readSyncHostsState would silently fall
|
||||||
|
// back to defaults, dropping every configured host).
|
||||||
export function writeSyncHostsState(
|
export function writeSyncHostsState(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
state: SyncHostsState,
|
state: SyncHostsState,
|
||||||
deps?: {
|
deps?: {
|
||||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||||
|
renameSync?: (from: string, to: string) => void;
|
||||||
|
rmSync?: (target: string, options: { force: true }) => void;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
|
const renameSync = deps?.renameSync ?? fs.renameSync;
|
||||||
|
const rmSync = deps?.rmSync ?? fs.rmSync;
|
||||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
const tempPath = `${filePath}.tmp`;
|
||||||
|
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||||
|
try {
|
||||||
|
renameSync(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
rmSync(tempPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// best effort: the temp file is disposable
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user