mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
thread launcher config dir through app control and overlay calls
- startOverlay and isRunningAppControlServerAvailable accept explicit configDir to avoid re-resolving from env mid-flight - emit connection-change on reconnect when previously connected - handle errored client sockets in app control server with logWarn and destroy
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state } from '../mpv.js';
|
||||||
@@ -271,6 +274,68 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
|
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
let availabilityConfigDir: string | undefined;
|
||||||
|
let overlayConfigDir: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||||
|
overlayConfigDir = configDir;
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||||
|
availabilityConfigDir = configDir;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||||
|
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||||
|
} finally {
|
||||||
|
if (originalXdgConfigHome === undefined) {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args = {
|
context.args = {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
|||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
function getLauncherConfigDir(): string {
|
||||||
|
return getDefaultConfigDir({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function checkDependencies(args: Args): void {
|
function checkDependencies(args: Args): void {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
@@ -100,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!appPath) return;
|
if (!appPath) return;
|
||||||
|
|
||||||
const configDir = getDefaultConfigDir({
|
const configDir = getLauncherConfigDir();
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
});
|
|
||||||
const statePath = getSetupStatePath(configDir);
|
const statePath = getSetupStatePath(configDir);
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
@@ -166,7 +170,7 @@ type PlaybackCommandDeps = {
|
|||||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
startOverlay: typeof startOverlay;
|
startOverlay: typeof startOverlay;
|
||||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||||
isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise<boolean>;
|
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
|
||||||
log: typeof log;
|
log: typeof log;
|
||||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||||
getMpvProc: () => typeof state.mpvProc;
|
getMpvProc: () => typeof state.mpvProc;
|
||||||
@@ -211,6 +215,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||||
const youtubeMode = args.youtubeMode ?? 'download';
|
const youtubeMode = args.youtubeMode ?? 'download';
|
||||||
|
const configDir = getLauncherConfigDir();
|
||||||
|
|
||||||
if (isYoutubeUrl) {
|
if (isYoutubeUrl) {
|
||||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||||
@@ -222,7 +227,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
!args.startOverlay &&
|
!args.startOverlay &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
!isAppOwnedYoutubeFlow &&
|
!isAppOwnedYoutubeFlow &&
|
||||||
((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false);
|
((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false);
|
||||||
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||||
? { ...pluginRuntimeConfig, autoStart: false }
|
? { ...pluginRuntimeConfig, autoStart: false }
|
||||||
: pluginRuntimeConfig;
|
: pluginRuntimeConfig;
|
||||||
@@ -287,7 +292,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
|
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
|
|||||||
@@ -313,7 +313,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||||
if (action === 'path') parsed.configPath = true;
|
if (action === 'path') parsed.configPath = true;
|
||||||
else if (action === 'show') parsed.configShow = true;
|
else if (action === 'show') parsed.configShow = true;
|
||||||
else fail(`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`);
|
else
|
||||||
|
fail(
|
||||||
|
`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invocations.settingsInvocation) {
|
if (invocations.settingsInvocation) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
|
import { getAppControlSocketPath } from '../src/shared/app-control';
|
||||||
import {
|
import {
|
||||||
buildConfiguredMpvDefaultArgs,
|
buildConfiguredMpvDefaultArgs,
|
||||||
buildMpvBackendArgs,
|
buildMpvBackendArgs,
|
||||||
@@ -826,6 +827,87 @@ test('startOverlay attaches through the running app control socket without spawn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startOverlay uses caller config dir for app control socket discovery', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const configDir = path.join(dir, 'launcher-config');
|
||||||
|
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
|
const receivedControlArgv: string[][] = [];
|
||||||
|
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||||
|
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const mpvServer = net.createServer((socket) => socket.end());
|
||||||
|
const controlServer = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
const newlineIndex = buffer.indexOf('\n');
|
||||||
|
if (newlineIndex < 0) return;
|
||||||
|
const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
|
||||||
|
if (Array.isArray(payload.argv)) {
|
||||||
|
receivedControlArgv.push(
|
||||||
|
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
mpvServer.once('error', reject);
|
||||||
|
mpvServer.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
controlServer.once('error', reject);
|
||||||
|
controlServer.listen(controlSocketPath, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath, [], configDir);
|
||||||
|
|
||||||
|
const invocationText = fs.existsSync(appInvocationsPath)
|
||||||
|
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||||
|
: '';
|
||||||
|
assert.equal(invocationText, '');
|
||||||
|
assert.equal(receivedControlArgv.length, 1);
|
||||||
|
assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [
|
||||||
|
'--start',
|
||||||
|
'--managed-playback',
|
||||||
|
'--backend',
|
||||||
|
'x11',
|
||||||
|
'--socket',
|
||||||
|
socketPath,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (originalControlSocket === undefined) {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
state.appPath = '';
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('startOverlay falls back to legacy app startup when control command fails', async () => {
|
test('startOverlay falls back to legacy app startup when control command fails', async () => {
|
||||||
if (process.platform === 'win32') return;
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1006,6 +1006,7 @@ export async function startOverlay(
|
|||||||
args: Args,
|
args: Args,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
extraAppArgs: string[] = [],
|
extraAppArgs: string[] = [],
|
||||||
|
configDir: string = getLauncherConfigDir(),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const backend = detectBackend(args.backend);
|
const backend = detectBackend(args.backend);
|
||||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||||
@@ -1024,7 +1025,7 @@ export async function startOverlay(
|
|||||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||||
|
|
||||||
const controlResult = await sendAppControlCommand(overlayArgs, {
|
const controlResult = await sendAppControlCommand(overlayArgs, {
|
||||||
configDir: getLauncherConfigDir(),
|
configDir,
|
||||||
});
|
});
|
||||||
if (controlResult.ok) {
|
if (controlResult.ok) {
|
||||||
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
|
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
|
||||||
@@ -1107,9 +1108,12 @@ function getLauncherConfigDir(): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise<boolean> {
|
export async function isRunningAppControlServerAvailable(
|
||||||
|
logLevel: LogLevel,
|
||||||
|
configDir: string = getLauncherConfigDir(),
|
||||||
|
): Promise<boolean> {
|
||||||
const available = await checkAppControlServerAvailable({
|
const available = await checkAppControlServerAvailable({
|
||||||
configDir: getLauncherConfigDir(),
|
configDir,
|
||||||
});
|
});
|
||||||
if (available) {
|
if (available) {
|
||||||
log('debug', logLevel, 'Running SubMiner app control socket detected');
|
log('debug', logLevel, 'Running SubMiner app control socket detected');
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
|
|||||||
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const connectionChanges: boolean[] = [];
|
||||||
const resolved: unknown[] = [];
|
const resolved: unknown[] = [];
|
||||||
|
client.on('connection-change', ({ connected }) => {
|
||||||
|
connectionChanges.push(connected);
|
||||||
|
});
|
||||||
(client as any).connected = true;
|
(client as any).connected = true;
|
||||||
(client as any).connecting = false;
|
(client as any).connecting = false;
|
||||||
(client as any).socket = {};
|
(client as any).socket = {};
|
||||||
@@ -191,6 +195,7 @@ test('MpvIpcClient reconnect clears stale connected state and starts a fresh tra
|
|||||||
assert.equal(client.connected, false);
|
assert.equal(client.connected, false);
|
||||||
assert.equal((client as any).connecting, true);
|
assert.equal((client as any).connecting, true);
|
||||||
assert.equal((client as any).socket, null);
|
assert.equal((client as any).socket, null);
|
||||||
|
assert.deepEqual(connectionChanges, [false]);
|
||||||
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
|
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -277,11 +277,15 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
|
|
||||||
reconnect(): void {
|
reconnect(): void {
|
||||||
logger.debug('MPV IPC reconnect requested.');
|
logger.debug('MPV IPC reconnect requested.');
|
||||||
|
const wasConnected = this.connected;
|
||||||
this.transport.shutdown();
|
this.transport.shutdown();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.playbackPaused = null;
|
this.playbackPaused = null;
|
||||||
|
if (wasConnected) {
|
||||||
|
this.emit('connection-change', { connected: false });
|
||||||
|
}
|
||||||
this.failPendingRequests();
|
this.failPendingRequests();
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -13,9 +15,7 @@ async function waitForSocketPath(socketPath: string): Promise<void> {
|
|||||||
if (fs.existsSync(socketPath)) return;
|
if (fs.existsSync(socketPath)) return;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
|
||||||
`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('app control server dispatches argv requests and replies ok', async () => {
|
test('app control server dispatches argv requests and replies ok', async () => {
|
||||||
@@ -62,9 +62,12 @@ test('app control server rejects requests larger than 64KB by UTF-8 byte length'
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForSocketPath(socketPath);
|
await waitForSocketPath(socketPath);
|
||||||
const result = await sendAppControlCommand(Array.from({ length: 4 }, () => 'あ'.repeat(6000)), {
|
const result = await sendAppControlCommand(
|
||||||
socketPath,
|
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
|
||||||
});
|
{
|
||||||
|
socketPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
|
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
|
||||||
assert.deepEqual(received, []);
|
assert.deepEqual(received, []);
|
||||||
@@ -73,3 +76,56 @@ test('app control server rejects requests larger than 64KB by UTF-8 byte length'
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('app control server logs and closes errored client sockets', () => {
|
||||||
|
const originalCreateServer = net.createServer;
|
||||||
|
let socketHandler: ((socket: net.Socket) => void) | null = null;
|
||||||
|
const fakeServer = new EventEmitter() as net.Server;
|
||||||
|
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
|
||||||
|
fakeServer.close = ((callback?: (err?: Error) => void) => {
|
||||||
|
callback?.();
|
||||||
|
return fakeServer;
|
||||||
|
}) as net.Server['close'];
|
||||||
|
const received: string[][] = [];
|
||||||
|
const warnings: Array<{ message: string; error?: unknown }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
net.createServer = ((handler?: (socket: net.Socket) => void) => {
|
||||||
|
socketHandler = handler ?? null;
|
||||||
|
return fakeServer;
|
||||||
|
}) as typeof net.createServer;
|
||||||
|
|
||||||
|
const server = startAppControlServer({
|
||||||
|
socketPath: '\\\\.\\pipe\\subminer-test-control',
|
||||||
|
platform: 'win32',
|
||||||
|
handleArgv: (argv) => {
|
||||||
|
received.push(argv);
|
||||||
|
},
|
||||||
|
logWarn: (message, error) => {
|
||||||
|
warnings.push({ message, error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new Error('client reset');
|
||||||
|
let destroyed = false;
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => {
|
||||||
|
destroyed = true;
|
||||||
|
return socket;
|
||||||
|
}) as net.Socket['destroy'];
|
||||||
|
|
||||||
|
const handler = socketHandler as ((socket: net.Socket) => void) | null;
|
||||||
|
assert.ok(handler);
|
||||||
|
handler(socket);
|
||||||
|
socket.emit('error', error);
|
||||||
|
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
|
||||||
|
|
||||||
|
assert.equal(destroyed, true);
|
||||||
|
assert.deepEqual(received, []);
|
||||||
|
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
} finally {
|
||||||
|
net.createServer = originalCreateServer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export function startAppControlServer(options: AppControlServerOptions): AppCont
|
|||||||
let byteCount = 0;
|
let byteCount = 0;
|
||||||
let handled = false;
|
let handled = false;
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
if (handled) return;
|
||||||
|
handled = true;
|
||||||
|
options.logWarn?.('App control client socket error.', error);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
if (handled) return;
|
if (handled) return;
|
||||||
byteCount += chunk.length;
|
byteCount += chunk.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user