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
+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);
});