fix: await SSH tunnel readiness
This commit is contained in:
@@ -160,8 +160,9 @@ export interface ConcreteDiagnosticAdapterDependencies {
|
||||
directProtocol?: DirectProtocolFactory;
|
||||
sshProcess?: SshProcessFactory;
|
||||
databaseClient?: DatabaseDiagnosticClientFactory;
|
||||
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean };
|
||||
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean; once?(event: "error" | "exit", listener: (...args: any[]) => void): unknown };
|
||||
reserveLoopbackPort?: () => Promise<number>;
|
||||
waitForSshReady?: (tunnel: LoopbackTunnel, signal: AbortSignal) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +232,18 @@ async function reserveLoopbackPort(): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLoopbackTunnel(tunnel: LoopbackTunnel, signal: AbortSignal): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await connectTcp(tunnel.host, tunnel.port, signal);
|
||||
return;
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
throw new Error("SSH tunnel readiness failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
|
||||
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
|
||||
@@ -240,6 +253,7 @@ export function createConcreteDiagnosticAdapters(
|
||||
): DiagnosticAdapters {
|
||||
const spawnSsh = dependencies.sshSpawn ?? ((args: readonly string[]) => spawn("ssh", [...args], { stdio: "ignore" }));
|
||||
const reserveSshPort = dependencies.reserveLoopbackPort ?? reserveLoopbackPort;
|
||||
const waitForSshReady = dependencies.waitForSshReady ?? waitForLoopbackTunnel;
|
||||
const sshProcess = dependencies.sshProcess ?? {
|
||||
async start(request: SshTunnelRequest, args: readonly string[]) {
|
||||
const port = await reserveSshPort();
|
||||
@@ -248,9 +262,29 @@ export function createConcreteDiagnosticAdapters(
|
||||
const child = spawnSsh(resolvedArgs);
|
||||
const abort = () => { child.kill("SIGTERM"); };
|
||||
request.signal.addEventListener("abort", abort, { once: true });
|
||||
const tunnel = { host: "127.0.0.1" as const, port };
|
||||
try {
|
||||
await withTimeout(request.timeoutMs, async (signal) => {
|
||||
await Promise.race([
|
||||
waitForSshReady(tunnel, signal),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
child.once?.("error", () => reject(new Error("SSH tunnel readiness failed")));
|
||||
child.once?.("exit", () => reject(new Error("SSH tunnel readiness failed")));
|
||||
}),
|
||||
]);
|
||||
});
|
||||
} catch (error) {
|
||||
request.signal.removeEventListener("abort", abort);
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
tunnel: { host: "127.0.0.1" as const, port },
|
||||
async close() { request.signal.removeEventListener("abort", abort); child.kill("SIGTERM"); },
|
||||
tunnel,
|
||||
async close() {
|
||||
request.signal.removeEventListener("abort", abort);
|
||||
child.kill("SIGTERM");
|
||||
if (child.once) await withTimeout(request.timeoutMs, () => new Promise<void>((resolve) => child.once?.("exit", resolve))).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -332,7 +366,7 @@ export function createConcreteDiagnosticAdapters(
|
||||
throw new Error("SSH probe failed");
|
||||
}
|
||||
const args = [
|
||||
"-N", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
|
||||
"-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=yes",
|
||||
"-o", `UserKnownHostsFile=${request.knownHostsFile}`, "-i", request.privateKeyFile,
|
||||
"-p", String(request.sshPort), "-L", `127.0.0.1:0:${request.targetHost}:${request.targetPort}`,
|
||||
`${request.sshUser}@${request.sshHost}`,
|
||||
|
||||
Reference in New Issue
Block a user