fix: stabilize ssh artifact downloads

This commit is contained in:
Codex
2026-06-02 09:13:11 +00:00
parent b6099492bc
commit 263b0cf3b2
4 changed files with 78 additions and 7 deletions
+60 -1
View File
@@ -53,7 +53,9 @@ class SshFileTransferError extends Error {
const fileTransferReadBlockBytes = 45_000;
const fileTransferWriteB64ArgvLimit = 48_000;
const fileTransferWriteB64ChunkChars = 12_000;
const fileTransferReadBlockMaxAttempts = 3;
const fileTransferReadBlockMaxAttempts = 12;
const fileTransferReadEmptyRetryDelayMs = 250;
const fileTransferProgressEveryChunks = 64;
export function isSshFileTransferOperation(args: string[]): boolean {
const subcommand = args[0] ?? "";
@@ -210,6 +212,7 @@ async function readRemoteFileVerified(
chunks.push(chunk);
actualBytes += chunk.length;
chunkCount += 1;
emitDownloadProgress(invocation, remotePath, blockIndex, chunkCount, actualBytes, remote.bytes, chunk.length);
}
const content = Buffer.concat(chunks);
const actual = { bytes: content.length, sha256: sha256HexBuffer(content) };
@@ -234,11 +237,19 @@ async function readRemoteBase64BlockWithRetry(
const encoded = read.stdout.replace(/\s+/gu, "");
if (encoded.length > 0) return encoded;
attemptErrors.push({ attempt, exitCode: read.exitCode, stdoutBytes: read.stdout.length, stderrTail: read.stderr.slice(-500) });
if (attempt < fileTransferReadBlockMaxAttempts) {
emitEmptyReadRetryProgress(invocation, remotePath, blockIndex, attempt, expectedBytes, actualBytes);
await delayMs(fileTransferReadEmptyRetryDelayMs);
}
} catch (error) {
attemptErrors.push({
attempt,
error: error instanceof Error ? error.message : String(error),
});
if (attempt < fileTransferReadBlockMaxAttempts) {
emitEmptyReadRetryProgress(invocation, remotePath, blockIndex, attempt, expectedBytes, actualBytes);
await delayMs(fileTransferReadEmptyRetryDelayMs);
}
}
}
throw new SshFileTransferError("remote download returned an empty block before EOF after retries", {
@@ -252,6 +263,54 @@ async function readRemoteBase64BlockWithRetry(
});
}
function emitDownloadProgress(
invocation: ParsedSshInvocation,
remotePath: string,
blockIndex: number,
chunkCount: number,
actualBytes: number,
expectedBytes: number,
lastChunkBytes: number,
): void {
if (chunkCount !== 1 && actualBytes < expectedBytes && chunkCount % fileTransferProgressEveryChunks !== 0) return;
process.stderr.write(`${JSON.stringify({
event: "unidesk.ssh.download.progress",
route: invocation.route.raw,
providerId: invocation.providerId,
remotePath,
blockIndex,
chunks: chunkCount,
actualBytes,
expectedBytes,
lastChunkBytes,
})}\n`);
}
function emitEmptyReadRetryProgress(
invocation: ParsedSshInvocation,
remotePath: string,
blockIndex: number,
attempt: number,
expectedBytes: number,
actualBytes: number,
): void {
process.stderr.write(`${JSON.stringify({
event: "unidesk.ssh.download.empty-read-retry",
route: invocation.route.raw,
providerId: invocation.providerId,
remotePath,
blockIndex,
attempt,
maxAttempts: fileTransferReadBlockMaxAttempts,
actualBytes,
expectedBytes,
})}\n`);
}
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function statRemoteFile(
invocation: ParsedSshInvocation,
executor: SshRemoteCommandExecutor,