fix(mpv): recover from stalled IPC connects (#204)

This commit is contained in:
2026-08-16 22:58:28 -07:00
committed by GitHub
parent e11a5fea0d
commit 00b1b79bf4
8 changed files with 342 additions and 3 deletions
+127
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
MpvIpcClient,
MpvIpcClientDeps,
@@ -23,6 +24,18 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
};
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for MPV retry connection');
}
await wait(10);
}
}
function captureWarnLogs(run: () => void): string[] {
const originalWarn = console.warn;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
@@ -756,3 +769,117 @@ test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playi
assert.equal((client as any).pendingPauseAtSubEnd, true);
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
});
class HangingTestSocket extends EventEmitter {
public connectedPaths: string[] = [];
public destroyed = false;
connect(path: string): void {
this.connectedPaths.push(path);
// Never resolves: models a stalled named-pipe dial.
}
write(): boolean {
return true;
}
destroy(): void {
this.destroyed = true;
}
}
class RetryTestSocket extends EventEmitter {
public connectedPaths: string[] = [];
public destroyed = false;
constructor(private readonly shouldConnect: boolean) {
super();
}
connect(path: string): void {
this.connectedPaths.push(path);
if (this.shouldConnect) {
setTimeout(() => this.emit('connect'), 0);
}
}
write(): boolean {
return true;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.emit('close');
}
}
test('MpvIpcClient automatically retries the same socket path after a connect timeout', async () => {
const sockets: RetryTestSocket[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
connectTimeoutMs: 5,
getReconnectTimer: () => reconnectTimer,
setReconnectTimer: (timer) => {
reconnectTimer = timer;
},
socketFactory: () => {
const socket = new RetryTestSocket(sockets.length > 0);
sockets.push(socket);
return socket as unknown as import('node:net').Socket;
},
}),
);
process.env.SUBMINER_LOG_LEVEL = 'error';
try {
client.connect();
await waitFor(() => client.connected);
assert.equal(sockets.length, 2);
assert.equal(sockets[0]!.destroyed, true);
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv.sock');
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
assert.equal(client.connected, true);
} finally {
if (originalLogLevel === undefined) {
delete process.env.SUBMINER_LOG_LEVEL;
} else {
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
}
if (reconnectTimer) clearTimeout(reconnectTimer);
(client as any).transport.shutdown();
}
});
test('MpvIpcClient.setSocketPath aborts an in-flight connect so the next dial targets the new path', () => {
const sockets: HangingTestSocket[] = [];
const client = new MpvIpcClient(
'/tmp/mpv-old.sock',
makeDeps({
socketFactory: () => {
const socket = new HangingTestSocket();
sockets.push(socket);
return socket as unknown as import('node:net').Socket;
},
}),
);
client.connect();
assert.equal(sockets.length, 1);
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv-old.sock');
assert.equal((client as any).connecting, true);
client.setSocketPath('/tmp/mpv-new.sock');
assert.equal((client as any).connecting, false);
assert.equal(sockets[0]!.destroyed, true);
client.connect();
assert.equal(sockets.length, 2);
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv-new.sock');
(client as any).transport.shutdown();
});