Compare commits

...
Author SHA1 Message Date
bincxz 6b7b830b3c fix: do not stat remote files again after a failed preflight
A stat that returns no usable size, times out, or never runs is not repeated for the whole selection. The transfer plan stays unknown so the bridge measures the live size.
2026-09-29 20:26:13 +08:00
bincxz fd9fc6eb69 fix: stop preflight after a timeout and honor cancel
A half-open browse session no longer walks the whole selection at 10 seconds per file. Cancel during the overwrite size stat is checked before an apply-to-all replace deletes the existing target.
2026-09-29 20:22:27 +08:00
bincxz eda9ec462c fix: keep listing size out of the dual-pane transfer plan
A stat-less source can show the pane listing in the overwrite dialog, but that size must not be written back as totalBytes or a short listing truncates the file again. Files past the preflight cap stay on an unknown plan and are not statted a second time.
2026-09-29 20:15:24 +08:00
netcatty-bot cea26c51ba fix: address Codex review on PR #3560 2026-09-29 12:08:21 +00:00
netcatty-bot 78f17256d2 fix: address Codex review on PR #3560 2026-09-29 11:40:44 +00:00
netcatty-bot 905f00f56b fix: address Codex review on PR #3560 2026-09-29 11:08:19 +00:00
netcatty-bot 783966afd3 fix: address Codex review on PR #3560 2026-09-29 10:40:50 +00:00
netcatty-bot f02236a39d fix(#3559): automated AI fix 2026-09-29 10:02:41 +00:00
2 changed files with 761 additions and 20 deletions

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();
}
});
+210 -20
View File
@@ -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,