Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { TransferTask } from "./models";
|
|
import { createEmptyDirectoryResumeCheckpoint } from "./sftpDirectoryCheckpoint";
|
|
import {
|
|
createSftpTransferCenter,
|
|
deserializeSftpTransferCenter,
|
|
pruneSftpTransferHistory,
|
|
serializeSftpTransferCenter,
|
|
validateTransferResumeSource,
|
|
} from "./sftpTransferCenter";
|
|
|
|
const task = (id: string, status: TransferTask["status"], startTime: number): TransferTask => ({
|
|
id,
|
|
fileName: `${id}.bin`,
|
|
sourcePath: `/source/${id}.bin`,
|
|
targetPath: `/target/${id}.bin`,
|
|
sourceConnectionId: "local",
|
|
targetConnectionId: "remote-1",
|
|
targetHostId: "host-1",
|
|
direction: "upload",
|
|
status,
|
|
totalBytes: 100,
|
|
transferredBytes: status === "completed" ? 100 : 25,
|
|
speed: 0,
|
|
startTime,
|
|
isDirectory: false,
|
|
origin: "manual",
|
|
resumable: true,
|
|
});
|
|
|
|
test("global transfer center queues fairly and promotes a task to the front", () => {
|
|
const center = createSftpTransferCenter({ concurrency: 2 });
|
|
center.add("panel-a", [task("a", "queued", 1), task("b", "queued", 2)]);
|
|
center.add("panel-b", [task("c", "queued", 3)]);
|
|
|
|
assert.deepEqual(center.takeRunnable().map((item) => item.id), ["a", "c"]);
|
|
center.prioritize("b");
|
|
center.complete("a");
|
|
|
|
assert.deepEqual(center.takeRunnable().map((item) => item.id), ["b"]);
|
|
});
|
|
|
|
test("pause and resume preserve the transfer checkpoint", () => {
|
|
const center = createSftpTransferCenter({ concurrency: 1 });
|
|
center.add("panel-a", [task("a", "transferring", 1)]);
|
|
center.update("a", { transferredBytes: 48, checkpointBytes: 48 });
|
|
center.pause("a");
|
|
|
|
assert.equal(center.getTask("a")?.status, "paused");
|
|
assert.equal(center.getTask("a")?.transferredBytes, 48);
|
|
assert.equal(center.getTask("a")?.checkpointBytes, 48);
|
|
|
|
center.resume("a");
|
|
assert.equal(center.getTask("a")?.status, "queued");
|
|
assert.equal(center.getTask("a")?.checkpointBytes, 48);
|
|
});
|
|
|
|
test("restoring already-interrupted rows still requires reconnect after force-quit", () => {
|
|
const raw = JSON.stringify({
|
|
version: 1,
|
|
tasks: [{
|
|
id: "was-interrupted",
|
|
fileName: "a.txt",
|
|
sourcePath: "/s/a.txt",
|
|
targetPath: "/t/a.txt",
|
|
sourceConnectionId: "local",
|
|
targetConnectionId: "remote",
|
|
direction: "upload",
|
|
status: "interrupted",
|
|
totalBytes: 10,
|
|
transferredBytes: 3,
|
|
speed: 1,
|
|
startTime: Date.now() - 1000,
|
|
isDirectory: false,
|
|
// Stale process left reconnectRequired false / lifecycle epoch set.
|
|
reconnectRequired: false,
|
|
lifecycleEpoch: 9,
|
|
targetHostId: "host-1",
|
|
}],
|
|
});
|
|
const restored = deserializeSftpTransferCenter(raw);
|
|
assert.equal(restored.tasks[0]?.status, "interrupted");
|
|
assert.equal(restored.tasks[0]?.reconnectRequired, true);
|
|
assert.equal(restored.tasks[0]?.lifecycleEpoch, undefined);
|
|
assert.equal(restored.tasks[0]?.speed, 0);
|
|
});
|
|
|
|
test("restoring persisted state interrupts unfinished work and ignores secrets", () => {
|
|
const restored = deserializeSftpTransferCenter(JSON.stringify({
|
|
version: 1,
|
|
tasks: [{
|
|
...task("a", "transferring", 1),
|
|
phase: "transferring",
|
|
password: "must-not-survive",
|
|
privateKey: "must-not-survive",
|
|
}],
|
|
}));
|
|
|
|
assert.equal(restored.tasks[0]?.status, "interrupted");
|
|
assert.equal(restored.tasks[0]?.reconnectRequired, true);
|
|
assert.equal(restored.tasks[0]?.phase, undefined);
|
|
assert.equal("password" in (restored.tasks[0] ?? {}), false);
|
|
assert.equal("privateKey" in (restored.tasks[0] ?? {}), false);
|
|
});
|
|
|
|
test("restoring a paused task also marks it interrupted after restart", () => {
|
|
const restored = deserializeSftpTransferCenter(JSON.stringify({
|
|
version: 1,
|
|
tasks: [{
|
|
...task("paused", "paused", 1),
|
|
phase: "transferring",
|
|
checkpointBytes: 40,
|
|
lifecycleEpoch: 7,
|
|
}],
|
|
}));
|
|
|
|
assert.equal(restored.tasks[0]?.status, "interrupted");
|
|
assert.equal(restored.tasks[0]?.reconnectRequired, true);
|
|
assert.equal(restored.tasks[0]?.phase, undefined);
|
|
assert.equal(restored.tasks[0]?.checkpointBytes, 40);
|
|
assert.equal(restored.tasks[0]?.lifecycleEpoch, undefined);
|
|
});
|
|
|
|
test("restoring terminal tasks strips stale conflict payloads (skip-without-clear legacy)", () => {
|
|
const conflict = {
|
|
transferId: "css-dir",
|
|
fileName: "css",
|
|
sourcePath: "/src/css",
|
|
targetPath: "/dst/css",
|
|
isDirectory: true,
|
|
existingType: "directory" as const,
|
|
existingSize: 8192,
|
|
newSize: 0,
|
|
existingModified: 1,
|
|
newModified: 2,
|
|
};
|
|
const restored = deserializeSftpTransferCenter(JSON.stringify({
|
|
version: 1,
|
|
tasks: [{
|
|
...task("css-dir", "cancelled", 1),
|
|
isDirectory: true,
|
|
conflict,
|
|
endTime: Date.now(),
|
|
}, {
|
|
...task("still-open", "attention", 2),
|
|
isDirectory: true,
|
|
conflict: { ...conflict, transferId: "still-open" },
|
|
}],
|
|
}));
|
|
|
|
assert.equal(restored.tasks[0]?.status, "cancelled");
|
|
assert.equal(restored.tasks[0]?.conflict, undefined);
|
|
assert.equal(restored.tasks[1]?.status, "attention");
|
|
assert.equal(restored.tasks[1]?.conflict?.fileName, "css");
|
|
});
|
|
|
|
test("history keeps unfinished tasks and caps terminal tasks by age and count", () => {
|
|
const now = Date.UTC(2026, 6, 23);
|
|
const old = now - 31 * 24 * 60 * 60 * 1000;
|
|
const recent = now - 1000;
|
|
const tasks = [
|
|
task("paused", "paused", old),
|
|
task("old", "completed", old),
|
|
...Array.from({ length: 205 }, (_, index) => task(`done-${index}`, "completed", recent + index)),
|
|
];
|
|
|
|
const pruned = pruneSftpTransferHistory(tasks, now);
|
|
assert.equal(pruned.some((item) => item.id === "paused"), true);
|
|
assert.equal(pruned.some((item) => item.id === "old"), false);
|
|
assert.equal(pruned.filter((item) => item.status === "completed").length, 200);
|
|
});
|
|
|
|
test("resume rejects changed or shortened source files", () => {
|
|
const resumable = {
|
|
...task("a", "interrupted", 1),
|
|
totalBytes: 100,
|
|
sourceLastModified: 50,
|
|
checkpointBytes: 60,
|
|
};
|
|
assert.equal(validateTransferResumeSource(resumable, { size: 100, lastModified: 50 }), null);
|
|
assert.match(validateTransferResumeSource(resumable, { size: 90, lastModified: 50 }) ?? "", /size changed/);
|
|
assert.match(validateTransferResumeSource(resumable, { size: 100, lastModified: 51 }) ?? "", /modified/);
|
|
assert.match(validateTransferResumeSource(resumable, { size: 40, lastModified: 50 }) ?? "", /checkpoint/);
|
|
// Upload sources still reject growth.
|
|
assert.match(validateTransferResumeSource(resumable, { size: 120, lastModified: 50 }) ?? "", /size changed/);
|
|
// Download snapshots of append-only files may grow; mtime drift is then expected.
|
|
assert.equal(
|
|
validateTransferResumeSource(
|
|
resumable,
|
|
{ size: 120, lastModified: 51 },
|
|
{ allowSourceGrowth: true },
|
|
),
|
|
null,
|
|
);
|
|
// Shrink remains unsafe even when growth is allowed.
|
|
assert.match(
|
|
validateTransferResumeSource(
|
|
resumable,
|
|
{ size: 90, lastModified: 50 },
|
|
{ allowSourceGrowth: true },
|
|
) ?? "",
|
|
/size changed/,
|
|
);
|
|
// Explicit zero-byte download snapshots may also grow (empty log → first lines).
|
|
const emptyPlan = {
|
|
...task("empty", "interrupted", 1),
|
|
totalBytes: 0,
|
|
sourceLastModified: 50,
|
|
checkpointBytes: 0,
|
|
};
|
|
assert.equal(
|
|
validateTransferResumeSource(
|
|
emptyPlan,
|
|
{ size: 40, lastModified: 51 },
|
|
{ allowSourceGrowth: true },
|
|
),
|
|
null,
|
|
);
|
|
assert.match(
|
|
validateTransferResumeSource(
|
|
emptyPlan,
|
|
{ size: 0, lastModified: 51 },
|
|
{ allowSourceGrowth: true },
|
|
) ?? "",
|
|
/modified/,
|
|
);
|
|
});
|
|
|
|
test("prune upgrades legacy completed children into the parent checkpoint", () => {
|
|
const now = Date.now();
|
|
const parent: TransferTask = {
|
|
...task("dir", "interrupted", now - 1000),
|
|
isDirectory: true,
|
|
fileName: "folder",
|
|
};
|
|
const children = Array.from({ length: 5 }, (_, i) => ({
|
|
...task(`child-${i}`, "completed", now - 500 + i),
|
|
parentTaskId: "dir",
|
|
endTime: now - 400,
|
|
}));
|
|
// Add many other terminal tasks that would push children out of the cap.
|
|
const noise = Array.from({ length: 220 }, (_, i) => ({
|
|
...task(`old-${i}`, "completed", now - 10_000 + i),
|
|
endTime: now - 1000 + i,
|
|
}));
|
|
const pruned = pruneSftpTransferHistory([parent, ...children, ...noise], now);
|
|
assert.deepEqual(pruned.filter((item) => item.id === "dir").map((item) => item.id), ["dir"]);
|
|
assert.equal(pruned.some((item) => item.parentTaskId === "dir"), false);
|
|
assert.equal(pruned.find((item) => item.id === "dir")?.directoryResumeCheckpoint?.completedEntries, 5);
|
|
});
|
|
|
|
test("prune compacts 50,000 completed directory children into a bounded parent checkpoint", () => {
|
|
const pruneStartedAt = Date.now();
|
|
const now = Date.now();
|
|
const parent: TransferTask = {
|
|
...task("huge-dir", "interrupted", now - 1000),
|
|
isDirectory: true,
|
|
fileName: "huge-folder",
|
|
totalBytes: 50_000,
|
|
transferredBytes: 50_000,
|
|
};
|
|
const children = Array.from({ length: 50_000 }, (_, index) => ({
|
|
...task(`huge-child-${index}`, "completed", now - 500 + index),
|
|
parentTaskId: parent.id,
|
|
sourcePath: `/source/file-${index}`,
|
|
targetPath: `/target/file-${index}`,
|
|
endTime: now,
|
|
directoryEntryIndex: index,
|
|
directoryEntryIdentity: index.toString(16).padStart(64, "0"),
|
|
} as TransferTask));
|
|
|
|
const pruned = pruneSftpTransferHistory([parent, ...children], now);
|
|
const compactedParent = pruned.find((item) => item.id === parent.id) as TransferTask & {
|
|
directoryResumeCheckpoint?: { coveredEntries: number; completedEntries: number };
|
|
};
|
|
|
|
assert.equal(pruned.length, 1, "completed children must leave the full task array");
|
|
assert.equal(compactedParent.directoryResumeCheckpoint?.coveredEntries, 50_000);
|
|
assert.equal(compactedParent.directoryResumeCheckpoint?.completedEntries, 50_000);
|
|
assert.ok(
|
|
serializeSftpTransferCenter(pruned).length < 10_000,
|
|
"persisted history must stay bounded instead of serializing every completed child",
|
|
);
|
|
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter(pruned));
|
|
assert.equal(restored.tasks.length, 1);
|
|
assert.equal(restored.tasks[0]?.directoryResumeCheckpoint?.version, 2);
|
|
assert.equal(restored.tasks[0]?.directoryResumeCheckpoint?.completedEntries, 50_000);
|
|
assert.ok(Date.now() - pruneStartedAt < 5_000, "50k compaction should finish within 5 seconds");
|
|
});
|
|
|
|
test("history pruning groups many directory parents without quadratic rescans", () => {
|
|
const now = Date.now();
|
|
const tasks: TransferTask[] = [];
|
|
for (let index = 0; index < 20_000; index += 1) {
|
|
const parent: TransferTask = {
|
|
...task(`many-parent-${index}`, "paused", index),
|
|
isDirectory: true,
|
|
directoryResumeCheckpoint: createEmptyDirectoryResumeCheckpoint(),
|
|
};
|
|
tasks.push(parent, {
|
|
...task(`many-child-${index}`, "paused", index),
|
|
parentTaskId: parent.id,
|
|
directoryEntryIndex: 0,
|
|
directoryEntryIdentity: "a".repeat(64),
|
|
});
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const pruned = pruneSftpTransferHistory(tasks, now);
|
|
assert.equal(pruned.length, tasks.length);
|
|
assert.ok(Date.now() - startedAt < 2_000, "parent grouping should stay linear");
|
|
});
|
|
|
|
test("out-of-order completion keeps the unfinished hole while compacting the later child", () => {
|
|
const now = Date.now();
|
|
const parent: TransferTask = {
|
|
...task("out-of-order-dir", "paused", now - 1000),
|
|
isDirectory: true,
|
|
totalBytes: 2,
|
|
transferredBytes: 1,
|
|
};
|
|
const unfinished = {
|
|
...task("index-0", "paused", now),
|
|
parentTaskId: parent.id,
|
|
directoryEntryIndex: 0,
|
|
directoryEntryIdentity: "a".repeat(64),
|
|
} as TransferTask;
|
|
const completed = {
|
|
...task("index-1", "completed", now),
|
|
parentTaskId: parent.id,
|
|
directoryEntryIndex: 1,
|
|
directoryEntryIdentity: "b".repeat(64),
|
|
} as TransferTask;
|
|
|
|
const pruned = pruneSftpTransferHistory([parent, unfinished, completed], now);
|
|
const compactedParent = pruned.find((item) => item.id === parent.id)!;
|
|
|
|
assert.deepEqual(pruned.map((item) => item.id).sort(), [parent.id, unfinished.id].sort());
|
|
assert.equal(compactedParent.directoryResumeCheckpoint?.coveredEntries, 2);
|
|
assert.equal(compactedParent.directoryResumeCheckpoint?.completedEntries, 1);
|
|
assert.equal(pruned.find((item) => item.id === unfinished.id)?.status, "paused");
|
|
});
|
|
|
|
test("failed directory history drops an ambiguous compact checkpoint before child history is capped", () => {
|
|
const now = Date.now();
|
|
const parent: TransferTask = {
|
|
...task("failed-dir", "failed", now - 1000),
|
|
isDirectory: true,
|
|
checkpointBytes: 500,
|
|
endTime: now,
|
|
directoryResumeCheckpoint: {
|
|
version: 1,
|
|
coveredEntries: 500,
|
|
completedEntries: 0,
|
|
manifestHash: "a".repeat(64),
|
|
},
|
|
};
|
|
const failedChildren = Array.from({ length: 500 }, (_, index) => ({
|
|
...task(`failed-child-${index}`, "failed", now - 900 + index),
|
|
parentTaskId: parent.id,
|
|
directoryEntryIndex: index,
|
|
directoryEntryIdentity: index.toString(16).padStart(64, "0"),
|
|
endTime: now - 1 - index,
|
|
} as TransferTask));
|
|
|
|
const pruned = pruneSftpTransferHistory([parent, ...failedChildren], now);
|
|
const retainedParent = pruned.find((item) => item.id === parent.id);
|
|
assert.equal(retainedParent?.directoryResumeCheckpoint, undefined);
|
|
assert.ok(pruned.length <= 200);
|
|
});
|