Compare commits

...
Author SHA1 Message Date
陈大猫 5974d2e946 fix(sftp): distinguish live compression recovery from restored attention 2026-10-08 17:33:30 +08:00
陈大猫 1dfbd411ae fix(sftp): dispatch cancel batches before walk settlement 2026-10-08 17:17:09 +08:00
陈大猫 108b545bd6 fix(sftp): satisfy cancellation result and fixture types 2026-10-08 16:38:35 +08:00
陈大猫 5487d4522b fix(sftp): release skipped cancel-all pre-latches 2026-10-08 16:32:41 +08:00
陈大猫 620c9c88c4 fix(sftp): preserve settled children during cancel recovery 2026-10-08 16:23:30 +08:00
陈大猫 a2fe1cd99f fix(sftp): batch pending cancel cleanup for large queues 2026-10-08 15:51:36 +08:00
陈大猫 83dab66c3c fix(sftp): report failed cancel attempts without blocking retries 2026-10-08 14:56:58 +08:00
陈大猫 ffe57f9672 fix(sftp): settle cancelled walks before directory recovery 2026-10-08 14:47:18 +08:00
netcatty-bot 89de0f8755 fix: address Codex review on PR #3574 2026-10-02 21:51:10 +00:00
netcatty-bot a6edbbc650 fix: address Codex review on PR #3574 2026-09-30 17:22:55 +00:00
netcatty-bot 34343da81f fix: address Codex review on PR #3574 2026-09-30 16:56:13 +00:00
netcatty-bot 85cfc7c4ba fix: address Codex review on PR #3574 2026-09-30 16:17:03 +00:00
netcatty-bot 270e1dacf4 fix: address Codex review on PR #3574 2026-09-30 15:45:57 +00:00
netcatty-bot 34f999d3b7 fix: address Codex review on PR #3574 2026-09-30 15:12:13 +00:00
netcatty-bot 01a16ece0e fix: address Codex review on PR #3574 2026-09-30 14:35:26 +00:00
netcatty-bot 0d3238db88 fix: address Codex review on PR #3574 2026-09-30 13:55:56 +00:00
netcatty-bot 4f8a1983c9 fix: address Codex review on PR #3574 2026-09-30 13:13:28 +00:00
netcatty-bot b26b14d009 fix: address Codex review on PR #3574 2026-09-30 12:51:42 +00:00
netcatty-bot 64ee11b017 fix: address Codex review on PR #3574 2026-09-30 12:17:25 +00:00
netcatty-bot dc741f5a19 fix: address Codex review on PR #3574 2026-09-30 11:49:40 +00:00
netcatty-bot a4d3b3d90a fix: address Codex review on PR #3574 2026-09-30 11:22:52 +00:00
netcatty-bot d497dc050d fix: address Codex review on PR #3574 2026-09-30 10:54:14 +00:00
netcatty-bot 20996e4a4a fix: address Codex review on PR #3574 2026-09-30 10:12:24 +00:00
netcatty-bot b192089679 fix: address Codex review on PR #3574 2026-09-30 09:36:37 +00:00
netcatty-bot d558893c23 fix: address Codex review on PR #3574 2026-09-30 08:54:26 +00:00
netcatty-bot 9588012d12 fix: address Codex review on PR #3574 2026-09-30 08:08:37 +00:00
binaricat 2170aed553 feat(sftp): add cancel all to transfer center 2026-09-30 07:35:48 +00:00
26 changed files with 1194 additions and 129 deletions

No files matched your search

