fix(sync): harden detached launch, bounded --check probes, and pipe-clos

- `sync --ui` now launches detached; closing standalone Sync window exits app
- `--check` uses BatchMode + ConnectTimeout + 15s timeoutMs on all SSH calls
- `runSyncLauncher` settles on `exit` (not just `close`) for Electron pipe inheritance
- Added `timeoutMs` to `runSyncLauncher`; check-host passes 30s deadline
- `handleSyncCliAtEntry` wraps exit deps for testability; fixes Linux AppImage GUI fallthrough
- `config-settings-window` gains optional `onClosed` hook
- Added preload-syncui bundle to `build:syncui` script
This commit is contained in:
2026-07-12 19:42:34 -07:00
parent a4c12165af
commit 5d8673f299
20 changed files with 246 additions and 68 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
type: added type: added
area: sync area: sync
- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically. When launched with `subminer sync --ui`, closing the window also exits the attached CLI process cleanly. - Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically. `subminer sync --ui` launches silently in the background and returns the shell immediately; closing that standalone Sync window shuts down the app.
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes). - Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, using bounded noninteractive probes and settling when the check process exits even if an inherited output pipe remains open. Headless sync commands now exit directly after completion so Linux AppImage runs cannot fall through into GUI startup. Added `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
+1 -1
View File
@@ -118,7 +118,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
### Sync window ### Sync window
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine: `subminer sync --ui` opens a dedicated window for the same engine in a detached app process, returning the shell immediately. Closing that standalone-launched window exits its app instance. Opening **Sync Stats & History** from the tray keeps the resident app running when the window closes:
- **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically. - **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
- **Add a device** — test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration. - **Add a device** — test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
+1 -1
View File
@@ -172,7 +172,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
SubMiner.AppImage --help # Show all options SubMiner.AppImage --help # Show all options
``` ```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically. `--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk. The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
+8 -4
View File
@@ -1,14 +1,17 @@
import { import {
launchAppCommandDetached,
launchAppBackgroundDetached, launchAppBackgroundDetached,
launchTexthookerOnly, launchTexthookerOnly,
runAppCommandInteractive,
runAppCommandWithInherit, runAppCommandWithInherit,
} from '../mpv.js'; } from '../mpv.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
type AppCommandDeps = { type AppCommandDeps = {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void; launchSyncUiDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAppBackgroundDetached: ( launchAppBackgroundDetached: (
appPath: string, appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'], logLevel: LauncherCommandContext['args']['logLevel'],
@@ -17,7 +20,8 @@ type AppCommandDeps = {
const defaultAppCommandDeps: AppCommandDeps = { const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit, runAppCommandWithInherit,
runAppCommandInteractive, launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached, launchAppBackgroundDetached,
}; };
@@ -34,7 +38,7 @@ export function runAppPassthroughCommand(
return true; return true;
} }
if (args.syncUi) { if (args.syncUi) {
deps.runAppCommandInteractive(appPath, ['--sync-window']); deps.launchSyncUiDetached(appPath, args.logLevel);
return true; return true;
} }
if (!args.appPassthrough) { if (!args.appPassthrough) {
+6 -6
View File
@@ -206,7 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
runAppCommandWithInherit: () => { runAppCommandWithInherit: () => {
calls.push('attached'); calls.push('attached');
}, },
runAppCommandInteractive: () => calls.push('interactive'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`); calls.push(`detached:${appPath}:${logLevel}`);
}, },
@@ -226,7 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
runAppCommandWithInherit: () => { runAppCommandWithInherit: () => {
calls.push('attached'); calls.push('attached');
}, },
runAppCommandInteractive: () => calls.push('interactive'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`); calls.push(`detached:${appPath}:${logLevel}`);
}, },
@@ -247,7 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
runAppCommandWithInherit: (_appPath, appArgs) => { runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs); forwarded.push(appArgs);
}, },
runAppCommandInteractive: () => detached.push('interactive'), launchSyncUiDetached: () => detached.push('sync-ui'),
launchAppBackgroundDetached: () => { launchAppBackgroundDetached: () => {
detached.push('detached'); detached.push('detached');
}, },
@@ -258,19 +258,19 @@ test('app command keeps explicit passthrough args attached', () => {
assert.deepEqual(detached, []); assert.deepEqual(detached, []);
}); });
test('sync UI command attaches the app directly to the terminal', () => { test('sync UI command launches the app detached from the terminal', () => {
const context = createContext(); const context = createContext();
context.args.syncUi = true; context.args.syncUi = true;
const calls: string[] = []; const calls: string[] = [];
const handled = runAppPassthroughCommand(context, { const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'), runAppCommandWithInherit: () => calls.push('piped'),
runAppCommandInteractive: (_appPath, appArgs) => calls.push(`direct:${appArgs.join(' ')}`), launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAppBackgroundDetached: () => calls.push('detached'), launchAppBackgroundDetached: () => calls.push('detached'),
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(calls, ['direct:--sync-window']); assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
}); });
test('mpv pre-app command exits non-zero when socket is not ready', async () => { test('mpv pre-app command exits non-zero when socket is not ready', async () => {
+1 -1
View File
@@ -23,7 +23,7 @@
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap", "build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap", "build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release", "changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs", "changelog:docs": "bun run scripts/build-changelog.ts docs",
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };
const command = packageJson.scripts['build:syncui'] ?? '';
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
+19 -2
View File
@@ -6,6 +6,12 @@ export interface RemoteRunResult {
stderr: string; stderr: string;
} }
export interface RunSshOptions {
batchMode?: boolean;
connectTimeoutSeconds?: number;
timeoutMs?: number;
}
/** /**
* ssh/scp have no `--` terminator for the destination, so a host that starts * ssh/scp have no `--` terminator for the destination, so a host that starts
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those * with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
@@ -22,11 +28,22 @@ export function assertSafeSshHost(host: string): void {
* can still read from the terminal; stdout/stderr are captured for callers * can still read from the terminal; stdout/stderr are captured for callers
* that need actionable remote failure messages. * that need actionable remote failure messages.
*/ */
export function runSsh(host: string, remoteCommand: string): RemoteRunResult { export function runSsh(
host: string,
remoteCommand: string,
options: RunSshOptions = {},
): RemoteRunResult {
assertSafeSshHost(host); assertSafeSshHost(host);
const result = spawnSync('ssh', [host, remoteCommand], { const args: string[] = [];
if (options.batchMode) args.push('-o', 'BatchMode=yes');
if (options.connectTimeoutSeconds !== undefined) {
args.push('-o', `ConnectTimeout=${options.connectTimeoutSeconds}`);
}
args.push(host, remoteCommand);
const result = spawnSync('ssh', args, {
encoding: 'utf8', encoding: 'utf8',
stdio: ['inherit', 'pipe', 'pipe'], stdio: ['inherit', 'pipe', 'pipe'],
timeout: options.timeoutMs,
}); });
if (result.error) { if (result.error) {
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`); throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
+15 -1
View File
@@ -351,11 +351,17 @@ test('runHostSync records host sync results for saved-host bookkeeping', async (
test('runCheckMode --json reports ssh and remote SubMiner status', async () => { test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const lines: string[] = []; const lines: string[] = [];
const sshOptions: unknown[] = [];
const deps = makeDeps({ const deps = makeDeps({
consoleLog: (line) => { consoleLog: (line) => {
lines.push(line); lines.push(line);
}, },
runSsh: (_host, command) => { resolveRemoteSubminerCommand: (host, _preferred, _flavor, runRemote) => {
runRemote!(host, 'subminer --help');
return 'subminer';
},
runSsh: (_host, command, options) => {
sshOptions.push(options);
if (command.includes('--version')) return ok('SubMiner 0.18.0\n'); if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
return ok('subminer-check-ok\n'); return ok('subminer-check-ok\n');
}, },
@@ -380,6 +386,14 @@ test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
assert.equal(check.remoteVersion, 'SubMiner 0.18.0'); assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
assert.equal(check.ok, true); assert.equal(check.ok, true);
assert.equal(check.error, null); assert.equal(check.error, null);
assert.equal(sshOptions.length, 3);
assert.ok(
sshOptions.every(
(options) =>
JSON.stringify(options) ===
JSON.stringify({ batchMode: true, connectTimeoutSeconds: 10, timeoutMs: 15_000 }),
),
);
const failLines: string[] = []; const failLines: string[] = [];
await assert.rejects(() => await assert.rejects(() =>
+18 -6
View File
@@ -1,7 +1,7 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { quoteForRemoteShell } from './ssh'; import { quoteForRemoteShell } from './ssh';
import type { RemoteRunResult, RemoteShellFlavor } from './ssh'; import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events'; import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store'; import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
@@ -45,9 +45,10 @@ export interface SyncFlowDeps {
host: string, host: string,
preferred: string | null, preferred: string | null,
flavor: RemoteShellFlavor, flavor: RemoteShellFlavor,
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
) => string; ) => string;
runScp: (from: string, to: string) => void; runScp: (from: string, to: string) => void;
runSsh: (host: string, remoteCommand: string) => RemoteRunResult; runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
fail: (message: string) => never; fail: (message: string) => never;
log: (level: string, configuredLevel: string, message: string) => void; log: (level: string, configuredLevel: string, message: string) => void;
canConnectUnixSocket: (socketPath: string) => Promise<boolean>; canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
@@ -198,17 +199,28 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps)
let remoteVersion: string | null = null; let remoteVersion: string | null = null;
let error: string | null = null; let error: string | null = null;
const probe = deps.runSsh(host, 'echo subminer-check-ok'); const runCheck = (checkHost: string, command: string) =>
deps.runSsh(checkHost, command, {
batchMode: true,
connectTimeoutSeconds: 10,
timeoutMs: 15_000,
});
const probe = runCheck(host, 'echo subminer-check-ok');
const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok'); const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok');
if (!sshOk) { if (!sshOk) {
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe); error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
} else { } else {
deps.consoleLog('SSH connection: ok'); deps.consoleLog('SSH connection: ok');
try { try {
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh); const flavor = deps.detectRemoteShellFlavor(host, runCheck);
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`); if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor); remoteCommand = deps.resolveRemoteSubminerCommand(
const version = deps.runSsh(host, `${remoteCommand} --version`); host,
args.syncRemoteCmd || null,
flavor,
runCheck,
);
const version = runCheck(host, `${remoteCommand} --version`);
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null; remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
deps.consoleLog( deps.consoleLog(
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`, `Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
+2 -6
View File
@@ -30,7 +30,7 @@ import {
} from './main/runtime/first-run-setup-plugin'; } from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { runSyncCliFromProcess, shouldHandleSyncCliAtEntry } from './main/sync-cli'; import { handleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import { import {
@@ -224,11 +224,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
async function runEntryProcess(): Promise<void> { async function runEntryProcess(): Promise<void> {
// Headless sync CLI: must run first (its own --help/--version handling) and // Headless sync CLI: must run first (its own --help/--version handling) and
// exit before app.whenReady() so it works over SSH with no display server. // exit before app.whenReady() so it works over SSH with no display server.
if (shouldHandleSyncCliAtEntry(process.argv, process.env)) { if (await handleSyncCliAtEntry(process.argv, process.env, app.getVersion())) return;
const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion());
app.exit(exitCode);
return;
}
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) { if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env); const sanitizedEnv = sanitizeHelpEnv(process.env);
+3
View File
@@ -2245,6 +2245,9 @@ const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'), settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow), promoteSettingsWindowAboveOverlay(window as BrowserWindow),
onClosed: () => {
if (appState.initialArgs?.syncWindow) app.quit();
},
log: (message) => logger.error(message), log: (message) => logger.error(message),
}); });
const openSyncUiWindow = () => openSyncUiWindowHandler(); const openSyncUiWindow = () => openSyncUiWindowHandler();
@@ -45,13 +45,14 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
createSettingsWindow: () => created, createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html', settingsHtmlPath: '/tmp/settings.html',
promoteSettingsWindowAboveOverlay: () => calls.push('promote'), promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
onClosed: () => calls.push('on-closed'),
}); });
assert.equal(open(), true); assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']); assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
assert.ok(handlers.closed); assert.ok(handlers.closed);
handlers.closed(); handlers.closed();
assert.equal(calls.at(-1), 'set:null'); assert.deepEqual(calls.slice(-2), ['set:null', 'on-closed']);
}); });
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => { test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
@@ -13,6 +13,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
createSettingsWindow(): TWindow; createSettingsWindow(): TWindow;
settingsHtmlPath: string; settingsHtmlPath: string;
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void; promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
onClosed?: () => void;
log?: (message: string) => void; log?: (message: string) => void;
} }
@@ -42,6 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
deps.setSettingsWindow(window); deps.setSettingsWindow(window);
window.on('closed', () => { window.on('closed', () => {
deps.setSettingsWindow(null); deps.setSettingsWindow(null);
deps.onClosed?.();
}); });
showAndFocus(window); showAndFocus(window);
return true; return true;
@@ -89,6 +89,23 @@ test('runSyncLauncher falls back to stderr when no result event arrives', async
assert.match(result.error ?? '', /boom/); assert.match(result.error ?? '', /boom/);
}); });
test('runSyncLauncher settles when the child exits before its stdio pipes close', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
});
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => { test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => {
const { spawn, children } = makeSpawn(); const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({ const handle = runSyncLauncher({
@@ -105,6 +122,24 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async (
assert.match(result.error ?? '', /cancel/i); assert.match(result.error ?? '', /cancel/i);
}); });
test('runSyncLauncher times out a child that never completes', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.equal(children[0]!.killed, true);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
});
test('runSyncLauncher surfaces spawn errors', async () => { test('runSyncLauncher surfaces spawn errors', async () => {
const { spawn, children } = makeSpawn(); const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({ const handle = runSyncLauncher({
+46 -31
View File
@@ -9,6 +9,7 @@ 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;
on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown; on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'exit', listener: (code: number | null, signal: string | null) => void): unknown;
on(event: 'error', listener: (error: Error) => void): unknown; on(event: 'error', listener: (error: Error) => void): unknown;
kill(signal?: NodeJS.Signals): boolean; kill(signal?: NodeJS.Signals): boolean;
} }
@@ -54,6 +55,7 @@ export function runSyncLauncher(options: {
onEvent: (event: SyncProgressEvent) => void; onEvent: (event: SyncProgressEvent) => void;
onStderr?: (text: string) => void; onStderr?: (text: string) => void;
spawn?: SyncLauncherSpawn; spawn?: SyncLauncherSpawn;
timeoutMs?: number;
}): SyncLauncherRunHandle { }): SyncLauncherRunHandle {
const spawn = const spawn =
options.spawn ?? options.spawn ??
@@ -70,7 +72,7 @@ export function runSyncLauncher(options: {
let stdoutBuffer = ''; let stdoutBuffer = '';
let stderrTail = ''; let stderrTail = '';
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null; let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
let cancelled = false; let terminationError: string | null = null;
child.stdout?.on('data', (chunk) => { child.stdout?.on('data', (chunk) => {
stdoutBuffer += chunk.toString(); stdoutBuffer += chunk.toString();
@@ -92,19 +94,29 @@ export function runSyncLauncher(options: {
options.onStderr?.(text); options.onStderr?.(text);
}); });
let killTimer: ReturnType<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = (): void => {
if (killTimer !== null) clearTimeout(killTimer);
if (operationTimer !== null) clearTimeout(operationTimer);
killTimer = null;
operationTimer = null;
};
const done = new Promise<SyncLauncherRunResult>((resolve) => { const done = new Promise<SyncLauncherRunResult>((resolve) => {
let settled = false; let settled = false;
const settle = (result: SyncLauncherRunResult): void => { const settle = (result: SyncLauncherRunResult): void => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimers();
resolve(result); resolve(result);
}; };
child.on('error', (error) => { child.on('error', (error) => {
settle({ ok: false, error: error.message }); settle({ ok: false, error: error.message });
}); });
child.on('close', (code) => { const settleFromExit = (code: number | null) => {
if (cancelled) { if (terminationError) {
settle({ ok: false, error: 'Sync cancelled.' }); settle({ ok: false, error: terminationError });
return; return;
} }
if (code === 0) { if (code === 0) {
@@ -114,39 +126,42 @@ export function runSyncLauncher(options: {
const error = const error =
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`); resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
settle({ ok: false, error }); settle({ ok: false, error });
}); };
// Electron descendants can retain inherited stdio pipes after the main
// child exits, delaying `close` indefinitely. `exit` is authoritative for
// process completion; keep `close` as a fallback for test/process shims.
child.on('exit', settleFromExit);
child.on('close', settleFromExit);
}); });
// A sync child blocked on an ssh password prompt ignores SIGTERM, so escalate // A sync child blocked on an ssh password prompt may ignore SIGTERM, so
// to SIGKILL if it is still alive after a grace period. The timer is cleared // escalate to SIGKILL after a grace period.
// on close so a cancelled run cannot hold the process open. const terminate = (error: string): void => {
let killTimer: ReturnType<typeof setTimeout> | null = null; if (terminationError) return;
const clearKillTimer = (): void => { terminationError = error;
if (killTimer === null) return; killTimer = setTimeout(() => {
clearTimeout(killTimer); killTimer = null;
killTimer = null;
};
child.on('close', clearKillTimer);
return {
cancel: () => {
if (cancelled) return;
cancelled = true;
try { try {
child.kill('SIGTERM'); child.kill('SIGKILL');
} catch { } catch {
// process may already be gone // process may already be gone
} }
killTimer = setTimeout(() => { }, CANCEL_GRACE_MS);
killTimer = null; killTimer.unref?.();
try { try {
child.kill('SIGKILL'); child.kill('SIGTERM');
} catch { } catch {
// process may already be gone // process may already be gone
} }
}, CANCEL_GRACE_MS); };
killTimer.unref?.();
}, if (options.timeoutMs !== undefined) {
operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs);
operationTimer.unref?.();
}
return {
cancel: () => terminate('Sync cancelled.'),
done, done,
}; };
} }
+3
View File
@@ -9,6 +9,7 @@ import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
interface LauncherCall { interface LauncherCall {
args: string[]; args: string[];
timeoutMs: number | undefined;
onEvent: (event: SyncProgressEvent) => void; onEvent: (event: SyncProgressEvent) => void;
finish: (result: { ok: boolean; error: string | null }) => void; finish: (result: { ok: boolean; error: string | null }) => void;
cancelled: boolean; cancelled: boolean;
@@ -42,6 +43,7 @@ function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
}); });
const call: LauncherCall = { const call: LauncherCall = {
args: options.args, args: options.args,
timeoutMs: options.timeoutMs,
onEvent: options.onEvent, onEvent: options.onEvent,
finish, finish,
cancelled: false, cancelled: false,
@@ -216,6 +218,7 @@ test('check-host resolves with the check-result event', () =>
}>; }>;
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']); assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
assert.equal(launcherCalls[0]!.timeoutMs, 30_000);
launcherCalls[0]!.onEvent({ launcherCalls[0]!.onEvent({
type: 'check-result', type: 'check-result',
host: 'media-box', host: 'media-box',
+1
View File
@@ -252,6 +252,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
const handle = deps.runLauncher({ const handle = deps.runLauncher({
command: resolution.command!, command: resolution.command!,
args: ['sync', trimmed, '--check', '--json'], args: ['sync', trimmed, '--check', '--json'],
timeoutMs: 30_000,
onEvent: (event) => { onEvent: (event) => {
if (event.type === 'check-result') { if (event.type === 'check-result') {
checkResult = { checkResult = {
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { handleSyncCliAtEntry } from './sync-cli';
test('handleSyncCliAtEntry exits through the process exit dependency', async () => {
const exits: number[] = [];
const handled = await handleSyncCliAtEntry(
['SubMiner', '--sync-cli', 'sync', '--make-temp'],
{},
'0.18.0',
{
run: async () => 7,
exit: (code) => {
exits.push(code);
},
},
);
assert.equal(handled, true);
assert.deepEqual(exits, [7]);
});
test('handleSyncCliAtEntry ignores normal GUI startup', async () => {
const handled = await handleSyncCliAtEntry(['SubMiner', '--start'], {}, '0.18.0', {
run: async () => {
throw new Error('should not run');
},
exit: () => {
throw new Error('should not exit');
},
});
assert.equal(handled, false);
});
+31 -6
View File
@@ -10,10 +10,7 @@ import {
parseSyncCliTokens, parseSyncCliTokens,
syncCliUsage, syncCliUsage,
} from '../core/services/stats-sync/cli-args'; } from '../core/services/stats-sync/cli-args';
import { import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
createDbSnapshot,
findLiveStatsDaemonPid,
} from '../core/services/stats-sync/shared';
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import { import {
assertSafeSshHost, assertSafeSshHost,
@@ -28,9 +25,17 @@ import {
runSyncFlow, runSyncFlow,
type SyncFlowDeps, type SyncFlowDeps,
} from '../core/services/stats-sync/sync-flow'; } from '../core/services/stats-sync/sync-flow';
import { recordSyncResult, readSyncHostsState, writeSyncHostsState, getSyncHostsPath } from '../shared/sync/sync-hosts-store'; import {
recordSyncResult,
readSyncHostsState,
writeSyncHostsState,
getSyncHostsPath,
} from '../shared/sync/sync-hosts-store';
export function shouldHandleSyncCliAtEntry(argv: readonly string[], env: NodeJS.ProcessEnv): boolean { export function shouldHandleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false; if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return extractSyncCliTokens(argv) !== null; return extractSyncCliTokens(argv) !== null;
} }
@@ -188,3 +193,23 @@ export async function runSyncCliFromProcess(
return 1; return 1;
} }
} }
export async function handleSyncCliAtEntry(
argv: readonly string[],
env: NodeJS.ProcessEnv,
appVersion: string,
deps: {
run: typeof runSyncCliFromProcess;
exit: (code: number) => void;
} = {
run: runSyncCliFromProcess,
exit: (code) => process.exit(code),
},
): Promise<boolean> {
if (!shouldHandleSyncCliAtEntry(argv, env)) return false;
const exitCode = await deps.run(argv, appVersion);
// This path runs before app readiness and must not call app.exit(): on Linux
// that can throw and make the entrypoint fall back to full GUI startup.
deps.exit(exitCode);
return true;
}