mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7b830b3c | ||
|
|
fd9fc6eb69 | ||
|
|
eda9ec462c | ||
|
|
cea26c51ba | ||
|
|
78f17256d2 | ||
|
|
905f00f56b | ||
|
|
783966afd3 | ||
|
|
f02236a39d |
No files matched your search
@@ -0,0 +1,551 @@
|
||||
/** @test issue #3559 — dual-pane transfers must re-stat remote sources before planning */
|
||||
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 { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
totalBytes?: number;
|
||||
sourceSftpId?: string;
|
||||
};
|
||||
|
||||
/** Remote pane whose listing was captured before the file grew to 3626 bytes. */
|
||||
function makePane(side: "left" | "right"): SftpPane {
|
||||
const isLocal = side === "right";
|
||||
return {
|
||||
id: side,
|
||||
connection: {
|
||||
id: isLocal ? "local-conn" : "remote-conn",
|
||||
hostId: isLocal ? undefined : "host-1",
|
||||
hostLabel: isLocal ? "Local" : "SSH host",
|
||||
isLocal,
|
||||
status: "connected",
|
||||
currentPath: isLocal ? "/Users/reporter/Downloads" : "/opt",
|
||||
},
|
||||
files: isLocal
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3622,
|
||||
sizeFormatted: "3.5 KB",
|
||||
lastModified: 1758000000000,
|
||||
lastModifiedFormatted: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set<string>(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: true,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function installGlobals(netty: Record<string, unknown>) {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const previousStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousRaf = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame;
|
||||
const previousCancelRaf = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame;
|
||||
// Progress patches are rAF-coalesced; provide a microtask-ish shim.
|
||||
const raf = (cb: (time: number) => void) => setTimeout(() => cb(Date.now()), 0);
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = raf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (id: unknown) => clearTimeout(id as number);
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: netty,
|
||||
requestAnimationFrame: raf,
|
||||
cancelAnimationFrame: (id: unknown) => clearTimeout(id as number),
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
return () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRaf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelRaf;
|
||||
};
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
? "dual-pane download plan uses the live source size, not the stale listing (#3559)"
|
||||
: "dual-pane download plan falls back to live size measurement when stat is unavailable (#3559)",
|
||||
async () => {
|
||||
const statCalls: Array<{ sftpId: string; path: string }> = [];
|
||||
const startedOptions: StartOptions[] = [];
|
||||
let resolveTransfer!: (options: StartOptions) => void;
|
||||
const transferStarted = new Promise<StartOptions>((resolve) => { resolveTransfer = resolve; });
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async (sftpId: string, target: string) => {
|
||||
if (!liveStatAvailable) return undefined;
|
||||
statCalls.push({ sftpId, path: target });
|
||||
return {
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3626,
|
||||
sizeKnown: true,
|
||||
lastModified: 1758000001000,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
startedOptions.push(options);
|
||||
resolveTransfer(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const running = ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const options = await transferStarted;
|
||||
startedOptions.length = 0;
|
||||
startedOptions.push(options);
|
||||
await running;
|
||||
});
|
||||
|
||||
// Every fresh remote plan must come from a live re-stat of the source.
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(statCalls.length, 1);
|
||||
assert.equal(statCalls[0].sftpId, "sftp-remote");
|
||||
assert.equal(statCalls[0].path, "/opt/aops_dns_view_demo.sh");
|
||||
}
|
||||
const planBytes = startedOptions[0]?.totalBytes;
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(planBytes, 3626, "plan must use the live remote size, not the stale listing 3622");
|
||||
} else {
|
||||
// No live stat possible: plan 0 must be omitted so the transfer
|
||||
// bridge measures the live size instead of truncating.
|
||||
assert.equal(planBytes, undefined);
|
||||
}
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("stat-less remote source keeps the listing size in the overwrite dialog and leaves the plan unknown", async () => {
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
}),
|
||||
statLocal: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 100,
|
||||
lastModified: 1,
|
||||
}),
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, 3622);
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000000000);
|
||||
const task = ops!.transfers.find((row) => row.fileName === "aops_dns_view_demo.sh");
|
||||
assert.equal(task?.totalBytes, 0, "listing size must not become the transfer plan");
|
||||
assert.equal(task?.status, "attention");
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote transfer preflight stats stay within the concurrency cap and file bound", async () => {
|
||||
const limit = PREFLIGHT_STAT_CONCURRENCY;
|
||||
const fileCount = limit + 4;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
let releaseAll = () => {};
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
let reachedCap = () => {};
|
||||
const hitCap = new Promise<void>((resolve) => { reachedCap = resolve; });
|
||||
const names = Array.from({ length: fileCount }, (_, index) => `file-${index}.bin`);
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
if (inflight >= limit) reachedCap();
|
||||
try {
|
||||
await gate;
|
||||
} finally {
|
||||
inflight -= 1;
|
||||
}
|
||||
return {
|
||||
name: "file",
|
||||
type: "file" as const,
|
||||
size: 10,
|
||||
sizeKnown: true,
|
||||
lastModified: 1,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-concurrency",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
let running: Promise<unknown> = Promise.resolve();
|
||||
await act(async () => {
|
||||
running = ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const capTimeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("preflight did not reach the concurrency cap")), 2000);
|
||||
});
|
||||
await Promise.race([hitCap, capTimeout]);
|
||||
});
|
||||
assert.equal(maxInflight, limit);
|
||||
assert.ok(inflight <= limit);
|
||||
releaseAll();
|
||||
await act(async () => { await running; });
|
||||
assert.equal(maxInflight, limit);
|
||||
} finally {
|
||||
releaseAll();
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preflight timeout stops the rest of the batch", async () => {
|
||||
const extra = 4;
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + extra },
|
||||
(_, index) => `hung-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: () => {
|
||||
statCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-timeout",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
// One preflight wave, then stop. Timed-out files are not statted again.
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_CONCURRENCY);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less preflight results are not statted again before transfer", async () => {
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + 2 },
|
||||
(_, index) => `legacy-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
await Promise.resolve();
|
||||
inflight -= 1;
|
||||
return {
|
||||
name: "legacy",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless-batch",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, names.length);
|
||||
assert.ok(maxInflight <= PREFLIGHT_STAT_CONCURRENCY);
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(started.filter((options) => options.totalBytes === undefined).length, names.length);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("files past the preflight bound are not statted again and keep an unknown plan", async () => {
|
||||
const extra = 2;
|
||||
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
|
||||
let statCalls = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
name: "bulk",
|
||||
type: "file" as const,
|
||||
size: 20,
|
||||
sizeKnown: true,
|
||||
lastModified: 5,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-bound",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
|
||||
assert.equal(started.length, names.length);
|
||||
const omitted = started.filter((options) => options.totalBytes === undefined);
|
||||
assert.equal(omitted.length, extra);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -63,6 +63,21 @@ import {
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a half-open
|
||||
// session can leave a stat pending forever. Every stat issued outside the
|
||||
// bridge is bounded with this timeout; on timeout the caller falls back to
|
||||
// stale metadata / a 0 plan instead of one hung stat blocking a batch.
|
||||
const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Preflight re-stats share the browse SFTP session and the renderer IPC
|
||||
// queue. Launching one stat per file for a huge multi-file selection would
|
||||
// flood both before any transfer task reaches the UI, so cap the number of
|
||||
// in-flight preflight stats and skip the re-stat entirely once the selection
|
||||
// grows past a bound (files beyond the cap stay on the safe 0 plan, which
|
||||
// makes the bridge measure the live size anyway).
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -207,6 +222,9 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
// Remote files past the preflight cap keep an unknown plan. Mark them so
|
||||
// deferred discovery does not stat the whole tail at once.
|
||||
const preflightSkippedTaskIdsRef = useRef(new Set<string>());
|
||||
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
@@ -613,9 +631,30 @@ export const useSftpTransfers = ({
|
||||
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
const discoverTransferSize = async () => {
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
// metadata from the local task copy.
|
||||
const discoverTransferSize = async (): Promise<{
|
||||
size: number;
|
||||
lastModified?: number;
|
||||
/** False for pane-listing display data. That size must not become the plan. */
|
||||
plan: boolean;
|
||||
} | null> => {
|
||||
// When the endpoint cannot stat the source (legacy SCP returns the
|
||||
// sizeKnown: false placeholder) or the stat fails, the transfer plan
|
||||
// stays unknown (0 bytes, so the bridge measures the live size). The
|
||||
// overwrite dialog still needs display metadata, so fall back to the
|
||||
// retained cached listing entry instead of reporting 0 bytes — but only
|
||||
// for the dialog; the plan itself must never take the listing's
|
||||
// possibly stale size back.
|
||||
const cachedListingStatForDialog = (): { size: number; lastModified?: number; plan: false } | null => {
|
||||
if (getParentPath(task.sourcePath) !== sourcePane.connection?.currentPath) return null;
|
||||
const entry = sourcePane.files.find((candidate) => candidate.name === task.fileName);
|
||||
if (!entry || !(entry.size > 0)) return null;
|
||||
return { size: entry.size, lastModified: entry.lastModified, plan: false };
|
||||
};
|
||||
try {
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return null;
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
@@ -628,17 +667,41 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
if (preflightSkippedTaskIdsRef.current.has(task.id)) {
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
// This stat runs deferred (started before the conflict check) and
|
||||
// can be awaited by the conflict path, so it must not hang on a
|
||||
// half-open session. Bound it like the preflight stat and settle
|
||||
// early once the task is cancelled, since a marked task cannot
|
||||
// settle an in-flight SFTP callback itself.
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => resolve(undefined), STAT_SFTP_TIMEOUT_MS);
|
||||
}),
|
||||
new Promise<undefined>((resolve) => {
|
||||
cancelTimer = setInterval(() => {
|
||||
if (cancelledTasksRef.current.has(task.id)) resolve(undefined);
|
||||
}, 250);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (cancelTimer) clearInterval(cancelTimer);
|
||||
});
|
||||
if (stat && stat.sizeKnown !== false) {
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
@@ -647,12 +710,18 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
// Unknown size (e.g. legacy SCP stat): keep the transfer plan
|
||||
// unknown so a 0-byte placeholder never replaces trusted metadata,
|
||||
// but give the conflict dialog the retained cached listing entry.
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isTransferCancelledError(err)) {
|
||||
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
|
||||
}
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -746,16 +815,35 @@ export const useSftpTransfers = ({
|
||||
return null;
|
||||
})();
|
||||
|
||||
// For single files: fire-and-forget size discovery
|
||||
if (!task.isDirectory) {
|
||||
void discoverTransferSize();
|
||||
}
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
const conflict = await conflictCheckPromise;
|
||||
// Cancel/Stop may have won while conflict stats were in flight.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current
|
||||
.get(task.batchId ?? "global")
|
||||
@@ -819,7 +907,9 @@ export const useSftpTransfers = ({
|
||||
setConflicts((prev) => [...prev, conflict]);
|
||||
updateTask({
|
||||
status: "attention",
|
||||
totalBytes: conflict.newSize || task.totalBytes || 0,
|
||||
// Dialog newSize may be the listing fallback (plan: false). Only a
|
||||
// verified stat may become the plan; 0 stays omitted at startStreamTransfer.
|
||||
totalBytes: verifiedPlanBytes ?? 0,
|
||||
conflict,
|
||||
});
|
||||
return "attention";
|
||||
@@ -1094,6 +1184,84 @@ export const useSftpTransfers = ({
|
||||
? new Map(sourcePane.files.map(f => [f.name, f]))
|
||||
: null;
|
||||
|
||||
// Pane listings can be stale when the file changed in a terminal or
|
||||
// another session after the pane was listed (#3559). A stale undersized
|
||||
// size becomes the transfer plan and silently truncates the download, so
|
||||
// remote single files are always re-statted before the plan is built.
|
||||
// When no live stat is possible the plan stays 0, which makes the bridge
|
||||
// measure the live size instead of trusting the stale listing.
|
||||
const freshRemoteMetadata = new Map<string, { size: number; lastModified: number }>();
|
||||
const sourceSftpIdForPlan = sourcePane.connection.isLocal
|
||||
? null
|
||||
: (sftpSessionsRef.current.get(sourceConnectionId) ?? null);
|
||||
const remoteSingleFiles = sourcePane.connection.isLocal
|
||||
? []
|
||||
: sourceFiles.filter((file) => !file.isDirectory);
|
||||
const preflightFiles = remoteSingleFiles.slice(0, PREFLIGHT_STAT_MAX_FILES);
|
||||
// Remote files without a verified live stat are not statted again in
|
||||
// discoverTransferSize. That covers the tail past the cap, a missing
|
||||
// browse session, a timeout that stops the pool, and a stat that
|
||||
// returns no usable size. The plan stays unknown so the bridge measures
|
||||
// the live size; the overwrite dialog still uses the pane listing.
|
||||
const preflightSkippedNames = new Set<string>();
|
||||
if (remoteSingleFiles.length > 0 && sourceSftpIdForPlan) {
|
||||
const sourceEncoding = sourcePane.filenameEncoding || "auto";
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a
|
||||
// half-open session can leave a stat pending forever. Bound each
|
||||
// preflight stat: on timeout the file keeps its 0 plan and the bridge
|
||||
// measures the live size during the transfer instead of one hung stat
|
||||
// blocking the entire selected batch.
|
||||
const PREFLIGHT_STAT_TIMEOUT_MS = STAT_SFTP_TIMEOUT_MS;
|
||||
// These stats share the browse session. Keep a few in flight, and
|
||||
// after the first timeout stop claiming more files so a half-open
|
||||
// session cannot run the whole selection out to 10s each.
|
||||
let preflightStopped = false;
|
||||
const statOneRemoteFile = async (file: { name: string; isDirectory: boolean }) => {
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
try {
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpIdForPlan,
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve(undefined);
|
||||
}, PREFLIGHT_STAT_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timedOut) {
|
||||
preflightStopped = true;
|
||||
return;
|
||||
}
|
||||
if (stat && stat.type !== "symlink" && stat.sizeKnown !== false && stat.size >= 0) {
|
||||
freshRemoteMetadata.set(file.name, { size: stat.size, lastModified: stat.lastModified });
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to a 0 plan so the bridge measures the live size.
|
||||
logger.debug?.("[SFTP] Transfer plan re-stat failed; falling back to live size", err);
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
}
|
||||
};
|
||||
const workers = Math.min(PREFLIGHT_STAT_CONCURRENCY, preflightFiles.length);
|
||||
let preflightCursor = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: workers }, async () => {
|
||||
while (!preflightStopped && preflightCursor < preflightFiles.length) {
|
||||
const file = preflightFiles[preflightCursor++];
|
||||
await statOneRemoteFile(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const file of remoteSingleFiles) {
|
||||
if (!freshRemoteMetadata.has(file.name)) preflightSkippedNames.add(file.name);
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
@@ -1102,12 +1270,30 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network, but only when the transfer
|
||||
// source matches the pane's currently listed directory.
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
@@ -1167,8 +1353,12 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const taskId = crypto.randomUUID();
|
||||
if (preflightSkippedNames.has(file.name)) {
|
||||
preflightSkippedTaskIdsRef.current.add(taskId);
|
||||
}
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
|
||||
Reference in new issue
Block a user