mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -1,5 +1,5 @@
|
||||
type: added
|
||||
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 `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 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, 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).
|
||||
|
||||
@@ -118,7 +118,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
|
||||
|
||||
### 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.
|
||||
- **Add a device** — test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
launchAppCommandDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchTexthookerOnly,
|
||||
runAppCommandInteractive,
|
||||
runAppCommandWithInherit,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type AppCommandDeps = {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void;
|
||||
launchSyncUiDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAppBackgroundDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
@@ -17,7 +20,8 @@ type AppCommandDeps = {
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
runAppCommandWithInherit,
|
||||
runAppCommandInteractive,
|
||||
launchSyncUiDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
||||
launchAppBackgroundDetached,
|
||||
};
|
||||
|
||||
@@ -34,7 +38,7 @@ export function runAppPassthroughCommand(
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.runAppCommandInteractive(appPath, ['--sync-window']);
|
||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
|
||||
@@ -206,7 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -226,7 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -247,7 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
runAppCommandInteractive: () => detached.push('interactive'),
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -258,19 +258,19 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
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();
|
||||
context.args.syncUi = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
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'),
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
+1
-1
@@ -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: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: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:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -6,6 +6,12 @@ export interface RemoteRunResult {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface RunSshOptions {
|
||||
batchMode?: boolean;
|
||||
connectTimeoutSeconds?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ssh/scp have no `--` terminator for the destination, so a host that starts
|
||||
* 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
|
||||
* 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);
|
||||
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',
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
|
||||
|
||||
@@ -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 () => {
|
||||
const lines: string[] = [];
|
||||
const sshOptions: unknown[] = [];
|
||||
const deps = makeDeps({
|
||||
consoleLog: (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');
|
||||
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.ok, true);
|
||||
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[] = [];
|
||||
await assert.rejects(() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
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 { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
|
||||
|
||||
@@ -45,9 +45,10 @@ export interface SyncFlowDeps {
|
||||
host: string,
|
||||
preferred: string | null,
|
||||
flavor: RemoteShellFlavor,
|
||||
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
|
||||
) => string;
|
||||
runScp: (from: string, to: string) => void;
|
||||
runSsh: (host: string, remoteCommand: string) => RemoteRunResult;
|
||||
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
|
||||
fail: (message: string) => never;
|
||||
log: (level: string, configuredLevel: string, message: string) => void;
|
||||
canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
|
||||
@@ -198,17 +199,28 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps)
|
||||
let remoteVersion: 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');
|
||||
if (!sshOk) {
|
||||
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
|
||||
} else {
|
||||
deps.consoleLog('SSH connection: ok');
|
||||
try {
|
||||
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
|
||||
const flavor = deps.detectRemoteShellFlavor(host, runCheck);
|
||||
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
|
||||
remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
|
||||
const version = deps.runSsh(host, `${remoteCommand} --version`);
|
||||
remoteCommand = deps.resolveRemoteSubminerCommand(
|
||||
host,
|
||||
args.syncRemoteCmd || null,
|
||||
flavor,
|
||||
runCheck,
|
||||
);
|
||||
const version = runCheck(host, `${remoteCommand} --version`);
|
||||
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
|
||||
deps.consoleLog(
|
||||
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
|
||||
|
||||
+2
-6
@@ -30,7 +30,7 @@ import {
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
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 { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
@@ -224,11 +224,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
async function runEntryProcess(): Promise<void> {
|
||||
// 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.
|
||||
if (shouldHandleSyncCliAtEntry(process.argv, process.env)) {
|
||||
const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion());
|
||||
app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await handleSyncCliAtEntry(process.argv, process.env, app.getVersion())) return;
|
||||
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
|
||||
@@ -2245,6 +2245,9 @@ const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({
|
||||
settingsHtmlPath: path.join(__dirname, 'syncui', 'index.html'),
|
||||
promoteSettingsWindowAboveOverlay: (window) =>
|
||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||
onClosed: () => {
|
||||
if (appState.initialArgs?.syncWindow) app.quit();
|
||||
},
|
||||
log: (message) => logger.error(message),
|
||||
});
|
||||
const openSyncUiWindow = () => openSyncUiWindowHandler();
|
||||
|
||||
@@ -45,13 +45,14 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
|
||||
createSettingsWindow: () => created,
|
||||
settingsHtmlPath: '/tmp/settings.html',
|
||||
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||
onClosed: () => calls.push('on-closed'),
|
||||
});
|
||||
|
||||
assert.equal(open(), true);
|
||||
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
|
||||
assert.ok(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 () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
|
||||
createSettingsWindow(): TWindow;
|
||||
settingsHtmlPath: string;
|
||||
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
||||
onClosed?: () => void;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
||||
deps.setSettingsWindow(window);
|
||||
window.on('closed', () => {
|
||||
deps.setSettingsWindow(null);
|
||||
deps.onClosed?.();
|
||||
});
|
||||
showAndFocus(window);
|
||||
return true;
|
||||
|
||||
@@ -89,6 +89,23 @@ test('runSyncLauncher falls back to stderr when no result event arrives', async
|
||||
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 () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
@@ -105,6 +122,24 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async (
|
||||
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 () => {
|
||||
const { spawn, children } = makeSpawn();
|
||||
const handle = runSyncLauncher({
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
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;
|
||||
kill(signal?: NodeJS.Signals): boolean;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export function runSyncLauncher(options: {
|
||||
onEvent: (event: SyncProgressEvent) => void;
|
||||
onStderr?: (text: string) => void;
|
||||
spawn?: SyncLauncherSpawn;
|
||||
timeoutMs?: number;
|
||||
}): SyncLauncherRunHandle {
|
||||
const spawn =
|
||||
options.spawn ??
|
||||
@@ -70,7 +72,7 @@ export function runSyncLauncher(options: {
|
||||
let stdoutBuffer = '';
|
||||
let stderrTail = '';
|
||||
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
|
||||
let cancelled = false;
|
||||
let terminationError: string | null = null;
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
@@ -92,19 +94,29 @@ export function runSyncLauncher(options: {
|
||||
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) => {
|
||||
let settled = false;
|
||||
const settle = (result: SyncLauncherRunResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
resolve(result);
|
||||
};
|
||||
child.on('error', (error) => {
|
||||
settle({ ok: false, error: error.message });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (cancelled) {
|
||||
settle({ ok: false, error: 'Sync cancelled.' });
|
||||
const settleFromExit = (code: number | null) => {
|
||||
if (terminationError) {
|
||||
settle({ ok: false, error: terminationError });
|
||||
return;
|
||||
}
|
||||
if (code === 0) {
|
||||
@@ -114,39 +126,42 @@ export function runSyncLauncher(options: {
|
||||
const error =
|
||||
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
|
||||
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
|
||||
// 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;
|
||||
// A sync child blocked on an ssh password prompt may ignore SIGTERM, so
|
||||
// escalate to SIGKILL after a grace period.
|
||||
const terminate = (error: string): void => {
|
||||
if (terminationError) return;
|
||||
terminationError = error;
|
||||
killTimer = setTimeout(() => {
|
||||
killTimer = null;
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
child.kill('SIGKILL');
|
||||
} 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?.();
|
||||
},
|
||||
}, CANCEL_GRACE_MS);
|
||||
killTimer.unref?.();
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
};
|
||||
|
||||
if (options.timeoutMs !== undefined) {
|
||||
operationTimer = setTimeout(() => terminate('Sync operation timed out.'), options.timeoutMs);
|
||||
operationTimer.unref?.();
|
||||
}
|
||||
|
||||
return {
|
||||
cancel: () => terminate('Sync cancelled.'),
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createSyncUiRuntime, type SyncUiRuntimeDeps } from './sync-ui-runtime';
|
||||
|
||||
interface LauncherCall {
|
||||
args: string[];
|
||||
timeoutMs: number | undefined;
|
||||
onEvent: (event: SyncProgressEvent) => void;
|
||||
finish: (result: { ok: boolean; error: string | null }) => void;
|
||||
cancelled: boolean;
|
||||
@@ -42,6 +43,7 @@ function makeTestRig(root: string, overrides: Partial<SyncUiRuntimeDeps> = {}) {
|
||||
});
|
||||
const call: LauncherCall = {
|
||||
args: options.args,
|
||||
timeoutMs: options.timeoutMs,
|
||||
onEvent: options.onEvent,
|
||||
finish,
|
||||
cancelled: false,
|
||||
@@ -216,6 +218,7 @@ test('check-host resolves with the check-result event', () =>
|
||||
}>;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--check', '--json']);
|
||||
assert.equal(launcherCalls[0]!.timeoutMs, 30_000);
|
||||
launcherCalls[0]!.onEvent({
|
||||
type: 'check-result',
|
||||
host: 'media-box',
|
||||
|
||||
@@ -252,6 +252,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
const handle = deps.runLauncher({
|
||||
command: resolution.command!,
|
||||
args: ['sync', trimmed, '--check', '--json'],
|
||||
timeoutMs: 30_000,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'check-result') {
|
||||
checkResult = {
|
||||
|
||||
@@ -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
@@ -10,10 +10,7 @@ import {
|
||||
parseSyncCliTokens,
|
||||
syncCliUsage,
|
||||
} from '../core/services/stats-sync/cli-args';
|
||||
import {
|
||||
createDbSnapshot,
|
||||
findLiveStatsDaemonPid,
|
||||
} from '../core/services/stats-sync/shared';
|
||||
import { createDbSnapshot, findLiveStatsDaemonPid } from '../core/services/stats-sync/shared';
|
||||
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
@@ -28,9 +25,17 @@ import {
|
||||
runSyncFlow,
|
||||
type SyncFlowDeps,
|
||||
} 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;
|
||||
return extractSyncCliTokens(argv) !== null;
|
||||
}
|
||||
@@ -188,3 +193,23 @@ export async function runSyncCliFromProcess(
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user