fix(launcher): ignore stale sync sockets

This commit is contained in:
2026-07-09 18:35:36 -07:00
parent 2d2813ec2b
commit 32c62b076b
6 changed files with 62 additions and 36 deletions
+38 -13
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import type { Args } from '../types.js';
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
import type { LauncherCommandContext } from './context.js';
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
return {
@@ -35,7 +35,29 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', () => {
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps: Partial<SyncCommandDeps> = {
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
findLiveStatsDaemonPid: () => null,
canConnectUnixSocket: async () => socketConnectable,
fail: (message: string): never => {
throw new Error(message);
},
};
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (dbPath: string, outPath: string) => {
@@ -46,7 +68,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: () => {
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host: string) => {
@@ -66,7 +88,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
};
assert.equal(
runSyncCommand(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
@@ -74,23 +96,26 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
runSyncCommand(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.ok(calls.includes('host:media-box'));
assert.throws(
await assert.rejects(
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', () => {
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = {
@@ -103,7 +128,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: () => {
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
@@ -127,7 +152,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
},
};
assert.throws(
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
@@ -139,12 +164,12 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', () => {
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: () => {},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
@@ -157,7 +182,7 @@ test('runHostSync includes remote snapshot stderr in failures', () => {
runScp: () => {},
};
assert.throws(
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
+20 -19
View File
@@ -17,6 +17,7 @@ import {
shellQuote,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
@@ -31,13 +32,13 @@ export interface SyncCommandDeps {
runSsh: typeof runSsh;
fail: typeof fail;
log: typeof log;
existsSync: typeof fs.existsSync;
canConnectUnixSocket: typeof canConnectUnixSocket;
realpathSync: typeof fs.realpathSync;
mkdtempSync: typeof fs.mkdtempSync;
rmSync: typeof fs.rmSync;
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => void;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
}
function resolveDbPath(context: LauncherCommandContext): string {
@@ -54,11 +55,11 @@ function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
}
}
export function ensureTrackerQuiescent(
export async function ensureTrackerQuiescent(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
@@ -70,7 +71,7 @@ export function ensureTrackerQuiescent(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && deps.existsSync(context.mpvSocketPath)) {
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
@@ -88,13 +89,13 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
runSsh,
fail,
log,
existsSync: fs.existsSync,
canConnectUnixSocket,
realpathSync: fs.realpathSync,
mkdtempSync: fs.mkdtempSync,
rmSync: fs.rmSync,
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
@@ -112,13 +113,13 @@ export function runSnapshotMode(
deps.consoleLog(outPath);
}
export function runMergeMode(
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.consoleLog(deps.formatMergeSummary(summary));
@@ -134,17 +135,17 @@ function formatRemoteRunError(message: string, run: RemoteRunResult): string {
return stderr ? `${message}\n${stderr}` : message;
}
export function runHostSync(
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
deps.assertSafeSshHost(host);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
@@ -183,12 +184,12 @@ export function runHostSync(
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
deps.consoleLog(`\nMerging ${host} -> local:`);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
deps.consoleLog(deps.formatMergeSummary(summary));
deps.consoleLog(`\nMerging local -> ${host}:`);
deps.ensureTrackerQuiescent(context, dbPath);
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
@@ -216,10 +217,10 @@ export function runHostSync(
}
}
export function runSyncCommand(
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): boolean {
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
@@ -228,9 +229,9 @@ export function runSyncCommand(
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
runMergeMode(context, dbPath, deps);
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
runHostSync(context, dbPath, deps);
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
+1 -1
View File
@@ -108,7 +108,7 @@ async function main(): Promise<void> {
return;
}
if (runSyncCommand(context)) {
if (await runSyncCommand(context)) {
return;
}
+1 -1
View File
@@ -1683,7 +1683,7 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
export async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;