Compare commits

...
Author SHA1 Message Date
bincxz b2fc27d168 fix(sftp): settle direct folder downloads after pause and resume 2026-09-06 16:36:00 +08:00
5 changed files with 242 additions and 125 deletions

No files matched your search

@@ -0,0 +1,100 @@
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 { useSftpTransfers } from "./useSftpTransfers";
import { transferRuntime } from "./transferRuntime";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { releaseTransferPauseTree } from "./transferPauseLatch";
for (const closePanel of [false, true]) {
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let finishListing!: () => void;
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
let listingStarted!: () => void;
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
let listCalls = 0;
let maxCount = 0;
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
});
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => undefined,
startStreamTransfer: async (options: { transferId: string }) => {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
return {};
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let running: Promise<unknown> | undefined;
let rootId = "";
let reconnects = 0;
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
reconnects++;
return { success: false, error: "unexpected reconnect" };
});
function Probe() {
ops = useSftpTransfers({
ownerId: "direct-runtime-owner", getActivePane: () => null,
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => {
listCalls++; listingStarted(); await listingGate;
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(ops);
await act(async () => {
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
await started;
});
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
assert.ok(root); rootId = root.id;
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
await act(async () => { await ops!.pauseTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
await act(async () => { await ops!.resumeTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
assert.equal(reconnects, 0);
finishListing();
await act(async () => { assert.equal(await running, "completed"); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
assert.equal(listCalls, 1);
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
} finally {
releaseTransferPauseTree(rootId, []);
finishListing();
await act(async () => { await running; renderer?.unmount(); });
if (rootId) sftpTransferCenterStore.dismiss(rootId);
unsubscribe();
sftpTransferCenterStore.setDedicatedResumeHandler(null);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
@@ -500,3 +500,23 @@ for (const newerResume of [false, true]) {
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
registerTransferWalk(root.id);
const { host, getTasks } = createHost([root, child], () => ({
pauseTransfer: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}));
await softPauseTransfer(host, root.id);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(child.id), true);
// Completion during soft-drain compacts this row before the user resumes.
host.setTasks(getTasks().filter((task) => task.id !== child.id));
const resumed = await softResumeTransfer(host, root.id);
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");
});
+10 -2
View File
@@ -840,7 +840,9 @@ export function useSftpDirectoryTransferOps({
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
return { ...row, transferredBytes: row.transferredBytes + 1 };
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
return { ...row, transferredBytes: completed };
});
});
return;
@@ -924,6 +926,12 @@ export function useSftpDirectoryTransferOps({
|| parentRow.status === "pausing"
|| isPauseLatched(rootTaskId)
);
// Background completion may have already compacted this child and
// advanced the checkpoint. Count retained + compacted completions
// instead of incrementing the same file again when invoke settles.
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
+ prev.filter((row) => row.parentTaskId === rootTaskId
&& (row.status === "completed" || row.id === fileId)).length;
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
@@ -934,7 +942,7 @@ export function useSftpDirectoryTransferOps({
}
return {
...t,
transferredBytes: t.transferredBytes + 1,
transferredBytes: completed,
speed: t.speed,
};
}
+12 -1
View File
@@ -8,6 +8,9 @@
const pausedIds = new Set<string>();
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
// A completed child's history row can disappear while its worker still waits
// for the folder to resume. Keep the paused tree independent of UI history.
const pausedChildren = new Map<string, Set<string>>();
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
let barrier = barriers.get(taskId);
@@ -45,11 +48,18 @@ export function releaseTransferPause(taskId: string): void {
}
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
pausedChildren.delete(rootTaskId);
releaseTransferPause(rootTaskId);
for (const id of childIds) releaseTransferPause(id);
for (const id of children) releaseTransferPause(id);
}
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
if (childIds.length > 0) {
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) children.add(id);
pausedChildren.set(rootTaskId, children);
}
latchTransferPause(rootTaskId);
for (const id of childIds) latchTransferPause(id);
}
@@ -77,6 +87,7 @@ export function resetTransferPauseLatchesForTests(): void {
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
+100 -122
View File
@@ -37,10 +37,7 @@ import {
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
import {
bumpTransferControlEpoch,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
import { bumpTransferControlEpoch } from "./transferControlEpoch";
import { transferRuntime } from "./transferRuntime";
import {
clearTransferCancelled,
@@ -48,7 +45,6 @@ import {
isTransferCancelledFlag,
markTransferCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./transferCancelLatch";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import type { TransferConnectionLease } from "./transferConnectionPool";
@@ -1809,127 +1805,109 @@ export const useSftpTransfers = ({
return "attention";
}
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
const patchDownload = (updates: Partial<TransferTask>) => {
transferRuntime.patchTask(task.id, updates);
const canonical = transferRuntime.getTask(task.id);
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
};
const executeDownload = async (): Promise<TransferStatus> => {
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
await waitUntilTransferResumed(task.id);
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
try {
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
// Use childFailureCount (tracked outside React state) to determine
// final status reliably, regardless of render timing.
// Cancel must win: transferDirectory counts cancelled children as errors,
// but cancelTransfer already marked the parent cancelled — do not demote
// it to failed with "Some files failed to transfer".
// Re-read cancel inside the state update so a cancel that lands after
// transferDirectory returns cannot be overwritten by completed/failed.
let appliedStatus: TransferStatus = "completed";
setTransfers((prev) => {
const liveParent = prev.find((candidate) => candidate.id === task.id);
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
(t) => t.parentTaskId === task.id && t.status === "completed",
).length;
return prev.map((t) => {
if (t.id !== task.id) return t;
const parentCancelled = t.status === "cancelled"
|| cancelledTasksRef.current.has(task.id);
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled,
childFailureCount,
});
appliedStatus = resolved.status;
if (resolved.status === "cancelled") {
cancelledTasksRef.current.delete(task.id);
return {
...t,
status: "cancelled" as TransferStatus,
error: undefined,
endTime: Date.now(),
// Keep partial progress — do not look 100% complete when cancelled.
speed: 0,
};
}
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
const hasFailures = resolved.status === "failed";
return {
...t,
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
totalBytes: finalTotal,
transferredBytes: hasFailures ? completedCount : finalTotal,
speed: 0,
};
// Runtime owns lifecycle while the walk is registered. Publish the
// final root there before mirroring it to the panel.
const rows = sftpTransferCenterStore.getSnapshot().tasks;
const liveParent = transferRuntime.getTask(task.id) ?? task;
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
childFailureCount,
});
});
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? {
...t,
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
}
: t,
),
);
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
const childIds = sftpTransferCenterStore.getSnapshot().tasks
.filter((candidate) => candidate.parentTaskId === task.id)
.map((candidate) => candidate.id);
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
settleTransferControlEpochTree(task.id, relatedChildIds);
}
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
patchDownload({
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
speed: 0,
...(resolved.status === "cancelled" ? {} : {
totalBytes: finalTotal,
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
}),
});
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
speed: 0,
});
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
}
};
let result: TransferStatus = "failed";
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
transferRuntime.runWalk(task.id, async () => {
result = await executeDownload();
}),
);
return result;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sftpSessionsRef, acquireTransferSession],