mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
* 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>
27 lines
1.7 KiB
TypeScript
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);
|
|
});
|