+1
View File
@@ -407,6 +407,7 @@ export const enVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transfer next',
'sftp.transferCenter.pauseAll': 'Pause all',
'sftp.transferCenter.resumeAll': 'Resume all',
'sftp.transferCenter.cancelAll': 'Cancel all',
'sftp.transferCenter.empty': 'No transfers in this category',
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
'sftp.transferCenter.hideBackground': 'Hide background transfers',
+1
View File
@@ -407,6 +407,7 @@ export const esVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Transferir a continuación',
'sftp.transferCenter.pauseAll': 'Pausar todas',
'sftp.transferCenter.resumeAll': 'Reanudar todas',
'sftp.transferCenter.cancelAll': 'Cancelar todas',
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
+1
View File
@@ -445,6 +445,7 @@ export const ruVaultMessages: Messages = {
'sftp.transferCenter.prioritize': 'Передать следующей',
'sftp.transferCenter.pauseAll': 'Приостановить все',
'sftp.transferCenter.resumeAll': 'Продолжить все',
'sftp.transferCenter.cancelAll': 'Отменить все',
'sftp.transferCenter.empty': 'В этой категории нет передач',
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
+1
View File
@@ -1051,6 +1051,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '优先传输',
'sftp.transferCenter.pauseAll': '全部暂停',
'sftp.transferCenter.resumeAll': '全部继续',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '当前分类没有传输任务',
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
'sftp.transferCenter.hideBackground': '隐藏后台任务',
+1
View File
@@ -1053,6 +1053,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transferCenter.prioritize': '優先傳輸',
'sftp.transferCenter.pauseAll': '全部暫停',
'sftp.transferCenter.resumeAll': '全部繼續',
'sftp.transferCenter.cancelAll': '全部取消',
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
'sftp.transferCenter.hideBackground': '隱藏背景工作',
@@ -520,3 +520,24 @@ test("folder resume releases a completed child compacted while pause was drainin
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch");
const { bumpTransferControlEpoch } = await import("./transferControlEpoch");
const { releaseTransferPauseTree } = await import("./transferPauseLatch");
t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); });
let finishPause!: (value: { success: boolean }) => void;
let resumes = 0;
const { host } = createHost([makeTask("cancel-during-pause")], () => ({
pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }),
resumeTransfer: async () => { resumes += 1; return { success: true }; },
}));
const pause = softPauseTransfer(host, "cancel-during-pause");
markTransferCancelledTree("cancel-during-pause");
bumpTransferControlEpoch("cancel-during-pause");
releaseTransferPauseTree("cancel-during-pause");
finishPause({ success: true });
await pause;
assert.equal(resumes, 0);
});
@@ -24,6 +24,7 @@ import {
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
import { isTransferOrRootCancelled } from "./transferCancelLatch";
import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
@@ -72,7 +73,7 @@ export function reconcileSupersededControls(
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) return;
const task = host.getTasks().find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
@@ -234,7 +235,7 @@ export async function softPauseTransfer(
const undoObsoletePause = async (id: string) => {
const live = host.getTasks().find((candidate) => candidate.id === taskId);
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
if (isTransferOrRootPauseLatched(taskId, id)) return;
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
@@ -0,0 +1,46 @@
import type { TransferTask } from "../../../domain/models";
import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint";
/** Apply only after backend cancellation AND the old walk have settled. */
export function settleCancelledTransferTree(
tasks: readonly TransferTask[],
rootId: string,
cancelledIds: ReadonlySet<string>,
failedIds: ReadonlySet<string>,
retainedTasks: readonly TransferTask[] = [],
): TransferTask[] {
const existingTasks = new Map(tasks.map((task) => [task.id, task]));
// Use observed terminal outcomes rather than stale pre-cancel snapshots.
// Missing completed children already covered by the parent stay compacted;
// cancelled/failed exceptions still need their rows for the recovery walk.
const settledTasks = failedIds.size > 0
? [...tasks, ...retainedTasks.filter((task) => {
if (existingTasks.has(task.id)) return false;
const checkpoint = task.parentTaskId
? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint
: undefined;
return !(task.status === "completed"
&& isValidDirectoryResumeCheckpoint(checkpoint)
&& Number.isSafeInteger(task.directoryEntryIndex)
&& (task.directoryEntryIndex ?? -1) >= 0
&& task.directoryEntryIndex! < checkpoint.coveredEntries);
})]
: tasks;
return settledTasks.map((task) => {
// Natural completion/failure wins a race with Cancel. Rows cancelled by
// this attempt can be re-admitted only when the whole tree needs recovery.
if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task;
const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0);
const recoverable = failedIds.size > 0;
return {
...task,
status: failed ? "attention" : recoverable ? "interrupted" : "cancelled",
error: failed ? "Could not cancel transfer. Please try again." : undefined,
reconnectRequired: recoverable,
endTime: recoverable ? undefined : Date.now(),
speed: 0,
phase: undefined,
conflict: undefined,
};
});
}
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch";
import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime";
import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry";
const task = (id: string, extra: Partial<TransferTask> = {}): TransferTask => ({
id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`,
sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload",
status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0,
startTime: 1, isDirectory: false, resumable: true, ...extra,
});
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => {
const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-"));
const originalGet = netcattyBridge.get;
t.after(async () => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
await rm(root, { recursive: true, force: true });
});
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
const ids = ["active", "queued-1", "queued-2"];
const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`));
const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) }));
store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
let softResumeCalls = 0;
let pauseCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "active" }),
pauseTransfer: async () => { pauseCalls += 1; return { success: true }; },
clearPendingTransferCancel: async () => undefined,
resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
let starts = 0;
const walking = runtime.runWalk("folder", async () => {
await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run(
"closed-panel", child.id, ["cancel-fixture"], () => 1, async () => {
starts += 1;
await held;
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
},
).catch(() => store.patchTask(child.id, { status: "cancelled" }))));
store.patchTask("folder", { status: "cancelled" });
});
await tick();
assert.equal(starts, 1);
let cancelSettled = false;
const cancelling = store.cancel("folder").then(() => { cancelSettled = true; });
const resuming = store.resume("folder");
await tick();
assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry");
assert.equal(store.getTask("folder")?.status, "attention");
assert.equal(isTransferOrRootCancelled("folder"), true);
assert.equal(softResumeCalls, 0, "must not unlatch the old walk");
await store.pause("folder");
assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk");
let freshWalks = 0;
store.setDedicatedResumeHandler(async () => {
freshWalks += 1;
assert.equal(runtime.isWalkInFlight("folder"), false);
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
if (store.getTask(child.id)?.status === "completed") continue;
assert.equal(store.admitTaskRun(child), "ready");
await writeFile(child.targetPath, expected[index]);
store.patchTask(child.id, { status: "completed" });
}
return { success: true };
});
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(freshWalks, 1);
assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files");
assert.equal(store.getTask("folder")?.status, "completed");
for (let index = 0; index < children.length; index += 1) {
assert.deepEqual(await readFile(children[index].targetPath), expected[index]);
}
});
test("failed ownerless compression cancellation retries the same backend", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async (id: string) => {
calls.push(`compressed:${id}`);
return { success: calls.length > 1 };
},
cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]);
await store.cancel("archive");
assert.equal(store.getTask("archive")?.status, "attention");
await store.cancel("archive");
assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]);
assert.equal(store.getTask("archive")?.status, "cancelled");
});
test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]);
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
store.patchTask(id, { status: "completed" });
return { success: true };
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("selected");
assert.deepEqual(calls, ["selected"]);
assert.equal(store.getTask("selected")?.status, "completed");
assert.equal(store.getTask("peer")?.status, "paused");
});
test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => {
const { markTransferCancelledTree } = await import("./transferCancelLatch");
t.after(resetTransferCancelLatchesForTests);
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]);
markTransferCancelledTree("waiting-cancel-batch");
await store.resume("waiting-cancel-batch");
assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused");
assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true);
});
test("failed cancel can be retried while its old walk is still running", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
t.after(() => release());
const walking = runtime.runWalk("retry-live-cancel", async () => { await held; });
let attempts = 0;
netcattyBridge.get = () => ({
cancelTransfer: async () => {
attempts += 1;
if (attempts === 1) return { success: false };
release();
return { success: true };
},
cleanupTransferArtifacts: async () => undefined,
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("retry-live-cancel");
assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true);
assert.equal(store.getTask("retry-live-cancel")?.status, "attention");
const resume = store.resume("retry-live-cancel");
await store.cancel("retry-live-cancel");
await Promise.all([walking, resume]);
assert.equal(attempts, 2, "second cancel must reach the backend before natural completion");
assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome");
});
test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }),
...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]);
let active = 0;
let peak = 0;
let cleared = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }),
clearPendingTransferCancel: async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => setImmediate(resolve));
active -= 1;
cleared += 1;
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
await store.cancel("cleanup-root");
assert.equal(cleared, 2201);
assert.ok(peak > 1, "recovery must not serialize one IPC per row");
assert.ok(peak <= 32, "recovery uses the cancellation batch bound");
});
for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) {
test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint");
const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } =
await import("./dedicatedTransferResume");
const originalGet = netcattyBridge.get;
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const parent = task(`compacted-cancel-${completeDuring}`, {
sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host",
isDirectory: true, progressMode: "files", totalBytes: 3,
});
const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => {
const child = task(`${parent.id}-${name}`, {
parentTaskId: parent.id, fileName: name,
sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`,
sourceLastModified: 1, directoryEntryIndex: index,
});
return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1,
}) };
});
// Match the sorted traversal order so dedicated resume validates the actual checkpoint.
children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
children.forEach((child, index) => { child.directoryEntryIndex = index; });
const completed = children.find((child) => child.fileName === "completed")!;
const cancelled = children.find((child) => child.fileName === "cancelled")!;
const failed = children.find((child) => child.fileName === "cancel-failed")!;
const runtime = createTransferRuntime(store);
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let walking: Promise<unknown> | undefined;
t.after(async () => {
release();
await walking;
store.setDedicatedResumeHandler(null);
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
resetDedicatedSessionOpenGateForTests();
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const uploads: string[] = [];
const progress: number[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
if (completeDuring === "backend-cancel" && id === completed.id) {
store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 });
}
return { success: id !== failed.id };
},
clearPendingTransferCancel: async () => undefined,
openSftp: async () => "reconnected-sftp", closeSftp: async () => {},
listLocalTree: async () => children.map((child) => ({
localPath: child.sourcePath, relativePath: child.fileName,
type: "file", size: 8, lastModified: 1,
})),
mkdirSftp: async () => {},
statLocal: async () => ({ size: 8, lastModified: 1 }),
startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => {
if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4);
uploads.push(options.sourcePath);
return {};
},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.publishOwner("closed-panel", [parent, ...children]);
walking = runtime.runWalk(parent.id, async () => {
await held;
if (completeDuring === "walk-settlement") {
store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 });
}
store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 });
store.patchTask(parent.id, { status: "cancelled" });
});
await store.cancel(parent.id);
assert.equal(runtime.isWalkInFlight(parent.id), true);
store.setDedicatedResumeHandler(async (root) => {
assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1);
assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted");
assert.equal(store.getTask(cancelled.id)?.status, "interrupted");
assert.equal(store.getTask(failed.id)?.status, "attention");
return resumeTransferWithDedicatedSession(root, {
hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22,
username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }],
keys: [], identities: [],
}, (value) => { progress.push(value.transferred); }, {
children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id),
onChildUpdate: (child) => { store.upsertTasks([child]); },
onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }),
});
});
const resuming = store.resume(parent.id);
release();
await Promise.all([walking, resuming]);
assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(),
"only cancelled and failed-to-cancel children should upload again");
assert.equal(store.getTask(parent.id)?.status, "completed");
assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once");
assert.equal(progress.at(-1), 3);
});
}
for (const rejects of [false, true]) {
test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
const store = createSftpTransferCenterStore();
const calls: string[] = [];
netcattyBridge.get = () => ({
cancelCompressedUpload: async () => {
if (rejects) throw new Error("Cancel IPC failed");
return { success: false };
},
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
store.publishOwner("closed-panel", [task("live-compressed", {
isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
})]);
await store.cancel("live-compressed");
assert.equal(store.getTask("live-compressed")?.status, "attention");
await store.resume("live-compressed");
assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer");
assert.equal(store.getTask("live-compressed")?.status, "transferring");
});
}
for (const hasConflict of [false, true]) {
test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => {
const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter");
const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions");
const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration");
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const saved = task("restored-compressed", {
status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
error: "Could not cancel the compressed upload.",
...(hasConflict ? { conflict: {
transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder",
isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1,
} } : {}),
});
const raw = serializeSftpTransferCenter([saved]);
const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} });
const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] }));
assert.equal(restored.getTask(saved.id)?.reconnectRequired, true);
assert.equal(cooperative.tasks[0]?.reconnectRequired, true);
assert.equal(restored.getTask(saved.id)?.status, "attention");
assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict);
assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row");
const calls: string[] = [];
netcattyBridge.get = () => ({
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
clearPendingTransferCancel: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks);
assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]);
for (const id of resumeIds) await restored.resume(id);
assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]);
});
}
@@ -972,13 +972,10 @@ export function useSftpDirectoryTransferOps({
if (isTransferCancelledError(err)) {
// Keep cancelled status; do not rethrow — other workers must finish
// and the parent should not become a clean completed tree.
setTransfers((prev) =>
prev.map((t) =>
t.id === fileId
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: t,
),
);
setTransfers((prev) => prev.map((task) =>
task.id === fileId
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
: task));
errors.push(err instanceof Error ? err : new Error(message));
return;
}
@@ -51,6 +51,7 @@ export interface TransferRuntime {
* walk is dead / reconnectRequired. Single external operation.
*/
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
cancel(taskId: string): Promise<void>;
/**
+14 -11
View File
@@ -71,21 +71,24 @@ export function useSftpTransferTaskOps({
const bridge = netcattyBridge.get();
const cancelTransferAtBackend = bridge?.cancelTransfer;
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return [];
await Promise.all(
Array.from(idsToCancel).map(async (id) => {
const failed: string[] = [];
const ids = [...idsToCancel];
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (id) => {
const candidate = currentTransfers.find((task) => task.id === id);
const compressed = candidate?.controlKind === "compressed-upload";
const operation = compressed
? cancelCompressedUpload?.(id)
: cancelTransferAtBackend?.(id);
const results = operation ? await Promise.allSettled([operation]) : [];
if (results.some((result) => result.status === "rejected")) {
logger.warn("Failed to cancel one or more transfer backends");
try {
const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id));
if (result && result.success === false) failed.push(id);
} catch {
failed.push(id);
}
}),
);
}));
}
if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends");
return failed;
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
const markBatchStopped = useCallback(
+15 -1
View File
@@ -6,6 +6,7 @@
*/
const inFlightRootIds = new Set<string>();
const settlementWaiters = new Map<string, Set<() => void>>();
export function registerTransferWalk(rootTaskId: string): void {
inFlightRootIds.add(rootTaskId);
@@ -13,15 +14,28 @@ export function registerTransferWalk(rootTaskId: string): void {
export function unregisterTransferWalk(rootTaskId: string): void {
inFlightRootIds.delete(rootTaskId);
const waiters = settlementWaiters.get(rootTaskId);
settlementWaiters.delete(rootTaskId);
for (const resolve of waiters ?? []) resolve();
}
export function isTransferWalkInFlight(rootTaskId: string): boolean {
return inFlightRootIds.has(rootTaskId);
}
/** Wait for the current walk to finish; cancellation must not resume a dying walk. */
export function waitForTransferWalkSettled(rootTaskId: string): Promise<void> {
if (!inFlightRootIds.has(rootTaskId)) return Promise.resolve();
return new Promise((resolve) => {
const waiters = settlementWaiters.get(rootTaskId) ?? new Set();
waiters.add(resolve);
settlementWaiters.set(rootTaskId, waiters);
});
}
/** Test helper. */
export function resetTransferWalkRegistryForTests(): void {
inFlightRootIds.clear();
for (const id of inFlightRootIds) unregisterTransferWalk(id);
}
export function listTransferWalksForTests(): string[] {
@@ -561,3 +561,105 @@ test("files past the preflight bound stay un-statted after conflict resolution",
restore();
}
});
test("owned cancelled directory settles recoverable children without dropping completed bytes", async () => {
const { writeFile, readFile, mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { transferRuntime } = await import("./transferRuntime");
const root = await mkdtemp(join(tmpdir(), "netcatty-owner-cancel-"));
const completedPath = join(root, "completed.txt");
await writeFile(completedPath, "completed bytes must remain");
const ownerId = "cancel-owner-large";
const rootId = "cancel-root-large";
const base = {
id: rootId, fileName: "folder", sourcePath: "/opt/folder", targetPath: root,
sourceConnectionId: "remote-conn", targetConnectionId: "local-conn",
sourceHostId: "host-1", direction: "download" as const, status: "transferring" as const,
totalBytes: 2201, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true,
};
const children = Array.from({ length: 2200 }, (_, index) => ({
...base, id: `cancel-child-${index}`, parentTaskId: rootId, isDirectory: false,
sourcePath: `/opt/folder/${index}`, targetPath: join(root, String(index)),
totalBytes: 10, transferredBytes: 4, status: "queued" as const,
}));
sftpTransferCenterStore.publishOwner(ownerId, [base, ...children, {
...base, id: "cancel-completed", parentTaskId: rootId, isDirectory: false,
sourcePath: "/opt/folder/completed.txt", targetPath: completedPath, status: "completed",
}]);
let active = 0;
let peak = 0;
let clearing = 0;
let clearPeak = 0;
let clearCount = 0;
const cleaned: string[] = [];
const restore = installGlobals({
cancelTransfer: async (id: string) => {
assert.notEqual(id, "cancel-completed");
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== "cancel-child-0" };
},
clearPendingTransferCancel: async () => {
clearing += 1;
clearPeak = Math.max(clearPeak, clearing);
await new Promise((resolve) => setImmediate(resolve));
clearing -= 1;
clearCount += 1;
},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side),
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
});
return null;
}
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const walking = transferRuntime.runWalk(rootId, async () => {
await held;
// Old scheduler callbacks settle as cancelled before the outcome is applied.
sftpTransferCenterStore.publishOwner(ownerId, sftpTransferCenterStore.getOwnerTasks(ownerId).map((task) =>
task.status === "completed" ? task : {
...task, status: "cancelled",
checkpointBytes: task.id === "cancel-child-1" ? 6 : task.checkpointBytes,
}));
});
await act(async () => {
const cancellation = ops!.cancelTransfer(rootId);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
release();
await Promise.all([walking, cancellation]);
});
assert.ok(peak <= 32, `bounded cancellation IPC, got ${peak}`);
assert.equal(clearCount, 2201);
assert.ok(clearPeak > 1 && clearPeak <= 32, `bounded concurrent cleanup, got ${clearPeak}`);
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "attention");
assert.equal(sftpTransferCenterStore.getTask("cancel-child-0")?.status, "attention");
const remaining = sftpTransferCenterStore.getTask("cancel-child-1");
assert.equal(remaining?.status, "interrupted", "evicted cancelled children remain recoverable");
assert.equal(remaining?.checkpointBytes, 6, "retain the terminal checkpoint, not the pre-cancel snapshot");
assert.deepEqual(cleaned, [], "no stage cleanup during partial cancellation recovery");
assert.equal(await readFile(completedPath, "utf8"), "completed bytes must remain");
} finally {
release();
await act(async () => { renderer?.unmount(); });
restore();
await rm(root, { recursive: true, force: true });
}
});
+52 -64
View File
@@ -1418,77 +1418,66 @@ export const useSftpTransfers = ({
releasePausedTransfer(transferId);
globalSftpTransferScheduler.cancel(transferId);
// Cancel parent + remove child tasks
const childIdsToCancel = new Set<string>();
const childrenToCleanup: TransferTask[] = [];
for (const t of transfersRef.current) {
if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) {
childIdsToCancel.add(t.id);
childrenToCleanup.push(t);
}
// Keep child rows and checkpoints until the old walk has stopped. A
// partial backend failure needs a fresh re-walk, including queued jobs
// the scheduler has already rejected and will never run again.
const children = transfersRef.current.filter((task) =>
task.parentTaskId === transferId
&& !["completed", "cancelled", "failed"].includes(task.status));
const childIds = new Set([
...children.map((task) => task.id),
...(activeChildIdsRef.current.get(transferId) ?? []),
]);
for (const id of childIds) {
cancelledTasksRef.current.add(id);
releasePausedTransfer(id);
globalSftpTransferScheduler.cancel(id);
}
for (const cid of activeChildIdsRef.current.get(transferId) ?? []) {
if (!childIdsToCancel.has(cid)) {
childIdsToCancel.add(cid);
if (taskToCancel) {
childrenToCleanup.push({
...taskToCancel,
id: cid,
parentTaskId: transferId,
});
}
}
}
for (const cid of childIdsToCancel) {
cancelledTasksRef.current.add(cid);
markTransferCancelled(cid);
globalSftpTransferScheduler.cancel(cid);
}
markTransferCancelledTree(transferId, [...childIdsToCancel]);
// Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel.
const nextTransfers = transfersRef.current
.filter((t) => t.parentTaskId !== transferId)
.map((t) =>
t.id === transferId
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
: t,
);
setTransfers(nextTransfers);
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId));
markTransferCancelledTree(transferId, [...childIds]);
conflictsRef.current = conflictsRef.current.filter((conflict) =>
conflict.transferId !== transferId && !childIds.has(conflict.transferId));
setConflicts(conflictsRef.current);
await cancelBackendTransfers([transferId, ...childIdsToCancel]);
if (taskToCancel) await cleanupTaskArtifacts(taskToCancel);
// Child stages are keyed by per-file transferId — clean each known child.
for (const child of childrenToCleanup) {
try {
await cleanupTaskArtifacts(child);
} catch {
// best-effort
}
}
const ids = new Set([transferId, ...childIds]);
const retainedTasks = (taskToCancel ? [taskToCancel, ...children] : children)
.map((task) => ({ task, ...sftpTransferCenterStore.observeTaskSettlement(task) }));
const failedIds = new Set(await cancelBackendTransfers([...ids]));
await sftpTransferCenterStore.settleCancellation(
transferId, ids, failedIds, retainedTasks,
async () => {
setTransfers(sftpTransferCenterStore.getOwnerTasks(ownerId));
for (const id of ids) clearCancelledTask(id);
clearTransferCancelledTree(transferId, [...childIds]);
if (failedIds.size > 0) {
// Preserve stages while any writer could not be cancelled. The
// fresh recovery walk reuses ids only after this settlement.
const recoveryIds = [...ids];
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
return;
}
if (taskToCancel && sftpTransferCenterStore.getTask(transferId)?.status === "cancelled") {
try { await cleanupTaskArtifacts(taskToCancel); } catch { /* best-effort */ }
}
for (const child of children) {
if (sftpTransferCenterStore.getTask(child.id)?.status === "completed") continue;
try { await cleanupTaskArtifacts(child); } catch { /* best-effort */ }
}
},
);
},
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
[cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers],
);
// Soft pause/resume: single TransferRuntime entry (store soft-control +
// dedicated hard reconnect). No panel-local soft-control dual path.
// All controls go through the process-global runtime, including cancellation
// settlement. A panel must never clear cancellation latches on Resume.
const resumeTransfer = useCallback(async (transferId: string) => {
// Clear sticky child cancel latches so re-walk can retry same child ids.
clearCancelledTask(transferId);
for (const child of transfersRef.current) {
if (child.parentTaskId === transferId) clearCancelledTask(child.id);
}
for (const childId of activeChildIdsRef.current.get(transferId) ?? []) {
clearCancelledTask(childId);
}
clearTransferCancelledTree(
transferId,
[...(activeChildIdsRef.current.get(transferId) ?? [])],
);
await transferRuntime.resume(transferId);
}, [clearCancelledTask]);
}, []);
const prioritizeTransfer = useCallback((transferId: string) => {
globalSftpTransferScheduler.prioritize(transferId);
@@ -2265,7 +2254,6 @@ export const useSftpTransfers = ({
await transferRuntime.pause(taskId);
},
resume: async (taskId: string) => {
// Use resumeTransfer so cancel latches are cleared before soft/hard resume.
await resumeTransfer(taskId);
},
cancel: cancelTransfer,
@@ -2290,7 +2278,7 @@ export const useSftpTransfers = ({
activeTransfersCount,
startTransfer,
downloadToLocal,
cancelTransfer,
cancelTransfer: transferRuntime.cancel,
// Single process-level control surface (TransferRuntime).
pauseTransfer: async (transferId: string) => {
await transferRuntime.pause(transferId);
@@ -1622,6 +1622,46 @@ test("orphan compressed upload resume and cancel keep using the compression job"
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("compressed upload completing during cancel IPC keeps its completed paint", async (t) => {
const calls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let storeRef: ReturnType<typeof createSftpTransferCenterStore> | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
// Inactive-compression responses report success even after the upload
// already finished; the completion repaint must survive the cancel.
cancelCompressedUpload: async (id: string) => {
calls.push(`cancel:${id}`);
storeRef?.ingestBackgroundEvent({ type: "completed", transferId: id });
return { success: true };
},
},
},
});
const store = createSftpTransferCenterStore();
storeRef = store;
store.publishOwner("closed-panel", [{
...makeTask("compressed-race", "transferring"),
fileName: "done (compressed)",
isDirectory: true,
phase: "compressing",
controlKind: "compressed-upload",
} as TransferTask]);
await store.cancel("compressed-race");
assert.deepEqual(calls, ["cancel:compressed-race"]);
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
});
test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => {
const {
isTransferCancelledFlag,
@@ -1650,6 +1690,10 @@ test("orphan cancel marks process-global cancel so surviving walks stop", async
}]);
await store.cancel("walk-1");
assert.equal(isTransferCancelledFlag("walk-1"), true);
const resuming = store.resume("walk-1");
unregisterTransferWalk("walk-1");
await resuming;
assert.equal(isTransferCancelledFlag("walk-1"), false);
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled");
});
@@ -2706,6 +2750,39 @@ test("an interrupted task without its old controller can still be cancelled", as
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
});
test("orphan cancel keeps failed work visible and leaves completed children alone", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const cancelledIds: string[] = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
cancelTransfer: async (id: string) => {
cancelledIds.push(id);
return { success: id !== "unfinished" };
},
} },
});
const store = createSftpTransferCenterStore();
store.publishOwner("closed-panel", [
{ ...makeTask("directory", "paused"), isDirectory: true },
{ ...makeTask("finished", "completed"), parentTaskId: "directory" },
{ ...makeTask("unfinished", "queued"), parentTaskId: "directory" },
]);
const finishedBeforeCancel = store.getTask("finished");
await store.cancel("directory");
assert.deepEqual(cancelledIds, ["directory", "unfinished"]);
assert.equal(store.getTask("finished")?.status, finishedBeforeCancel?.status);
assert.equal(store.getTask("unfinished")?.status, "attention");
assert.equal(store.getTask("directory")?.status, "attention");
assert.match(store.getTask("directory")?.error ?? "", /Could not cancel/);
});
test("concurrent resume clicks adopt a task only once", async () => {
let adoptCount = 0;
const store = createSftpTransferCenterStore();
@@ -3867,3 +3944,66 @@ for (const outcome of ["rejected", "stream-gone"] as const) {
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
test("partial cancel failure resets recovered folder children so the re-walk can re-admit them", async (t) => {
const { netcattyBridge } = await import("../../infrastructure/services/netcattyBridge");
const { resetTransferCancelLatchesForTests, isTransferOrRootCancelled } =
await import("./sftp/transferCancelLatch");
const { resetTransferWalkRegistryForTests } = await import("./sftp/transferWalkRegistry");
const originalGet = netcattyBridge.get;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
t.after(() => {
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
});
// Only child2 fails to cancel; the folder must stay recoverable via Resume.
let failChild2 = true;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => ({ success: id !== "dir-child-2" || !failChild2 }),
cleanupTransferArtifacts: async () => ({}),
} as unknown as ReturnType<typeof netcattyBridge.get>);
const store = createSftpTransferCenterStore();
const child = (id: string): TransferTask => ({
...makeTask(id),
parentTaskId: "dir-root",
isDirectory: false,
transferredBytes: 4,
directoryEntryIndex: 0,
directoryEntryIdentity: "a".repeat(64),
});
store.publishOwner("gone-panel", [
{ ...makeTask("dir-root"), isDirectory: true, transferredBytes: 10, totalBytes: 20, directoryEntryIndex: 0 },
child("dir-child-1"),
child("dir-child-2"),
]);
await store.cancel("dir-root");
const rows = store.getSnapshot().tasks;
const parent = rows.find((row) => row.id === "dir-root");
const child1 = rows.find((row) => row.id === "dir-child-1");
const child2 = rows.find((row) => row.id === "dir-child-2");
assert.equal(parent?.status, "attention");
assert.equal(parent?.error, "Could not cancel transfer. Please try again.");
// A failed backend cancellation keeps the folder recoverable: successfully
// cancelled siblings must not settle as terminal "cancelled", or admitTaskRun
// would reject the recovery re-walk and leave the destination incomplete.
assert.equal(child1?.status, "interrupted");
assert.equal(child1?.error, undefined);
assert.equal(child2?.status, "attention");
assert.equal(isTransferOrRootCancelled("dir-root"), false, "recovery latch must be cleared");
// The directory re-walk (Resume) can re-admit the reset sibling.
const incoming = { ...child("dir-child-1"), directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64) };
assert.equal(store.admitTaskRun(incoming), "ready");
assert.equal(store.getTask("dir-child-1")?.status, "transferring");
// Without any cancellation failure the tree still settles fully cancelled.
failChild2 = false;
await store.cancel("dir-root");
const afterFullCancel = store.getSnapshot().tasks;
for (const id of ["dir-root", "dir-child-1", "dir-child-2"]) {
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
}
});
+133 -34
View File
@@ -26,8 +26,10 @@ import {
isTransferControlEpochCurrent,
settleTransferControlEpochTree,
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry";
import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome";
import {
clearTransferCancelledTree,
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
@@ -93,6 +95,12 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{
resetCheckpoint?: boolean;
}>;
interface RetainedCancellationTask {
task: TransferTask;
read(): TransferTask | undefined;
dispose(): void;
}
export interface SftpTransferCenterStore {
subscribe(listener: Listener): () => void;
/**
@@ -121,7 +129,10 @@ export interface SftpTransferCenterStore {
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
/** Wait for backend cancellation requests; live walks settle separately. */
cancel(taskId: string): Promise<void>;
/** Publish one cancellation outcome after the old execution has settled. */
settleCancellation(taskId: string, taskIds: ReadonlySet<string>, failedIds: ReadonlySet<string>, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise<void>): Promise<void>;
retry(taskId: string): Promise<void>;
prioritize(taskId: string): Promise<void>;
dismiss(taskId: string): void;
@@ -464,6 +475,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
const cancelInvocations = new Map<string, Promise<void>>();
const cancelSettlements = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
@@ -818,8 +831,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Compressed soft-control is process-global: do not gate on a live panel owner.
const liveCompressedJob = task?.controlKind === "compressed-upload"
&& task.reconnectRequired !== true
&& task.status !== "interrupted"
&& task.status !== "attention";
&& task.status !== "interrupted";
if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) {
const bridge = netcattyBridge.get();
if (action === "pause") {
@@ -879,8 +891,17 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (!result.success && result.reason) notify.warning(result.reason, "SFTP");
return;
}
const result = await (bridge?.cancelCompressedUpload?.(taskId)
?? { success: false });
const result = await Promise.resolve(bridge?.cancelCompressedUpload?.(taskId)
?? { success: false }).catch(() => ({ success: false }));
// The upload may finish while this cancel is still awaiting IPC: its
// completion event has already repainted the row terminal. The new
// inactive-compression response still reports success, so recheck the
// latest row before applying the cancellation result — otherwise Cancel
// all misreports a successfully completed upload as cancelled.
const latestAfterCancelIpc = tasks.find((candidate) => candidate.id === taskId);
if (latestAfterCancelIpc && ["completed", "failed", "cancelled"].includes(latestAfterCancelIpc.status)) {
return;
}
if (!result.success) {
const reason = "Could not cancel the compressed upload.";
tasks = tasks.map((candidate) => candidate.id === taskId ? {
@@ -889,6 +910,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
error: reason,
speed: 0,
} : candidate);
if (isTransferOrRootCancelled(taskId)) {
// The row is kept in attention for recovery instead of settling as
// cancelled. Drop the pre-installed cancellation latch now, or a
// later Resume is rejected outright by admitTaskRun's cancelled-root
// check (compressed uploads have no runWalk settlement to clear it).
clearTransferCancelledTree(taskId);
}
emit();
notify.warning(reason, "SFTP");
return;
@@ -1301,9 +1329,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
emit();
}
}
if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) {
if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId)
.filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status))
.map((candidate) => candidate.id);
// Stop surviving processTransfer walks immediately (panel-local
// cancelledTasksRef is gone after unmount).
@@ -1315,33 +1343,43 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
} catch {
// best-effort
}
try {
await netcattyBridge.get()?.cancelTransfer?.(taskId);
for (const childId of childIds) {
try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ }
const failedIds = new Set<string>();
const cancelIds = [taskId, ...childIds];
const cancelledIdSet = new Set(cancelIds);
const retainedTasks = tasks.filter((candidate) => cancelledIdSet.has(candidate.id))
.map((task) => ({ task, ...store.observeTaskSettlement(task) }));
for (let offset = 0; offset < cancelIds.length; offset += 32) {
await Promise.all(cancelIds.slice(offset, offset + 32).map(async (id) => {
try {
const result: unknown = await netcattyBridge.get()?.cancelTransfer?.(id);
if (result && typeof result === "object" && "success" in result && result.success === false) {
failedIds.add(id);
}
} catch {
failedIds.add(id);
}
}));
}
await store.settleCancellation(taskId, cancelledIdSet, failedIds, retainedTasks, async () => {
clearTransferCancelledTree(taskId, childIds);
if (failedIds.size > 0) {
const recoveryIds = cancelIds;
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}));
}
} else if (tasks.find((candidate) => candidate.id === taskId)?.status === "cancelled") {
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch { /* best-effort */ }
}
} catch {
// Best-effort backend cancel when the owning panel is gone / no window.
}
try {
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
transferId: taskId,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
stagedTargetPath: task.stagedTargetPath,
});
} catch {
// best-effort temp/.part cleanup
}
const cancelIds = new Set([taskId, ...childIds]);
tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? {
...candidate,
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
conflict: undefined,
} : candidate);
});
emit();
return;
}
@@ -1794,10 +1832,28 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
if (cancelInvocations.has(taskId) || isTransferOrRootCancelled(taskId)) return Promise.resolve();
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
// A cancelled scheduler job cannot be soft-resumed. Wait for the cancel
// outcome and fresh re-walk rather than unlatching the old walk.
const cancelling = cancelInvocations.get(taskId);
if (cancelling) {
await cancelling;
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
}
// Failed cancellation reports immediately, but its old walk must still
// settle before Resume can start a fresh scan. A retried Cancel may have
// replaced the awaited outcome, so drain the latest settlement as well.
while (cancelSettlements.has(taskId)) await cancelSettlements.get(taskId);
const afterCancellation = tasks.find((candidate) => candidate.id === taskId);
if (!afterCancellation || ["completed", "cancelled"].includes(afterCancellation.status)) return;
// Cancel all latches every selected tree before its bounded IPC batches.
// A row-level Resume must not revive a tree whose batch has not run yet.
if (isTransferOrRootCancelled(taskId)) return;
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
@@ -1913,9 +1969,52 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
},
async settleCancellation(taskId, taskIds, failedIds, retainedTasks, onSettled) {
const walkStillRunning = isTransferWalkInFlight(taskId);
const settlement = (async () => {
await waitForTransferWalkSettled(taskId);
// A later cancel attempt owns the outcome, even if this older attempt
// failed. Never repaint a successful retry as a failed cancellation.
if (cancelSettlements.get(taskId) !== settlement) return;
tasks = settleCancelledTransferTree(
tasks, taskId, taskIds, failedIds,
retainedTasks.map((retained) => retained.read() ?? retained.task),
);
await onSettled();
emit();
})().finally(() => {
for (const retained of retainedTasks) retained.dispose();
if (cancelSettlements.get(taskId) === settlement) cancelSettlements.delete(taskId);
});
cancelSettlements.set(taskId, settlement);
if (walkStillRunning) {
if (failedIds.size > 0) {
tasks = tasks.map((task) => task.id === taskId
&& !["completed", "failed"].includes(task.status)
? { ...task, status: "attention", error: "Could not cancel transfer. Please try again.", speed: 0 }
: task);
emit();
}
// Backend requests are done: let Cancel all dispatch its next batch.
// Resume, final status, and cleanup still wait on this settlement.
void settlement.catch(() => {});
return;
}
await settlement;
},
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
const existing = cancelInvocations.get(taskId);
if (existing) return existing;
bumpTransferControlEpoch(taskId);
for (const task of tasks) {
if (task.parentTaskId === taskId) bumpTransferControlEpoch(task.id);
}
const running = invoke(taskId, "cancel").finally(() => {
if (cancelInvocations.get(taskId) === running) cancelInvocations.delete(taskId);
});
cancelInvocations.set(taskId, running);
return running;
},
async retry(taskId) {
const ownerId = findOwner(taskId);
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { sftpTransferCenterStore as store } from "./sftpTransferCenterStore";
import { useGlobalSftpTransferActions } from "./useGlobalSftpTransferActions";
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./sftp/transferCancelLatch";
import { resetTransferRuntimeRunsForTests, transferRuntime } from "./sftp/transferRuntime";
import { resetTransferWalkRegistryForTests } from "./sftp/transferWalkRegistry";
test("Cancel all releases a skipped late failure so one Retry executes while the old walk unwinds", async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `late-failure-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
const later = tasks[32];
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const walking = transferRuntime.runWalk(later.id, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const cancellations: string[] = [];
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
cancellations.push(id);
if (id === tasks[0].id) store.patchTask(later.id, { status: "failed", error: "Connection lost" });
return { success: true };
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
clearPendingTransferCancel: async () => {}, cleanupTransferArtifacts: async () => {},
} as unknown as ReturnType<typeof netcattyBridge.get>);
let retries = 0;
store.setDedicatedResumeHandler(async () => { retries += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => { await actions!.cancelAll(); });
assert.equal(cancellations.length, 32, "the late failure must be skipped by the second batch");
assert.equal(store.getTask(later.id)?.status, "failed");
assert.equal(store.getTask(later.id)?.error, "Connection lost");
assert.equal(transferRuntime.isWalkInFlight(later.id), true);
await store.retry(later.id);
assert.equal(retries, 1, "one Retry must reach recovery instead of being swallowed by the pre-latch");
assert.equal(store.getTask(later.id)?.status, "completed");
assert.equal(isTransferOrRootCancelled(later.id), false);
});
for (const firstCancelSucceeds of [true, false]) {
test(`Cancel all dispatches later batches before walk settlement: success=${firstCancelSucceeds}`, async (t) => {
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalGet = netcattyBridge.get;
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
id: `dispatch-${firstCancelSucceeds}-${index}`, fileName: `${index}.bin`,
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
}));
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let renderer: ReactTestRenderer | undefined;
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
let cancelling: Promise<void> | undefined;
function Probe() {
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
return null;
}
store.publishOwner("closed-panel", tasks);
const first = tasks[0].id;
const walking = transferRuntime.runWalk(first, async () => { await held; });
t.after(async () => {
release();
await walking;
await act(async () => { await cancelling; renderer?.unmount(); });
store.setDedicatedResumeHandler(null);
for (const task of tasks) store.dismiss(task.id);
netcattyBridge.get = originalGet;
resetTransferCancelLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferRuntimeRunsForTests();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
});
const calls: string[] = [];
const cleaned: string[] = [];
let active = 0;
let peak = 0;
let recoveryCalls = 0;
netcattyBridge.get = () => ({
cancelTransfer: async (id: string) => {
calls.push(id);
peak = Math.max(peak, ++active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { success: id !== first || firstCancelSucceeds };
},
clearPendingTransferCancel: async () => {},
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
} as unknown as ReturnType<typeof netcattyBridge.get>);
store.setDedicatedResumeHandler(async () => { recoveryCalls += 1; return { success: true }; });
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
cancelling = actions!.cancelAll();
for (let tick = 0; tick < 5; tick += 1) await new Promise((resolve) => setImmediate(resolve));
});
assert.equal(calls.length, 33, "the later active stream must receive cancellation before the first walk finishes");
assert.ok(peak > 1 && peak <= 32, "backend cancellation remains bounded");
assert.equal(transferRuntime.isWalkInFlight(first), true);
assert.equal(isTransferOrRootCancelled(first), true, "dispatch completion must not clear the old walk latch");
assert.equal(cleaned.includes(first), false, "artifact cleanup still waits for settlement");
let resumeFinished = false;
const resuming = store.resume(first).then(() => { resumeFinished = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeFinished, false, "Resume must still wait for the cancelling walk");
assert.equal(recoveryCalls, 0);
release();
await Promise.all([walking, cancelling, resuming]);
assert.equal(recoveryCalls, firstCancelSucceeds ? 0 : 1);
assert.equal(store.getTask(first)?.status, firstCancelSucceeds ? "cancelled" : "completed");
});
}
@@ -1,28 +1,95 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import type { TransferTask } from "../../domain/models";
import {
getGlobalTransferBatchEligibility,
listGloballyCancellableTransferIds,
listGloballyPausableTransferIds,
listGloballyResumableTransferIds,
} from "../../domain/sftpTransferActions";
import { sftpTransferCenterStore } from "./sftpTransferCenterStore";
import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler";
import { clearTransferCancelledTree, markTransferCancelledTree } from "./sftp/transferCancelLatch";
import { transferRuntime } from "./sftp/transferRuntime";
export function useGlobalSftpTransferActions(tasks: readonly TransferTask[]) {
const cancellingRef = useRef(false);
const [isCancelling, setIsCancelling] = useState(false);
const batchEligibility = useMemo(
() => getGlobalTransferBatchEligibility(tasks),
[tasks],
);
const pauseAll = useCallback(() => {
// Ignore clicks racing a cancelAll batch: re-enabling these tasks would
// restart work cancelAll has already pre-cancelled at the scheduler.
if (cancellingRef.current) return;
for (const taskId of listGloballyPausableTransferIds(tasks)) {
void sftpTransferCenterStore.pause(taskId);
}
}, [tasks]);
const resumeAll = useCallback(() => {
if (cancellingRef.current) return;
for (const taskId of listGloballyResumableTransferIds(tasks)) {
void sftpTransferCenterStore.resume(taskId);
}
}, [tasks]);
return { batchEligibility, pauseAll, resumeAll };
const cancelAll = useCallback(async () => {
if (cancellingRef.current) return;
cancellingRef.current = true;
setIsCancelling(true);
const currentTasks = transferRuntime.getSnapshot().tasks;
const ids = listGloballyCancellableTransferIds(currentTasks);
try {
// Stop queued work before yielding to IPC, including children of folder
// transfers. Otherwise later batches could start while earlier ones stop.
const selected = new Set(ids);
const children = new Map<string, string[]>();
for (const task of currentTasks) {
if (!task.parentTaskId || !selected.has(task.parentTaskId)
|| ["completed", "failed", "cancelled"].includes(task.status)) continue;
const idsForParent = children.get(task.parentTaskId) ?? [];
idsForParent.push(task.id);
children.set(task.parentTaskId, idsForParent);
globalSftpTransferScheduler.cancel(task.id);
}
for (const id of ids) {
markTransferCancelledTree(id, children.get(id));
globalSftpTransferScheduler.cancel(id);
}
// Bound IPC work for large queues while active transfers stop promptly.
for (let offset = 0; offset < ids.length; offset += 32) {
await Promise.all(ids.slice(offset, offset + 32).map(async (taskId) => {
const current = transferRuntime.getTask(taskId);
// "failed" is terminal here just like the initial eligibility filter:
// a queued task can fail naturally while its cancel batch awaits IPC,
// and cancel's owner/orphan recovery paths would repaint the row as
// cancelled, hiding the real failure and its diagnostic.
if (!current || ["completed", "failed", "cancelled"].includes(current.status)) {
// No cancel invocation will own this pre-latch. Release it here so
// Retry is not discarded while the finished walk is unwinding.
clearTransferCancelledTree(taskId, children.get(taskId));
return;
}
try {
await transferRuntime.cancel(taskId);
} catch {
// The row is kept in attention for recovery, so the pre-installed
// cancellation latch must be dropped or a later Resume is rejected
// outright by admitTaskRun's cancelled-root check.
clearTransferCancelledTree(taskId, children.get(taskId));
sftpTransferCenterStore.patchTask(taskId, {
status: "attention",
error: "Could not cancel transfer. Please try again.",
});
}
}));
}
} finally {
cancellingRef.current = false;
setIsCancelling(false);
}
}, []);
return { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling };
}
+17 -2
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../domain/models";
import { listGloballyCancellableTransferIds } from "../domain/sftpTransferActions";
import {
buildGlobalTransferProgressDisplay,
getGlobalConflictActionPresentation,
@@ -65,14 +66,28 @@ test("batch actions are disabled when no task can accept them", () => {
assert.deepEqual(getGlobalTransferBatchEligibility([
task("done", "completed"),
task("cancelled", "cancelled"),
]), { pausableCount: 0, resumableCount: 0 });
]), { cancellableCount: 0, pausableCount: 0, resumableCount: 0 });
assert.deepEqual(getGlobalTransferBatchEligibility([
task("running", "transferring"),
task("waiting", "queued"),
task("paused", "paused"),
{ ...task("failed", "failed"), checkpointBytes: 4 },
]), { pausableCount: 2, resumableCount: 2 });
]), { cancellableCount: 3, pausableCount: 2, resumableCount: 2 });
});
test("cancel all selects unfinished top-level tasks without touching completed files", () => {
const statuses: TransferTask["status"][] = [
"pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention",
];
const tasks = [
...statuses.map((status) => task(status, status)),
task("completed", "completed"),
task("failed", "failed"),
task("cancelled", "cancelled"),
{ ...task("child", "queued"), parentTaskId: "pending" },
];
assert.deepEqual(listGloballyCancellableTransferIds(tasks), statuses);
});
test("paused transfer errors remain visible instead of looking safely paused", () => {
+7 -4
View File
@@ -837,7 +837,7 @@ export function GlobalSftpTransferCenter() {
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]);
const { visible, collapsed } = splitBackgroundTransfers(bucketTasks);
const displayed = showBackground ? [...visible, ...collapsed] : visible;
const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks);
const { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling } = useGlobalSftpTransferActions(snapshot.tasks);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -877,13 +877,16 @@ export function GlobalSftpTransferCenter() {
<div className="min-w-0 pr-2 text-sm font-semibold">
{t("sftp.transferCenter.title")}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
<div className="flex flex-wrap items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={isCancelling || batchEligibility.pausableCount === 0}>
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={isCancelling || batchEligibility.resumableCount === 0}>
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-destructive" onClick={() => { void cancelAll(); }} disabled={isCancelling || batchEligibility.cancellableCount === 0}>
<X size={12} className="mr-1" />{t("sftp.transferCenter.cancelAll")}
</Button>
</div>
</div>
+8
View File
@@ -3,6 +3,7 @@ import type { TransferTask } from "./models";
export function getGlobalTransferBatchEligibility(tasks: readonly TransferTask[]) {
const topLevel = tasks.filter((task) => !task.parentTaskId);
return {
cancellableCount: listGloballyCancellableTransferIds(tasks).length,
pausableCount: topLevel.filter((task) => (
["pending", "queued", "transferring"].includes(task.status)
&& task.resumable !== false
@@ -19,6 +20,13 @@ export function getGlobalTransferBatchEligibility(tasks: readonly TransferTask[]
};
}
export function listGloballyCancellableTransferIds(tasks: readonly TransferTask[]): string[] {
return tasks.filter((task) => (
!task.parentTaskId
&& ["pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention"].includes(task.status)
)).map((task) => task.id);
}
export function listGloballyPausableTransferIds(tasks: readonly TransferTask[]): string[] {
return tasks.filter((task) => (
!task.parentTaskId
+6
View File
@@ -93,6 +93,12 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
// progress after dedicated reconnect.
task.lifecycleEpoch = undefined;
}
// An attention row can still control a live compression job after Cancel
// fails. A persisted copy cannot: keep its attention/conflict details, but
// require reconnect rather than routing Resume to the old compression job.
if (task.controlKind === "compressed-upload" && task.status === "attention") {
task.reconnectRequired = true;
}
// Terminal rows must not keep conflict payloads. Older Skip paths cancelled the
// task without clearing conflict, so SFTP remount reopened the conflict dialog
// while the transfer center looked empty (no attention bucket row).
+4 -1
View File
@@ -942,9 +942,12 @@ async function cancelCompression(event, payload) {
}
}
// A missing registry entry means the compression already settled and was
// cleaned up: cancelling it is an idempotent no-op, not a failure. Report
// success so UI cancel paths do not revert settled rows back to attention.
return compression
? { success: true }
: { success: false, reason: 'Compression is not active' };
: { success: true, inactive: true };
}
async function pauseCompression(event, payload) {
@@ -531,6 +531,30 @@ test("a late cancel after completion cannot overwrite the completed terminal eve
assert.deepEqual(terminalAfter.map((event) => event.type), ["completed"]);
});
test("cancelling a settled compression that left the registry reports success", async (t) => {
const harness = createSuccessfulCompressionHarness(t);
await harness.handlers.get("netcatty:compress:start")({ sender: harness.sender }, {
compressionId: "settled-before-cancel",
folderPath: harness.folderPath,
targetPath: "/tmp",
sftpId: "sftp-harness",
folderName: "folder",
totalBytes: 7,
});
const result = await harness.handlers.get("netcatty:compress:cancel")({ sender: harness.sender }, {
compressionId: "settled-before-cancel",
});
assert.equal(result.success, true);
assert.equal(result.inactive, true);
const resultUnknownId = await harness.handlers.get("netcatty:compress:cancel")({ sender: harness.sender }, {
compressionId: "never-started",
});
assert.equal(resultUnknownId.success, true);
assert.equal(resultUnknownId.inactive, true);
});
test("compressed upload skips remote tar exec on single-channel SSH", async () => {
let execCalls = 0;
compressUploadBridge._resetCompressionSupportCacheForTests();
+1 -1
View File
@@ -47,7 +47,7 @@ declare global {
totalBytes: number;
}
): Promise<{ compressionId: string; success?: boolean; error?: string }>;
cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean }>;
cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean; inactive?: boolean }>;
pauseCompressedUpload?(compressionId: string): Promise<{ success: boolean; deferred?: boolean; lifecycleEpoch?: number; reason?: string }>;
resumeCompressedUpload?(compressionId: string): Promise<{ success: boolean; lifecycleEpoch?: number; reason?: string }>;
checkCompressedUploadSupport?(sftpId: string): Promise<{