fix(sync): decode multibyte output correctly and settle on exit drain

- Use StringDecoder to avoid corrupting multibyte chars (e.g. Japanese titles) split across stdout/stderr chunks
- Settle a run within EXIT_DRAIN_MS (2s) after child exit when no terminal NDJSON arrives, instead of waiting on `close` that retained pipes can withhold indefinitely
This commit is contained in:
2026-07-13 00:09:11 -07:00
parent 8c97b48721
commit 4c63f7e3b0
3 changed files with 81 additions and 4 deletions
+2 -1
View File
@@ -2,4 +2,5 @@ type: fixed
area: sync
- Fixed the app staying alive in the background after closing a standalone Sync window (`subminer sync --ui`) on macOS. The quit re-issued after async will-quit cleanup was dropped by Electron when the cleanup settled within the same tick as the will-quit dispatch; the re-quit now runs on a fresh macrotask, and the standalone Sync window close path uses the same forced-exit fallback as SIGTERM. Windows opened from the tray menu of a running app are unaffected: closing them still leaves the app running.
- Fixed cancelling or timing out a sync run hanging when the sync child had already exited without emitting a terminal result: the run waited on `close`, which a descendant holding the inherited stdio pipes can delay indefinitely, stalling the quit-time sync shutdown. Cancellation now settles the run immediately once the child has exited.
- Fixed a sync run hanging when the sync child exited without emitting a terminal result: the run waited on `close`, which a descendant holding the inherited stdio pipes can delay indefinitely, stalling the quit-time sync shutdown. Cancelling now settles the run as soon as the child has exited, and an exit with no result settles after a bounded stdout drain window.
- Fixed sync progress and error text being corrupted when a multibyte character (e.g. a Japanese media title) straddled a chunk boundary in the sync child's output.
@@ -55,6 +55,52 @@ test('runSyncLauncher parses NDJSON events across chunk boundaries', async () =>
assert.deepEqual(result, { ok: true, error: null });
});
test('runSyncLauncher preserves multibyte characters split across chunks', 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]!;
const line = Buffer.from('{"type":"result","ok":false,"error":"進捗の同期に失敗"}\n');
// Split mid-character: the boundary lands inside a multibyte code point.
const boundary = line.indexOf(Buffer.from('進')) + 1;
child.stdout.emit('data', line.subarray(0, boundary));
child.stdout.emit('data', line.subarray(boundary));
child.emit('close', 1, null);
const result = await handle.done;
assert.deepEqual(events.at(-1), {
type: 'result',
ok: false,
error: '進捗の同期に失敗',
});
assert.equal(result.error, '進捗の同期に失敗');
});
test('runSyncLauncher settles after exit when close never arrives', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
child.stderr.emit('data', Buffer.from('[ERROR] remote refused\n'));
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never fires.
child.emit('exit', 1, null);
const result = await handle.done;
assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/);
});
test('runSyncLauncher reports failures with the result event error or stderr tail', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
@@ -213,6 +259,7 @@ test('runSyncLauncher times out a child that already exited without a result', a
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that never completes', async () => {
+32 -3
View File
@@ -1,10 +1,17 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
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;
/**
* How long a child that exited without terminal NDJSON gets to flush its
* remaining stdout before the exit is treated as authoritative.
*/
const EXIT_DRAIN_MS = 2000;
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;
@@ -77,8 +84,16 @@ export function runSyncLauncher(options: {
let settleAfterTerminalEvent: (() => void) | null = null;
let settleAfterTermination: (() => boolean) | null = null;
// Decode incrementally: a multibyte character can straddle a chunk boundary,
// and a per-chunk toString() would corrupt it (sync payloads carry Japanese
// media titles and error detail).
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
const decodeChunk = (decoder: StringDecoder, chunk: Buffer | string): string =>
typeof chunk === 'string' ? chunk : decoder.write(chunk);
child.stdout?.on('data', (chunk) => {
stdoutBuffer += chunk.toString();
stdoutBuffer += decodeChunk(stdoutDecoder, chunk);
let newlineIndex = stdoutBuffer.indexOf('\n');
while (newlineIndex !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex);
@@ -95,18 +110,21 @@ export function runSyncLauncher(options: {
}
});
child.stderr?.on('data', (chunk) => {
const text = chunk.toString();
const text = decodeChunk(stderrDecoder, chunk);
stderrTail = `${stderrTail}${text}`.slice(-4000);
options.onStderr?.(text);
});
let killTimer: ReturnType<typeof setTimeout> | null = null;
let operationTimer: ReturnType<typeof setTimeout> | null = null;
let drainTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = (): void => {
if (killTimer !== null) clearTimeout(killTimer);
if (operationTimer !== null) clearTimeout(operationTimer);
if (drainTimer !== null) clearTimeout(drainTimer);
killTimer = null;
operationTimer = null;
drainTimer = null;
};
const done = new Promise<SyncLauncherRunResult>((resolve) => {
@@ -149,7 +167,18 @@ export function runSyncLauncher(options: {
child.on('exit', (code) => {
exitObserved = true;
exitCode = code;
if (resultEvent || terminationError) settleFromExit(code);
if (resultEvent || terminationError) {
settleFromExit(code);
return;
}
// No terminal event yet: stdout may still be flushing, so give it a
// bounded window rather than waiting on a `close` that retained pipes
// can withhold forever.
drainTimer = setTimeout(() => {
drainTimer = null;
settleFromExit(code);
}, EXIT_DRAIN_MS);
drainTimer.unref?.();
});
child.on('close', settleFromExit);
});