Files
Netcatty/application/state/sftp/retryFromCheckpoint.test.ts
T
ea376bd0ca fix(sftp): Retry after a dropped connection resumes from the checkpoint (#3823)
* fix(sftp): Retry after a dropped connection resumes from the staged checkpoint

Retry on a failed live task always cloned it under a fresh transfer id with
checkpoint 0. After a connection drop that throws away the uploaded bytes, and
because the remote stage path is derived from the transfer id, the new attempt
stages a new .part while the old one stays orphaned on the server (cleanup runs
against the dead session).

When a top-level file failed with a session-loss error and has a checkpoint,
Retry now demotes it to interrupted/reconnectRequired and goes through the same
dedicated reconnect-resume path a restored task uses: same id, same stage,
truncated to the checkpoint. Content errors and orphaned rows keep the existing
restart-from-0 contract.

* fix(sftp): resume a dropped upload from the verified stage checkpoint

A failed upload never pauses, so Retry has a checkpoint and no SHA-256.
The bridge treated that as an unproven resume and restarted at byte 0,
truncating the same .part. Stat and trim the stage on the new connection
instead, and still restart when the .part does not cover the checkpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): trim a resumed upload stage with SFTP setstat

ssh2 has no ftruncate and ssh2-sftp-client has no truncate, so a longer
.part after a reset always failed the checkpoint trim. SETSTAT {size},
or FSETSTAT on an r+ handle, shortens the stage on the live channel.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): require a stable local source before an unfingerprinted resume

A dropped upload has a checkpoint and no SHA-256. Retry kept that
checkpoint even after the local file was rewritten at the same size, and
the same predicate also resumed downloads and remote copies.

Remember size and mtimeMs when the local upload starts. On retry, keep
the checkpoint only when both still match; otherwise restart from 0.
Limit same-id resume to local-to-remote uploads.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): reject an unfingerprinted resume when prefix bytes changed

Size and mtime can both be restored after a same-size rewrite, so Retry
was appending the new suffix onto the old staged prefix. Keep the head
content sample, and a 64 KiB window ending at the published checkpoint
when that sample does not cover it. Resume only when those still match.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): resume an unfingerprinted upload only when the prefix digest matches

Hash the bytes actually acknowledged on the remote .part, in order, and
store that SHA-256 with the checkpoint offset. Retry re-reads the local
prefix and restarts when the digest or size differs, including a same-size
rewrite the head sample and checkpoint window both missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): cancel prefix verification and drop unused upload digests

Pass the transfer abort signal into the retry prefix re-hash so Cancel
stops the read and the task ends cancelled. Drop the in-memory digest
when an upload is cancelled, fails without a resumable checkpoint, or
the task is cleaned up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 21:00:07 +08:00

27 lines
1.7 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
assert.equal(canRetryFromCheckpoint(undefined), false);
});