fix(sync): settle launcher client only after terminal NDJSON on exit

- `runSyncLauncher`: defer exit settlement until result event parsed; handle exit-before-stdout race
- AppImage sync commands now run in Node-only mode (`ELECTRON_RUN_AS_NODE=1 -e ...`) so remote sync needs no display server
- Update docs and changelog to reflect both changes
This commit is contained in:
2026-07-12 20:11:14 -07:00
parent 5d8673f299
commit 344a8b44c0
6 changed files with 99 additions and 7 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ 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. `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).
- 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, without dropping terminal progress that races process exit. Linux AppImage sync commands now run the bundled engine in Node-only mode and exit directly, so remote snapshots never initialize the GUI or require a display server. Added `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
+1 -1
View File
@@ -114,7 +114,7 @@ Unfinished sessions (a crash mid-playback) are skipped until the app finalizes t
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically.
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically and runs AppImages in Node-only mode, so remote sync does not require a graphical session.
### Sync window
+31
View File
@@ -119,6 +119,37 @@ test('runAppCommandCaptureOutput transports Linux AppImage args through environm
}
});
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'SubMiner.AppImage');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
'printf "args:%s\\n" "$*"',
'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
try {
const result = withPlatform('linux', () =>
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
);
assert.equal(result.status, 0);
assert.match(result.stdout, /^args:-e /m);
assert.match(result.stdout, /^electron-node:1$/m);
assert.match(result.stdout, /^argc:4$/m);
assert.match(result.stdout, /^arg0:--sync-cli$/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
+19
View File
@@ -1277,6 +1277,15 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean {
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
}
const APPIMAGE_SYNC_NODE_RUNNER = [
'const root=process.env.APPDIR+"/resources/app.asar";',
'const {runSyncCliFromProcess}=require(root+"/dist/main/sync-cli.js");',
'const count=Number(process.env.SUBMINER_APP_ARGC);',
'const argv=[process.execPath,...Array.from({length:count},(_,i)=>process.env["SUBMINER_APP_ARG_"+i]??"")];',
'runSyncCliFromProcess(argv,require(root+"/package.json").version)',
'.then(code=>process.exit(code),error=>{console.error(error);process.exit(1)});',
].join('');
function buildAppEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
extraEnv: NodeJS.ProcessEnv = {},
@@ -1432,6 +1441,16 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
if (shouldTransportAppArgsForAppImage(appPath)) {
if (appArgs[0] === '--sync-cli') {
return {
command: appPath,
args: ['-e', APPIMAGE_SYNC_NODE_RUNNER],
env: {
...buildTransportedAppArgsEnv(appArgs),
ELECTRON_RUN_AS_NODE: '1',
},
};
}
return {
command: appPath,
args: [],
+30 -1
View File
@@ -89,7 +89,7 @@ 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 () => {
test('runSyncLauncher settles on exit when the terminal event is already parsed', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
@@ -97,6 +97,10 @@ test('runSyncLauncher settles when the child exits before its stdio pipes close'
onEvent: () => {},
spawn,
});
children[0]!.stdout.emit(
'data',
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
);
children[0]!.emit('exit', 0, null);
const result = await Promise.race([
@@ -106,6 +110,31 @@ test('runSyncLauncher settles when the child exits before its stdio pipes close'
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher waits for terminal NDJSON when exit precedes final stdout data', async () => {
const { spawn, children } = makeSpawn();
const events: SyncProgressEvent[] = [];
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: (event) => events.push(event),
spawn,
});
const child = children[0]!;
let settled = false;
void handle.done.then(() => {
settled = true;
});
child.emit('exit', 0, null);
await Promise.resolve();
assert.equal(settled, false);
child.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
assert.deepEqual(await handle.done, { ok: true, error: null });
assert.deepEqual(events.at(-1), { type: 'result', ok: true, error: null });
});
test('runSyncLauncher cancel kills the child and resolves as cancelled', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
+17 -4
View File
@@ -73,6 +73,7 @@ export function runSyncLauncher(options: {
let stderrTail = '';
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
let terminationError: string | null = null;
let settleAfterTerminalEvent: (() => void) | null = null;
child.stdout?.on('data', (chunk) => {
stdoutBuffer += chunk.toString();
@@ -82,7 +83,10 @@ export function runSyncLauncher(options: {
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
const event = parseSyncProgressLine(line);
if (event) {
if (event.type === 'result') resultEvent = event;
if (event.type === 'result') {
resultEvent = event;
settleAfterTerminalEvent?.();
}
options.onEvent(event);
}
newlineIndex = stdoutBuffer.indexOf('\n');
@@ -105,6 +109,8 @@ export function runSyncLauncher(options: {
const done = new Promise<SyncLauncherRunResult>((resolve) => {
let settled = false;
let exitObserved = false;
let exitCode: number | null = null;
const settle = (result: SyncLauncherRunResult): void => {
if (settled) return;
settled = true;
@@ -127,10 +133,17 @@ export function runSyncLauncher(options: {
resultEvent?.error ?? (stderrTail.trim() || `Launcher exited with code ${code ?? 'null'}.`);
settle({ ok: false, error });
};
settleAfterTerminalEvent = () => {
if (exitObserved) settleFromExit(exitCode);
};
// 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 exits, delaying `close` indefinitely. Once terminal NDJSON has
// arrived, `exit` is authoritative; keep `close` as the fallback.
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent) settleFromExit(code);
});
child.on('close', settleFromExit);
});