mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5974d2e946 | ||
|
|
1dfbd411ae | ||
|
|
108b545bd6 | ||
|
|
5487d4522b | ||
|
|
620c9c88c4 | ||
|
|
a2fe1cd99f | ||
|
|
83dab66c3c | ||
|
|
ffe57f9672 | ||
|
|
89de0f8755 | ||
|
|
a6edbbc650 | ||
|
|
34343da81f | ||
|
|
85cfc7c4ba | ||
|
|
270e1dacf4 | ||
|
|
34f999d3b7 | ||
|
|
01a16ece0e | ||
|
|
0d3238db88 | ||
|
|
4f8a1983c9 | ||
|
|
b26b14d009 | ||
|
|
64ee11b017 | ||
|
|
dc741f5a19 | ||
|
|
a4d3b3d90a | ||
|
|
d497dc050d | ||
|
|
20996e4a4a | ||
|
|
b192089679 | ||
|
|
d558893c23 | ||
|
|
9588012d12 | ||
|
|
2170aed553 |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Скрыть фоновые',
|
||||
|
||||
@@ -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': '隐藏后台任务',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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<{
|
||||
|
||||
Reference in new issue
Block a user