[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createSftpBookmark,
moveSftpBookmark,
renameSftpBookmark,
} from "./bookmarkHelpers.ts";
test("moveSftpBookmark reorders by id and ignores unknown ids", () => {
const bookmarks = [
{ id: "a", path: "/a" },
{ id: "b", path: "/b" },
{ id: "c", path: "/c" },
];
assert.deepEqual(
moveSftpBookmark(bookmarks, "c", "a").map((item) => item.id),
["c", "a", "b"],
);
assert.equal(moveSftpBookmark(bookmarks, "missing", "a"), bookmarks);
assert.equal(moveSftpBookmark(bookmarks, "a", "a"), bookmarks);
});
test("renameSftpBookmark updates a trimmed label", () => {
const bookmarks = [
createSftpBookmark("/var/www"),
createSftpBookmark("/etc"),
];
const renamed = renameSftpBookmark(bookmarks, bookmarks[0].id, " Web ");
assert.equal(renamed[0].label, "Web");
assert.equal(renamed[1].label, bookmarks[1].label);
assert.equal(renameSftpBookmark(bookmarks, bookmarks[0].id, " "), bookmarks);
});

View File

@@ -0,0 +1,49 @@
import type { SftpBookmark } from "../../../domain/models";
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
export function getSftpBookmarkLabel(path: string): string {
const trimmed = path.trim();
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
}
export function createSftpBookmark(
path: string,
options: { global?: boolean; idPrefix?: string } = {},
): SftpBookmark {
const global = options.global === true;
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
return {
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label: getSftpBookmarkLabel(path),
...(global ? { global: true } : {}),
};
}
export function moveSftpBookmark<T extends { id: string }>(
bookmarks: T[],
fromId: string,
toId: string,
): T[] {
const from = bookmarks.findIndex((bookmark) => bookmark.id === fromId);
const to = bookmarks.findIndex((bookmark) => bookmark.id === toId);
if (from < 0 || to < 0 || from === to) return bookmarks;
const next = bookmarks.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
export function renameSftpBookmark<T extends { id: string; label: string }>(
bookmarks: T[],
id: string,
label: string,
): T[] {
const nextLabel = label.trim();
if (!nextLabel) return bookmarks;
return bookmarks.map((bookmark) => (
bookmark.id === id ? { ...bookmark, label: nextLabel } : bookmark
));
}

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
listRemoteBrowseConnectionIds,
listRemoteBrowseSftpTabIds,
isBrowseSessionInteractive,
listRemoteConnectionIdsForRestore,
shouldParkBrowseSessions,
shouldRestoreBrowseSessions,
takeBrowseSessionsForClose,
} from "./browseSessionLifecycle.ts";
test("editor retention tracks remote browse connections but excludes local panes", () => {
assert.deepEqual(listRemoteBrowseConnectionIds([
{ connection: { id: "remote-a", isLocal: false } },
{ connection: { id: "local-a", isLocal: true } },
{ connection: null },
{ connection: { id: "remote-a", isLocal: false } },
]), ["remote-a"]);
});
test("editor ownership tracks stable remote pane tab ids", () => {
assert.deepEqual(listRemoteBrowseSftpTabIds([
{ id: "pane-remote-a", connection: { id: "remote-a", isLocal: false } },
{ id: "pane-local", connection: { id: "local-a", isLocal: true } },
{ id: "pane-empty", connection: null },
{ id: "pane-remote-b", connection: { id: "remote-b", isLocal: false } },
]), ["pane-remote-a", "pane-remote-b"]);
});
test("keeps a hidden SFTP owner interactive while its promoted editor tab is open", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: true,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("keeps browse warm while the terminal side panel stays open on another tool", () => {
// SFTP→History/System replaces the focused pane tool, so surfaceVisible is
// false, but the owner stays mounted for instant switch-back. Parking here
// forces reconnect + directory reload (and intermittent blank lists).
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: true,
hasOwnedEditorTab: false,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("parks browse when the side panel is closed and nothing else retains it", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: false,
});
assert.equal(interactive, false);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true);
});
test("parks browse only when the interactive surface hides and not already parked", () => {
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
assert.equal(shouldParkBrowseSessions({ interactive: true, browseParked: false }), false);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeTransfersCount: 2,
}), false);
});
test("keeps a hidden SFTP owner live while an external editor temp file is open", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: true,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 1,
}), false);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 0,
}), true);
});
test("restores browse when the surface becomes interactive again after park", () => {
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: true }), true);
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: false }), false);
assert.equal(shouldRestoreBrowseSessions({ interactive: false, browseParked: true }), false);
});
test("takeBrowseSessionsForClose snapshots and clears the map", () => {
const sessions = new Map([
["conn-a", "sftp-1"],
["conn-b", "sftp-2"],
]);
assert.deepEqual(takeBrowseSessionsForClose(sessions), [
{ connectionId: "conn-a", sftpId: "sftp-1" },
{ connectionId: "conn-b", sftpId: "sftp-2" },
]);
assert.equal(sessions.size, 0);
});
test("listRemoteConnectionIdsForRestore skips local and already-live remotes", () => {
const ids = listRemoteConnectionIdsForRestore({
leftTabs: [
{ connection: { id: "local", isLocal: true } },
{ connection: { id: "remote-a", isLocal: false } },
{ connection: null },
],
rightTabs: [
{ connection: { id: "remote-b", isLocal: false } },
{ connection: { id: "remote-a", isLocal: false } },
],
liveSessionConnectionIds: new Set(["remote-b"]),
});
assert.deepEqual(ids, ["remote-a"]);
});

View File

@@ -0,0 +1,113 @@
/**
* Browse vs transfer session lifecycle helpers.
*
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
* SFTP channels while bulk transfers keep dedicated pool connections (and any
* leased browse sessions held by in-flight streams). Switching to another side
* panel tool (History / System / …) must keep browse warm — the owner stays
* mounted for instant switch-back, and parking would force reconnect + reload.
*/
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
/**
* Terminal side panel still open for this owner (another tool may be focused).
* Keeps browse sessions alive across tool switches without treating that as a
* full panel dismiss.
*/
ownerPanelOpen?: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible
|| !!params.ownerPanelOpen
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
}
export function listRemoteBrowseConnectionIds(
tabs: ReadonlyArray<{
connection: { id: string; isLocal: boolean } | null;
}>,
): string[] {
const ids = new Set<string>();
for (const tab of tabs) {
const connection = tab.connection;
if (!connection || connection.isLocal) continue;
ids.add(connection.id);
}
return [...ids];
}
/** Stable SFTP pane tab ids for editor ownership across browse reconnects. */
export function listRemoteBrowseSftpTabIds(
tabs: ReadonlyArray<{
id: string;
connection: { id: string; isLocal: boolean } | null;
}>,
): string[] {
const ids = new Set<string>();
for (const tab of tabs) {
const connection = tab.connection;
if (!connection || connection.isLocal) continue;
ids.add(tab.id);
}
return [...ids];
}
export function shouldParkBrowseSessions(params: {
interactive: boolean;
/** True after we already soft-closed browse while the owner stayed mounted. */
browseParked: boolean;
/** Defer park while unfinished transfers may still use browse sessions pre-lease. */
activeTransfersCount?: number;
/**
* Defer park while an external editor still holds a downloaded temp file.
* Parking calls closeSftp, which deletes those temps and breaks save/sync.
*/
activeExternalEditCount?: number;
}): boolean {
if (params.activeTransfersCount && params.activeTransfersCount > 0) return false;
if (params.activeExternalEditCount && params.activeExternalEditCount > 0) return false;
return !params.interactive && !params.browseParked;
}
export function shouldRestoreBrowseSessions(params: {
interactive: boolean;
browseParked: boolean;
}): boolean {
return params.interactive && params.browseParked;
}
export interface BrowseSessionEntry {
connectionId: string;
sftpId: string;
}
/** Snapshot + clear the connectionId→sftpId map used by the file browser. */
export function takeBrowseSessionsForClose(
sessions: Map<string, string>,
): BrowseSessionEntry[] {
const entries = [...sessions.entries()].map(([connectionId, sftpId]) => ({
connectionId,
sftpId,
}));
sessions.clear();
return entries;
}
export function listRemoteConnectionIdsForRestore(params: {
leftTabs: ReadonlyArray<{ connection: { id: string; isLocal: boolean } | null }>;
rightTabs: ReadonlyArray<{ connection: { id: string; isLocal: boolean } | null }>;
liveSessionConnectionIds: ReadonlySet<string>;
}): string[] {
const ids = new Set<string>();
for (const tab of [...params.leftTabs, ...params.rightTabs]) {
const connection = tab.connection;
if (!connection || connection.isLocal) continue;
if (params.liveSessionConnectionIds.has(connection.id)) continue;
ids.add(connection.id);
}
return [...ids];
}

View File

@@ -0,0 +1,35 @@
export type SortField = "name" | "size" | "modified" | "type" | "owner";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
name: number;
modified: number;
size: number;
type: number;
owner: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
name: true,
modified: true,
size: true,
type: true,
owner: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return DEFAULT_SFTP_COLUMN_VISIBILITY;
}
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
return {
name: true,
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
owner: stored.owner !== false,
};
};

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
});
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
});
test("restored compressed task may restart when its old worker no longer exists", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-restored",
reconnectRequired: true,
resume: async () => ({ success: false, reason: "Compression is not active" }),
});
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
});
test("successful compressed resume rejoins the existing job", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: true }),
});
assert.deepEqual(outcome, { kind: "resumed" });
});

View File

@@ -0,0 +1,16 @@
export type CompressedUploadResumeOutcome =
| { kind: "resumed" }
| { kind: "restart"; reason?: string }
| { kind: "failed"; reason: string };
export async function resumeCompressedUploadSafely(params: {
transferId: string;
reconnectRequired: boolean;
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
}): Promise<CompressedUploadResumeOutcome> {
const result = await (params.resume?.(params.transferId)
?? { success: false, reason: "Resume unavailable" });
if (result.success) return { kind: "resumed" };
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
}

View File

@@ -0,0 +1,40 @@
import { compressedUploadRequiresDedicatedSession } from "../../../domain/sftpDedicatedStreamPolicy";
export interface CompressedUploadSessionLease {
sftpId: string;
release: () => void;
discard: () => void;
}
export async function runWithCompressedUploadSession<T>(params: {
enabled: boolean;
hasDirectory: boolean;
isLocal: boolean;
hostId?: string;
jobId: string;
prepSftpId: string | null;
acquire?: (hostId: string, jobId: string) => Promise<CompressedUploadSessionLease>;
shouldDiscard: (error: unknown) => boolean;
run: (sftpId: string | null) => Promise<T>;
}): Promise<T> {
const required = compressedUploadRequiresDedicatedSession(params);
if (required && (!params.acquire || !params.hostId)) {
throw new Error("Dedicated transfer session unavailable");
}
let lease: CompressedUploadSessionLease | null = null;
try {
if (required && params.acquire && params.hostId) {
lease = await params.acquire(params.hostId, params.jobId);
}
return await params.run(lease?.sftpId ?? params.prepSftpId);
} catch (error) {
if (lease && params.shouldDiscard(error)) {
lease.discard();
lease = null;
}
throw error;
} finally {
lease?.release();
}
}

View File

@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
compressedUploadRequiresDedicatedSession,
remoteEndpointRequiresPool,
resolveDedicatedStreamEndpointIds,
resolveUploadStreamTargetSftpId,
} from "../../../domain/sftpDedicatedStreamPolicy.ts";
import { runWithCompressedUploadSession } from "./compressedUploadSession.ts";
test("compressed folder uploads hold a dedicated session for the whole job", async () => {
const calls: Array<[string, string]> = [];
const lease = {
sftpId: "dedicated-compressed",
poolKey: "host-1",
release: () => {},
discard: () => {},
};
const result = await runWithCompressedUploadSession({
enabled: true,
hasDirectory: true,
isLocal: false,
hostId: "host-1",
jobId: "compressed-job-1",
prepSftpId: "browse-session",
acquire: async (hostId, jobId) => {
calls.push([hostId, jobId]);
return lease;
},
shouldDiscard: () => false,
run: async (sftpId) => sftpId,
});
assert.equal(result, "dedicated-compressed");
assert.deepEqual(calls, [["host-1", "compressed-job-1"]]);
});
test("compressed upload refuses a page-owned fallback when a dedicated session is required", async () => {
await assert.rejects(
runWithCompressedUploadSession({
enabled: true,
hasDirectory: true,
isLocal: false,
hostId: "host-1",
jobId: "compressed-job-2",
prepSftpId: "browse-session",
shouldDiscard: () => false,
run: async (sftpId) => sftpId,
}),
/Dedicated transfer session unavailable/,
);
});
test("compressed upload discards a failed dedicated session instead of releasing it", async () => {
let discarded = 0;
let released = 0;
await assert.rejects(runWithCompressedUploadSession({
enabled: true,
hasDirectory: true,
isLocal: false,
hostId: "host-1",
jobId: "compressed-job-3",
prepSftpId: "browse-session",
acquire: async () => ({
sftpId: "dead-session",
discard: () => { discarded += 1; },
release: () => { released += 1; },
}),
shouldDiscard: () => true,
run: async () => { throw new Error("session closed"); },
}), /session closed/);
assert.equal(discarded, 1);
assert.equal(released, 0);
});
test("plain file upload keeps using its preparation session", async () => {
let acquireCalls = 0;
const result = await runWithCompressedUploadSession({
enabled: true,
hasDirectory: false,
isLocal: false,
hostId: "host-1",
jobId: "plain-file",
prepSftpId: "browse-session",
acquire: async () => {
acquireCalls += 1;
throw new Error("must not acquire");
},
shouldDiscard: () => false,
run: async (sftpId) => sftpId,
});
assert.equal(result, "browse-session");
assert.equal(acquireCalls, 0);
assert.equal(compressedUploadRequiresDedicatedSession({
enabled: true,
hasDirectory: false,
isLocal: false,
hostId: "host-1",
}), false);
});
test("remote ends with host id require the transfer pool when available", () => {
assert.equal(
remoteEndpointRequiresPool({ isLocal: false, hostId: "h1", poolAvailable: true }),
true,
);
assert.equal(
remoteEndpointRequiresPool({ isLocal: true, hostId: "h1", poolAvailable: true }),
false,
);
assert.equal(
remoteEndpointRequiresPool({ isLocal: false, hostId: "h1", poolAvailable: false }),
false,
);
});
test("resolveDedicatedStreamEndpointIds refuses silent browse fallback for required ends", () => {
const failed = resolveDedicatedStreamEndpointIds({
sourceIsLocal: true,
targetIsLocal: false,
targetHostId: "h1",
panelTargetSftpId: "browse-1",
poolAvailable: true,
});
assert.equal(failed.error, "Dedicated target transfer session unavailable");
assert.equal(failed.targetSftpId, undefined);
const ok = resolveDedicatedStreamEndpointIds({
sourceIsLocal: true,
targetIsLocal: false,
targetHostId: "h1",
targetPoolSftpId: "pool-1",
panelTargetSftpId: "browse-1",
poolAvailable: true,
});
assert.equal(ok.error, undefined);
assert.equal(ok.targetSftpId, "pool-1");
});
test("resolveUploadStreamTargetSftpId never substitutes prep when pool required", () => {
assert.deepEqual(
resolveUploadStreamTargetSftpId({
requirePool: true,
poolSftpId: null,
prepSftpId: "browse",
}),
{ error: "Dedicated transfer session unavailable" },
);
assert.deepEqual(
resolveUploadStreamTargetSftpId({
requirePool: true,
poolSftpId: "pool",
prepSftpId: "browse",
}),
{ sftpId: "pool" },
);
assert.deepEqual(
resolveUploadStreamTargetSftpId({
requirePool: false,
prepSftpId: "browse",
}),
{ sftpId: "browse" },
);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
});
}

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getDirectoryCacheEntry,
setDirectoryCacheEntry,
type DirectoryListingCache,
} from "./directoryListingCache";
const files = (count: number) => Array.from({ length: count }, (_value, index) => ({
name: `file-${index}`,
})) as never[];
test("directory cache removes expired entries when it is read", () => {
const cache: DirectoryListingCache = new Map([
["expired", { files: files(1), timestamp: 0 }],
["fresh", { files: files(1), timestamp: 95 }],
]);
assert.equal(getDirectoryCacheEntry(cache, "fresh", 100, 10)?.files.length, 1);
assert.equal(cache.has("expired"), false);
});
test("directory cache evicts least-recently-used listings by entry count", () => {
const cache: DirectoryListingCache = new Map();
setDirectoryCacheEntry(cache, "a", { files: files(1), timestamp: 1 }, { now: 1, ttlMs: 1_000, maxEntries: 2 });
setDirectoryCacheEntry(cache, "b", { files: files(1), timestamp: 2 }, { now: 2, ttlMs: 1_000, maxEntries: 2 });
assert.ok(getDirectoryCacheEntry(cache, "a", 3, 1_000));
setDirectoryCacheEntry(cache, "c", { files: files(1), timestamp: 4 }, { now: 4, ttlMs: 1_000, maxEntries: 2 });
assert.deepEqual([...cache.keys()], ["a", "c"]);
});
test("directory cache bounds retained file rows", () => {
const cache: DirectoryListingCache = new Map();
setDirectoryCacheEntry(cache, "large-a", { files: files(6), timestamp: 1 }, { now: 1, ttlMs: 1_000, maxFiles: 10 });
setDirectoryCacheEntry(cache, "large-b", { files: files(6), timestamp: 2 }, { now: 2, ttlMs: 1_000, maxFiles: 10 });
assert.deepEqual([...cache.keys()], ["large-b"]);
});
test("directory cache does not retain one listing larger than the file budget", () => {
const cache: DirectoryListingCache = new Map();
setDirectoryCacheEntry(
cache,
"oversized",
{ files: files(11), timestamp: 1 },
{ now: 1, ttlMs: 1_000, maxFiles: 10 },
);
assert.equal(cache.has("oversized"), false);
});

View File

@@ -0,0 +1,69 @@
import type { SftpFileEntry } from "../../../domain/models";
export interface DirectoryListingCacheEntry {
files: SftpFileEntry[];
timestamp: number;
}
export type DirectoryListingCache = Map<string, DirectoryListingCacheEntry>;
export const MAX_DIRECTORY_CACHE_ENTRIES = 128;
export const MAX_DIRECTORY_CACHE_FILES = 20_000;
function removeExpiredEntries(
cache: DirectoryListingCache,
now: number,
ttlMs: number,
): void {
for (const [key, entry] of cache) {
if (now - entry.timestamp >= ttlMs) cache.delete(key);
}
}
export function getDirectoryCacheEntry(
cache: DirectoryListingCache,
key: string,
now: number,
ttlMs: number,
): DirectoryListingCacheEntry | undefined {
removeExpiredEntries(cache, now, ttlMs);
const entry = cache.get(key);
if (!entry) return undefined;
cache.delete(key);
cache.set(key, entry);
return entry;
}
export function setDirectoryCacheEntry(
cache: DirectoryListingCache,
key: string,
entry: DirectoryListingCacheEntry,
options: {
now?: number;
ttlMs?: number;
maxEntries?: number;
maxFiles?: number;
} = {},
): void {
const now = options.now ?? Date.now();
const ttlMs = options.ttlMs ?? 10_000;
const maxEntries = options.maxEntries ?? MAX_DIRECTORY_CACHE_ENTRIES;
const maxFiles = options.maxFiles ?? MAX_DIRECTORY_CACHE_FILES;
removeExpiredEntries(cache, now, ttlMs);
cache.delete(key);
// A cache budget must remain a hard bound. Truncating a directory listing
// would be incorrect, so oversized listings are served to the caller but
// deliberately not retained.
if (entry.files.length > maxFiles || maxEntries < 1) return;
cache.set(key, entry);
let totalFiles = 0;
for (const value of cache.values()) totalFiles += value.files.length;
while (cache.size > 1 && (cache.size > maxEntries || totalFiles > maxFiles)) {
const oldestKey = cache.keys().next().value as string | undefined;
if (!oldestKey) break;
const oldest = cache.get(oldestKey);
cache.delete(oldestKey);
totalFiles -= oldest?.files.length ?? 0;
}
}

View File

@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";
import { promoteDirectoryReplaceStage } from "./directoryReplacePromotion";
function createPathHarness(initialPaths: string[]) {
const paths = new Set(initialPaths);
const operations: string[] = [];
return {
paths,
operations,
statPath: async (candidate: string) => paths.has(candidate) ? { type: "directory" } : null,
renamePath: async (source: string, target: string) => {
operations.push(`rename:${source}->${target}`);
if (!paths.has(source)) throw new Error(`ENOENT: ${source}`);
if (paths.has(target)) throw new Error(`EEXIST: ${target}`);
paths.delete(source);
paths.add(target);
},
deletePath: async (candidate: string) => {
operations.push(`delete:${candidate}`);
if (!paths.delete(candidate)) throw new Error(`ENOENT: ${candidate}`);
},
};
}
const targetPath = "/target/final";
const stagedPath = "/target/final.netcatty-live.part";
const backupPath = "/target/final.netcatty-live.backup";
test("live directory replace restores an interrupted backup before retrying publication", async () => {
const harness = createPathHarness([stagedPath, backupPath]);
await promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath: harness.deletePath,
});
assert.equal(harness.operations[0], `rename:${backupPath}->${targetPath}`);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(stagedPath), false);
assert.equal(harness.paths.has(backupPath), false);
});
test("live directory replace stops when the existing target cannot be backed up", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
const renamePath = async (source: string, target: string) => {
harness.operations.push(`rename:${source}->${target}`);
if (source === targetPath && target === backupPath) throw new Error("EACCES: backup denied");
return harness.renamePath(source, target);
};
await assert.rejects(
promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath,
deletePath: harness.deletePath,
}),
/backup denied/,
);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(stagedPath), true);
assert.equal(harness.paths.has(backupPath), false);
assert.equal(harness.operations.some((operation) => operation === `rename:${stagedPath}->${targetPath}`), false);
});
test("live directory replace retries transient backup cleanup", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
let deleteAttempts = 0;
const deletePath = async (candidate: string) => {
deleteAttempts += 1;
if (deleteAttempts < 3) throw new Error("EBUSY: backup locked");
return harness.deletePath(candidate);
};
await promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath,
});
assert.equal(deleteAttempts, 3);
assert.equal(harness.paths.has(targetPath), true);
assert.equal(harness.paths.has(backupPath), false);
});
test("live directory replace keeps the committed target recoverable when backup cleanup persists", async () => {
const harness = createPathHarness([targetPath, stagedPath]);
let deleteAttempts = 0;
await assert.rejects(
promoteDirectoryReplaceStage({
targetPath,
stagedPath,
backupPath,
statPath: harness.statPath,
renamePath: harness.renamePath,
deletePath: async () => {
deleteAttempts += 1;
throw new Error("EPERM: backup retained");
},
}),
/backup retained/,
);
assert.equal(deleteAttempts, 3);
assert.equal(harness.paths.has(targetPath), true, "the new committed target remains published");
assert.equal(harness.paths.has(backupPath), true, "the old tree remains available for recovery");
assert.equal(harness.paths.has(stagedPath), false);
});
test("live and restart-resume directory replacement both call the shared promotion helper", () => {
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
const resumeSource = fs.readFileSync(new URL("./dedicatedTransferResume.ts", import.meta.url), "utf8");
assert.match(liveSource, /promoteDirectoryReplacePaths\(\{/);
assert.match(resumeSource, /promoteDirectoryReplacePaths\(\{/);
});
test("live directory replacement bypasses the merge-only same-host copy shortcut", () => {
const liveSource = fs.readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
const sameHostCopyGuard = liveSource.slice(
liveSource.indexOf("if (\n task.isDirectory"),
liveSource.indexOf("sameHostCopyDirectory!", liveSource.indexOf("if (\n task.isDirectory")),
);
assert.match(sameHostCopyGuard, /!task\.replaceExistingTarget/);
});

View File

@@ -0,0 +1,132 @@
export const DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS = 3;
export function isMissingDirectoryReplacePathError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /\bENOENT\b|no such file|not found|does not exist/i.test(message);
}
export async function deleteDirectoryReplaceBackup(
deleteBackup: () => Promise<unknown>,
options: {
attempts?: number;
delay?: (attempt: number) => Promise<void>;
} = {},
): Promise<void> {
const attempts = Math.max(1, options.attempts ?? DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS);
const delay = options.delay ?? ((attempt: number) => new Promise<void>((resolve) => {
setTimeout(resolve, attempt * 25);
}));
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await deleteBackup();
return;
} catch (error) {
lastError = error;
if (attempt < attempts) await delay(attempt);
}
}
throw lastError;
}
export interface DirectoryReplacePromotionOptions {
targetPath: string;
stagedPath: string;
backupPath: string;
statPath: (path: string) => Promise<unknown>;
renamePath: (source: string, target: string) => Promise<unknown>;
deletePath: (path: string) => Promise<unknown>;
}
async function pathExists(
statPath: DirectoryReplacePromotionOptions["statPath"],
candidate: string,
): Promise<boolean> {
try {
return Boolean(await statPath(candidate));
} catch (error) {
if (isMissingDirectoryReplacePathError(error)) return false;
throw error;
}
}
function createDirectoryReplaceRecoveryError(
promotionError: unknown,
restoreError: unknown,
options: Pick<DirectoryReplacePromotionOptions, "targetPath" | "stagedPath" | "backupPath">,
): Error {
const error = new Error(
`Directory replacement failed and the original could not be restored. `
+ `Target: ${options.targetPath}; backup: ${options.backupPath}; stage: ${options.stagedPath}. `
+ `Promotion error: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}. `
+ `Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`,
);
(error as Error & { cause?: unknown }).cause = promotionError;
return error;
}
/**
* Publish a fully-written replacement directory with one shared recovery rule.
*
* Both live transfers and restart resume use this transaction:
* - restore the only known-good backup before touching anything else;
* - remove only a stale backup that accompanies a live final target;
* - ignore only a genuinely missing final target;
* - restore the original if stage promotion fails;
* - keep a committed target and its backup recoverable if cleanup keeps failing.
*/
export async function promoteDirectoryReplaceStage(
options: DirectoryReplacePromotionOptions,
): Promise<void> {
const {
targetPath,
stagedPath,
backupPath,
statPath,
renamePath,
deletePath,
} = options;
if (!targetPath || !stagedPath || !backupPath || stagedPath === targetPath) {
throw new Error("Invalid directory replacement paths");
}
const backupExists = await pathExists(statPath, backupPath);
if (backupExists) {
if (await pathExists(statPath, targetPath)) {
await deleteDirectoryReplaceBackup(() => deletePath(backupPath));
} else {
// An interrupted prior commit left the backup as the only known-good tree.
// Restore it before creating a new backup or publishing a rebuilt stage.
await renamePath(backupPath, targetPath);
}
}
let backedUp = false;
try {
await renamePath(targetPath, backupPath);
backedUp = true;
} catch (error) {
// Permission, conflict, and transport failures must stop publication. Only
// a missing target is safe to treat as a new-directory replacement.
if (!isMissingDirectoryReplacePathError(error)) throw error;
}
try {
await renamePath(stagedPath, targetPath);
} catch (promotionError) {
if (backedUp) {
try {
await renamePath(backupPath, targetPath);
} catch (restoreError) {
throw createDirectoryReplaceRecoveryError(promotionError, restoreError, options);
}
}
throw promotionError;
}
if (backedUp) {
// Publication is already committed. Persistent cleanup failure deliberately
// leaves both final and backup present and reports a retryable failure.
await deleteDirectoryReplaceBackup(() => deletePath(backupPath));
}
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createDirectDownloadTransferTask,
resolveDirectDirectoryDownloadFinalStatus,
} from "./downloadTransferTask";
test("download tasks retain the remote host needed for reconnecting after the original SSH session closes", () => {
const task = createDirectDownloadTransferTask({
id: "download-1",
fileName: "archive.bin",
sourcePath: "/remote/archive.bin",
targetPath: "/local/archive.bin",
sourceConnectionId: "connection-1",
sourceHostId: "host-1",
sourceHostLabel: "Production",
totalBytes: 128,
isDirectory: false,
});
assert.equal(task.sourceHostId, "host-1");
assert.equal(task.sourceHostLabel, "Production");
assert.equal(task.targetConnectionId, "local");
assert.equal(task.resumable, true);
assert.equal(task.status, "queued");
assert.equal(task.phase, undefined);
});
test("directory download final status stays cancelled when parent was cancelled mid-tree", () => {
// transferDirectory counts cancelled children as errors; parent cancel must win.
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: true,
childFailureCount: 3,
});
assert.equal(resolved.status, "cancelled");
assert.equal(resolved.error, undefined);
});
test("directory download final status is failed only when parent was not cancelled", () => {
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: false,
childFailureCount: 2,
});
assert.equal(resolved.status, "failed");
assert.equal(resolved.error, "Some files failed to transfer");
});
test("directory download final status is completed when no child failures", () => {
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: false,
childFailureCount: 0,
});
assert.equal(resolved.status, "completed");
assert.equal(resolved.error, undefined);
});
test("cancel wins even when child error count is zero (late cancel race)", () => {
// Snapshot may have been non-cancelled; re-check still forces cancelled.
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: true,
childFailureCount: 0,
});
assert.equal(resolved.status, "cancelled");
});

View File

@@ -0,0 +1,59 @@
import type { TransferStatus, TransferTask } from "../../../domain/models";
export interface DirectDownloadTransferTaskInput {
id: string;
fileName: string;
sourcePath: string;
targetPath: string;
sourceConnectionId: string;
sourceHostId: string;
sourceHostLabel: string;
totalBytes: number;
isDirectory: boolean;
}
export function createDirectDownloadTransferTask(
input: DirectDownloadTransferTaskInput,
): TransferTask {
return {
id: input.id,
fileName: input.fileName,
originalFileName: input.fileName,
sourcePath: input.sourcePath,
targetPath: input.targetPath,
sourceConnectionId: input.sourceConnectionId,
targetConnectionId: "local",
sourceHostId: input.sourceHostId,
sourceHostLabel: input.sourceHostLabel,
targetHostLabel: "Local",
direction: "download",
status: "queued",
totalBytes: input.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: input.isDirectory,
progressMode: input.isDirectory ? "files" : "bytes",
retryable: true,
origin: "manual",
resumable: true,
};
}
/**
* Final parent status after downloadToLocal finishes a directory tree.
* Cancel must win over child error counts — cancelled children are counted as
* errors by transferDirectory, but the parent was cancelled by the user.
*/
export function resolveDirectDirectoryDownloadFinalStatus(input: {
parentCancelled: boolean;
childFailureCount: number;
}): { status: TransferStatus; error?: string } {
if (input.parentCancelled) {
return { status: "cancelled" };
}
if (input.childFailureCount > 0) {
return { status: "failed", error: "Some files failed to transfer" };
}
return { status: "completed" };
}

View File

@@ -0,0 +1,260 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../../domain/models";
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
import type { SftpPane } from "./types";
const host = {
id: "host-1",
label: "CI-Build-01",
hostname: "ci.example",
port: 22,
username: "root",
protocol: "ssh",
} as Host;
const remotePane = (connectionId: string): SftpPane => ({
id: "pane-1",
connection: {
id: connectionId,
hostId: "host-1",
hostLabel: "CI-Build-01",
isLocal: false,
status: "connected",
currentPath: "/root",
},
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
connectionLogs: [],
} as unknown as SftpPane);
const noopReleaseConnection = async () => {};
test("SFTP session probe accepts a virtual root directory", async () => {
const calls: Array<[string, string]> = [];
const ok = await probeSftpSession({
realpathSftp: async (sftpId, remotePath) => {
calls.push([sftpId, remotePath]);
return "/";
},
}, "sftp-jumpserver");
assert.equal(ok, true);
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
});
test("SFTP session probe does not require home discovery", async () => {
const ok = await probeSftpSession(undefined, "sftp-live");
assert.equal(ok, true);
});
test("returns an existing mapped SFTP session without reconnecting", async () => {
let connectCalls = 0;
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: { current: new Map([["conn-1", "sftp-live"]]) },
lastConnectedHostRef: { current: { left: host, right: null } },
connect: async () => { connectCalls += 1; },
releaseConnection: noopReleaseConnection,
});
assert.equal(sftpId, "sftp-live");
assert.equal(connectCalls, 0);
});
test("reconnects when the mapped session is missing", async () => {
let connectCalls = 0;
const sessions = { current: new Map<string, string>() };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => {
// After reconnect, connection id stays and mapping is filled by connect mock.
return remotePane("conn-1");
},
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: host, right: null } },
connect: async () => {
connectCalls += 1;
sessions.current.set("conn-1", "sftp-reconnected");
},
releaseConnection: noopReleaseConnection,
});
assert.equal(connectCalls, 1);
assert.equal(sftpId, "sftp-reconnected");
});
test("resolves source session when restoring a missing browse session", async () => {
let connectOptions: { initialPath?: string; sourceSessionId?: string } | undefined;
let resolvedHost: Host | undefined;
const sessions = { current: new Map<string, string>() };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: host, right: null } },
resolveSourceSessionId: (hostId, reconnectHost) => {
resolvedHost = reconnectHost;
return hostId === "host-1" ? "term-1" : undefined;
},
connect: async (_side, _host, options) => {
connectOptions = options;
sessions.current.set("conn-1", "sftp-restored");
},
releaseConnection: noopReleaseConnection,
});
assert.equal(sftpId, "sftp-restored");
assert.equal(resolvedHost?.hostname, "ci.example");
assert.equal(connectOptions?.initialPath, "/root");
assert.equal(connectOptions?.sourceSessionId, "term-1");
});
test("forceReconnect reopens even when a mapping exists", async () => {
let connectCalls = 0;
const released: string[] = [];
const sessions = { current: new Map([["conn-1", "sftp-stale"]]) };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: host, right: null } },
forceReconnect: true,
releaseConnection: async (connectionId) => { released.push(connectionId); },
connect: async () => {
connectCalls += 1;
sessions.current.set("conn-1", "sftp-new");
},
});
assert.equal(connectCalls, 1);
assert.deepEqual(released, ["conn-1"]);
assert.equal(sftpId, "sftp-new");
});
test("probe failure triggers reconnect", async () => {
let connectCalls = 0;
const released: string[] = [];
const sessions = { current: new Map([["conn-1", "sftp-dead"]]) };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: host, right: null } },
probeSession: async () => {
throw new Error("SFTP session not found");
},
releaseConnection: async (connectionId) => { released.push(connectionId); },
connect: async () => {
connectCalls += 1;
sessions.current.set("conn-1", "sftp-fresh");
},
});
assert.equal(connectCalls, 1);
assert.deepEqual(released, ["conn-1"]);
assert.equal(sftpId, "sftp-fresh");
});
test("uses resolveHostById when lastConnectedHostRef is empty", async () => {
let connectedHost: Host | "local" | null = null;
const sessions = { current: new Map<string, string>() };
const sftpId = await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: null, right: null } },
resolveHostById: (id) => (id === "host-1" ? host : null),
connect: async (_side, resolved) => {
connectedHost = resolved;
sessions.current.set("conn-1", "sftp-vault");
},
releaseConnection: noopReleaseConnection,
});
assert.equal(sftpId, "sftp-vault");
assert.equal((connectedHost as Host).hostname, "ci.example");
assert.equal((connectedHost as Host).username, "root");
});
test("prefers per-tab connect-time host over vault base endpoint", async () => {
const vaultHost = {
...host,
hostname: "vault.example",
port: 22,
username: "root",
} as Host;
const sessionHost = {
...host,
hostname: "session.example",
port: 2222,
username: "override",
} as Host;
let connectedHost: Host | "local" | null = null;
const sessions = { current: new Map<string, string>() };
await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: null, right: null } },
resolveConnectedHost: (id) => (id === "pane-1" ? sessionHost : null),
resolveHostById: () => vaultHost,
connect: async (_side, resolved) => {
connectedHost = resolved;
sessions.current.set("conn-1", "sftp-session");
},
releaseConnection: noopReleaseConnection,
});
assert.equal((connectedHost as Host).hostname, "session.example");
assert.equal((connectedHost as Host).port, 2222);
assert.equal((connectedHost as Host).username, "override");
});
test("does not retarget background reconnect via side-wide lastConnectedHost overrides", async () => {
const vaultHost = {
...host,
hostname: "vault.example",
port: 22,
username: "root",
} as Host;
const activeTabOverrides = {
...host,
hostname: "other-tab.example",
port: 2222,
username: "other",
} as Host;
let connectedHost: Host | "local" | null = null;
const sessions = { current: new Map<string, string>() };
await ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: sessions,
lastConnectedHostRef: { current: { left: activeTabOverrides, right: null } },
resolveHostById: () => vaultHost,
connect: async (_side, resolved) => {
connectedHost = resolved;
sessions.current.set("conn-1", "sftp-vault");
},
releaseConnection: noopReleaseConnection,
});
assert.equal((connectedHost as Host).hostname, "vault.example");
assert.equal((connectedHost as Host).port, 22);
});
test("refuses synthetic root@label:22 when host metadata is missing", async () => {
await assert.rejects(
() => ensureRemoteSftpSession({
side: "left",
getActivePane: () => remotePane("conn-1"),
sftpSessionsRef: { current: new Map() },
lastConnectedHostRef: { current: { left: null, right: null } },
connect: async () => {
throw new Error("should not connect");
},
releaseConnection: noopReleaseConnection,
}),
/credentials are unavailable/,
);
});

View File

@@ -0,0 +1,155 @@
import type { Host } from "../../../domain/models";
import type { MutableRefObject } from "react";
import { isSessionError } from "./errors";
import type { SftpPane } from "./types";
export interface SftpSessionProbeBridge {
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
}
/**
* Probe the SFTP protocol itself instead of using remote home discovery.
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
* root while the SFTP session remains fully usable.
*/
export async function probeSftpSession(
bridge: SftpSessionProbeBridge | null | undefined,
sftpId: string,
): Promise<boolean> {
if (!bridge?.realpathSftp) return true;
await bridge.realpathSftp(sftpId, ".");
return true;
}
export interface EnsureRemoteSftpSessionParams {
side: "left" | "right";
getActivePane: (side: "left" | "right") => SftpPane | null;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connect: (
side: "left" | "right",
host: Host | "local",
options?: { initialPath?: string; ignoreSharedCache?: boolean; tabId?: string; sourceSessionId?: string },
) => Promise<void>;
/** Preferred already-authenticated SSH session for this reconnect. */
sourceSessionId?: string;
/** Resolve an SSH session after the reconnect host has been resolved. */
resolveSourceSessionId?: (hostId: string, host: Host) => string | undefined;
/**
* Per-tab connect-time host (includes session hostname/port/user overrides).
* Prefer this over the vault entry so upload reconnects keep the same endpoint.
*/
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
/** Resolve vault host by id when per-tab connect-time host is unavailable. */
resolveHostById?: (hostId: string) => Host | null | undefined;
probeSession?: (sftpId: string) => Promise<boolean>;
/** Remove connection metadata and close the mapped backend session. */
releaseConnection: (connectionId: string) => Promise<void>;
forceReconnect?: boolean;
/** Stable tab identity — reconnect replaces connection ids, not tab ids. */
tabId?: string;
}
/**
* Return a live remote SFTP session id for the active pane, reconnecting the
* host when the mapping is missing or the backend session is gone.
*/
export async function ensureRemoteSftpSession(
params: EnsureRemoteSftpSessionParams,
): Promise<string> {
const {
side,
getActivePane,
sftpSessionsRef,
lastConnectedHostRef,
connect,
resolveConnectedHost,
resolveHostById,
probeSession,
releaseConnection,
forceReconnect = false,
tabId,
sourceSessionId,
resolveSourceSessionId,
} = params;
const resolveHost = (): Host => {
const pane = getActivePane(side);
const hostId = pane?.connection && !pane.connection.isLocal ? pane.connection.hostId : undefined;
const resolvedTabId = tabId ?? pane?.id;
// Prefer the full Host captured when this tab connected (session-time
// hostname/port/username overrides). Vault lookup by hostId alone would
// reconnect the base endpoint and can open the wrong server before the
// upload endpoint assertion aborts.
if (resolvedTabId && resolveConnectedHost) {
const fromTab = resolveConnectedHost(resolvedTabId);
if (fromTab && fromTab !== "local") return fromTab;
}
// Vault next — never prefer side-wide lastConnectedHost over vault when
// another tab on this side may hold different overrides for the same hostId.
if (hostId && resolveHostById) {
const fromVault = resolveHostById(hostId);
if (fromVault) return fromVault;
}
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && lastHost !== "local" && (!hostId || lastHost.id === hostId)) {
return lastHost;
}
// Pane connection only stores hostId/label — inventing root@label:22 would
// open the wrong endpoint. Fail clearly so the caller can reconnect via
// vault host metadata instead of a synthetic identity.
if (pane?.connection && !pane.connection.isLocal) {
throw new Error(
`Cannot reconnect SFTP for "${pane.connection.hostLabel}": host credentials are unavailable. Reopen the host from the vault.`,
);
}
throw new Error("No remote host available to reconnect");
};
const readMappedId = (): string | undefined => {
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) {
throw new Error("No remote SFTP connection on this pane");
}
return sftpSessionsRef.current.get(pane.connection.id);
};
if (!forceReconnect) {
const existing = readMappedId();
if (existing) {
if (!probeSession) return existing;
try {
const ok = await probeSession(existing);
if (ok) return existing;
} catch (error) {
if (!isSessionError(error)) throw error;
}
const pane = getActivePane(side);
if (pane?.connection) {
await releaseConnection(pane.connection.id);
}
}
} else {
const pane = getActivePane(side);
if (pane?.connection) {
await releaseConnection(pane.connection.id);
}
}
const paneBefore = getActivePane(side);
const resumePath = paneBefore?.connection?.currentPath;
const host = resolveHost();
const resolvedSourceSessionId = sourceSessionId ?? resolveSourceSessionId?.(host.id, host);
await connect(side, host, {
initialPath: resumePath,
ignoreSharedCache: true,
...(tabId ? { tabId } : {}),
...(resolvedSourceSessionId ? { sourceSessionId: resolvedSourceSessionId } : {}),
});
const sftpId = readMappedId();
if (!sftpId) {
throw new Error("SFTP session not found after reconnect");
}
return sftpId;
}

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isMissingStatError } from "./errors";
test("isMissingStatError accepts only true path absence codes", () => {
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
const error = new Error("missing") as Error & { code: string | number };
error.code = code;
assert.equal(isMissingStatError(error), true, String(code));
}
assert.equal(isMissingStatError(new Error("ENOENT")), true);
});
test("isMissingStatError treats Electron-wrapped SFTP absence as missing", () => {
// ipcRenderer.invoke strips custom `code` and wraps the ssh2 message.
// New-file uploads lstat the destination first; this is the toast users see.
assert.equal(
isMissingStatError(
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
),
true,
);
assert.equal(isMissingStatError(new Error("No such file")), true);
assert.equal(isMissingStatError(new Error("No such file or directory")), true);
assert.equal(isMissingStatError(new Error("No such file: /tmp/tool.sh")), true);
assert.equal(
isMissingStatError(new Error("ENOENT: no such file or directory, lstat '/tmp/tool.sh'")),
true,
);
});
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
code: string;
lstatUnavailable: boolean;
};
enotsup.code = "ENOTSUP";
enotsup.lstatUnavailable = true;
assert.equal(isMissingStatError(enotsup), false);
const eperm = new Error("denied") as Error & { code: string };
eperm.code = "EPERM";
assert.equal(isMissingStatError(eperm), false);
assert.equal(isMissingStatError(new Error("channel closed")), false);
assert.equal(
isMissingStatError(
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: Permission denied"),
),
false,
);
// Path names are user-controlled; do not treat a substring hit as absence.
assert.equal(
isMissingStatError(
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
),
false,
);
assert.equal(
isMissingStatError(
new Error(
"Error invoking remote method 'netcatty:sftp:lstat': Error: EACCES: permission denied, lstat '/private/enoent/report.txt'",
),
),
false,
);
});

View File

@@ -0,0 +1,21 @@
export const isSessionError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("session lost") ||
msg.includes("channel not ready") ||
msg.includes("readdir is not a function") ||
msg.includes("channel closed") ||
msg.includes("connection closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("not connected") ||
msg.includes("client disconnected") ||
msg.includes("timed out")
);
};
export { isMissingStatError } from "../../../domain/sftpStatError";

View File

@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import {
isExternalDragDropFileUpload,
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
const baseTask = (overrides: Partial<TransferTask> = {}): TransferTask => ({
id: "child-1",
fileName: "a.txt",
sourcePath: "/tmp/docs/a.txt",
targetPath: "/remote/docs/a.txt",
sourceConnectionId: "external",
targetConnectionId: "conn-1",
targetHostId: "host-1",
direction: "upload",
status: "failed",
totalBytes: 12,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: false,
origin: "drag-drop",
parentTaskId: "folder-1",
error: "Network error",
...overrides,
});
test("detects progressive drag-drop file children as retryable", () => {
assert.equal(isExternalDragDropFileUpload(baseTask()), true);
assert.equal(isExternalDragDropFileUpload(baseTask({ isDirectory: true })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ origin: "manual" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ sourceConnectionId: "local" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ status: "completed" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ retryable: false })), false);
});
test("retry reuses the same transfer id and starts a stream upload", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const streams: Array<Record<string, unknown>> = [];
// No pool acquire → falls back to browse sftp id.
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
streams.push(options as unknown as Record<string, unknown>);
return { transferId: options.transferId };
},
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
assert.equal(streams.length, 1);
assert.equal(streams[0].transferId, "child-1");
assert.equal(streams[0].sourcePath, "/tmp/docs/a.txt");
assert.equal(streams[0].targetPath, "/remote/docs/a.txt");
assert.equal(streams[0].targetSftpId, "sftp-live");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches.at(-1)?.updates.status, "completed");
});
test("retry prefers a dedicated pool session over the browse sftp id", async () => {
let released = false;
let acquired = false;
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
acquireTransferSession: async () => {
acquired = true;
return {
sftpId: "sftp-pool",
poolKey: "host-1",
release: () => { released = true; },
discard: () => {},
};
},
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-pool");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
assert.equal(acquired, true);
assert.equal(released, true);
});
test("retry falls back to browse when pool acquire is unavailable", async () => {
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-live");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
});
test("retry rolls parent to completed when all siblings succeed", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "failed",
sourcePath: "/tmp/docs",
targetPath: "/remote/docs",
error: "1 of 2 files failed",
totalBytes: 2,
transferredBytes: 1,
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
const parentPatch = patches.find((p) => p.id === "folder-1");
assert.ok(parentPatch);
assert.equal(parentPatch?.updates.status, "completed");
});
test("retry does not rollup a still-transferring progressive parent", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "transferring",
sourcePath: "/tmp/docs",
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(patches.some((p) => p.id === "folder-1"), false);
});
test("retry fails clearly when no sftp session can be opened", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => undefined,
startStreamTransfer: async () => {
throw new Error("should not start");
},
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.match(result.error || "", /No SFTP session/);
assert.equal(patches.at(-1)?.status, "failed");
});
test("stream error keeps the row failed with the backend message", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({ error: "Permission denied" }),
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.equal(result.error, "Permission denied");
assert.equal(patches.at(-1)?.status, "failed");
assert.equal(patches.at(-1)?.error, "Permission denied");
});

View File

@@ -0,0 +1,267 @@
/**
* Low-cost retry for progressive / external drag-drop file uploads.
*
* These rows use sourceConnectionId "external" and never have dual-pane
* endpoints, so the generic processTransfer retry path silently no-ops.
* Retry re-opens a single startStreamTransfer with the stored local path.
*/
import type { TransferTask } from "../../../domain/models";
import type { TransferConnectionLease } from "./transferConnectionPool";
import { isTransferCancelledFlag } from "./transferCancelLatch";
export function isExternalDragDropFileUpload(
task: Pick<
TransferTask,
| "origin"
| "direction"
| "sourceConnectionId"
| "isDirectory"
| "sourcePath"
| "targetPath"
| "retryable"
| "status"
>,
): boolean {
if (task.retryable === false) return false;
if (task.origin !== "drag-drop") return false;
if (task.direction !== "upload") return false;
if (task.sourceConnectionId !== "external") return false;
if (task.isDirectory) return false;
if (!task.sourcePath || task.sourcePath === "local") return false;
if (!task.targetPath) return false;
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
}
export type ExternalDragDropRetryDeps = {
getBrowseSftpId: (connectionId: string) => string | undefined;
acquireTransferSession?: (
hostId: string,
transferId: string,
) => Promise<TransferConnectionLease>;
startStreamTransfer: (options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: "local";
targetType: "local" | "sftp";
targetSftpId?: string;
targetHostId?: string;
totalBytes?: number;
resumable?: boolean;
checkpointBytes?: number;
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
clearPendingCancel?: (transferId: string) => Promise<unknown>;
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
/** Live store lookup for terminal cancel races and completion bytes. */
getTask?: (taskId: string) => TransferTask | undefined;
/** Children of a progressive parent (for rollup after child success). */
getChildTasks?: (parentTaskId: string) => TransferTask[];
};
/**
* Restart a failed/cancelled external drag-drop file upload in place (same id).
* Returns true when the stream completed successfully.
*/
export async function retryExternalDragDropFileUpload(
task: TransferTask,
deps: ExternalDragDropRetryDeps,
): Promise<{ success: boolean; error?: string }> {
if (!isExternalDragDropFileUpload(task)) {
return { success: false, error: "Not an external drag-drop file upload" };
}
try {
await deps.clearPendingCancel?.(task.id);
} catch {
// best-effort
}
try {
await deps.cleanupArtifacts?.(task);
} catch {
// best-effort
}
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
let lease: TransferConnectionLease | null = null;
let targetSftpId: string | undefined;
try {
if (!targetIsLocal) {
// Prefer a dedicated pool session when hostId is known — same policy as
// progressive upload (do not pin the browse/tab session).
if (task.targetHostId && deps.acquireTransferSession) {
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
targetSftpId = lease.sftpId;
}
if (!targetSftpId) {
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
}
if (!targetSftpId) {
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
deps.onPatch(task.id, {
status: "failed",
error,
endTime: Date.now(),
speed: 0,
});
return { success: false, error };
}
}
deps.onPatch(task.id, {
status: "transferring",
error: undefined,
transferredBytes: 0,
checkpointBytes: 0,
speed: 0,
endTime: undefined,
phase: "transferring",
reconnectRequired: false,
pauseUnavailableReason: undefined,
startTime: Date.now(),
});
const result = await deps.startStreamTransfer({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: "local",
targetType: targetIsLocal ? "local" : "sftp",
targetSftpId: targetIsLocal ? undefined : targetSftpId,
targetHostId: targetIsLocal ? undefined : task.targetHostId,
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
resumable: true,
checkpointBytes: 0,
});
// Late cancel can settle the row before this return; never resurrect it.
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
if (result?.error) {
deps.onPatch(task.id, {
status: "failed",
error: result.error,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: result.error };
}
const live = deps.getTask?.(task.id);
const completedBytes = Math.max(
live?.totalBytes ?? 0,
live?.transferredBytes ?? 0,
task.totalBytes,
0,
);
deps.onPatch(task.id, {
status: "completed",
error: undefined,
transferredBytes: completedBytes,
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
endTime: Date.now(),
speed: 0,
phase: undefined,
});
// Progressive parents finalize once with "N of M failed". After a child
// retry succeeds, re-roll the parent when no failed children remain.
if (task.parentTaskId && deps.getChildTasks) {
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
}
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
deps.onPatch(task.id, {
status: "failed",
error: message,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
try { lease.discard(); } catch { /* best-effort */ }
lease = null;
}
return { success: false, error: message };
} finally {
try { lease?.release(); } catch { /* best-effort */ }
}
}
function rollupParentAfterChildSuccess(
parentTaskId: string,
completedChildId: string,
deps: ExternalDragDropRetryDeps,
): void {
// Only re-roll parents that already finished the progressive walk as failed.
// Never promote a still-scanning/transferring parent mid-walk.
const parent = deps.getTask?.(parentTaskId);
if (
parent
&& parent.status !== "failed"
&& parent.status !== "attention"
&& parent.status !== "cancelled"
) {
return;
}
const children = deps.getChildTasks?.(parentTaskId) ?? [];
if (children.length === 0) return;
const stillFailed = children.some(
(child) =>
child.id !== completedChildId
&& (child.status === "failed" || child.status === "attention"),
);
if (stillFailed) return;
const active = children.some(
(child) =>
child.id !== completedChildId
&& (
child.status === "transferring"
|| child.status === "pending"
|| child.status === "queued"
|| child.status === "pausing"
|| child.status === "paused"
),
);
if (active) return;
const completedCount = children.filter(
(child) => child.id === completedChildId || child.status === "completed",
).length;
const total = Math.max(
Number(parent?.totalBytes) || 0,
children.length,
completedCount,
);
deps.onPatch(parentTaskId, {
status: "completed",
error: undefined,
transferredBytes: completedCount,
totalBytes: total,
speed: 0,
endTime: Date.now(),
phase: undefined,
});
}

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
const retention = createExternalEditTempRetention();
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
assert.equal(retention.size, 2);
assert.equal(retention.forgetSftp("sftp-a"), true);
assert.equal(retention.size, 1);
assert.equal(retention.forgetSftp("sftp-a"), false);
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
assert.equal(retention.size, 0);
});
test("clear drops every retainer after session-wide cleanup", () => {
const retention = createExternalEditTempRetention();
retention.remember("sftp-1", "/tmp/one.txt");
retention.remember("sftp-2", "/tmp/two.txt");
assert.equal(retention.clear(), true);
assert.equal(retention.size, 0);
assert.equal(retention.clear(), false);
});

View File

@@ -0,0 +1,59 @@
/**
* Renderer-side retainers for remote temps opened in external editors.
* closeSftp deletes those files; forget matching retainers so park/auto-connect
* checks do not keep treating a deleted temp as active work.
*/
export type ExternalEditTempRetention = {
remember(sftpId: string, localPath: string): boolean;
forgetPath(localPath: string): boolean;
forgetSftp(sftpId: string): boolean;
clear(): boolean;
readonly size: number;
};
export function createExternalEditTempRetention(): ExternalEditTempRetention {
const bySftp = new Map<string, Set<string>>();
const recount = (): number => {
let total = 0;
for (const paths of bySftp.values()) total += paths.size;
return total;
};
return {
remember(sftpId, localPath) {
if (!sftpId || !localPath) return false;
let paths = bySftp.get(sftpId);
if (!paths) {
paths = new Set();
bySftp.set(sftpId, paths);
}
if (paths.has(localPath)) return false;
paths.add(localPath);
return true;
},
forgetPath(localPath) {
if (!localPath) return false;
let changed = false;
for (const [sftpId, paths] of bySftp) {
if (!paths.delete(localPath)) continue;
changed = true;
if (paths.size === 0) bySftp.delete(sftpId);
}
return changed;
},
forgetSftp(sftpId) {
if (!sftpId || !bySftp.has(sftpId)) return false;
bySftp.delete(sftpId);
return true;
},
clear() {
if (bySftp.size === 0) return false;
bySftp.clear();
return true;
},
get size() {
return recount();
},
};
}

View File

@@ -0,0 +1,181 @@
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 {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
type ExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
test("failed external app launch unregisters and deletes its remote temp file", async () => {
const calls: string[] = [];
await cleanupFailedExternalOpenTemp({
unregisterTempFile: async (sftpId, localPath) => {
calls.push(`${sftpId}:${localPath}`);
return { success: true };
},
}, "sftp-1", "/tmp/edit.txt");
assert.deepEqual(calls, ["sftp-1:/tmp/edit.txt"]);
});
test("renderer tracks a reused watch once and releases it on unmount", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
lifecycle!.remember("watch-reused");
lifecycle!.remember("watch-reused");
});
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{ watchId: "watch-reused", cleanupTempFile: false }]);
});
test("explicit SFTP lifecycle cleanup releases every tracked watch and temp file", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
lifecycle!.remember("watch-a");
lifecycle!.remember("watch-b");
await lifecycle!.releaseAll(true);
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(stopped, [
{ watchId: "watch-a", cleanupTempFile: true },
{ watchId: "watch-b", cleanupTempFile: true },
]);
await act(async () => renderer!.unmount());
});
test("a watch that starts after unmount is stopped instead of being retained", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
const lateLifecycle = lifecycle!;
await act(async () => renderer!.unmount());
lateLifecycle.remember("watch-started-after-unmount");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{
watchId: "watch-started-after-unmount",
cleanupTempFile: false,
}]);
assert.equal(lateLifecycle.activeCountRef.current, 0);
});
test("release invalidates pending watch starts while a new generation remains usable", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
const oldGeneration = lifecycle!.captureGeneration();
await lifecycle!.releaseAll(true);
lifecycle!.remember("late-old-watch", oldGeneration);
const currentGeneration = lifecycle!.captureGeneration();
lifecycle!.remember("fresh-watch", currentGeneration);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{ watchId: "late-old-watch", cleanupTempFile: true }]);
assert.equal(lifecycle!.activeCountRef.current, 1);
await lifecycle!.releaseAll(false);
assert.deepEqual(stopped, [
{ watchId: "late-old-watch", cleanupTempFile: true },
{ watchId: "fresh-watch", cleanupTempFile: false },
]);
await act(async () => renderer!.unmount());
});
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let onBackendStopped: ((payload: {
watchId: string;
localPath?: string;
}) => void) | null = null;
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
const forgottenPaths: string[] = [];
function Probe() {
lifecycle = useExternalFileWatchLifecycle(
async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
},
(callback) => {
onBackendStopped = callback;
return () => { onBackendStopped = null; };
},
(localPath) => { forgottenPaths.push(localPath); },
);
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
lifecycle!.remember("watch-closed-with-session");
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => {
onBackendStopped?.({
watchId: "watch-closed-with-session",
localPath: "/tmp/externally-deleted.txt",
});
});
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [], "backend-owned cleanup must not issue a duplicate stop");
});

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
export async function cleanupFailedExternalOpenTemp(
bridge: Pick<NetcattyBridge, "unregisterTempFile" | "deleteTempFile">,
sftpId: string,
localPath: string,
): Promise<void> {
if (!localPath) return;
if (bridge.unregisterTempFile) {
await bridge.unregisterTempFile(sftpId, localPath);
return;
}
await bridge.deleteTempFile?.(localPath);
}
export type StopExternalFileWatch = (
watchId: string,
cleanupTempFile: boolean,
) => void | Promise<unknown>;
export type SubscribeExternalFileWatchStopped = (
callback: (payload: {
watchId: string;
localPath?: string;
remotePath?: string;
sftpId?: string;
}) => void,
) => (() => void) | void;
export interface ExternalFileWatchLifecycle {
activeCountRef: MutableRefObject<number>;
captureGeneration(): number;
remember(watchId: string | undefined, generation?: number): void;
releaseAll(cleanupTempFiles?: boolean): Promise<void>;
}
export function useExternalFileWatchLifecycle(
stopWatch: StopExternalFileWatch,
subscribeStopped?: SubscribeExternalFileWatchStopped,
onStoppedLocalPath?: (localPath: string) => void,
): ExternalFileWatchLifecycle {
const watchIdsRef = useRef<Set<string>>(new Set());
const activeCountRef = useRef(0);
const stopWatchRef = useRef(stopWatch);
const subscribeStoppedRef = useRef(subscribeStopped);
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
const disposedRef = useRef(false);
const generationRef = useRef(0);
const invalidatedCleanupTempFilesRef = useRef(false);
stopWatchRef.current = stopWatch;
subscribeStoppedRef.current = subscribeStopped;
onStoppedLocalPathRef.current = onStoppedLocalPath;
const captureGeneration = useCallback(() => generationRef.current, []);
const remember = useCallback((watchId: string | undefined, generation = generationRef.current) => {
if (!watchId) return;
if (disposedRef.current || generation !== generationRef.current) {
// startFileWatch may resolve after the owning React tree unmounts. There
// may also have been an explicit disconnect cleanup while IPC was pending.
// There will be no later cleanup for the old generation, so release now.
const cleanupTempFile = disposedRef.current
? false
: invalidatedCleanupTempFilesRef.current;
void Promise.resolve()
.then(() => stopWatchRef.current(watchId, cleanupTempFile))
.catch(() => {});
return;
}
watchIdsRef.current.add(watchId);
activeCountRef.current = watchIdsRef.current.size;
}, []);
const releaseAll = useCallback(async (cleanupTempFiles = false) => {
invalidatedCleanupTempFilesRef.current = cleanupTempFiles;
generationRef.current += 1;
const watchIds = [...watchIdsRef.current];
watchIdsRef.current.clear();
activeCountRef.current = 0;
await Promise.all(watchIds.map(async (watchId) => {
try {
await stopWatchRef.current(watchId, cleanupTempFiles);
} catch {
// The owning SFTP session or worker may already have released it.
}
}));
}, []);
useEffect(() => {
disposedRef.current = false;
return () => {
disposedRef.current = true;
void releaseAll(false);
};
}, [releaseAll]);
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
activeCountRef.current = watchIdsRef.current.size;
if (localPath) onStoppedLocalPathRef.current?.(localPath);
}), []);
return { activeCountRef, captureGeneration, remember, releaseAll };
}

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { drainUploadConflictResolvers } from "./useSftpExternalOperations";
import { UploadController } from "../../../lib/uploadService";
test("external upload cancellation is keyed by transfer task id", () => {
const ops = readFileSync(new URL("./useSftpExternalOperations.ts", import.meta.url), "utf8");
assert.match(ops, /registerExternalUploadController/);
assert.match(ops, /const cancelExternalUpload = useCallback\(async \(taskId\?: string\)/);
assert.match(ops, /bindUploadControllerCallbacks/);
assert.doesNotMatch(ops, /for \(const controller of controllers\) void controller\.cancel\(\)/);
const queue = readFileSync(
new URL("../../../components/sftp/SftpTransferQueue.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(queue, /cancelExternalUpload\(task\.id\)/);
assert.match(queue, /sftpTransferCenterStore\.cancel\(task\.id\)/);
});
test("upload conflict cancel is scoped to owning controller", () => {
const ops = readFileSync(new URL("./useSftpExternalOperations.ts", import.meta.url), "utf8");
assert.match(ops, /uploadConflictOwnersRef/);
assert.match(ops, /cancelPendingUploadConflicts = useCallback\(\(controller\?: UploadController\)/);
});
test("upload conflict cleanup resolves every pending prompt on unmount", () => {
const first = new UploadController();
const second = new UploadController();
const resolved: string[] = [];
const resolvers = new Map([
["first", { resolve: (action: string) => resolved.push(`first:${action}`), setDefault() {} }],
["second", { resolve: (action: string) => resolved.push(`second:${action}`), setDefault() {} }],
]) as Parameters<typeof drainUploadConflictResolvers>[0];
const owners = new Map([["first", first], ["second", second]]);
assert.deepEqual(drainUploadConflictResolvers(resolvers, owners), ["first", "second"]);
assert.deepEqual(resolved, ["first:stop", "second:stop"]);
assert.equal(resolvers.size, 0);
assert.equal(owners.size, 0);
});
test("all four external folder upload entry points keep compression and conflict handling wired together", () => {
const source = readFileSync(new URL("./useSftpExternalOperations.ts", import.meta.url), "utf8");
const entryPoints = [
"uploadExternalFiles",
"uploadExternalFileList",
"uploadExternalFolderPath",
"uploadExternalEntries",
];
for (let index = 0; index < entryPoints.length; index += 1) {
const start = source.indexOf(`const ${entryPoints[index]} = useCallback`);
const nextName = entryPoints[index + 1];
const end = nextName ? source.indexOf(`const ${nextName} = useCallback`, start + 1) : source.length;
assert.ok(start >= 0, `${entryPoints[index]} must remain present`);
const section = source.slice(start, end >= 0 ? end : source.length);
assert.match(section, /runWithCompressedUploadSession\(\{/);
assert.match(section, /enabled: useCompressedUpload/);
assert.match(section, /resolveConflict: createUploadConflictResolver\(controller\)/);
}
});

View File

@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
registerExternalUploadController,
resetExternalUploadRuntimeForTests,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
test("external upload controls survive their originating panel unmount", async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const cancelled: string[] = [];
const controller = new UploadController();
controller.setBridge({
mkdirSftp: async () => {},
cancelTransfer: async (transferId) => {
cancelled.push(transferId);
},
});
controller.addActiveTransfer("child-1");
registerExternalUploadController("folder-1", controller);
registerExternalUploadController("child-1", controller);
// A React panel unmount does not unregister process-level upload controls.
assert.equal(getExternalUploadController("folder-1"), controller);
await cancelExternalUploadRuntime("folder-1");
assert.equal(controller.isCancelled(), true);
assert.deepEqual(cancelled, ["child-1"]);
unregisterExternalUploadController(controller);
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});

View File

@@ -0,0 +1,54 @@
import type { UploadController } from "../../../lib/uploadService";
// External folder uploads start in a React panel, but their work is not owned
// by that panel. Keep the controller registry at module scope so closing the
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
if (previous && previous !== controller) {
const previousIds = taskIdsByController.get(previous);
previousIds?.delete(taskId);
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
}
export function unregisterExternalUploadController(controller: UploadController): void {
const taskIds = taskIdsByController.get(controller);
if (!taskIds) return;
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
}
export function getExternalUploadController(taskId: string): UploadController | undefined {
return controllersByTaskId.get(taskId);
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
if (controllers.length === 0) return false;
await Promise.all([...new Set(controllers)].map((controller) => controller.cancel()));
return true;
}
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
taskIdsByController.clear();
}

View File

@@ -0,0 +1,45 @@
import type { SftpBookmark } from "../../../domain/models";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
export function subscribeGlobalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getGlobalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateGlobalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setGlobalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
}
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalSftpBookmarks();
}
});
}

View File

@@ -0,0 +1,522 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import {
softPauseTransfer,
softResumeTransfer,
type TransferControlHost,
} from "./globalSftpTransferControl";
import {
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} from "./transferPauseLatch";
import {
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} from "./transferWalkRegistry";
function makeTask(id: string, status: TransferTask["status"] = "transferring"): TransferTask {
return {
id,
fileName: `${id}.bin`,
sourcePath: `/src/${id}`,
targetPath: `/dst/${id}`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 100,
transferredBytes: 10,
speed: 1,
startTime: 1,
isDirectory: false,
resumable: true,
};
}
function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBridge"]): {
host: TransferControlHost;
getTasks: () => TransferTask[];
} {
let tasks = initial.map((t) => ({ ...t }));
return {
getTasks: () => tasks,
host: {
getTasks: () => tasks,
setTasks: (next) => { tasks = next; },
getBridge: bridge ?? (() => undefined),
},
};
}
test("an older pause response must not resume a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
let calls = 0;
let backendPaused = true;
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
pauseTransfer: async () => {
backendPaused = true;
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
return { success: true, lifecycleEpoch: 2 };
},
resumeTransfer: async () => {
backendPaused = false;
return { success: true, lifecycleEpoch: 3 };
},
}));
const first = softPauseTransfer(host, "overlapping-pause");
await softPauseTransfer(host, "overlapping-pause");
finishFirstPause({ success: true, lifecycleEpoch: 1 });
await first;
assert.equal(getTasks()[0].status, "paused");
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
});
test("a delayed resume response must not repaint a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
}));
const resume = softResumeTransfer(host, "resume-then-pause");
await softPauseTransfer(host, "resume-then-pause");
finishResume({ success: true, lifecycleEpoch: 2 });
await resume;
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
assert.equal(getTasks()[0].lifecycleEpoch, 3);
});
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("dir");
const { host, getTasks } = createHost([
{ ...makeTask("dir"), isDirectory: true, progressMode: "files", totalBytes: 5, transferredBytes: 1 },
{ ...makeTask("c1"), parentTaskId: "dir" },
]);
const outcome = await softPauseTransfer(host, "dir");
assert.equal(outcome, "paused");
assert.equal(getTasks().find((t) => t.id === "dir")?.status, "paused");
assert.equal(isTransferPauseLatched("dir"), true);
unregisterTransferWalk("dir");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("softResumeTransfer with live walk paints transferring without requiring bridge success", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("dir");
const { host, getTasks } = createHost([
{ ...makeTask("dir", "paused"), isDirectory: true, progressMode: "files", totalBytes: 5, transferredBytes: 1, speed: 0 },
]);
const handled = await softResumeTransfer(host, "dir");
assert.equal(handled.handled, true);
assert.equal(getTasks().find((t) => t.id === "dir")?.status, "transferring");
assert.equal(isTransferPauseLatched("dir"), false);
unregisterTransferWalk("dir");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("single-file softResume with live walk + bridge resume fail returns false (no false transferring)", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("file-1");
const { host, getTasks } = createHost(
[{ ...makeTask("file-1", "paused"), transferredBytes: 10, speed: 0 }],
() => ({
resumeTransfer: async () => ({ success: false, reason: "not active" }),
pauseTransfer: async () => ({ success: true, checkpointBytes: 10, lifecycleEpoch: 1 }),
}),
);
const handled = await softResumeTransfer(host, "file-1");
assert.equal(handled.handled, false, "must not soft-succeed when every bridge resume fails");
assert.match(handled.reason || "", /not active/i);
// Must not paint transferring — hard reconnect path must remain available.
assert.notEqual(getTasks().find((t) => t.id === "file-1")?.status, "transferring");
unregisterTransferWalk("file-1");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("directory softResume with live walk and no bridge success still rejoins", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("dir-2");
const { host, getTasks } = createHost(
[{ ...makeTask("dir-2", "paused"), isDirectory: true, progressMode: "files", totalBytes: 3, transferredBytes: 1, speed: 0 }],
() => ({
resumeTransfer: async () => ({ success: false, reason: "not active" }),
}),
);
const handled = await softResumeTransfer(host, "dir-2");
assert.equal(handled.handled, true);
assert.equal(getTasks().find((t) => t.id === "dir-2")?.status, "transferring");
// lifecycleEpoch cleared so child stream progress is accepted
assert.equal(getTasks().find((t) => t.id === "dir-2")?.lifecycleEpoch, undefined);
unregisterTransferWalk("dir-2");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("single-file softPause demotes dead streams instead of painting paused", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
const { host, getTasks } = createHost(
[makeTask("dead-file", "transferring")],
() => ({
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}),
);
const outcome = await softPauseTransfer(host, "dead-file");
assert.equal(outcome, "interrupted");
const row = getTasks().find((t) => t.id === "dead-file");
assert.equal(row?.status, "interrupted");
assert.equal(row?.reconnectRequired, true);
assert.match(row?.error || "", /no longer active/i);
assert.equal(isTransferPauseLatched("dead-file"), false);
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("soft resume without bridge lifecycleEpoch keeps a monotonic epoch (no stale re-pause)", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
const { host, getTasks } = createHost(
[{ ...makeTask("no-epoch", "paused"), lifecycleEpoch: 3, speed: 0 }],
() => ({
// Older bridges returned only { success: true } — must not clear epoch.
resumeTransfer: async () => ({ success: true }),
}),
);
const result = await softResumeTransfer(host, "no-epoch");
assert.equal(result.handled, true);
const row = getTasks().find((t) => t.id === "no-epoch");
assert.equal(row?.status, "transferring");
assert.equal(row?.lifecycleEpoch, 4, "must advance past pause epoch when bridge omits epoch");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("soft pause/resume stamps bridge lifecycleEpoch so later progress is not stale-dropped", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
let bridgeEpoch = 0;
const { host, getTasks } = createHost(
[makeTask("stream", "transferring")],
() => ({
pauseTransfer: async () => {
bridgeEpoch += 1;
return { success: true, checkpointBytes: 10, lifecycleEpoch: bridgeEpoch };
},
resumeTransfer: async () => {
bridgeEpoch += 1;
return { success: true, lifecycleEpoch: bridgeEpoch };
},
}),
);
// Double soft-pause (second hits already-paused — still returns bridge epoch).
await softPauseTransfer(host, "stream");
await softPauseTransfer(host, "stream");
const pausedEpoch = getTasks().find((t) => t.id === "stream")?.lifecycleEpoch;
assert.ok(typeof pausedEpoch === "number" && pausedEpoch > 0);
const handled = await softResumeTransfer(host, "stream");
assert.equal(handled.handled, true);
const resumed = getTasks().find((t) => t.id === "stream");
assert.equal(resumed?.status, "transferring");
// Bridge-aligned: must equal last resume bridge epoch, not control-plane bumps.
assert.equal(resumed?.lifecycleEpoch, bridgeEpoch);
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
test("directory softResume stamps bridge epoch only on successIds; queued siblings clear epoch", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
registerTransferWalk("folder-mix");
const { host, getTasks } = createHost(
[
{ ...makeTask("folder-mix", "paused"), isDirectory: true, progressMode: "files", totalBytes: 3, transferredBytes: 1, speed: 0, lifecycleEpoch: 9 },
{ ...makeTask("live-child", "paused"), parentTaskId: "folder-mix", transferredBytes: 50, speed: 0, lifecycleEpoch: 9 },
{ ...makeTask("queued-child", "queued"), parentTaskId: "folder-mix", transferredBytes: 0, speed: 0, lifecycleEpoch: 9 },
],
() => ({
resumeTransfer: async (id: string) => {
if (id === "live-child") return { success: true, lifecycleEpoch: 4 };
return { success: false, reason: "not active" };
},
}),
);
const handled = await softResumeTransfer(host, "folder-mix");
assert.equal(handled.handled, true);
const parent = getTasks().find((t) => t.id === "folder-mix");
const live = getTasks().find((t) => t.id === "live-child");
const queued = getTasks().find((t) => t.id === "queued-child");
assert.equal(parent?.status, "transferring");
assert.equal(parent?.lifecycleEpoch, 4, "parent gets max of successful child bridge epochs");
assert.equal(live?.status, "transferring");
assert.equal(live?.lifecycleEpoch, 4, "resumed child keeps its bridge epoch");
assert.equal(queued?.status, "queued");
assert.equal(queued?.lifecycleEpoch, undefined, "non-resumed sibling must not inherit parent resume epoch");
unregisterTransferWalk("folder-mix");
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
for (const isDirectory of [false, true]) {
for (const newerLocalPause of [false, true]) {
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
let pauses = 0;
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
const { host, getTasks } = createHost(initial, () => ({
pauseTransfer: () => ++pauses === 1
? new Promise((resolve) => { finish = resolve; })
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
}));
const pending = softPauseTransfer(host, id);
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
if (newerLocalPause) await softPauseTransfer(host, id);
finish({ success: false, superseded: true, supersededBy: "resume" });
await pending;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
});
}
}
for (const action of ["pause", "resume"] as const) {
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: boolean }) => void;
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
const id = getTasks()[0].id;
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
// A global event from another window changes lifecycle, not this window's local control epoch.
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
finish({ success: false, superseded: true });
const result = await operation;
assert.equal(getTasks()[0].status, "paused");
assert.equal(getTasks()[0].lifecycleEpoch, 8);
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
});
}
for (const isDirectory of [false, true]) {
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `remote-pause-${isDirectory}`;
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
const { host, getTasks } = createHost(tasks, () => ({
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
}));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(isTransferPauseLatched(id), true);
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
assert.equal(getTasks()[0].status, "paused");
});
}
test("a child-only remote resume does not release the folder pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const { host } = createHost([
{ ...makeTask("mixed-root"), isDirectory: true },
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
], () => ({ pauseTransfer: async id => id === "mixed-one"
? { success: false, superseded: true, supersededBy: "resume" }
: { success: true } }));
await softPauseTransfer(host, "mixed-root");
await new Promise(resolve => setImmediate(resolve));
assert.equal(isTransferPauseLatched("mixed-root"), true);
assert.equal(isTransferPauseLatched("mixed-two"), true);
});
test("directory resume joins successful and remotely resumed children", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "mixed-success-resume";
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
? { success: true, lifecycleEpoch: 3 }
: { success: false, superseded: true, supersededBy: "resume" } }));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
});
for (const newerPause of [false, true]) {
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `rejected-folder-resume-${newerPause}`;
registerTransferWalk(id);
let rejectResume!: (error: Error) => void;
let backendPaused = true;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
}));
const running = softResumeTransfer(host, id);
if (newerPause) await softPauseTransfer(host, id);
rejectResume(new Error("resume transport disconnected"));
const result = await running;
assert.equal(result.handled, newerPause);
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
assert.equal(backendPaused, true);
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
});
}
for (const newerResume of [false, true]) {
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `partial-reject-${newerResume}`;
const successfulId = `${id}-one`;
let rejectResume!: (error: Error) => void;
let successfulBackendPaused = true;
let round = 0;
let rollbackCalls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: async childId => {
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
if (round > 0) return { success: true };
return new Promise((_, reject) => { rejectResume = reject; });
},
pauseTransfer: async childId => {
rollbackCalls++;
if (childId === successfulId) successfulBackendPaused = true;
return { success: true };
},
}));
const running = softResumeTransfer(host, id);
if (newerResume) { round++; await softResumeTransfer(host, id); }
rejectResume(new Error("second child IPC rejected"));
const result = await running;
assert.equal(result.handled, true);
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
assert.equal(isTransferPauseLatched(id), false);
const rejected = getTasks().find(task => task.id === `${id}-two`);
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
});
}
for (const resolvedFailure of [false, true]) {
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "remote-partial-reject";
const runningId = `${id}-one`;
const rejectedId = `${id}-two`;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(runningId, "paused"), parentTaskId: id },
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => {
if (childId === rejectedId) {
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
throw new Error("sibling resume rejected");
}
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
return { success: false, superseded: true, supersededBy: "resume" };
} }));
assert.equal((await softResumeTransfer(host, id)).handled, true);
assert.equal(getTasks()[0].status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
assert.equal(isTransferPauseLatched(rejectedId), true);
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
});
}
for (const newerResume of [false, true]) {
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `resolved-verification-${newerResume}`;
registerTransferWalk(id);
let finish!: (value: { success: boolean; reason: string }) => void;
let calls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: () => ++calls === 1
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
const running = softResumeTransfer(host, id);
if (newerResume) await softResumeTransfer(host, id);
finish({ success: false, reason: "Could not verify the source file for resume" });
const result = await running;
assert.equal(result.handled, newerResume);
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(id), !newerResume);
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");
});

View File

@@ -0,0 +1,683 @@
/**
* Process-global soft pause/resume for SFTP transfers.
*
* Single control plane: not tied to SFTP panel / terminal-tab React owners.
* The transfer center store always uses this for live soft-control; walks
* listen to process-global pause latches (transferPauseLatch) regardless of
* which UI started the job.
*/
import type { TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
import {
allPauseResultsDeadTransfer,
isBenignPauseMiss,
planPartialPauseRollback,
} from "./pauseTransferOutcome";
import {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
} from "./transferControlEpoch";
import {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
pauseTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
sourceFingerprint?: string;
/** Main-process stream lifecycle epoch (must not mix with control-plane epochs). */
lifecycleEpoch?: number;
}>;
resumeTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
lifecycleEpoch?: number;
}>;
cancelTransfer?: (id: string) => Promise<unknown>;
};
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
return result?.superseded === true;
}
type SupersededControlResult = {
success?: boolean;
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
};
export function reconcileSupersededControls(
host: TransferControlHost,
taskId: string,
childIds: string[],
backendIds: string[],
results: ReadonlyArray<SupersededControlResult | undefined>,
epoch: number,
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) 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") => {
if (action === "resume") releaseTransferPauseTree(id, descendants);
else latchTransferPauseTree(id, descendants);
for (const affectedId of [id, ...descendants]) {
try {
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
else globalSftpTransferScheduler.pause(affectedId);
} catch { /* best-effort */ }
}
};
const decisions = results.map((result) => result?.superseded
? result.supersededBy
: result?.success ? requestedAction : undefined);
// A child-only resume does not establish a folder-wide decision. Require all
// relevant children to agree, or an already-authoritative resumed root row.
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
apply(taskId, childIds, "resume");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
apply(taskId, childIds, "pause");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
} else if (task.status === "transferring" && decisions.includes("resume")) {
apply(taskId, [], "resume");
}
decisions.forEach((action, index) => {
if (action) apply(backendIds[index], [], action);
});
}
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
function maxBridgeLifecycleEpoch(
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
): number | undefined {
let max: number | undefined;
for (const result of results) {
if (!result?.success) continue;
const epoch = result.lifecycleEpoch;
if (!Number.isFinite(epoch)) continue;
const value = epoch as number;
max = max === undefined ? value : Math.max(max, value);
}
return max;
}
export type TransferControlHost = {
getTasks: () => TransferTask[];
setTasks: (next: TransferTask[]) => void;
getBridge: () => TransferControlBridge | undefined;
};
function unfinishedChildren(tasks: readonly TransferTask[], taskId: string): string[] {
return tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& !["completed", "cancelled", "failed"].includes(candidate.status))
.map((candidate) => candidate.id);
}
function paintTreeStatus(
tasks: readonly TransferTask[],
taskId: string,
status: TransferTask["status"],
extras?: Partial<TransferTask>,
/**
* Bridge-aligned lifecycle epoch to stamp, or `null` to clear a stale store
* epoch so main-process progress is not dropped after soft resume.
* Omit to leave existing task.lifecycleEpoch unchanged.
*/
lifecycleEpoch?: number | null,
): TransferTask[] {
return tasks.map((candidate) => {
if (candidate.id !== taskId && candidate.parentTaskId !== taskId) return candidate;
if (["completed", "cancelled", "failed"].includes(candidate.status)) return candidate;
const epochPatch = lifecycleEpoch === undefined
? null
: { lifecycleEpoch: lifecycleEpoch === null ? undefined : lifecycleEpoch };
return {
...candidate,
status,
speed: 0,
phase: undefined,
...epochPatch,
...(candidate.id === taskId ? extras : null),
};
});
}
/** Soft-pause a live transfer (folder or file). UI-independent. */
export async function softPauseTransfer(
host: TransferControlHost,
taskId: string,
): Promise<"paused" | "pausing" | "interrupted" | "noop"> {
const tasks = host.getTasks();
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled"].includes(task.status)) return "noop";
if (task.ownerId === "dedicated-resume" && task.reconnectRequired) return "noop";
const childIds = unfinishedChildren(tasks, taskId);
const treeIds = task.isDirectory
? childIds
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Control-plane epoch supersedes in-flight soft-drain only. Do NOT stamp it as
// task.lifecycleEpoch — that field tracks main-process bridge epochs for ingest.
const pauseEpoch = bumpTransferControlEpoch(taskId);
latchTransferPauseTree(taskId, childIds);
for (const id of [taskId, ...childIds]) {
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
const immediateStatus = task.isDirectory ? "paused" as const : "pausing" as const;
// Freeze via latch/status; leave lifecycleEpoch alone until bridge responds.
host.setTasks(paintTreeStatus(
host.getTasks(),
taskId,
immediateStatus,
task.isDirectory
? { checkpointBytes: task.transferredBytes, pauseUnavailableReason: undefined }
: { pauseUnavailableReason: undefined },
));
let bridge: TransferControlBridge | undefined;
try { bridge = host.getBridge(); } catch { bridge = undefined; }
if (!bridge?.pauseTransfer) {
if (isTransferWalkInFlight(taskId) || task.isDirectory) {
host.setTasks(paintTreeStatus(host.getTasks(), taskId, "paused", {
checkpointBytes: task.isDirectory ? task.transferredBytes : undefined,
pauseUnavailableReason: undefined,
}));
return "paused";
}
// Dead row after restart — demote so UI can reconnect.
for (const id of [taskId, ...childIds]) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort */ }
}
host.setTasks(host.getTasks().map((candidate) => (
candidate.id === taskId || candidate.parentTaskId === taskId
? {
...candidate,
status: (["completed", "cancelled"].includes(candidate.status)
? candidate.status
: "interrupted") as TransferTask["status"],
speed: 0,
phase: undefined,
reconnectRequired: true,
error: candidate.id === taskId
? (candidate.error ?? "Transfer was interrupted. Resume to continue.")
: candidate.error,
}
: candidate
)));
return "interrupted";
}
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
// An obsolete pause can be superseded by another pause or cancellation,
// not only by resume. Compensate only while the tree still wants to run.
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;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
let result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
const maxAttempts = task.isDirectory ? 4 : 16;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (wasSuperseded(result)) return result;
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
if (result.success) {
await undoObsoletePause(id);
}
return { success: false, reason: "Pause superseded by resume" };
}
if (result.success || isBenignPauseMiss(result.reason)) return result;
if (!/cannot be paused yet|Could not verify the saved transfer checkpoint/i.test(result.reason || "")) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, 40));
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
return { success: false, reason: "Pause superseded by newer control" };
}
result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
}
return result;
};
if (task.isDirectory) {
void (async () => {
const pauseStillCurrent = () => isTransferControlEpochCurrent(taskId, pauseEpoch);
const pauseResults = await Promise.all(backendIds.map(async (id) => {
if (!pauseStillCurrent()) {
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
const result = await pauseOne(id);
if (wasSuperseded(result)) return { id, result };
const live = host.getTasks().find((candidate) => candidate.id === taskId);
const userResumed = !pauseStillCurrent()
|| !live
|| (live.status !== "paused" && live.status !== "pausing");
if (userResumed) {
await undoObsoletePause(id);
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
return { id, result };
}));
if (!pauseStillCurrent()) return;
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return;
}
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!after || after.status === "cancelled") return;
if (after.status !== "paused" && after.status !== "pausing") return;
const byId = new Map(pauseResults.map((row) => [row.id, row.result]));
const bridgeEpoch = maxBridgeLifecycleEpoch(pauseResults.map((row) => row.result));
// Final directory paint: parent + unfinished children all go paused (do not
// leave children stuck on transferring under a paused parent).
host.setTasks(host.getTasks().map((candidate) => {
if (candidate.id === taskId) {
return {
...candidate,
status: "paused" as const,
speed: 0,
checkpointBytes: candidate.transferredBytes,
pauseUnavailableReason: undefined,
phase: undefined,
// Bridge epoch only — control-plane pauseEpoch must not poison ingest.
...(bridgeEpoch !== undefined ? { lifecycleEpoch: bridgeEpoch } : null),
};
}
if (candidate.parentTaskId !== taskId && !backendIds.includes(candidate.id)) return candidate;
if (["completed", "cancelled", "failed"].includes(candidate.status)) return candidate;
const result = byId.get(candidate.id);
const childEpoch = Number.isFinite(result?.lifecycleEpoch)
? (result!.lifecycleEpoch as number)
: bridgeEpoch;
return {
...candidate,
status: "paused" as const,
speed: 0,
checkpointBytes: result?.checkpointBytes ?? candidate.checkpointBytes ?? candidate.transferredBytes,
resumeStage: result?.resumeStage ?? candidate.resumeStage,
downloadCheckpointBytes: result?.downloadCheckpointBytes ?? candidate.downloadCheckpointBytes,
uploadCheckpointBytes: result?.uploadCheckpointBytes ?? candidate.uploadCheckpointBytes,
sourceFingerprint: result?.sourceFingerprint ?? candidate.sourceFingerprint,
pauseUnavailableReason: undefined,
...(childEpoch !== undefined ? { lifecycleEpoch: childEpoch } : null),
};
}));
})().catch(() => { /* best-effort */ });
return "paused";
}
// Single-file: await soft-drain with supersede guards.
const pauseResults = await Promise.all(backendIds.map(async (id) => ({
id,
result: await pauseOne(id),
})));
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return "noop";
}
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
if (afterLivePause?.status === "cancelled") {
releaseTransferPauseTree(taskId, childIds);
return "noop";
}
const pauseStillCurrent = isTransferControlEpochCurrent(taskId, pauseEpoch);
const userAlreadyResumed = !pauseStillCurrent
|| !afterLivePause
|| (afterLivePause.status !== "pausing" && afterLivePause.status !== "paused");
if (userAlreadyResumed) {
for (const { id, result } of pauseResults) {
if (result?.success) {
await undoObsoletePause(id);
}
}
return "noop";
}
const bridgePauseResults = pauseResults.map((row) => row.result);
const allBenignOrSuccess = backendIds.length === 0 || pauseResults.every(
({ result }) => result.success || isBenignPauseMiss(result.reason),
);
if (allBenignOrSuccess) {
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
for (const { id, result } of pauseResults) {
if (result?.success) {
await undoObsoletePause(id);
}
}
return "noop";
}
// Dead stream painted as "paused" made Resume soft-fail and fall into
// dedicated vault reconnect — which cannot resolve quick-connect hosts.
if (allPauseResultsDeadTransfer(bridgePauseResults)) {
releaseTransferPauseTree(taskId, childIds);
for (const id of [taskId, ...childIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
}
const deadReason = pauseResults.find(({ result }) => result?.reason)?.result?.reason
?? "Transfer is no longer active";
host.setTasks(host.getTasks().map((candidate) => (
candidate.id === taskId || candidate.parentTaskId === taskId
? {
...candidate,
status: (["completed", "cancelled"].includes(candidate.status)
? candidate.status
: "interrupted") as TransferTask["status"],
speed: 0,
phase: undefined,
reconnectRequired: true,
error: candidate.id === taskId
? (candidate.error ?? `${deadReason}. Resume will reconnect.`)
: candidate.error,
}
: candidate
)));
return "interrupted";
}
const byId = new Map(pauseResults.map((row) => [row.id, row.result]));
const bridgeEpoch = maxBridgeLifecycleEpoch(pauseResults.map((row) => row.result));
host.setTasks(host.getTasks().map((candidate) => {
if (candidate.id === taskId) {
if (candidate.status !== "pausing" && candidate.status !== "paused") return candidate;
return {
...candidate,
status: "paused" as const,
speed: 0,
checkpointBytes: byId.get(taskId)?.checkpointBytes ?? candidate.checkpointBytes,
resumeStage: byId.get(taskId)?.resumeStage ?? candidate.resumeStage,
downloadCheckpointBytes: byId.get(taskId)?.downloadCheckpointBytes ?? candidate.downloadCheckpointBytes,
uploadCheckpointBytes: byId.get(taskId)?.uploadCheckpointBytes ?? candidate.uploadCheckpointBytes,
sourceFingerprint: byId.get(taskId)?.sourceFingerprint ?? candidate.sourceFingerprint,
pauseUnavailableReason: undefined,
...(bridgeEpoch !== undefined ? { lifecycleEpoch: bridgeEpoch } : null),
};
}
if (candidate.parentTaskId !== taskId && !backendIds.includes(candidate.id)) return candidate;
if (["completed", "cancelled", "failed"].includes(candidate.status)) return candidate;
const result = byId.get(candidate.id);
const childEpoch = Number.isFinite(result?.lifecycleEpoch)
? (result!.lifecycleEpoch as number)
: bridgeEpoch;
return {
...candidate,
status: "paused" as const,
speed: 0,
checkpointBytes: result?.checkpointBytes ?? candidate.checkpointBytes ?? candidate.transferredBytes,
resumeStage: result?.resumeStage ?? candidate.resumeStage,
downloadCheckpointBytes: result?.downloadCheckpointBytes ?? candidate.downloadCheckpointBytes,
uploadCheckpointBytes: result?.uploadCheckpointBytes ?? candidate.uploadCheckpointBytes,
sourceFingerprint: result?.sourceFingerprint ?? candidate.sourceFingerprint,
pauseUnavailableReason: undefined,
...(childEpoch !== undefined ? { lifecycleEpoch: childEpoch } : null),
};
}));
return "paused";
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
releaseTransferPauseTree(taskId, childIds);
for (const id of [taskId, ...childIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
}
const rollback = planPartialPauseRollback({
activeIds: backendIds,
backendIds,
bridgeResults: pauseResults.map((row) => row.result),
});
for (const id of rollback.bridgeIdsToResume) {
await undoObsoletePause(id);
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
const hard = pauseResults.find(({ result }) =>
result && !result.success && !isBenignPauseMiss(result.reason),
)?.result;
host.setTasks(host.getTasks().map((candidate) => {
if (candidate.id !== taskId && !backendIds.includes(candidate.id)) return candidate;
if (candidate.status !== "pausing" && candidate.status !== "paused" && candidate.id !== taskId) {
return candidate;
}
return {
...candidate,
status: "transferring" as const,
pauseUnavailableReason: candidate.id === taskId
? (hard?.reason ?? candidate.pauseUnavailableReason)
: candidate.pauseUnavailableReason,
};
}));
return "noop";
}
export type SoftResumeResult = {
/** True when soft-resume rejoined without dedicated hard reconnect. */
handled: boolean;
/** Bridge miss reason when handled is false (drives demotion policy). */
reason?: string;
};
/**
* Soft-resume a live transfer.
*
* Single-file: requires at least one successful bridge resume (walkAlive alone is
* not enough — a paused/dead stream with a live walk would paint transferring and
* skip hard reconnect). Directory: walkAlive may rejoin after unlatch without
* per-child bridge success.
*
* task.lifecycleEpoch follows bridge epochs only (never control-plane bumps), so
* main-process progress ingest is not stale-dropped after soft pause/resume.
* Soft-resume must stamp a real bridge epoch (or keep the previous one) — never
* clear to `undefined`, or a late pause event re-applies "paused".
*/
export async function softResumeTransfer(
host: TransferControlHost,
taskId: string,
): Promise<SoftResumeResult> {
const tasks = host.getTasks();
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task) return { handled: false, reason: "Transfer not found" };
const childIds = unfinishedChildren(tasks, taskId);
// Always release the full tree known to the store so orphaned child latches
// (panel closed mid-folder) cannot leave the walk stuck after resume.
const knownChildIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId)
.map((candidate) => candidate.id);
const releaseIds = [...new Set([...childIds, ...knownChildIds])];
const treeIds = task.isDirectory
? childIds
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
const resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, releaseIds);
for (const id of [taskId, ...releaseIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
}
let bridge: TransferControlBridge | undefined;
try { bridge = host.getBridge(); } catch { bridge = undefined; }
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
const failedIds = new Set<string>();
const results = await Promise.all(resumeIds.map(async (id) => {
try {
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
} catch (error) {
// Rejections must pass the same stale-control check as ordinary failures.
failedIds.add(id);
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
}
}));
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
results.forEach((result, index) => {
// A missing stream can belong to queued directory work. Verification and
// other explicit failures still own a paused stream and must remain held.
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
});
if (results.some(wasSuperseded)) {
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
}
const successIds = resumeIds.filter((_, index) => results[index]?.success);
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
if (failedIds.size > 0) {
// A partial resume stays visibly active; hold only failed children. Do not
// hide successfully running streams behind a paused root barrier.
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
for (const id of heldIds) {
latchTransferPauseTree(id, []);
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
if (!effectiveRunning) return { handled: false, reason: failureReason };
}
if (results.some(wasSuperseded) && failedIds.size === 0) {
return { handled: true };
}
const walkAlive = isTransferWalkInFlight(taskId);
// Directory walk can continue after unlatch without bridge resume on every child.
// Single-file must not claim success when every bridge resume fails (stuck bar).
if (!effectiveRunning) {
if (task.isDirectory && walkAlive) {
host.setTasks(paintTreeStatus(
host.getTasks(),
taskId,
"transferring",
{ error: undefined, reconnectRequired: false, pauseUnavailableReason: undefined },
// Clear any control-plane / stale pause epoch so child stream progress is accepted.
null,
));
return { handled: true };
}
const reason = results.find((row) => row?.reason)?.reason
?? "Transfer is no longer active";
return { handled: false, reason };
}
const resumed = new Set(successIds);
const bridgeEpochById = new Map<string, number>();
for (let index = 0; index < resumeIds.length; index += 1) {
const result = results[index];
if (!result?.success) continue;
const epoch = result.lifecycleEpoch;
if (Number.isFinite(epoch)) bridgeEpochById.set(resumeIds[index]!, epoch as number);
}
const parentBridgeEpoch = maxBridgeLifecycleEpoch(results);
host.setTasks(host.getTasks().map((candidate) => {
if (
candidate.id !== taskId
&& !resumed.has(candidate.id)
&& candidate.parentTaskId !== taskId
) {
return candidate;
}
if (["completed", "cancelled", "failed"].includes(candidate.status)) return candidate;
if (
candidate.id !== taskId
&& !resumed.has(candidate.id)
&& !["paused", "pausing", "queued", "pending", "transferring"].includes(candidate.status)
) {
return candidate;
}
if (failedIds.has(candidate.id)) {
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
}
const candidateResult = results[resumeIds.indexOf(candidate.id)];
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
if (candidate.id === taskId) {
const nextEpoch = parentBridgeEpoch !== undefined
? parentBridgeEpoch
: (Number.isFinite(candidate.lifecycleEpoch)
? Math.max(0, candidate.lifecycleEpoch as number) + 1
: 1);
return {
...candidate,
status: "transferring" as const,
error: failureReason,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
speed: 0,
lifecycleEpoch: nextEpoch,
};
}
// Bridge-resumed children only: use their own stream epoch when present.
if (resumed.has(candidate.id)) {
const childEpoch = bridgeEpochById.get(candidate.id);
const nextEpoch = childEpoch !== undefined
? childEpoch
: (Number.isFinite(candidate.lifecycleEpoch)
? Math.max(0, candidate.lifecycleEpoch as number) + 1
: 1);
return {
...candidate,
status: "transferring" as const,
error: undefined,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
speed: 0,
lifecycleEpoch: nextEpoch,
};
}
// Non-resumed siblings under the folder (queued/pending/later files): keep
// queue status and CLEAR lifecycleEpoch. Stamping the parent's resume epoch
// here poisons startStreamTransfer children that arm at bridge epoch 0.
const nextStatus = (
candidate.status === "queued" || candidate.status === "pending"
)
? candidate.status
: "transferring" as const;
return {
...candidate,
status: nextStatus,
error: undefined,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
speed: 0,
lifecycleEpoch: undefined,
};
}));
return { handled: true };
}
/** Default bridge accessor for Electron / tests with window.netcatty. */
export function defaultTransferControlBridge(): TransferControlBridge | undefined {
try {
return netcattyBridge.get() as TransferControlBridge | undefined;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createGlobalSftpTransferScheduler,
} from "./globalTransferScheduler";
import { resolveProgressiveFolderUploadConcurrency } from "../../../lib/progressiveFolderUpload";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "./transferConcurrency";
test("scheduler limits each remote host independently", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const releases: Array<() => void> = [];
let active = 0;
let maxActive = 0;
let id = 0;
const run = (ownerId: string, hostId: string) => scheduler.run(ownerId, `${ownerId}-${id += 1}`, [hostId], () => 1, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise<void>((resolve) => releases.push(resolve));
active -= 1;
});
const jobs = [run("a", "host-a"), run("a", "host-a"), run("b", "host-b")];
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
assert.equal(maxActive, 2);
releases.splice(0).forEach((release) => release());
while (releases.length < 1) await new Promise((resolve) => setImmediate(resolve));
releases.splice(0).forEach((release) => release());
await jobs;
assert.equal(maxActive, 2);
});
test("progressive folder uploads use six slots by default and preserve a saved limit", async () => {
const displayedDefault = resolveSftpTransferConcurrency(() => null);
const effectiveDefault = resolveProgressiveFolderUploadConcurrency(null);
assert.equal(displayedDefault, DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(effectiveDefault, displayedDefault);
assert.equal(effectiveDefault, 6);
assert.equal(resolveProgressiveFolderUploadConcurrency(3), 3);
assert.equal(resolveProgressiveFolderUploadConcurrency(99), 6);
const scheduler = createGlobalSftpTransferScheduler();
const releases: Array<() => void> = [];
let active = 0;
let maxActive = 0;
const jobs = Array.from({ length: 8 }, (_, index) => scheduler.run(
"progressive",
`file-${index}`,
["host:one"],
() => null,
async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise<void>((resolve) => releases.push(resolve));
active -= 1;
},
));
while (releases.length < 6) await new Promise((resolve) => setImmediate(resolve));
assert.equal(maxActive, 6);
releases.splice(0).forEach((release) => release());
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
releases.splice(0).forEach((release) => release());
await Promise.all(jobs);
});
test("scheduler alternates owners when both have queued work", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const order: string[] = [];
let releaseFirst: (() => void) | undefined;
const first = scheduler.run("a", "a1", ["host"], () => 1, async () => {
order.push("a1");
await new Promise<void>((resolve) => { releaseFirst = resolve; });
});
const a2 = scheduler.run("a", "a2", ["host"], () => 1, async () => { order.push("a2"); });
const b1 = scheduler.run("b", "b1", ["host"], () => 1, async () => { order.push("b1"); });
while (!releaseFirst) await new Promise((resolve) => setImmediate(resolve));
releaseFirst();
await Promise.all([first, a2, b1]);
assert.deepEqual(order, ["a1", "b1", "a2"]);
});
test("prioritize moves a queued transfer ahead of fairness ordering", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const order: string[] = [];
let releaseFirst: (() => void) | undefined;
const first = scheduler.run("a", "a1", ["host"], () => 1, async () => {
order.push("a1");
await new Promise<void>((resolve) => { releaseFirst = resolve; });
});
const b1 = scheduler.run("b", "b1", ["host"], () => 1, async () => { order.push("b1"); });
const a2 = scheduler.run("a", "a2", ["host"], () => 1, async () => { order.push("a2"); });
scheduler.prioritize("a2");
while (!releaseFirst) await new Promise((resolve) => setImmediate(resolve));
releaseFirst();
await Promise.all([first, b1, a2]);
assert.deepEqual(order, ["a1", "a2", "b1"]);
});
test("queued work stays paused until resumed and can be cancelled", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const order: string[] = [];
let releaseFirst: (() => void) | undefined;
const first = scheduler.run("a", "a1", ["host"], () => 1, async () => {
await new Promise<void>((resolve) => { releaseFirst = resolve; });
});
const paused = scheduler.run("b", "b1", ["host"], () => 1, async () => { order.push("b1"); });
const cancelled = scheduler.run("c", "c1", ["host"], () => 1, async () => { order.push("c1"); });
assert.equal(scheduler.pause("b1"), true);
assert.equal(scheduler.cancel("c1"), true);
while (!releaseFirst) await new Promise((resolve) => setImmediate(resolve));
releaseFirst();
await first;
await assert.rejects(cancelled, /Transfer cancelled/);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, []);
assert.equal(scheduler.resume("b1"), true);
await paused;
assert.deepEqual(order, ["b1"]);
});
test("enqueuing a large blocked batch does not repeatedly inspect the existing queue", async () => {
const scheduler = createGlobalSftpTransferScheduler();
let release: (() => void) | undefined;
let limitReads = 0;
const readLimit = () => { limitReads += 1; return 1; };
const blocker = scheduler.run("panel", "active", ["host"], readLimit, () => (
new Promise<void>((resolve) => { release = resolve; })
));
const count = 2_000;
const completed: number[] = [];
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
"panel", `queued-${index}`, ["host"], readLimit,
async () => { completed.push(index); },
));
await new Promise((resolve) => setImmediate(resolve));
const readsWhileBlocked = limitReads;
release?.();
await Promise.all([blocker, ...jobs]);
assert.deepEqual(completed, Array.from({ length: count }, (_, index) => index));
assert.ok(readsWhileBlocked <= count * 3,
`a blocked batch should be inspected linearly, got ${readsWhileBlocked} limit reads for ${count} files`);
});
test("large batches of immediately completed files yield to user input", async () => {
const scheduler = createGlobalSftpTransferScheduler();
let completed = 0;
const count = 1_000;
const inputTurn = new Promise<number>((resolve) => setTimeout(() => resolve(completed), 0));
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
"panel", `tiny-${index}`, ["host"], () => 2, async () => { completed += 1; },
));
const completedAtInput = await inputTurn;
await Promise.all(jobs);
assert.ok(completedAtInput < count, "input must run before the entire batch drains");
assert.equal(completed, count);
});
test("a synchronous job failure releases its slot for queued work", async () => {
const scheduler = createGlobalSftpTransferScheduler();
const failed = scheduler.run("panel", "failed", ["host"], () => 1, () => { throw new Error("read failed"); });
const next = scheduler.run("panel", "next", ["host"], () => 1, async () => "completed");
await assert.rejects(failed, /read failed/);
assert.equal(await next, "completed");
});

View File

@@ -0,0 +1,156 @@
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
type LimitReader = () => number | null | undefined;
interface ScheduledJob<T> {
ownerId: string;
taskId: string;
resourceKeys: string[];
priority: number;
readLimit: LimitReader;
work: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export interface GlobalSftpTransferScheduler {
run<T>(ownerId: string, taskId: string, resourceKeys: readonly string[], readLimit: LimitReader, work: () => Promise<T>): Promise<T>;
prioritize(taskId: string): void;
pause(taskId: string): boolean;
resume(taskId: string): boolean;
cancel(taskId: string): boolean;
}
function normalizeLimit(value: number | null | undefined): number {
return resolveSftpTransferConcurrency(() => value);
}
export function getSftpTransferResourceKeys(input: {
sourceHostId?: string;
targetHostId?: string;
sourceSftpId?: string;
targetSftpId?: string;
}): string[] {
const keys = [
input.sourceHostId ? `host:${input.sourceHostId}` : input.sourceSftpId ? `session:${input.sourceSftpId}` : null,
input.targetHostId ? `host:${input.targetHostId}` : input.targetSftpId ? `session:${input.targetSftpId}` : null,
].filter((key): key is string => Boolean(key));
return [...new Set(keys.length > 0 ? keys : ["local"])];
}
export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler {
const queue: Array<ScheduledJob<unknown>> = [];
const activeByResource = new Map<string, number>();
let lastOwnerId: string | null = null;
let prioritySequence = 0;
const pausedJobs = new Map<string, ScheduledJob<unknown>>();
let pumpScheduled = false;
let startsSinceYield = 0;
const schedulePump = () => {
if (pumpScheduled || queue.length === 0) return;
pumpScheduled = true;
const run = () => {
pumpScheduled = false;
pump();
};
// Coalesce a discovery/enqueue burst into one scan. Periodically yield to
// input/paint as well, including when many tiny jobs resolve immediately.
if (startsSinceYield >= 64) {
startsSinceYield = 0;
setTimeout(run, 0);
} else {
queueMicrotask(run);
}
};
const normalizeResourceKeys = (keys: readonly string[]) => [...new Set(keys.length > 0 ? keys : ["local"])];
const canRun = (job: ScheduledJob<unknown>) => {
const limit = normalizeLimit(job.readLimit());
return job.resourceKeys.every((key) => (activeByResource.get(key) ?? 0) < limit);
};
const adjustActive = (job: ScheduledJob<unknown>, delta: 1 | -1) => {
for (const key of job.resourceKeys) {
const next = (activeByResource.get(key) ?? 0) + delta;
if (next > 0) activeByResource.set(key, next);
else activeByResource.delete(key);
}
};
const pump = () => {
while (queue.length > 0) {
let index = -1;
for (let candidateIndex = 0; candidateIndex < queue.length; candidateIndex += 1) {
const candidate = queue[candidateIndex];
if (!canRun(candidate)) continue;
const selected = queue[index];
if (!selected || candidate.priority > selected.priority || (
candidate.priority === selected.priority
&& selected.ownerId === lastOwnerId
&& candidate.ownerId !== lastOwnerId
)) index = candidateIndex;
}
if (index < 0) return;
const [job] = queue.splice(index, 1);
if (!job) return;
adjustActive(job, 1);
lastOwnerId = job.ownerId;
startsSinceYield += 1;
void (async () => job.work())().then(job.resolve, job.reject).finally(() => {
adjustActive(job, -1);
schedulePump();
});
if (startsSinceYield >= 64) {
schedulePump();
return;
}
}
};
return {
run<T>(ownerId: string, taskId: string, resourceKeys: readonly string[], readLimit: LimitReader, work: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
queue.push({ ownerId, taskId, resourceKeys: normalizeResourceKeys(resourceKeys), priority: 0, readLimit, work, resolve, reject } as ScheduledJob<unknown>);
if (queue.length === 1 && !pumpScheduled && startsSinceYield < 64) pump();
else schedulePump();
});
},
prioritize(taskId: string) {
const job = queue.find((candidate) => candidate.taskId === taskId) ?? pausedJobs.get(taskId);
if (!job) return;
prioritySequence += 1;
job.priority = prioritySequence;
schedulePump();
},
pause(taskId: string) {
const index = queue.findIndex((job) => job.taskId === taskId);
if (index < 0) return false;
const [job] = queue.splice(index, 1);
if (!job) return false;
pausedJobs.set(taskId, job);
return true;
},
resume(taskId: string) {
const job = pausedJobs.get(taskId);
if (!job) return false;
pausedJobs.delete(taskId);
queue.push(job);
schedulePump();
return true;
},
cancel(taskId: string) {
const queueIndex = queue.findIndex((job) => job.taskId === taskId);
const job = queueIndex >= 0 ? queue.splice(queueIndex, 1)[0] : pausedJobs.get(taskId);
if (!job) return false;
pausedJobs.delete(taskId);
job.reject(new Error("Transfer cancelled"));
schedulePump();
return true;
},
};
}
export const globalSftpTransferScheduler = createGlobalSftpTransferScheduler();
/** Host admission unlimited — folder fan-out is capped separately by workers. */
export const unlimitedSftpSchedulerAdmission = (): number => Number.POSITIVE_INFINITY;

View File

@@ -0,0 +1,93 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
export function subscribeLocalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getLocalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateLocalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setLocalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
rehydrateLocalSftpBookmarks();
}
});
}
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
getLocalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setLocalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setLocalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};

View File

@@ -0,0 +1,454 @@
import { SftpFileEntry } from "../../../domain/models";
import { formatDate } from "./utils";
// Mock local file data for development (when backend is not available)
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
const mockData: Record<string, SftpFileEntry[]> = {
// Unix-style paths
"/": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Applications",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "System",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Shared",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
// Windows-style paths (normalized to forward slashes for matching)
"C:": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Program Files",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Windows",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Public",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Default",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
],
"C:/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
"C:/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"C:/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
};
return mockData[normPath] || [];
}

View File

@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
openTransferSftpSession,
resetDedicatedSessionOpenGateForTests,
} from "./dedicatedTransferResume.ts";
import type { Host } from "../../../domain/models.ts";
const host: Host = {
id: "h1",
label: "box",
hostname: "1.2.3.4",
port: 22,
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
authType: "password",
password: "secret",
created: 0,
order: 0,
} as Host;
test("openTransferSftpSession defaults to dedicated vault open (ignores terminal session)", async () => {
resetDedicatedSessionOpenGateForTests();
let openSftpCalls = 0;
let openForSessionCalls = 0;
const original = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
electron: {
openSftp: async () => {
openSftpCalls += 1;
return "dedicated-sftp";
},
openSftpForSession: async () => {
openForSessionCalls += 1;
return "session-sftp";
},
},
};
try {
// netcattyBridge reads window.electron — ensure adapter path works via mock.
const { netcattyBridge } = await import("../../../infrastructure/services/netcattyBridge.ts");
const restore = netcattyBridge.get;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => {
openSftpCalls += 1;
return "dedicated-sftp";
},
openSftpForSession: async () => {
openForSessionCalls += 1;
return "session-sftp";
},
});
try {
const id = await openTransferSftpSession(
host,
{ hosts: [host], keys: [], identities: [] },
{ sourceSessionId: "term-1", dedicated: true },
);
assert.equal(id, "dedicated-sftp");
assert.equal(openSftpCalls, 1);
assert.equal(openForSessionCalls, 0, "dedicated bulk path must not use terminal session channel");
} finally {
(netcattyBridge as { get: typeof restore }).get = restore;
}
} finally {
if (original === undefined) delete (globalThis as { window?: unknown }).window;
else (globalThis as { window?: unknown }).window = original;
resetDedicatedSessionOpenGateForTests();
}
});
test("openTransferSftpSession can use terminal session only when dedicated:false", async () => {
resetDedicatedSessionOpenGateForTests();
let openForSessionCalls = 0;
let expectedEndpoint: NetcattySSHOptions | undefined;
const { netcattyBridge } = await import("../../../infrastructure/services/netcattyBridge.ts");
const restore = netcattyBridge.get;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
openSftpForSession: async (_sessionId: string, endpoint?: NetcattySSHOptions) => {
openForSessionCalls += 1;
expectedEndpoint = endpoint;
return "session-sftp";
},
});
try {
const id = await openTransferSftpSession(
host,
{ hosts: [host], keys: [], identities: [] },
{ sourceSessionId: "term-1", dedicated: false },
);
assert.equal(id, "session-sftp");
assert.equal(openForSessionCalls, 1);
assert.equal(expectedEndpoint?.hostname, host.hostname);
assert.equal(expectedEndpoint?.password, host.password);
} finally {
(netcattyBridge as { get: typeof restore }).get = restore;
resetDedicatedSessionOpenGateForTests();
}
});
test("non-dedicated transfer without a terminal keeps unified transport reuse enabled", async () => {
resetDedicatedSessionOpenGateForTests();
const seen: NetcattySSHOptions[] = [];
let openForSessionCalls = 0;
const { netcattyBridge } = await import("../../../infrastructure/services/netcattyBridge.ts");
const restore = netcattyBridge.get;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: NetcattySSHOptions) => {
seen.push(options);
return "pooled-sftp";
},
openSftpForSession: async () => {
openForSessionCalls += 1;
return "unexpected-terminal-sftp";
},
});
try {
const id = await openTransferSftpSession(
host,
{ hosts: [host], keys: [], identities: [] },
{ dedicated: false },
);
assert.equal(id, "pooled-sftp");
assert.equal(openForSessionCalls, 0);
assert.equal(seen.length, 1);
assert.notEqual(seen[0]?.reuseTransport, false);
} finally {
(netcattyBridge as { get: typeof restore }).get = restore;
resetDedicatedSessionOpenGateForTests();
}
});
test("dedicated transfer delegates key and password fallback to one main-process open", async () => {
resetDedicatedSessionOpenGateForTests();
const mixedHost = {
...host,
authMethod: "auto",
identityFilePaths: ["/tmp/id_ed25519"],
password: "fallback-password",
} as Host;
const seen: NetcattySSHOptions[] = [];
const { netcattyBridge } = await import("../../../infrastructure/services/netcattyBridge.ts");
const restore = netcattyBridge.get;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: NetcattySSHOptions) => {
seen.push(options);
throw new Error("All configured authentication methods failed");
},
});
try {
await assert.rejects(
openTransferSftpSession(mixedHost, { hosts: [mixedHost], keys: [], identities: [] }),
/authentication/i,
);
assert.equal(seen.length, 1);
assert.equal(seen[0]?.password, "fallback-password");
assert.deepEqual(seen[0]?.identityFilePaths, ["/tmp/id_ed25519"]);
} finally {
(netcattyBridge as { get: typeof restore }).get = restore;
resetDedicatedSessionOpenGateForTests();
}
});

View File

@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createGlobalSftpTransferScheduler } from "./globalTransferScheduler.ts";
import {
allPauseResultsBenignOrSuccess,
allPauseResultsDeadTransfer,
isBenignPauseMiss,
isDeadTransferPauseMiss,
isHardPauseFailure,
planPartialPauseRollback,
resolveDirectoryPauseParentOutcome,
shouldLatchPauseWaiters,
} from "./pauseTransferOutcome.ts";
test("benign pause misses match store regex (no longer active / not found / session)", () => {
assert.equal(isBenignPauseMiss("Transfer is no longer active"), true);
assert.equal(isBenignPauseMiss("session not found"), true);
assert.equal(isBenignPauseMiss("SFTP session closed"), true);
assert.equal(isBenignPauseMiss("This transfer cannot be paused safely"), false);
assert.equal(isBenignPauseMiss("Pause unavailable"), false);
});
test("dead transfer pause misses are a strict subset of benign misses", () => {
assert.equal(isDeadTransferPauseMiss("Transfer is no longer active"), true);
assert.equal(isDeadTransferPauseMiss("session not found"), true);
assert.equal(isDeadTransferPauseMiss("SFTP session closed"), false);
assert.equal(allPauseResultsDeadTransfer([
{ success: false, reason: "Transfer is no longer active" },
]), true);
assert.equal(allPauseResultsDeadTransfer([
{ success: true },
{ success: false, reason: "Transfer is no longer active" },
]), false);
assert.equal(allPauseResultsDeadTransfer([
{ success: false, reason: "cannot be paused safely" },
]), false);
});
test("allPauseResultsBenignOrSuccess rejects mixed hard failures", () => {
assert.equal(allPauseResultsBenignOrSuccess([
{ success: true },
{ success: false, reason: "no longer active" },
]), true);
assert.equal(allPauseResultsBenignOrSuccess([
{ success: true },
{ success: false, reason: "cannot be paused safely" },
]), false);
assert.equal(allPauseResultsBenignOrSuccess([]), true);
});
test("directory parent stays paused even when some children hard-fail pause", () => {
// Latch-first: partial child failures must not unpause the folder parent.
assert.deepEqual(
resolveDirectoryPauseParentOutcome([
{ success: true },
{ success: false, reason: "cannot be paused safely" },
]),
{ kind: "paused", reason: "cannot be paused safely" },
);
assert.deepEqual(
resolveDirectoryPauseParentOutcome([
{ success: false, reason: "This transfer cannot be paused yet" },
]),
{ kind: "paused", reason: "This transfer cannot be paused yet" },
);
assert.deepEqual(
resolveDirectoryPauseParentOutcome([
{ success: false, reason: "Could not verify the saved transfer checkpoint" },
]),
{ kind: "paused", reason: "Could not verify the saved transfer checkpoint" },
);
assert.deepEqual(
resolveDirectoryPauseParentOutcome([
{ success: false, reason: "no longer active" },
{ success: true },
]),
{ kind: "paused" },
);
});
test("pause waiters latch only on successful pause", () => {
assert.equal(shouldLatchPauseWaiters({ pauseSucceeded: true }), true);
assert.equal(shouldLatchPauseWaiters({ pauseSucceeded: false }), false);
});
test("isHardPauseFailure treats missing result as hard", () => {
assert.equal(isHardPauseFailure(undefined), true);
assert.equal(isHardPauseFailure({ success: false, reason: "no longer active" }), false);
assert.equal(isHardPauseFailure({ success: true }), false);
});
test("planPartialPauseRollback unparks scheduler jobs and successful bridge pauses", () => {
// active: parent + 3 children. scheduler.pause succeeded for child-a (not in backendIds).
// bridge: child-b success, child-c hard fail, parent not in directory activeIds.
const plan = planPartialPauseRollback({
activeIds: ["child-a", "child-b", "child-c"],
backendIds: ["child-b", "child-c"],
bridgeResults: [
{ success: true },
{ success: false, reason: "cannot be paused safely" },
],
});
assert.deepEqual(plan.schedulerIdsToResume, ["child-a"]);
assert.deepEqual(plan.bridgeIdsToResume, ["child-b"]);
});
test("planPartialPauseRollback for single-file includes parent when only children hit bridge", () => {
// Single-file: activeIds = [parent, child]; scheduler parked parent, bridge paused child then hard-fails parent.
const plan = planPartialPauseRollback({
activeIds: ["parent", "child"],
backendIds: ["parent", "child"],
bridgeResults: [
{ success: false, reason: "cannot be paused safely" },
{ success: true },
],
});
assert.deepEqual(plan.schedulerIdsToResume, []);
assert.deepEqual(plan.bridgeIdsToResume, ["child"]);
});
test("mixed hard-fail rollback resumes real scheduler-parked jobs so work continues", async () => {
const scheduler = createGlobalSftpTransferScheduler();
let childAFinished = false;
const hold = { release: null as null | (() => void) };
const block = new Promise<void>((resolve) => {
hold.release = resolve;
});
// Concurrency 1: hold the only slot so child-a stays queued and can be pause()'d.
const holder = scheduler.run("owner", "holder", ["host-1"], () => 1, async () => {
await block;
});
const childA = scheduler.run("owner", "child-a", ["host-1"], () => 1, async () => {
childAFinished = true;
});
// Allow holder to become active and child-a to queue.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(scheduler.pause("child-a"), true, "child-a must be queued so pause parks it");
// Mimic panel path: activeIds include scheduler-parked + bridge targets; bridge hard-fails one.
const plan = planPartialPauseRollback({
activeIds: ["child-a", "child-b"],
backendIds: ["child-b"],
bridgeResults: [{ success: false, reason: "cannot be paused safely" }],
});
assert.deepEqual(plan.schedulerIdsToResume, ["child-a"]);
for (const id of plan.schedulerIdsToResume) {
assert.equal(scheduler.resume(id), true);
}
hold.release?.();
await holder;
await childA;
assert.equal(childAFinished, true, "parked child-a must run after rollback resume");
});
test("panel pause delegates to TransferRuntime; soft-control + rollback live in process-global control", () => {
// Dual soft-control path was removed: panel only calls transferRuntime.
const panelSource = readFileSync(new URL("./useSftpTransfers.ts", import.meta.url), "utf8");
assert.match(panelSource, /transferRuntime\.pause/);
assert.match(panelSource, /transferRuntime\.resume/);
assert.doesNotMatch(panelSource, /rollbackPartialPause/);
assert.doesNotMatch(panelSource, /resolveDirectoryPauseParentOutcome/);
// planPartialPauseRollback remains on the single soft-control plane.
const controlSource = readFileSync(new URL("./globalSftpTransferControl.ts", import.meta.url), "utf8");
assert.match(controlSource, /planPartialPauseRollback/);
assert.match(controlSource, /bridgeIdsToResume/);
assert.match(controlSource, /softPauseTransfer/);
assert.match(controlSource, /softResumeTransfer/);
});

View File

@@ -0,0 +1,121 @@
/**
* Shared pause-result classification for panel + global transfer center.
* Keep panel and store pause UX consistent.
*/
export type PauseBridgeResult = {
success: boolean;
reason?: string;
checkpointBytes?: number;
resumeStage?: string;
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
sourceFingerprint?: string;
};
/** Backend miss that means "nothing to pause" — not a hard failure. */
export function isBenignPauseMiss(reason?: string): boolean {
return /no longer active|not found|session/i.test(reason || "");
}
/**
* Bridge reports the live stream is gone. For a single top-level file this is
* not a successful pause — Resume must hard-reconnect rather than soft-unpause
* a dead row painted as "paused".
*/
export function isDeadTransferPauseMiss(reason?: string): boolean {
return /no longer active|not found/i.test(reason || "");
}
export function isHardPauseFailure(result: PauseBridgeResult | undefined): boolean {
if (!result) return true;
if (result.success) return false;
return !isBenignPauseMiss(result.reason);
}
/**
* Whether a multi-id pause (directory children or single+children) fully
* succeeded: every targeted id paused or was already gone.
*/
export function allPauseResultsBenignOrSuccess(
results: readonly PauseBridgeResult[],
): boolean {
if (results.length === 0) return true;
return results.every((result) => result.success || isBenignPauseMiss(result.reason));
}
/** True when every bridge pause miss means the stream is already dead. */
export function allPauseResultsDeadTransfer(
results: readonly PauseBridgeResult[],
): boolean {
if (results.length === 0) return false;
return results.every((result) => !result.success && isDeadTransferPauseMiss(result.reason));
}
export type DirectoryPauseParentOutcome =
| { kind: "paused"; reason?: string }
| { kind: "still_transferring"; reason?: string };
/**
* Parent directory pause outcome after per-child bridge results.
*
* Folder pause is latch-first: stop admitting new files even when some child
* streams refuse pause ("cannot be paused yet", checkpoint verify races).
* Rolling the parent back to transferring was worse — soft-drained children
* finished and the queue claimed the next file under a "failed" pause.
*
* `reason` may still surface a soft warning on the parent row.
*/
export function resolveDirectoryPauseParentOutcome(
results: readonly PauseBridgeResult[],
): DirectoryPauseParentOutcome {
if (allPauseResultsBenignOrSuccess(results)) {
return { kind: "paused" };
}
const hard = results.find((result) => isHardPauseFailure(result));
return { kind: "paused", reason: hard?.reason };
}
/** Soft/transient pause misses — keep retrying or tolerate for folder latch. */
export function isTransientPauseFailure(reason?: string): boolean {
return /cannot be paused yet|Could not verify the saved transfer checkpoint|Could not verify that the source is safe to resume/i
.test(reason || "");
}
/**
* Whether pauseTransfer should latch waiters (pausedTasksRef).
* Only latch on true pause success so workers do not soft-deadlock.
*/
export function shouldLatchPauseWaiters(params: {
pauseSucceeded: boolean;
}): boolean {
return params.pauseSucceeded;
}
/**
* After a multi-id pause attempt fails overall, which ids must be unpaused so
* work can continue (scheduler jobs + successfully bridge-paused streams).
*
* `activeIds` — every id we attempted to pause
* `backendIds` — ids sent to the bridge (scheduler.pause returned false)
* `bridgeResults` — bridge pause outcomes for backendIds (same order)
*/
export function planPartialPauseRollback(params: {
activeIds: readonly string[];
backendIds: readonly string[];
bridgeResults: readonly PauseBridgeResult[];
}): {
schedulerIdsToResume: string[];
bridgeIdsToResume: string[];
} {
const backendSet = new Set(params.backendIds);
const schedulerIdsToResume = params.activeIds.filter((id) => !backendSet.has(id));
const bridgeIdsToResume: string[] = [];
for (let i = 0; i < params.backendIds.length; i += 1) {
const result = params.bridgeResults[i];
if (result?.success) {
bridgeIdsToResume.push(params.backendIds[i]!);
}
}
return { schedulerIdsToResume, bridgeIdsToResume };
}

View File

@@ -0,0 +1,124 @@
/**
* SFTP Clipboard Store
*
* Manages clipboard state for SFTP file operations (copy/cut/paste)
* This is a simple store that holds the clipboard state and operation type.
*/
import { useSyncExternalStore } from "react";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;
sourceSide: "left" | "right";
operation: SftpClipboardOperation;
}
type ClipboardListener = () => void;
let clipboardState: SftpClipboardState | null = null;
const clipboardListeners = new Set<ClipboardListener>();
const notifyListeners = () => {
clipboardListeners.forEach((listener) => listener());
};
export const sftpClipboardStore = {
getSnapshot: (): SftpClipboardState | null => clipboardState,
subscribe: (listener: ClipboardListener) => {
clipboardListeners.add(listener);
return () => clipboardListeners.delete(listener);
},
/**
* Copy files to clipboard
*/
copy: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "copy",
};
notifyListeners();
},
/**
* Cut files to clipboard
*/
cut: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "cut",
};
notifyListeners();
},
/**
* Clear clipboard (called after paste for cut operation)
*/
clear: () => {
clipboardState = null;
notifyListeners();
},
/**
* Update clipboard file list (used for partial cut transfers)
*/
updateFiles: (files: SftpClipboardFile[]) => {
if (!clipboardState) return;
if (files.length === 0) {
clipboardState = null;
} else {
clipboardState = {
...clipboardState,
files,
};
}
notifyListeners();
},
/**
* Check if there are files in the clipboard
*/
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
/**
* Get the clipboard state
*/
get: (): SftpClipboardState | null => clipboardState,
};
/**
* React hook to subscribe to clipboard state changes
*/
export const useSftpClipboard = (): SftpClipboardState | null => {
return useSyncExternalStore(
sftpClipboardStore.subscribe,
sftpClipboardStore.getSnapshot,
sftpClipboardStore.getSnapshot
);
};

View File

@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
import {
normalizeSftpInitialPath,
resolveRemoteSftpStartState,
} from "./sftpConnectStartPath.ts";
const cached: RemoteSftpStartCache = {
path: "/var/cache",
homeDir: "/home/deploy",
files: [],
filenameEncoding: "auto",
};
test("remote SFTP default-path duplication ignores the shared host cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
ignoreSharedCache: true,
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, undefined);
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/");
});
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app",
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, "/var/www/app");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app");
});
test("remote SFTP initial paths preserve meaningful whitespace", () => {
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app ",
sharedHostCacheCandidate: {
...cached,
path: "/var/www/app",
},
});
assert.equal(state.initialPath, "/var/www/app ");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app ");
});

View File

@@ -0,0 +1,44 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface RemoteSftpStartCache {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
}
interface ResolveRemoteSftpStartStateParams {
filenameEncoding: SftpFilenameEncoding;
ignoreSharedCache?: boolean;
initialPath?: string;
sharedHostCacheCandidate: RemoteSftpStartCache | null;
}
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
}
export function resolveRemoteSftpStartState({
filenameEncoding,
ignoreSharedCache,
initialPath,
sharedHostCacheCandidate,
}: ResolveRemoteSftpStartStateParams): {
initialPath: string | undefined;
sharedHostCache: RemoteSftpStartCache | null;
cachedStartPath: string;
} {
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
const sharedHostCache =
!ignoreSharedCache
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
? sharedHostCacheCandidate
: null;
return {
initialPath: requestedInitialPath,
sharedHostCache,
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
};
}

View File

@@ -0,0 +1,124 @@
/**
* SFTP Dialog Action Store
*
* Manages dialog action triggers for SFTP operations.
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
type ActionListener = () => void;
let dialogAction: SftpDialogAction | null = null;
const actionListeners = new Set<ActionListener>();
const notifyListeners = () => {
actionListeners.forEach((listener) => listener());
};
export const sftpDialogActionStore = {
getSnapshot: (): SftpDialogAction | null => dialogAction,
subscribe: (listener: ActionListener) => {
actionListeners.add(listener);
return () => actionListeners.delete(listener);
},
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
}
notifyListeners();
},
/**
* Clear the current action (called after a pane handles it)
*/
clear: () => {
dialogAction = null;
notifyListeners();
},
/**
* Get the current action
*/
get: (): SftpDialogAction | null => dialogAction,
};
/**
* React hook to subscribe to dialog action changes
*/
export const useSftpDialogAction = (): SftpDialogAction | null => {
return useSyncExternalStore(
sftpDialogActionStore.subscribe,
sftpDialogActionStore.getSnapshot,
sftpDialogActionStore.getSnapshot
);
};
/**
* React hook for a pane to respond to dialog actions
* Only the pane matching the targetSide will execute the callback
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
case "rename":
if (handlers.onRename && action.targetFiles?.[0]) {
handlers.onRename(action.targetFiles[0]);
}
break;
case "delete":
if (handlers.onDelete && action.targetFiles) {
handlers.onDelete(action.targetFiles);
}
break;
case "newFolder":
handlers.onNewFolder?.();
break;
case "newFile":
handlers.onNewFile?.();
break;
}
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, scopeId, handlers, isActive]);
};

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
consumePendingDualPaneSftpRequest,
requestOpenDualPaneSftp,
resetDualPaneSftpOpenStore,
subscribeDualPaneSftpOpen,
} from "./sftpDualPaneOpenStore.ts";
test("requestOpenDualPaneSftp stores a pending request when nothing is listening", () => {
resetDualPaneSftpOpenStore();
requestOpenDualPaneSftp("host-1");
assert.deepEqual(consumePendingDualPaneSftpRequest(), { hostId: "host-1", seq: 1 });
assert.equal(consumePendingDualPaneSftpRequest(), null);
});
test("requestOpenDualPaneSftp delivers live to subscribers instead of queueing", () => {
resetDualPaneSftpOpenStore();
const received: string[] = [];
const unsubscribe = subscribeDualPaneSftpOpen((request) => {
received.push(request.hostId);
});
requestOpenDualPaneSftp("host-2");
assert.deepEqual(received, ["host-2"]);
assert.equal(consumePendingDualPaneSftpRequest(), null);
unsubscribe();
});

View File

@@ -0,0 +1,45 @@
import { activeTabStore } from "../activeTabStore";
export type DualPaneSftpRequest = {
hostId: string;
seq: number;
};
type Listener = (request: DualPaneSftpRequest) => void;
const listeners = new Set<Listener>();
let pending: DualPaneSftpRequest | null = null;
let seq = 0;
export function requestOpenDualPaneSftp(hostId: string): DualPaneSftpRequest {
const request: DualPaneSftpRequest = { hostId, seq: ++seq };
if (listeners.size > 0) {
pending = null;
for (const listener of listeners) listener(request);
} else {
pending = request;
}
if (typeof globalThis.window !== "undefined") {
activeTabStore.setActiveTabId("sftp");
}
return request;
}
export function consumePendingDualPaneSftpRequest(): DualPaneSftpRequest | null {
const current = pending;
pending = null;
return current;
}
export function subscribeDualPaneSftpOpen(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function resetDualPaneSftpOpenStore() {
pending = null;
listeners.clear();
seq = 0;
}

View File

@@ -0,0 +1,22 @@
type SftpFilterFocusListener = () => void;
const listenersByPaneId = new Map<string, Set<SftpFilterFocusListener>>();
export const sftpFilterFocusStore = {
request(paneId: string): void {
listenersByPaneId.get(paneId)?.forEach((listener) => listener());
},
subscribe(paneId: string, listener: SftpFilterFocusListener): () => void {
const listeners = listenersByPaneId.get(paneId) ?? new Set<SftpFilterFocusListener>();
listeners.add(listener);
listenersByPaneId.set(paneId, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
listenersByPaneId.delete(paneId);
}
};
},
};

View File

@@ -0,0 +1,54 @@
/**
* SFTP Focused Pane Store
*
* Tracks which SFTP pane (left or right) is currently focused.
* This is used to determine which pane should receive keyboard shortcut actions.
*/
import { useSyncExternalStore } from "react";
export type SftpFocusedSide = "left" | "right";
type FocusListener = () => void;
let focusedSide: SftpFocusedSide = "left";
const focusListeners = new Set<FocusListener>();
const notifyListeners = () => {
focusListeners.forEach((listener) => listener());
};
export const sftpFocusStore = {
getSnapshot: (): SftpFocusedSide => focusedSide,
subscribe: (listener: FocusListener) => {
focusListeners.add(listener);
return () => focusListeners.delete(listener);
},
/**
* Set the focused side
*/
setFocusedSide: (side: SftpFocusedSide) => {
if (focusedSide !== side) {
focusedSide = side;
notifyListeners();
}
},
/**
* Get the current focused side
*/
getFocusedSide: (): SftpFocusedSide => focusedSide,
};
/**
* React hook to subscribe to focused side changes
*/
export const useSftpFocusedSide = (): SftpFocusedSide => {
return useSyncExternalStore(
sftpFocusStore.subscribe,
sftpFocusStore.getSnapshot,
sftpFocusStore.getSnapshot
);
};

View File

@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}

View File

@@ -0,0 +1,67 @@
import { useCallback, useSyncExternalStore } from "react";
import {
DEFAULT_SFTP_LIST_DENSITY,
getNextSftpListDensity,
parseSftpListDensity,
type SftpListDensity,
} from "../../../domain/sftpListDensity";
import { STORAGE_KEY_SFTP_LIST_DENSITY } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpListDensity = parseSftpListDensity(
localStorageAdapter.readString(STORAGE_KEY_SFTP_LIST_DENSITY),
);
function emit() {
for (const listener of listeners) listener();
}
export function subscribeSftpListDensity(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSftpListDensitySnapshot(): SftpListDensity {
return snapshot;
}
export function setSftpListDensity(next: SftpListDensity) {
const density = parseSftpListDensity(next);
if (density === snapshot) return;
snapshot = density;
localStorageAdapter.writeString(STORAGE_KEY_SFTP_LIST_DENSITY, density);
emit();
}
export function toggleSftpListDensity() {
setSftpListDensity(getNextSftpListDensity(snapshot));
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== STORAGE_KEY_SFTP_LIST_DENSITY) return;
snapshot = parseSftpListDensity(event.newValue);
emit();
});
}
export function useSftpListDensity() {
const density = useSyncExternalStore(
subscribeSftpListDensity,
getSftpListDensitySnapshot,
() => DEFAULT_SFTP_LIST_DENSITY,
);
const setDensity = useCallback((next: SftpListDensity) => {
setSftpListDensity(next);
}, []);
const toggleDensity = useCallback(() => {
toggleSftpListDensity();
}, []);
return { density, setDensity, toggleDensity };
}

View File

@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sftpPaneViewModeStore } from './sftpPaneViewModeStore.ts';
test('SFTP pane view mode store distinguishes an empty list from tree view', () => {
const paneId = 'empty-list-pane';
assert.equal(sftpPaneViewModeStore.get(paneId), 'list');
sftpPaneViewModeStore.set(paneId, 'tree');
assert.equal(sftpPaneViewModeStore.get(paneId), 'tree');
sftpPaneViewModeStore.set(paneId, 'list');
assert.equal(sftpPaneViewModeStore.get(paneId), 'list');
sftpPaneViewModeStore.clear(paneId);
});

View File

@@ -0,0 +1,13 @@
import type { SftpViewMode } from '../../../domain/sftpTypeahead';
const paneViewModes = new Map<string, SftpViewMode>();
export const sftpPaneViewModeStore = {
get: (paneId: string): SftpViewMode => paneViewModes.get(paneId) ?? 'list',
set: (paneId: string, viewMode: SftpViewMode) => {
paneViewModes.set(paneId, viewMode);
},
clear: (paneId: string) => {
paneViewModes.delete(paneId);
},
};

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getSftpCurrentPathMemoryKey,
getSftpReopenMemoryKey,
resolveSftpAutoConnectPath,
resolveSftpOpenLocation,
} from "./sftpReopenLocation.ts";
test("first open of a terminal lands on the terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: null,
});
assert.equal(location, "/home/deploy");
});
test("reopening the same terminal restores the last browsed path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/home/deploy/projects/app");
});
test("remembered path for a different host falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-2",
connectionKey: "host-2:server-b:22:ssh::root",
terminalCwd: "/srv",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv");
});
test("explicit upload target wins over remembered path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: "/home/deploy",
explicitTargetPath: "/tmp/upload-here",
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/tmp/upload-here");
});
test("untargeted upload does not inherit a remembered reopen path", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
terminalCwd: undefined,
hasPendingUpload: true,
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, undefined);
});
test("remembered path for the same saved host but different endpoint falls back to terminal cwd", () => {
const location = resolveSftpOpenLocation({
hostId: "host-1",
connectionKey: "host-1:server-a:2200:ssh::root",
terminalCwd: "/srv/root",
remembered: {
hostId: "host-1",
connectionKey: "host-1:server-a:22:ssh::deploy",
path: "/home/deploy/projects/app",
},
});
assert.equal(location, "/srv/root");
});
test("workspace sftp memory is keyed by source session rather than workspace tab", () => {
const workspaceTabId = "workspace-1";
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-a" }),
"session-a",
);
assert.equal(
getSftpReopenMemoryKey({ tabId: workspaceTabId, sourceSessionId: "session-b" }),
"session-b",
);
assert.equal(getSftpReopenMemoryKey({ tabId: "terminal-tab-1" }), "terminal-tab-1");
});
test("path changes for non-reusable sessions still use the focused terminal session", () => {
assert.equal(
getSftpCurrentPathMemoryKey({
tabId: "workspace-1",
activeTerminalSessionIdForSftp: null,
focusedSessionId: "mosh-session-1",
}),
"mosh-session-1",
);
});
test("auto-connect prefers explicit open path over remembered browse path", () => {
assert.equal(
resolveSftpAutoConnectPath({
explicitPath: "/tmp/upload",
rememberedPath: "/home/deploy/projects",
}),
"/tmp/upload",
);
});
test("auto-connect restores remembered path when nothing explicit is requested", () => {
assert.equal(
resolveSftpAutoConnectPath({
rememberedPath: "/home/deploy/projects/app",
}),
"/home/deploy/projects/app",
);
});
test("auto-connect ignores empty remembered paths", () => {
assert.equal(
resolveSftpAutoConnectPath({
explicitPath: "",
rememberedPath: "",
}),
undefined,
);
});

View File

@@ -0,0 +1,66 @@
export interface SftpRememberedLocation {
hostId: string;
connectionKey: string;
path: string;
}
export function getSftpReopenMemoryKey(params: {
tabId: string;
sourceSessionId?: string | null;
}): string {
return params.sourceSessionId || params.tabId;
}
export function getSftpCurrentPathMemoryKey(params: {
tabId: string;
activeTerminalSessionIdForSftp?: string | null;
focusedSessionId?: string | null;
}): string {
return params.activeTerminalSessionIdForSftp || params.focusedSessionId || params.tabId;
}
export function resolveSftpOpenLocation(params: {
hostId: string;
connectionKey: string;
terminalCwd?: string;
explicitTargetPath?: string;
hasPendingUpload?: boolean;
remembered?: SftpRememberedLocation | null;
}): string | undefined {
const { hostId, connectionKey, terminalCwd, explicitTargetPath, hasPendingUpload, remembered } = params;
if (explicitTargetPath) {
return explicitTargetPath;
}
if (hasPendingUpload) {
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
if (
remembered &&
remembered.hostId === hostId &&
remembered.connectionKey === connectionKey &&
remembered.path
) {
return remembered.path;
}
return terminalCwd && terminalCwd.length > 0 ? terminalCwd : undefined;
}
/**
* Path used when the side panel auto-connects / rebinds (not a user open).
* Prefer an explicit open target, then the last browsed path for this endpoint.
* Terminal cwd is intentionally omitted here so "follow off" stays sticky;
* follow mode navigates after connect via the follow sync effect.
*/
export function resolveSftpAutoConnectPath(params: {
explicitPath?: string | null;
rememberedPath?: string | null;
}): string | undefined {
const explicit = params.explicitPath?.length ? params.explicitPath : undefined;
if (explicit) return explicit;
const remembered = params.rememberedPath?.length ? params.rememberedPath : undefined;
return remembered;
}

View File

@@ -0,0 +1,153 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES,
MAX_SHARED_REMOTE_HOST_CACHE_FILES,
_getSharedRemoteHostCacheStatsForTests,
_resetSharedRemoteHostCacheForTests,
getSharedRemoteHostCache,
setSharedRemoteHostCache,
} from "./sharedRemoteHostCache";
const files = (count: number) => Array.from({ length: count }, (_value, index) => ({
name: `file-${index}`,
})) as never[];
const entry = (fileCount: number) => ({
path: "/",
homeDir: "/",
files: files(fileCount),
filenameEncoding: "utf-8" as const,
});
test("shared host cache is bounded across many different hosts", () => {
_resetSharedRemoteHostCacheForTests();
for (let index = 0; index < MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES + 20; index += 1) {
setSharedRemoteHostCache(`host-${index}`, entry(1));
}
assert.equal(_getSharedRemoteHostCacheStatsForTests().entries, MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES);
assert.equal(getSharedRemoteHostCache("host-0"), null);
assert.ok(getSharedRemoteHostCache(`host-${MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES + 19}`));
});
test("shared host cache is bounded by retained file rows", () => {
_resetSharedRemoteHostCacheForTests();
const perHost = Math.floor(MAX_SHARED_REMOTE_HOST_CACHE_FILES * 0.6);
setSharedRemoteHostCache("large-a", entry(perHost));
setSharedRemoteHostCache("large-b", entry(perHost));
assert.deepEqual(_getSharedRemoteHostCacheStatsForTests(), {
entries: 1,
files: perHost,
});
assert.equal(getSharedRemoteHostCache("large-a"), null);
assert.ok(getSharedRemoteHostCache("large-b"));
});
test("shared host cache skips one listing larger than the global file budget", () => {
_resetSharedRemoteHostCacheForTests();
setSharedRemoteHostCache("oversized", entry(MAX_SHARED_REMOTE_HOST_CACHE_FILES + 1));
assert.deepEqual(_getSharedRemoteHostCacheStatsForTests(), {
entries: 0,
files: 0,
});
assert.equal(getSharedRemoteHostCache("oversized"), null);
});

View File

@@ -0,0 +1,94 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
updatedAt: number;
}
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
export const MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES = 64;
export const MAX_SHARED_REMOTE_HOST_CACHE_FILES = 20_000;
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
const pruneSharedRemoteHostCache = (now: number): void => {
for (const [key, entry] of sharedRemoteHostCache) {
if (now - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
sharedRemoteHostCache.delete(key);
}
}
let totalFiles = 0;
for (const entry of sharedRemoteHostCache.values()) totalFiles += entry.files.length;
while (
sharedRemoteHostCache.size > 1
&& (
sharedRemoteHostCache.size > MAX_SHARED_REMOTE_HOST_CACHE_ENTRIES
|| totalFiles > MAX_SHARED_REMOTE_HOST_CACHE_FILES
)
) {
const oldestKey = sharedRemoteHostCache.keys().next().value as string | undefined;
if (!oldestKey) break;
const oldest = sharedRemoteHostCache.get(oldestKey);
sharedRemoteHostCache.delete(oldestKey);
totalFiles -= oldest?.files.length ?? 0;
}
};
/**
* Build a cache key that includes connection details so that the same host ID
* with different session-time overrides (port, protocol) uses separate entries.
*/
export const buildCacheKey = (
hostId: string,
hostname?: string,
port?: number,
protocol?: string,
sftpSudo?: boolean,
username?: string,
sftpFileProtocol?: string,
): string => {
const fileProto = sftpFileProtocol && sftpFileProtocol !== "auto" ? sftpFileProtocol : "";
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}:${fileProto}`;
};
export const getSharedRemoteHostCache = (
cacheKey: string,
): SharedRemoteHostCacheEntry | null => {
pruneSharedRemoteHostCache(Date.now());
const entry = sharedRemoteHostCache.get(cacheKey);
if (!entry) return null;
sharedRemoteHostCache.delete(cacheKey);
sharedRemoteHostCache.set(cacheKey, entry);
return entry;
};
export const setSharedRemoteHostCache = (
cacheKey: string,
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
): void => {
const now = Date.now();
pruneSharedRemoteHostCache(now);
sharedRemoteHostCache.delete(cacheKey);
// Never exceed the advertised file-row budget with a single huge listing.
// Dropping it from cache preserves the full result for this read without
// retaining an unbounded array for the lifetime of the renderer.
if (entry.files.length > MAX_SHARED_REMOTE_HOST_CACHE_FILES) return;
sharedRemoteHostCache.set(cacheKey, {
...entry,
updatedAt: now,
});
pruneSharedRemoteHostCache(now);
};
export const _resetSharedRemoteHostCacheForTests = (): void => {
sharedRemoteHostCache.clear();
};
export const _getSharedRemoteHostCacheStatsForTests = () => ({
entries: sharedRemoteHostCache.size,
files: [...sharedRemoteHostCache.values()].reduce((sum, entry) => sum + entry.files.length, 0),
});

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearTransferCancelledTree,
isTransferCancelledFlag,
isTransferOrRootCancelled,
markTransferCancelled,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
settleTransferCancelTree,
} from "./transferCancelLatch";
test("cancel flags are process-global for root and children", () => {
resetTransferCancelLatchesForTests();
markTransferCancelledTree("parent", ["c1", "c2"]);
assert.equal(isTransferCancelledFlag("parent"), true);
assert.equal(isTransferOrRootCancelled("parent", "c1"), true);
assert.equal(isTransferCancelledFlag("other"), false);
clearTransferCancelledTree("parent", ["c1", "c2"]);
assert.equal(isTransferCancelledFlag("parent"), false);
assert.equal(isTransferCancelledFlag("c1"), false);
});
test("settled single-file cancellations do not accumulate across a large batch", () => {
resetTransferCancelLatchesForTests();
const taskIds = Array.from({ length: 4_000 }, (_, index) => `single-${index}`);
for (const taskId of taskIds) {
markTransferCancelled(taskId);
assert.equal(isTransferCancelledFlag(taskId), true);
settleTransferCancelTree(taskId);
}
for (const taskId of taskIds) {
assert.equal(isTransferCancelledFlag(taskId), false);
}
});
test("settling a directory root releases every child recorded by tree cancellation", () => {
resetTransferCancelLatchesForTests();
const childIds = Array.from({ length: 4_000 }, (_, index) => `directory-child-${index}`);
markTransferCancelledTree("directory-root", childIds);
settleTransferCancelTree("directory-root");
assert.equal(isTransferCancelledFlag("directory-root"), false);
for (const childId of childIds) {
assert.equal(isTransferCancelledFlag(childId), false);
}
});
test("same-id resume clears the old tree without masking a later cancellation", () => {
resetTransferCancelLatchesForTests();
markTransferCancelledTree("same-root", ["old-child"]);
clearTransferCancelledTree("same-root");
markTransferCancelledTree("same-root", ["new-child"]);
assert.equal(isTransferCancelledFlag("old-child"), false);
assert.equal(isTransferCancelledFlag("same-root"), true);
assert.equal(isTransferCancelledFlag("new-child"), true);
settleTransferCancelTree("same-root");
assert.equal(isTransferCancelledFlag("same-root"), false);
assert.equal(isTransferCancelledFlag("new-child"), false);
});

View File

@@ -0,0 +1,66 @@
/**
* Process-wide cancel flags for SFTP transfers.
*
* Directory walks and stream arms check these so Cancel from the global
* transfer center still stops work after the React owner unmounts.
*/
const cancelledIds = new Set<string>();
const childIdsByRoot = new Map<string, Set<string>>();
export function markTransferCancelled(taskId: string): void {
cancelledIds.add(taskId);
}
export function markTransferCancelledTree(rootTaskId: string, childIds: readonly string[] = []): void {
cancelledIds.add(rootTaskId);
if (childIds.length === 0) return;
const recordedChildren = childIdsByRoot.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) {
cancelledIds.add(id);
recordedChildren.add(id);
}
childIdsByRoot.set(rootTaskId, recordedChildren);
}
export function clearTransferCancelled(taskId: string): void {
cancelledIds.delete(taskId);
}
export function clearTransferCancelledTree(rootTaskId: string, childIds: readonly string[] = []): void {
cancelledIds.delete(rootTaskId);
for (const id of childIdsByRoot.get(rootTaskId) ?? []) cancelledIds.delete(id);
for (const id of childIds) cancelledIds.delete(id);
childIdsByRoot.delete(rootTaskId);
}
/**
* Release cancellation state after the transfer has fully settled.
* Returns every related child id, including children no longer present in UI
* history, so sibling control state can be released from the same tree.
*/
export function settleTransferCancelTree(
rootTaskId: string,
childIds: readonly string[] = [],
): string[] {
const relatedChildIds = [...new Set([
...(childIdsByRoot.get(rootTaskId) ?? []),
...childIds,
])];
clearTransferCancelledTree(rootTaskId, relatedChildIds);
return relatedChildIds;
}
export function isTransferCancelledFlag(taskId: string): boolean {
return cancelledIds.has(taskId);
}
export function isTransferOrRootCancelled(rootTaskId: string, taskId?: string): boolean {
return cancelledIds.has(rootTaskId) || (!!taskId && cancelledIds.has(taskId));
}
/** Test helper. */
export function resetTransferCancelLatchesForTests(): void {
cancelledIds.clear();
childIdsByRoot.clear();
}

View File

@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
DEFAULT_SFTP_SKIP_UNCHANGED,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
resolveSftpTransferConcurrency,
runBoundedConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
test("defaults folder file transfers to six concurrent files", () => {
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
});
test("defaults directory listing fanout to four concurrent readdirs", () => {
assert.equal(
resolveSftpDirectoryListingConcurrency(() => null),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY, 4);
});
test("defaults skip-unchanged to enabled", () => {
assert.equal(resolveSftpSkipUnchangedEnabled(() => null), DEFAULT_SFTP_SKIP_UNCHANGED);
assert.equal(DEFAULT_SFTP_SKIP_UNCHANGED, true);
assert.equal(resolveSftpSkipUnchangedEnabled(() => false), false);
});
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
assert.equal(resolveSftpTransferConcurrency(() => 1.5), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
test("keeps directory listing concurrency within the supported range", () => {
assert.equal(resolveSftpDirectoryListingConcurrency(() => 1), 1);
assert.equal(resolveSftpDirectoryListingConcurrency(() => 8), 8);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 0),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 9),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
});
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
let active = 0;
let maxActive = 0;
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 6);
});
test("runBoundedConcurrency respects an explicit limit", async () => {
let active = 0;
let maxActive = 0;
await runBoundedConcurrency([1, 2, 3, 4, 5, 6], 3, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 3);
});
test("runBoundedConcurrency drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runBoundedConcurrency([0, 1, 2, 3, 4], 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("boom");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledWhileSiblingRunning = false;
await Promise.race([
run.then(
() => {
settledWhileSiblingRunning = true;
},
() => {
settledWhileSiblingRunning = true;
},
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(
settledWhileSiblingRunning,
false,
"must wait for in-flight siblings before propagating the error",
);
assert.ok(started.includes(1));
assert.equal(finished.includes(1), false);
releaseSlow();
await assert.rejects(run, /boom/);
assert.ok(finished.includes(1), "in-flight sibling must finish before reject");
assert.deepEqual(
[...started].sort((a, b) => a - b),
[0, 1],
"must not claim additional queue items after the first error",
);
});
test("runSftpTransferWorkers drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runSftpTransferWorkers([0, 1, 2, 3, 4], () => 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("session lost");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledEarly = false;
await Promise.race([
run.then(
() => { settledEarly = true; },
() => { settledEarly = true; },
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(settledEarly, false);
releaseSlow();
await assert.rejects(run, /session lost/);
assert.ok(finished.includes(1));
assert.deepEqual([...started].sort((a, b) => a - b), [0, 1]);
});
test("beforeClaim runs before claiming the next queue index", async () => {
const events: string[] = [];
let paused = true;
const run = runSftpTransferWorkers(
["a", "b"],
() => 1,
async (item) => {
events.push(`work:${item}`);
},
{
beforeClaim: async () => {
events.push("claim-gate");
while (paused) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
},
},
);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(events, ["claim-gate"]);
paused = false;
await run;
assert.deepEqual(events, ["claim-gate", "work:a", "claim-gate", "work:b"]);
});

View File

@@ -0,0 +1,128 @@
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
export {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
MAX_SFTP_FILE_TRANSFER_CONCURRENCY,
MIN_SFTP_FILE_TRANSFER_CONCURRENCY,
resolveSftpTransferConcurrency,
} from "../../../domain/sftpTransferConcurrency";
/**
* Bounded parallel directory listings while walking a folder tree.
* SFTP has no recursive LIST; FileZilla/WinSCP still walk one dir at a time on
* a single control channel. We pipeline several OPENDIR/READDIR requests so
* wide trees discover total file counts much faster without a second full scan.
* Keep this modest - listing shares the transfer SFTP session with file I/O.
*/
export const DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY = 4;
export const MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY = 1;
export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
/** Default on: skip size+mtime matches like rsync's generator. */
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
export function resolveSftpDirectoryListingConcurrency(
readStoredValue?: () => number | null | undefined,
): number {
const stored = readStoredValue?.();
return stored != null &&
stored >= MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY &&
stored <= MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY
? stored
: DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY;
}
export function resolveSftpSkipUnchangedEnabled(
readStoredValue: () => boolean | null | undefined,
): boolean {
const stored = readStoredValue();
return stored == null ? DEFAULT_SFTP_SKIP_UNCHANGED : stored;
}
export async function runSftpTransferWorkers<T>(
items: T[],
readStoredConcurrency: () => number | null | undefined,
worker: (item: T, index: number) => Promise<void>,
options?: {
/**
* Called before claiming the next queue index. Folder pause must wait here
* so a worker that just finished soft-drain cannot claim the next file
* while the parent is still latched (claim-before-wait started new work).
*/
beforeClaim?: () => Promise<void>;
},
): Promise<void> {
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
let nextIndex = 0;
let failed = false;
let firstError: unknown;
const runNext = async () => {
while (!failed && nextIndex < items.length) {
try {
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
// index that arms as soon as soft-drain finishes the previous file.
if (options?.beforeClaim) {
await options.beforeClaim();
}
if (failed || nextIndex >= items.length) return;
const index = nextIndex++;
await worker(items[index], index);
} catch (err) {
if (!failed) {
failed = true;
firstError = err;
}
return;
}
}
};
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
() => runNext(),
);
// Settle every started worker before propagating - Promise.all would reject
// while siblings keep claiming files after the caller releases leases.
await Promise.all(workers);
if (failed) throw firstError;
}
/** Run workers over a queue with an explicit concurrency (not settings-backed). */
export async function runBoundedConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
options?: {
beforeClaim?: () => Promise<void>;
},
): Promise<void> {
const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length || 1));
if (items.length === 0) return;
let nextIndex = 0;
let failed = false;
let firstError: unknown;
const runNext = async () => {
while (!failed && nextIndex < items.length) {
try {
if (options?.beforeClaim) {
await options.beforeClaim();
}
// Re-check after beforeClaim: a sibling may have failed while we waited.
if (failed || nextIndex >= items.length) return;
const index = nextIndex++;
await worker(items[index], index);
} catch (err) {
if (!failed) {
failed = true;
firstError = err;
}
return;
}
}
};
// Settle every started worker before propagating - Promise.all would reject
// while siblings keep claiming directories / transferring after the caller cleans up.
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()));
if (failed) throw firstError;
}

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { FileConflictAction, TransferTask } from "../../../domain/models";
import {
captureDeferredTransferAttempt,
createDeferredTransferAttemptQueue,
isDeferredTransferAttemptCurrent,
pruneTransferConflictDefaults,
type TransferConflictDefaults,
} from "./transferConflictLifecycle";
const task = (id: string, batchId: string, status: TransferTask["status"]): TransferTask => ({
id,
batchId,
fileName: id,
sourcePath: `/source/${id}`,
targetPath: `/target/${id}`,
sourceConnectionId: "source",
targetConnectionId: "target",
direction: "remote-to-remote",
status,
totalBytes: 1,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: false,
});
test("terminal batches release their apply-to-all conflict defaults", () => {
const defaults: TransferConflictDefaults = new Map<string, Map<string, FileConflictAction>>([
["live", new Map([["file:file", "replace"]])],
["finished", new Map([["file:file", "skip"]])],
]);
pruneTransferConflictDefaults(defaults, [
task("live-task", "live", "transferring"),
task("finished-task", "finished", "completed"),
]);
assert.deepEqual([...defaults.keys()], ["live"]);
});
test("deferred conflict attempts are cancelled on owner unmount", async () => {
const timers = new Map<number, () => void>();
let nextTimerId = 0;
let calls = 0;
const queue = createDeferredTransferAttemptQueue({
setTimeoutFn(callback) {
const id = ++nextTimerId;
timers.set(id, callback);
return id;
},
clearTimeoutFn(id) {
timers.delete(id as number);
},
});
queue.schedule("task-a", 100, () => true, async () => { calls += 1; });
queue.schedule("task-b", 100, () => true, async () => { calls += 1; });
assert.equal(queue.size, 2);
queue.dispose();
for (const callback of timers.values()) callback();
await Promise.resolve();
assert.equal(calls, 0);
assert.equal(queue.size, 0);
assert.equal(timers.size, 0);
});
test("deferred conflict attempts revalidate the current task before starting", async () => {
const timers: Array<() => void> = [];
let stillCurrent = true;
let calls = 0;
const queue = createDeferredTransferAttemptQueue({
setTimeoutFn(callback) {
timers.push(callback);
return timers.length;
},
clearTimeoutFn() {},
});
queue.schedule("task-a", 100, () => stillCurrent, async () => { calls += 1; });
stillCurrent = false;
timers[0]?.();
await Promise.resolve();
assert.equal(calls, 0);
assert.equal(queue.size, 0);
});
test("deferred conflict identity rejects owner, endpoint, and task lifecycle changes", () => {
const current = { ...task("task-a", "batch-a", "pending"), ownerId: "owner-a" };
const connectionKeys = new Map([
["source", "source-generation-1"],
["target", "target-generation-1"],
]);
const identity = captureDeferredTransferAttempt(current, "owner-a", connectionKeys);
assert.equal(isDeferredTransferAttemptCurrent(current, identity, connectionKeys), true);
assert.equal(isDeferredTransferAttemptCurrent(
{ ...current, ownerId: "owner-b" },
identity,
connectionKeys,
), false);
assert.equal(isDeferredTransferAttemptCurrent(
current,
identity,
new Map(connectionKeys).set("target", "target-generation-2"),
), false);
assert.equal(isDeferredTransferAttemptCurrent(
{ ...current, status: "cancelled" },
identity,
connectionKeys,
), false);
});

View File

@@ -0,0 +1,131 @@
import type { FileConflictAction, TransferTask } from "../../../domain/models";
export type TransferConflictDefaults = Map<string, Map<string, FileConflictAction>>;
export interface DeferredTransferAttemptIdentity {
taskId: string;
ownerId: string;
sourceConnectionId: string;
targetConnectionId: string;
sourceConnectionKey?: string;
targetConnectionKey?: string;
sourcePath: string;
targetPath: string;
}
export function captureDeferredTransferAttempt(
task: Pick<TransferTask, "id" | "sourceConnectionId" | "targetConnectionId" | "sourcePath" | "targetPath">,
ownerId: string,
connectionKeys: ReadonlyMap<string, string>,
): DeferredTransferAttemptIdentity {
return {
taskId: task.id,
ownerId,
sourceConnectionId: task.sourceConnectionId,
targetConnectionId: task.targetConnectionId,
sourceConnectionKey: connectionKeys.get(task.sourceConnectionId),
targetConnectionKey: connectionKeys.get(task.targetConnectionId),
sourcePath: task.sourcePath,
targetPath: task.targetPath,
};
}
export function isDeferredTransferAttemptCurrent(
task: Pick<TransferTask, "id" | "ownerId" | "status" | "sourceConnectionId" | "targetConnectionId" | "sourcePath" | "targetPath"> | null | undefined,
identity: DeferredTransferAttemptIdentity,
connectionKeys: ReadonlyMap<string, string>,
): boolean {
return !!task
&& task.id === identity.taskId
&& task.ownerId === identity.ownerId
&& task.status === "pending"
&& task.sourceConnectionId === identity.sourceConnectionId
&& task.targetConnectionId === identity.targetConnectionId
&& task.sourcePath === identity.sourcePath
&& task.targetPath === identity.targetPath
&& connectionKeys.get(task.sourceConnectionId) === identity.sourceConnectionKey
&& connectionKeys.get(task.targetConnectionId) === identity.targetConnectionKey;
}
const TERMINAL_TRANSFER_STATUSES = new Set<TransferTask["status"]>([
"completed",
"failed",
"cancelled",
]);
/** Keep apply-to-all choices only while their transfer batch still has live work. */
export function pruneTransferConflictDefaults(
defaults: TransferConflictDefaults,
tasks: readonly Pick<TransferTask, "batchId" | "status">[],
): void {
const liveBatchIds = new Set<string>();
for (const task of tasks) {
if (task.batchId && !TERMINAL_TRANSFER_STATUSES.has(task.status)) {
liveBatchIds.add(task.batchId);
}
}
for (const batchId of defaults.keys()) {
// Legacy/adopted rows without a batch share a fixed, tiny conflict-type set.
if (batchId !== "global" && !liveBatchIds.has(batchId)) defaults.delete(batchId);
}
}
type TimerHandle = ReturnType<typeof setTimeout> | number;
export interface DeferredTransferAttemptQueue {
readonly size: number;
schedule(
taskId: string,
delayMs: number,
isCurrent: () => boolean,
run: () => void | Promise<void>,
): void;
cancel(taskId: string): void;
dispose(): void;
}
export function createDeferredTransferAttemptQueue(options: {
setTimeoutFn?: (callback: () => void, delayMs: number) => TimerHandle;
clearTimeoutFn?: (handle: TimerHandle) => void;
onError?: (error: unknown) => void;
} = {}): DeferredTransferAttemptQueue {
const setTimeoutFn = options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => clearTimeout(handle));
const entries = new Map<string, { handle: TimerHandle; token: object }>();
let disposed = false;
const cancel = (taskId: string) => {
const entry = entries.get(taskId);
if (!entry) return;
entries.delete(taskId);
clearTimeoutFn(entry.handle);
};
return {
get size() {
return entries.size;
},
schedule(taskId, delayMs, isCurrent, run) {
if (disposed || !taskId) return;
cancel(taskId);
const token = {};
const handle = setTimeoutFn(() => {
const entry = entries.get(taskId);
if (!entry || entry.token !== token) return;
entries.delete(taskId);
if (disposed || !isCurrent()) return;
void Promise.resolve()
.then(run)
.catch((error) => options.onError?.(error));
}, Math.max(0, delayMs));
entries.set(taskId, { handle, token });
},
cancel,
dispose() {
if (disposed) return;
disposed = true;
for (const entry of entries.values()) clearTimeoutFn(entry.handle);
entries.clear();
},
};
}

View File

@@ -0,0 +1,112 @@
import { useCallback } from "react";
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { isMissingStatError } from "./errors";
import type { SftpPane } from "./types";
import { getParentPath, joinPath } from "./utils";
export function useSftpTransferConflictOps() {
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
if (isDirectory) return { baseName: fileName, ext: "" };
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) return { baseName: fileName, ext: "" };
return {
baseName: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}, []);
const statTargetPath = useCallback(
async (
targetPane: SftpPane,
targetSftpId: string | null,
targetPath: string,
targetEncoding: SftpFilenameEncoding,
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
try {
if (targetPane.connection.isLocal) {
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatLocal ?? bridge?.statLocal)?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatSftp ?? bridge?.statSftp)?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
} catch (error) {
// Missing path = no conflict. ENOTSUP / unknown type must fail closed.
if (isMissingStatError(error)) return null;
throw error;
}
},
[],
);
const getDuplicateTarget = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
for (let index = 1; index < 1000; index++) {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
// Unsupported LSTAT must propagate — do not treat as a free name.
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
return { fileName: fallbackName, targetPath: joinPath(parentPath, fallbackName) };
},
[splitNameForDuplicate, statTargetPath],
);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
expectedType?: "file" | "directory" | "symlink",
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath, expectedType);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
},
[],
);
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
}

View File

@@ -0,0 +1,551 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildTransferPoolKey,
createTransferConnectionPool,
createTransferPoolKeyCache,
DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS,
DEFAULT_TRANSFER_CONNECTIONS_PER_HOST,
DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS,
} from "./transferConnectionPool.ts";
function createFakeClock() {
let current = 0;
let nextId = 1;
const timers = new Map<number, { callback: () => void; deadline: number }>();
return {
now: () => current,
setTimeoutFn(callback: () => void, delayMs: number) {
const id = nextId;
nextId += 1;
timers.set(id, { callback, deadline: current + Math.max(0, delayMs) });
return id;
},
clearTimeoutFn(id: unknown) {
timers.delete(id as number);
},
advance(ms: number) {
current += ms;
for (;;) {
const due = [...timers.entries()]
.filter(([, timer]) => timer.deadline <= current)
.sort((left, right) => left[1].deadline - right[1].deadline)[0];
if (!due) break;
timers.delete(due[0]);
due[1].callback();
}
},
};
}
test("transfer pool key cache resolves a host identity only once and retries failures", async () => {
let builds = 0;
let inputBuilds = 0;
let failFirst = true;
const cache = createTransferPoolKeyCache(async (input) => {
builds += 1;
if (failFirst) {
failFirst = false;
throw new Error("temporary identity failure");
}
return `key:${input.hostId}`;
});
const host = {};
const createInput = () => {
inputBuilds += 1;
return { hostId: "h1" };
};
await assert.rejects(cache.get(host, createInput), /temporary identity failure/);
assert.equal(await cache.get(host, createInput), "key:h1");
assert.equal(await cache.get(host, createInput), "key:h1");
assert.equal(builds, 2, "a rejected identity must be evicted and rebuilt once");
assert.equal(inputBuilds, 2, "cached calls must skip credential construction too");
assert.equal(await cache.get({}, createInput), "key:h1");
assert.equal(builds, 3, "different host objects must not share identity promises");
});
test("buildTransferPoolKey includes endpoint when hostname is known", async () => {
assert.equal(
await buildTransferPoolKey({ hostId: "h1", hostname: "vault.example", port: 22, username: "root" }),
"host:h1|ep:vault.example:22:root:ssh:nosudo",
);
// Same hostId with session override must not share the vault pool key.
assert.equal(
await buildTransferPoolKey({ hostId: "h1", hostname: "override.example", port: 2222, username: "ubuntu" }),
"host:h1|ep:override.example:2222:ubuntu:ssh:nosudo",
);
assert.equal(
await buildTransferPoolKey({ hostname: "ci.example", port: 22, username: "root" }),
"ep:ci.example:22:root:ssh:nosudo",
);
assert.equal(await buildTransferPoolKey({ hostId: "h1" }), "host:h1");
});
test("buildTransferPoolKey separates every transport security identity", async () => {
const connectionOptions = {
hostname: "target.example",
port: 22,
username: "root",
password: "secret-a",
keepaliveInterval: 30,
algorithmOverrides: { kex: ["curve25519-sha256"] },
jumpHosts: [{ hostname: "jump-a", port: 22, username: "jump" }],
verifyHostKeys: true,
knownHosts: [{
id: "known-a",
hostname: "target.example",
port: 22,
keyType: "ssh-ed25519",
publicKey: "AAAA",
fingerprint: "a",
discoveredAt: 0,
}],
} as unknown as NetcattySSHOptions;
const base = {
hostId: "h1",
hostname: "target.example",
port: 22,
username: "root",
connectionOptions,
};
const key = await buildTransferPoolKey(base);
const variant = (overrides: Partial<NetcattySSHOptions>): NetcattySSHOptions => ({
...connectionOptions,
...overrides,
});
const variants: NetcattySSHOptions[] = [
variant({ password: "secret-b" }),
variant({ keepaliveInterval: 60 }),
variant({ algorithmOverrides: { kex: ["diffie-hellman-group14-sha256"] } }),
variant({ jumpHosts: [{ hostname: "jump-b", port: 22, username: "jump" }] }),
variant({ verifyHostKeys: false }),
variant({ knownHosts: [{
id: "known-b",
hostname: "target.example",
port: 22,
keyType: "ssh-ed25519",
publicKey: "BBBB",
fingerprint: "b",
discoveredAt: 0,
}] }),
];
for (const connectionOptions of variants) {
assert.notEqual(await buildTransferPoolKey({ ...base, connectionOptions }), key);
}
});
test("pool opens at most maxPerHost channels and multiplexes when busy", async () => {
let opens = 0;
const closed: string[] = [];
const pool = createTransferConnectionPool({
maxPerHost: 2,
closeSession: async (id) => { closed.push(id); },
});
const open = async () => {
opens += 1;
return `sftp-${opens}`;
};
const a = await pool.acquire("host:a", "t1", open);
const b = await pool.acquire("host:a", "t2", open);
assert.equal(opens, 2);
assert.notEqual(a.sftpId, b.sftpId);
// Third transfer reuses least-loaded connection (both size 1 → first by age).
const c = await pool.acquire("host:a", "t3", open);
assert.equal(opens, 2);
assert.ok(c.sftpId === a.sftpId || c.sftpId === b.sftpId);
a.release();
b.release();
c.release();
assert.equal(closed.length, 0);
assert.equal(pool.getStats("host:a").connections, 2);
assert.equal(pool.getStats("host:a").idle, 2);
const d = await pool.acquire("host:a", "t4", open);
assert.equal(opens, 2, "the next small file must reuse a short-idle channel");
d.release();
await pool.closeAll();
assert.equal(closed.length, 2);
});
test("different hosts get independent channel pools", async () => {
let opens = 0;
const pool = createTransferConnectionPool({ maxPerHost: 1 });
const open = async () => {
opens += 1;
return `sftp-${opens}`;
};
const a = await pool.acquire("host:a", "t1", open);
const b = await pool.acquire("host:b", "t2", open);
assert.equal(opens, 2);
assert.notEqual(a.sftpId, b.sftpId);
a.release();
b.release();
});
test("failed opens do not retain empty host pool keys", async () => {
const pool = createTransferConnectionPool({ maxPerHost: 1 });
for (let index = 0; index < 100; index += 1) {
await assert.rejects(
pool.acquire(`host:failed-${index}`, `transfer-${index}`, async () => {
throw new Error("connection failed");
}),
/connection failed/,
);
}
assert.equal(pool.getStats().poolKeys, 0);
assert.equal(pool.getStats().pendingOpenLocks, 0);
});
test("sequential small files reuse one channel until the short idle TTL expires", async () => {
const clock = createFakeClock();
const closed: string[] = [];
let opens = 0;
const pool = createTransferConnectionPool({
maxPerHost: 1,
idleTtlMs: 5_000,
closeSession: async (id) => { closed.push(id); },
now: clock.now,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});
const open = async () => `sftp-${++opens}`;
const first = await pool.acquire("host:x", "file-1", open);
first.release();
assert.equal(pool.getStats("host:x").idle, 1);
assert.deepEqual(closed, []);
for (let index = 2; index <= 100; index += 1) {
const next = await pool.acquire("host:x", `file-${index}`, open);
assert.equal(next.sftpId, first.sftpId);
next.release();
}
assert.equal(opens, 1);
clock.advance(4_999);
assert.deepEqual(closed, []);
clock.advance(1);
assert.deepEqual(closed, ["sftp-1"]);
assert.equal(pool.getStats("host:x").connections, 0);
});
test("a pool slot retains its SFTP session for the whole directory walk", async () => {
const clock = createFakeClock();
const retained: string[] = [];
const released: string[] = [];
const closed: string[] = [];
const pool = createTransferConnectionPool({
maxPerHost: 1,
idleTtlMs: 5_000,
now: clock.now,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
retainSession: async (sftpId, leaseId) => { retained.push(`${sftpId}:${leaseId}`); },
releaseSession: async (sftpId, leaseId) => { released.push(`${sftpId}:${leaseId}`); },
closeSession: async (sftpId) => { closed.push(sftpId); },
});
const root = await pool.acquire("host:folder", "directory-root", async () => "sftp-folder");
const child = await pool.acquire("host:folder", "directory-child", async () => {
throw new Error("must reuse the retained directory session");
});
assert.deepEqual(retained, ["sftp-folder:pool:sftp-folder"]);
child.release();
assert.deepEqual(released, [], "finishing one child must not release the pool session");
root.release();
assert.deepEqual(released, [], "the idle reuse window still owns the session");
clock.advance(5_000);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(released, ["sftp-folder:pool:sftp-folder"]);
assert.deepEqual(closed, ["sftp-folder"]);
});
test("default max per host is FileZilla-like (2)", () => {
assert.equal(DEFAULT_TRANSFER_CONNECTIONS_PER_HOST, 2);
assert.equal(DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS, 5_000);
assert.equal(DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS, 16);
});
test("global idle cap evicts the oldest host channel", async () => {
const clock = createFakeClock();
const closed: string[] = [];
let opens = 0;
const pool = createTransferConnectionPool({
maxPerHost: 1,
maxIdleConnections: 2,
idleTtlMs: 60_000,
closeSession: async (id) => { closed.push(id); },
now: clock.now,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});
const open = async () => `sftp-${++opens}`;
for (const [index, hostKey] of ["host:a", "host:b", "host:c"].entries()) {
const lease = await pool.acquire(hostKey, `file-${index}`, open);
lease.release();
clock.advance(1);
}
assert.deepEqual(closed, ["sftp-1"]);
assert.equal(pool.getStats().connections, 2);
assert.equal(pool.getStats().idle, 2);
assert.equal(pool.getStats().poolKeys, 2);
});
test("concurrent acquires do not exceed maxPerHost", async () => {
let opens = 0;
let inFlightOpens = 0;
let maxInFlightOpens = 0;
const pool = createTransferConnectionPool({ maxPerHost: 2 });
const open = async () => {
inFlightOpens += 1;
maxInFlightOpens = Math.max(maxInFlightOpens, inFlightOpens);
await new Promise((r) => setTimeout(r, 10));
opens += 1;
inFlightOpens -= 1;
return `sftp-${opens}`;
};
const leases = await Promise.all([
pool.acquire("host:a", "t1", open),
pool.acquire("host:a", "t2", open),
pool.acquire("host:a", "t3", open),
pool.acquire("host:a", "t4", open),
]);
assert.equal(opens, 2);
assert.ok(maxInFlightOpens <= 2);
const ids = new Set(leases.map((l) => l.sftpId));
assert.equal(ids.size, 2);
for (const lease of leases) lease.release();
});
test("per-host open serialization state survives queued work and is released after the last waiter", async () => {
let markFirstStarted!: () => void;
let markSecondStarted!: () => void;
let finishFirst!: () => void;
let finishSecond!: () => void;
const firstStarted = new Promise<void>((resolve) => { markFirstStarted = resolve; });
const secondStarted = new Promise<void>((resolve) => { markSecondStarted = resolve; });
const firstFinished = new Promise<void>((resolve) => { finishFirst = resolve; });
const secondFinished = new Promise<void>((resolve) => { finishSecond = resolve; });
const pool = createTransferConnectionPool({ maxPerHost: 2 });
let opens = 0;
const open = async () => {
opens += 1;
if (opens === 1) {
markFirstStarted();
await firstFinished;
} else {
markSecondStarted();
await secondFinished;
}
return `sftp-ephemeral-${opens}`;
};
const firstAcquiring = pool.acquire("host:ephemeral", "t1", open);
await firstStarted;
const secondAcquiring = pool.acquire("host:ephemeral", "t2", open);
assert.equal(pool.getStats("host:ephemeral").pendingOpenLocks, 1);
finishFirst();
const firstLease = await firstAcquiring;
await secondStarted;
assert.equal(
pool.getStats("host:ephemeral").pendingOpenLocks,
1,
"the first opener must not clear a later waiter's serialization state",
);
finishSecond();
const secondLease = await secondAcquiring;
assert.equal(pool.getStats("host:ephemeral").pendingOpenLocks, 0);
firstLease.release();
secondLease.release();
});
test("busy first channel causes a second open (FileZilla style)", async () => {
let opens = 0;
const pool = createTransferConnectionPool({ maxPerHost: 2 });
const open = async () => {
opens += 1;
return `sftp-${opens}`;
};
const first = await pool.acquire("host:a", "t1", open);
assert.equal(opens, 1);
// First is still held → open a second dedicated channel.
const second = await pool.acquire("host:a", "t2", open);
assert.equal(opens, 2);
assert.notEqual(first.sftpId, second.sftpId);
first.release();
second.release();
});
test("a replacement opened while the last old holder releases stays tracked and closable", async () => {
const closed: string[] = [];
const pool = createTransferConnectionPool({
maxPerHost: 2,
idleTtlMs: 0,
closeSession: async (id) => { closed.push(id); },
});
const first = await pool.acquire("host:release-during-open", "t1", async () => "sftp-old");
let finishReplacementOpen!: () => void;
const replacementOpenGate = new Promise<void>((resolve) => {
finishReplacementOpen = resolve;
});
let replacementOpenStarted!: () => void;
const replacementStarted = new Promise<void>((resolve) => {
replacementOpenStarted = resolve;
});
const replacementPromise = pool.acquire(
"host:release-during-open",
"t2",
async () => {
replacementOpenStarted();
await replacementOpenGate;
return "sftp-new";
},
);
await replacementStarted;
first.release();
finishReplacementOpen();
const replacement = await replacementPromise;
assert.deepEqual(closed, ["sftp-old"]);
assert.equal(pool.getStats("host:release-during-open").connections, 1);
assert.equal(pool.getStats("host:release-during-open").holders, 1);
replacement.release();
assert.deepEqual(closed, ["sftp-old", "sftp-new"]);
assert.equal(pool.getStats().connections, 0);
assert.equal(pool.getStats().poolKeys, 0);
});
test("discard removes a dead session so next acquire reopens", async () => {
let opens = 0;
const closed: string[] = [];
const pool = createTransferConnectionPool({
maxPerHost: 1,
closeSession: async (id) => { closed.push(id); },
});
const open = async () => {
opens += 1;
return `sftp-${opens}`;
};
const a = await pool.acquire("host:a", "t1", open);
assert.equal(opens, 1);
a.discard();
assert.equal(closed.length, 1);
assert.equal(pool.getStats("host:a").connections, 0);
const b = await pool.acquire("host:a", "t2", open);
assert.equal(opens, 2);
assert.notEqual(a.sftpId, b.sftpId);
b.release();
});
test("cancellation returns a healthy channel, while a session error discards it", async () => {
const clock = createFakeClock();
const closed: string[] = [];
let opens = 0;
const pool = createTransferConnectionPool({
maxPerHost: 1,
idleTtlMs: 5_000,
closeSession: async (id) => { closed.push(id); },
now: clock.now,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});
const open = async () => `sftp-${++opens}`;
const cancelled = await pool.acquire("host:a", "cancelled-transfer", open);
cancelled.release();
assert.equal(pool.getStats("host:a").idle, 1);
assert.deepEqual(closed, []);
const next = await pool.acquire("host:a", "next-transfer", open);
assert.equal(next.sftpId, cancelled.sftpId);
assert.equal(opens, 1);
next.discard();
assert.deepEqual(closed, ["sftp-1"]);
assert.equal(pool.getStats("host:a").connections, 0);
const recovered = await pool.acquire("host:a", "recovered-transfer", open);
assert.equal(recovered.sftpId, "sftp-2");
recovered.release();
clock.advance(5_000);
assert.deepEqual(closed, ["sftp-1", "sftp-2"]);
});
test("closeIdle detaches only expired idle slots before awaiting close", async () => {
let opens = 0;
let closeStarted = 0;
let now = 0;
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => { releaseClose = resolve; });
const pool = createTransferConnectionPool({
maxPerHost: 1,
idleTtlMs: 100,
now: () => now,
closeSession: async () => {
closeStarted += 1;
await closeGate;
},
});
const open = async () => {
opens += 1;
return `sftp-${opens}`;
};
const a = await pool.acquire("host:a", "t1", open);
assert.equal(opens, 1);
a.release();
assert.equal(await pool.closeIdle(now), 0);
now = 100;
const closing = pool.closeIdle(now);
assert.equal(closeStarted, 1);
assert.equal(pool.getStats().connections, 0, "expired slot must detach before close awaits");
releaseClose();
assert.equal(await closing, 1);
});
test("setIdleTtlMs reschedules parked channels and zero closes them immediately", async () => {
const clock = createFakeClock();
const closed: string[] = [];
const pool = createTransferConnectionPool({
idleTtlMs: 60_000,
closeSession: async (id) => { closed.push(id); },
now: clock.now,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});
const lease = await pool.acquire("host:a", "t1", async () => "sftp-a");
lease.release();
pool.setIdleTtlMs(5_000);
assert.equal(pool.getIdleTtlMs(), 5_000);
clock.advance(4_999);
assert.deepEqual(closed, []);
pool.setIdleTtlMs(0);
assert.deepEqual(closed, ["sftp-a"]);
assert.equal(pool.getStats().connections, 0);
});

View File

@@ -0,0 +1,563 @@
/**
* Transfer channel pool (FileZilla-style concurrency, not a second SSH stack).
*
* Bulk transfers share the main-process SSH transport registry. This pool only
* limits how many SFTP channels (sftpIds) may be open per host for parallel
* transfers, and reuses a busy-vs-idle slot within that cap.
*
* A released channel stays available for a short bounded idle window so a
* directory of sequential small files does not reopen one SFTP channel per
* file. The global idle cap prevents many visited hosts from retaining an
* unbounded number of channels; SSH transports remain owned by the unified
* main-process registry.
*/
export const DEFAULT_TRANSFER_CONNECTIONS_PER_HOST = 2;
export const MIN_TRANSFER_CONNECTIONS_PER_HOST = 1;
export const MAX_TRANSFER_CONNECTIONS_PER_HOST = 4;
export const DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS = 5_000;
export const DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS = 16;
export type TransferPoolOpenFn = (poolKey: string) => Promise<string>;
export type TransferPoolCloseFn = (sftpId: string) => void | Promise<void>;
export type TransferPoolSessionLeaseFn = (sftpId: string, leaseId: string) => void | Promise<void>;
export interface TransferConnectionLease {
sftpId: string;
poolKey: string;
/** Drop the holder count; last holder enters the short idle park. */
release: () => void;
/** Drop holder, remove from pool, and close — use when the session is dead. */
discard: () => void;
}
interface PoolSlot {
sftpId: string;
/** Main-process hold that keeps this SFTP channel alive between child files. */
sessionLeaseId: string;
holders: Set<string>;
lastUsedAt: number;
idleSince?: number;
idleOrder?: number;
idleTimer?: unknown;
/** Session is dead; do not hand out to new transfers. Close when idle. */
unhealthy?: boolean;
opening?: Promise<string>;
}
export interface TransferConnectionPoolOptions {
maxPerHost?: number;
idleTtlMs?: number;
/** Global cap across all host pools; oldest idle channels are evicted first. */
maxIdleConnections?: number;
closeSession?: TransferPoolCloseFn;
retainSession?: TransferPoolSessionLeaseFn;
releaseSession?: TransferPoolSessionLeaseFn;
now?: () => number;
/** Deterministic timer hooks for tests. */
setTimeoutFn?: (callback: () => void, delayMs: number) => unknown;
clearTimeoutFn?: (handle: unknown) => void;
}
export interface TransferPoolKeyInput {
hostId?: string;
hostname?: string;
port?: number;
username?: string;
protocol?: string;
sftpSudo?: boolean;
/** Full resolved transport identity; kept private inside the in-memory pool. */
connectionOptions?: NetcattySSHOptions;
}
export interface TransferPoolKeyCache {
get(host: object, createInput: () => TransferPoolKeyInput): Promise<string>;
}
/**
* Resolving a transfer identity includes credential expansion and SHA-256.
* Directory transfers may acquire a lease once per file, so cache that work
* by the immutable Host object for the lifetime of the current vault inputs.
*/
export function createTransferPoolKeyCache(
build: (input: TransferPoolKeyInput) => Promise<string> = buildTransferPoolKey,
): TransferPoolKeyCache {
const cache = new WeakMap<object, Promise<string>>();
return {
get(host, createInput) {
const existing = cache.get(host);
if (existing) return existing;
const pending = Promise.resolve().then(() => build(createInput()));
cache.set(host, pending);
void pending.catch(() => {
if (cache.get(host) === pending) cache.delete(host);
});
return pending;
},
};
}
export interface TransferConnectionPool {
acquire(poolKey: string, transferId: string, open: TransferPoolOpenFn): Promise<TransferConnectionLease>;
release(poolKey: string, sftpId: string, transferId: string): void;
/** Remove a dead session from the pool and close it (best-effort). */
discard(sftpId: string): void;
getStats(poolKey?: string): {
poolKeys: number;
connections: number;
busy: number;
idle: number;
holders: number;
pendingOpenLocks: number;
};
/** Close channels whose idle deadline has passed. */
closeIdle(now?: number): Promise<number>;
closeAll(): Promise<void>;
setMaxPerHost(max: number): void;
setIdleTtlMs(ms: number): void;
getIdleTtlMs(): number;
}
function normalizeMaxPerHost(value: number | undefined): number {
if (!Number.isInteger(value)) return DEFAULT_TRANSFER_CONNECTIONS_PER_HOST;
return Math.min(
MAX_TRANSFER_CONNECTIONS_PER_HOST,
Math.max(MIN_TRANSFER_CONNECTIONS_PER_HOST, value as number),
);
}
function normalizeIdleTtlMs(value: number | undefined): number {
if (value === undefined) return DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS;
if (!Number.isFinite(value)) return DEFAULT_TRANSFER_CONNECTION_IDLE_TTL_MS;
return Math.max(0, Math.floor(value));
}
function normalizeMaxIdleConnections(value: number | undefined): number {
if (value === undefined) return DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS;
if (!Number.isFinite(value)) return DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS;
return Math.max(0, Math.floor(value));
}
export function createTransferConnectionPool(
options: TransferConnectionPoolOptions = {},
): TransferConnectionPool {
let maxPerHost = normalizeMaxPerHost(options.maxPerHost);
let idleTtlMs = normalizeIdleTtlMs(options.idleTtlMs);
const maxIdleConnections = normalizeMaxIdleConnections(options.maxIdleConnections);
const closeSession = options.closeSession;
const retainSession = options.retainSession;
const releaseSession = options.releaseSession;
const now = options.now ?? (() => Date.now());
const setTimeoutFn = options.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
let nextIdleOrder = 1;
/** poolKey -> open slots for that host endpoint */
const pools = new Map<string, PoolSlot[]>();
/** Serialize opens per host so we never exceed maxPerHost under concurrency */
const openLocks = new Map<string, Promise<void>>();
const getList = (poolKey: string): PoolSlot[] => {
let list = pools.get(poolKey);
if (!list) {
list = [];
pools.set(poolKey, list);
}
return list;
};
const withOpenLock = async <T>(poolKey: string, work: () => Promise<T>): Promise<T> => {
const previous = openLocks.get(poolKey) ?? Promise.resolve();
let releaseLock!: () => void;
const gate = new Promise<void>((resolve) => { releaseLock = resolve; });
// Chain waiters so concurrent acquires never exceed maxPerHost.
const tail = previous.catch(() => {}).then(() => gate);
openLocks.set(poolKey, tail);
await previous.catch(() => {});
try {
return await work();
} finally {
releaseLock();
// A later waiter replaces our tail. Only the last waiter may remove the
// per-host serialization entry, otherwise concurrent opens can overlap.
if (openLocks.get(poolKey) === tail) openLocks.delete(poolKey);
}
};
const clearIdleTimer = (slot: PoolSlot) => {
if (slot.idleTimer === undefined) return;
clearTimeoutFn(slot.idleTimer);
slot.idleTimer = undefined;
};
const detachSlot = (poolKey: string, list: PoolSlot[], idx: number): PoolSlot | undefined => {
const [slot] = list.splice(idx, 1);
if (slot) clearIdleTimer(slot);
if (list.length === 0) pools.delete(poolKey);
else pools.set(poolKey, list);
return slot;
};
const closeSlot = async (slot: PoolSlot) => {
if (releaseSession) {
try {
await releaseSession(slot.sftpId, slot.sessionLeaseId);
} catch {
// best-effort; the explicit close below is still required
}
}
try {
await closeSession?.(slot.sftpId);
} catch {
// best-effort
}
};
const closeSessionBestEffort = (slot: PoolSlot) => {
void closeSlot(slot);
};
const removeAndCloseSlot = (poolKey: string, list: PoolSlot[], idx: number) => {
const slot = detachSlot(poolKey, list, idx);
if (slot) {
closeSessionBestEffort(slot);
}
};
const createRetainedSlot = async (sftpId: string, transferId: string): Promise<PoolSlot> => {
const sessionLeaseId = `pool:${sftpId}`;
try {
await retainSession?.(sftpId, sessionLeaseId);
} catch (error) {
try { await closeSession?.(sftpId); } catch { /* best-effort */ }
throw error;
}
return {
sftpId,
sessionLeaseId,
holders: new Set([transferId]),
lastUsedAt: now(),
};
};
const scheduleIdleClose = (poolKey: string, slot: PoolSlot, delayMs?: number) => {
clearIdleTimer(slot);
if (slot.holders.size > 0) return;
const list = pools.get(poolKey);
const idx = list?.indexOf(slot) ?? -1;
if (!list || idx < 0) return;
if (slot.unhealthy || idleTtlMs <= 0) {
removeAndCloseSlot(poolKey, list, idx);
return;
}
const remaining = delayMs ?? Math.max(0, (slot.idleSince ?? now()) + idleTtlMs - now());
if (remaining <= 0) {
removeAndCloseSlot(poolKey, list, idx);
return;
}
const handle = setTimeoutFn(() => {
slot.idleTimer = undefined;
const currentList = pools.get(poolKey);
const currentIdx = currentList?.indexOf(slot) ?? -1;
if (!currentList || currentIdx < 0 || slot.holders.size > 0) return;
const deadline = (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs;
const remainingAtFire = deadline - now();
if (remainingAtFire > 0) {
scheduleIdleClose(poolKey, slot, remainingAtFire);
return;
}
removeAndCloseSlot(poolKey, currentList, currentIdx);
}, remaining);
slot.idleTimer = handle;
const maybeTimer = handle as { unref?: () => void } | null;
maybeTimer?.unref?.();
};
const enforceGlobalIdleCap = () => {
const idleSlots: Array<{ poolKey: string; slot: PoolSlot }> = [];
for (const [poolKey, list] of pools.entries()) {
for (const slot of list) {
if (slot.holders.size === 0) idleSlots.push({ poolKey, slot });
}
}
idleSlots.sort((left, right) => {
if (left.slot.lastUsedAt !== right.slot.lastUsedAt) {
return left.slot.lastUsedAt - right.slot.lastUsedAt;
}
return (left.slot.idleOrder ?? 0) - (right.slot.idleOrder ?? 0);
});
for (let index = 0; index < idleSlots.length - maxIdleConnections; index += 1) {
const candidate = idleSlots[index]!;
const currentList = pools.get(candidate.poolKey);
const currentIdx = currentList?.indexOf(candidate.slot) ?? -1;
if (currentList && currentIdx >= 0 && candidate.slot.holders.size === 0) {
removeAndCloseSlot(candidate.poolKey, currentList, currentIdx);
}
}
};
const pickSlot = (list: PoolSlot[]): PoolSlot | null => {
// Never hand out sessions marked dead by a prior discard.
const healthy = list.filter((slot) => !slot.unhealthy);
if (healthy.length === 0) return null;
// Prefer idle connections, else least-loaded (FileZilla-style multiplexing).
const sorted = [...healthy].sort((a, b) => {
if (a.holders.size !== b.holders.size) return a.holders.size - b.holders.size;
return a.lastUsedAt - b.lastUsedAt;
});
return sorted[0] ?? null;
};
const release = (poolKey: string, sftpId: string, transferId: string) => {
const list = pools.get(poolKey);
if (!list) return;
const idx = list.findIndex((candidate) => candidate.sftpId === sftpId);
if (idx < 0) return;
const slot = list[idx]!;
if (!slot.holders.delete(transferId)) return;
slot.lastUsedAt = now();
if (slot.holders.size === 0) {
if (slot.unhealthy) {
removeAndCloseSlot(poolKey, list, idx);
return;
}
slot.idleSince = slot.lastUsedAt;
slot.idleOrder = nextIdleOrder;
nextIdleOrder += 1;
scheduleIdleClose(poolKey, slot, idleTtlMs);
enforceGlobalIdleCap();
}
};
/**
* Mark a session unusable for new work. Only closes the underlying session
* when no other holders remain — multiplexed siblings must not be killed.
*/
const discard = (sftpId: string, options?: { transferId?: string }) => {
if (!sftpId) return;
for (const [poolKey, list] of pools.entries()) {
const idx = list.findIndex((slot) => slot.sftpId === sftpId);
if (idx < 0) continue;
const slot = list[idx]!;
if (options?.transferId) slot.holders.delete(options.transferId);
slot.unhealthy = true;
slot.lastUsedAt = now();
if (slot.holders.size > 0) {
// Peers still using this socket; close when the last one releases.
return;
}
removeAndCloseSlot(poolKey, list, idx);
return;
}
};
const makeLease = (poolKey: string, sftpId: string, transferId: string): TransferConnectionLease => ({
sftpId,
poolKey,
release: () => release(poolKey, sftpId, transferId),
discard: () => discard(sftpId, { transferId }),
});
const acquire = async (
poolKey: string,
transferId: string,
open: TransferPoolOpenFn,
): Promise<TransferConnectionLease> => {
if (!poolKey) throw new Error("Transfer pool key is required");
if (!transferId) throw new Error("Transfer id is required");
return withOpenLock(poolKey, async () => {
const list = getList(poolKey);
// Count only healthy slots toward the open budget; unhealthy ones drain
// as holders leave and should not block opening a replacement connection.
const healthyCount = list.filter((slot) => !slot.unhealthy).length;
const existing = pickSlot(list);
// Reuse when we already have max healthy connections, or when an idle one exists.
// FileZilla-style: open a second connection only when the first is busy.
// Idle slots are available during the short reuse window.
if (existing && (existing.holders.size === 0 || healthyCount >= maxPerHost)) {
clearIdleTimer(existing);
existing.idleSince = undefined;
existing.idleOrder = undefined;
existing.holders.add(transferId);
existing.lastUsedAt = now();
return makeLease(poolKey, existing.sftpId, transferId);
}
if (healthyCount < maxPerHost) {
let sftpId: string;
try {
sftpId = await open(poolKey);
} catch (error) {
// getList() installs the per-host array before opening. A failed
// connection must not leave one empty key behind forever in the
// process-wide pool (notably when many different hosts are tried).
if (list.length === 0 && pools.get(poolKey) === list) {
pools.delete(poolKey);
}
throw error;
}
const slot = await createRetainedSlot(sftpId, transferId);
// The last holder of the old list can release while open() is awaiting,
// which removes that empty list from `pools`. Re-read/recreate the
// canonical list before publishing the new slot; otherwise the lease is
// returned from a detached array and can never be released or closed.
getList(poolKey).push(slot);
return makeLease(poolKey, sftpId, transferId);
}
// Should be unreachable when maxPerHost >= 1, but keep safe fallback.
const fallback = pickSlot(list);
if (!fallback) {
const sftpId = await open(poolKey);
const slot = await createRetainedSlot(sftpId, transferId);
list.push(slot);
return makeLease(poolKey, sftpId, transferId);
}
fallback.holders.add(transferId);
clearIdleTimer(fallback);
fallback.idleSince = undefined;
fallback.idleOrder = undefined;
fallback.lastUsedAt = now();
return makeLease(poolKey, fallback.sftpId, transferId);
});
};
const closeIdle = async (sweepNow = now()): Promise<number> => {
const toClose: PoolSlot[] = [];
for (const [poolKey, list] of pools.entries()) {
const kept: PoolSlot[] = [];
for (const slot of list) {
const expired = slot.holders.size === 0
&& (slot.unhealthy || idleTtlMs <= 0 || (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs <= sweepNow);
if (expired) {
clearIdleTimer(slot);
toClose.push(slot);
continue;
}
kept.push(slot);
}
if (kept.length === 0) pools.delete(poolKey);
else pools.set(poolKey, kept);
}
let closed = 0;
for (const slot of toClose) {
closed += 1;
await closeSlot(slot);
}
return closed;
};
const closeAll = async () => {
const toClose = [...pools.values()].flatMap((list) => list);
pools.clear();
for (const slot of toClose) clearIdleTimer(slot);
for (const slot of toClose) {
await closeSlot(slot);
}
};
const getStats = (poolKey?: string) => {
const lists = poolKey ? [pools.get(poolKey) ?? []] : [...pools.values()];
let connections = 0;
let busy = 0;
let idle = 0;
let holders = 0;
for (const list of lists) {
for (const slot of list) {
connections += 1;
holders += slot.holders.size;
if (slot.holders.size > 0) busy += 1;
else idle += 1;
}
}
const pendingOpenLocks = poolKey
? (openLocks.has(poolKey) ? 1 : 0)
: openLocks.size;
return { poolKeys: pools.size, connections, busy, idle, holders, pendingOpenLocks };
};
return {
acquire,
release,
discard,
getStats,
closeIdle,
closeAll,
setMaxPerHost(max: number) {
maxPerHost = normalizeMaxPerHost(max);
},
setIdleTtlMs(ms: number) {
idleTtlMs = normalizeIdleTtlMs(ms);
for (const [poolKey, list] of [...pools.entries()]) {
for (const slot of [...list]) {
if (slot.holders.size > 0) continue;
scheduleIdleClose(
poolKey,
slot,
Math.max(0, (slot.idleSince ?? slot.lastUsedAt) + idleTtlMs - now()),
);
}
}
enforceGlobalIdleCap();
},
getIdleTtlMs() {
return idleTtlMs;
},
};
}
/** Shared process-wide pool used by SFTP bulk transfers in the renderer. */
let sharedPool: TransferConnectionPool | null = null;
/**
* Process-wide transfer channel pool (FileZilla-style, max 2 sftpIds per host).
* `closeSession` is applied on first creation; later callers share the same pool.
*/
export function getSharedTransferConnectionPool(
options?: TransferConnectionPoolOptions,
): TransferConnectionPool {
if (!sharedPool) {
sharedPool = createTransferConnectionPool({
maxPerHost: DEFAULT_TRANSFER_CONNECTIONS_PER_HOST,
...options,
});
}
return sharedPool;
}
/** Test-only: drop the singleton so tests start clean. */
export function resetSharedTransferConnectionPoolForTests(): void {
sharedPool = null;
}
export async function buildTransferPoolKey(input: TransferPoolKeyInput): Promise<string> {
const stableSerialize = (value: unknown): string => {
if (value === undefined) return '"__undefined__"';
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([key]) => key !== "sessionId")
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(",")}}`;
};
// Include endpoint identity whenever hostname is known so session-time
// hostname/port/username overrides do not share a pool with the vault host.
if (input.hostname) {
const port = input.port || 22;
const user = input.username || "root";
const protocol = input.protocol || "ssh";
const sudo = input.sftpSudo ? "sudo" : "nosudo";
const ep = `${input.hostname}:${port}:${user}:${protocol}:${sudo}`;
const base = input.hostId ? `host:${input.hostId}|ep:${ep}` : `ep:${ep}`;
if (!input.connectionOptions) return base;
const encoded = new TextEncoder().encode(stableSerialize(input.connectionOptions));
const digest = await crypto.subtle.digest("SHA-256", encoded);
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${base}|identity:${fingerprint}`;
}
if (input.hostId) return `host:${input.hostId}`;
return "ep:unknown:22:root:ssh:nosudo";
}

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
bumpTransferControlEpoch,
getTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
test("control epoch starts at 0 and bumps monotonically per id", () => {
resetTransferControlEpochsForTests();
assert.equal(getTransferControlEpoch("a"), 0);
assert.equal(bumpTransferControlEpoch("a"), 1);
assert.equal(bumpTransferControlEpoch("a"), 2);
assert.equal(getTransferControlEpoch("a"), 2);
assert.equal(getTransferControlEpoch("b"), 0);
assert.equal(isTransferControlEpochCurrent("a", 2), true);
assert.equal(isTransferControlEpochCurrent("a", 1), false);
});
test("resume-style bump invalidates a captured pause epoch", () => {
resetTransferControlEpochsForTests();
const pauseEpoch = bumpTransferControlEpoch("folder");
assert.equal(isTransferControlEpochCurrent("folder", pauseEpoch), true);
// User hits resume immediately.
bumpTransferControlEpoch("folder");
assert.equal(isTransferControlEpochCurrent("folder", pauseEpoch), false);
});
test("settled single-file control epochs do not accumulate across a large batch", () => {
resetTransferControlEpochsForTests();
const taskIds = Array.from({ length: 4_000 }, (_, index) => `single-${index}`);
for (const taskId of taskIds) {
const epoch = bumpTransferControlEpoch(taskId);
assert.equal(isTransferControlEpochCurrent(taskId, epoch), true);
settleTransferControlEpochTree(taskId);
assert.equal(getTransferControlEpoch(taskId), 0);
assert.equal(isTransferControlEpochCurrent(taskId, epoch), false);
}
});
test("reusing a settled task id cannot make an old pause epoch current again", () => {
resetTransferControlEpochsForTests();
const oldPauseEpoch = bumpTransferControlEpoch("same-id");
settleTransferControlEpochTree("same-id");
const retryPauseEpoch = bumpTransferControlEpoch("same-id");
assert.ok(retryPauseEpoch > oldPauseEpoch);
assert.equal(isTransferControlEpochCurrent("same-id", oldPauseEpoch), false);
assert.equal(isTransferControlEpochCurrent("same-id", retryPauseEpoch), true);
});
test("settling a directory tree releases parent and child control epochs together", () => {
resetTransferControlEpochsForTests();
const childIds = Array.from({ length: 4_000 }, (_, index) => `directory-child-${index}`);
const rootEpoch = bumpTransferControlEpoch("directory-root");
const childEpochs = childIds.map((childId) => bumpTransferControlEpoch(childId));
settleTransferControlEpochTree("directory-root", childIds);
assert.equal(isTransferControlEpochCurrent("directory-root", rootEpoch), false);
for (let index = 0; index < childIds.length; index += 1) {
assert.equal(getTransferControlEpoch(childIds[index]), 0);
assert.equal(isTransferControlEpochCurrent(childIds[index], childEpochs[index]), false);
}
});

View File

@@ -0,0 +1,43 @@
/**
* Monotonic control epoch per transfer id.
*
* Pause soft-drain / watchdogs capture an epoch when they start. Resume (or a
* newer pause) bumps the epoch so every in-flight soft-drain becomes a no-op
* instead of racing the live stream. Process-global so it survives panel unmount.
*/
const epochs = new Map<string, number>();
// A scalar generation prevents ABA when a settled task id is reused. It does
// not retain task ids, while making every new control round process-unique.
let nextEpoch = 0;
export function getTransferControlEpoch(taskId: string): number {
return epochs.get(taskId) ?? 0;
}
/** Bump and return the new epoch. Call on intentional Pause and Resume. */
export function bumpTransferControlEpoch(taskId: string): number {
const next = nextEpoch + 1;
nextEpoch = next;
epochs.set(taskId, next);
return next;
}
export function isTransferControlEpochCurrent(taskId: string, epoch: number): boolean {
return epochs.get(taskId) === epoch;
}
/** Release control epochs after the transfer tree has fully settled. */
export function settleTransferControlEpochTree(
rootTaskId: string,
childIds: readonly string[] = [],
): void {
epochs.delete(rootTaskId);
for (const childId of childIds) epochs.delete(childId);
}
/** Test helper. */
export function resetTransferControlEpochsForTests(): void {
epochs.clear();
nextEpoch = 0;
}

View File

@@ -0,0 +1,406 @@
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 { SftpFileEntry, TransferTask } from "../../../domain/models";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
const directoryEntry = (name: string): SftpFileEntry => ({
name,
type: "directory",
size: 0,
sizeFormatted: "0 B",
lastModified: 0,
lastModifiedFormatted: "",
});
const fileEntry = (name: string): SftpFileEntry => ({
name,
type: "file",
size: 1,
sizeFormatted: "1 B",
lastModified: 0,
lastModifiedFormatted: "",
});
const rootTask = (): TransferTask => ({
id: "root",
fileName: "source",
sourcePath: "/source",
targetPath: "/target",
sourceConnectionId: "source-sftp",
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 0,
isDirectory: true,
progressMode: "files",
});
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
const pausedTasksRef = { current: new Set<string>() };
let pauseCalls = 0;
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
let finishTransfer!: (value: { error?: string }) => void;
let pauseStarted!: () => void;
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
let resumeCalls = 0;
let childId = "";
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: (options: { transferId: string }) => {
childId = options.transferId;
// Reproduce a stream arming during the parent's initial pause round.
bumpTransferControlEpoch(root.id);
latchTransferPauseTree(root.id, [childId]);
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
return transferGate;
},
pauseTransfer: () => {
pauseCalls++;
pauseStarted();
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
},
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<unknown> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
pausedTasksRef,
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
await pauseGate;
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
// Another window's resumed event updates rows without bumping this local epoch.
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
finishPause({ success: false, superseded: true, supersededBy: "resume" });
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
assert.equal(isTransferPauseLatched(childId), false);
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
assert.equal(pausedTasksRef.current.has(childId), false);
return;
}
bumpTransferControlEpoch(root.id);
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
else {
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
resetTransferPauseLatchesForTests();
}
finishPause({ success: true });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
} finally {
pausedTasksRef.current.clear();
resetTransferPauseLatchesForTests();
finishPause?.({ success: true });
finishTransfer({});
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
resetTransferControlEpochsForTests();
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
}
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const directoryCount = 64;
const root = rootTask();
let tasks: TransferTask[] = [
root,
...Array.from({ length: directoryCount }, (_, index): TransferTask => ({
...root,
id: `completed-${index}`,
fileName: `file-${index}.txt`,
sourcePath: `/source/dir-${index}/file-${index}.txt`,
targetPath: `/target/dir-${index}/file-${index}.txt`,
status: "completed",
totalBytes: 1,
transferredBytes: 1,
isDirectory: false,
progressMode: "bytes",
parentTaskId: root.id,
})),
];
const transfersRef = { current: tasks };
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
};
let activeListings = 0;
let maxActiveListings = 0;
const listCalls = new Map<string, number>();
const listRemoteFiles = async (_sftpId: string, path: string): Promise<SftpFileEntry[]> => {
listCalls.set(path, (listCalls.get(path) ?? 0) + 1);
activeListings += 1;
maxActiveListings = Math.max(maxActiveListings, activeListings);
await new Promise((resolve) => setTimeout(resolve, 1));
activeListings -= 1;
if (path === "/source") {
return [
...Array.from({ length: directoryCount }, (_, index) => directoryEntry(`dir-${index}`)),
{ ...directoryEntry("loop"), type: "symlink", linkTarget: "directory" },
];
}
const index = Number(path.slice(path.lastIndexOf("-") + 1));
return [fileEntry(`file-${index}.txt`)];
};
(globalThis as { window?: unknown }).window = {
netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
realpathSftp: async (_sftpId: string, remotePath: string) => (
remotePath === "/source/loop" ? "/source" : remotePath
),
},
};
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "owner",
cancelledTasksRef: { current: new Set() },
pausedTasksRef: { current: new Set() },
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() },
transfersRef,
setTransfers,
listLocalFiles: async () => [],
listRemoteFiles,
});
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.ok(operations);
assert.equal("countDirectoryFiles" in operations, false, "directory startup must not expose a separate full-tree count pass");
await operations.transferDirectory(
root,
"source-sftp",
null,
false,
true,
"auto",
"auto",
root.id,
undefined,
0,
true,
);
assert.equal(listCalls.size, directoryCount + 1);
assert.ok(Array.from(listCalls.values()).every((count) => count === 1));
assert.equal(listCalls.has("/source/loop"), false, "canonical symlink cycles must not be listed");
// Interleaved walk processes sibling subdirectories sequentially so resume
// manifests stay deterministic (no full-tree pre-scan fan-out).
assert.equal(maxActiveListings, 1, `expected sequential listings, got ${maxActiveListings}`);
assert.equal(tasks.find((task) => task.id === root.id)?.totalBytes, directoryCount);
} finally {
await act(async () => {
renderer?.unmount();
});
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live directory download rejects a Windows backslash traversal entry", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = {
...rootTask(),
targetPath: "C:\\Users\\alice\\Downloads\\folder",
};
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
};
(globalThis as { window?: unknown }).window = {
netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
},
};
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "owner",
cancelledTasksRef: { current: new Set() },
pausedTasksRef: { current: new Set() },
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() },
transfersRef,
setTransfers,
listLocalFiles: async () => [],
listRemoteFiles: async () => [fileEntry("..\\outside.txt")],
});
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.ok(operations);
await assert.rejects(
operations.transferDirectory(
root,
"source-sftp",
null,
false,
true,
"auto",
"auto",
root.id,
),
/unsafe transfer path/i,
);
assert.equal(tasks.some((task) => task.parentTaskId === root.id), false);
} finally {
await act(async () => {
renderer?.unmount();
});
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
let childId: string | undefined;
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<number> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "live-compacted-owner", cancelledTasksRef,
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
sftpTransferCenterStore.dismiss(root.id);
(globalThis as { window?: unknown }).window = previousWindow;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("directory parent progress freezes while pausing without re-pausing child streams", () => {
const source = readFileSync(new URL("./transferDirectoryOps.ts", import.meta.url), "utf8");
assert.match(source, /const parentFrozen = !!parentRow/);
assert.match(source, /parentRow\.status === "paused"/);
assert.match(source, /parentRow\.status === "pausing"/);
assert.match(source, /\|\| isPauseLatched\(rootTaskId\)/);
assert.match(source, /if \(parentFrozen\) \{\s*return \{ \.\.\.t, speed: 0 \};/);
assert.doesNotMatch(source, /const pauseRequested = .*status === "paused"/);
});
test("directory pauseWatch undoes pause when control epoch is superseded", () => {
const source = readFileSync(new URL("./transferDirectoryOps.ts", import.meta.url), "utf8");
assert.match(source, /epochAtAttempt/);
assert.match(source, /isTransferControlEpochCurrent\(rootTaskId, epochAtAttempt\)/);
assert.match(source, /resumeTransfer\?\.\(task\.id\)/);
});
test("store soft-fail demotes and held rejoin uses bridge lifecycleEpoch (source contract)", () => {
const source = readFileSync(new URL("../sftpTransferCenterStore.ts", import.meta.url), "utf8");
// Soft-fail must demote before silent return
assert.match(source, /softFailedNeedsHard|Transfer session is no longer active/);
assert.match(source, /controller = undefined/);
// Held soft-rejoin must not stamp control-plane epoch onto task.lifecycleEpoch
assert.doesNotMatch(source, /lifecycleEpoch: heldResumeEpoch/);
assert.match(source, /Prefer bridge lifecycleEpoch|bridgeEpoch/);
});
test("directory childTask clears inherited lifecycleEpoch before stream arm", () => {
const source = readFileSync(new URL("./transferDirectoryOps.ts", import.meta.url), "utf8");
assert.match(source, /lifecycleEpoch: undefined/);
assert.match(source, /Never\s+inherit the parent's soft-resume epoch|New\/restarted child streams arm at bridge lifecycleEpoch 0/i);
});
test("softResume does not stamp parent resume epoch onto non-resumed siblings", () => {
const source = readFileSync(new URL("./globalSftpTransferControl.ts", import.meta.url), "utf8");
assert.match(source, /Non-resumed siblings under the folder/);
assert.match(source, /lifecycleEpoch: undefined/);
assert.match(source, /bridgeEpochById/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
import type { TransferTask } from "../../../domain/models";
import {
compareDirectoryTraversalPaths,
createDirectoryManifestAccumulator,
createDirectoryEntryIdentity,
createEmptyDirectoryResumeCheckpoint,
isValidDirectoryResumeCheckpoint,
} from "../../../domain/sftpDirectoryCheckpoint";
import {
pruneSftpTransferHistory,
sanitizeSftpTransferTask,
SFTP_TRANSFER_CENTER_VERSION,
} from "../../../domain/sftpTransferCenter";
const RESTORE_SYNC_BUDGET_MS = 8;
const RESTORE_CHECK_INTERVAL = 128;
const TERMINAL_STATUSES = new Set<TransferTask["status"]>(["completed", "failed", "cancelled"]);
export interface CooperativeTransferHistoryRestoreResult {
valid: boolean;
tasks: TransferTask[];
}
function monotonicNow(): number {
return typeof globalThis.performance?.now === "function"
? globalThis.performance.now()
: Date.now();
}
async function yieldToEventLoop(): Promise<void> {
const scheduler = (globalThis as typeof globalThis & {
scheduler?: { yield?: () => Promise<void> };
}).scheduler;
if (typeof scheduler?.yield === "function") {
await scheduler.yield();
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
function createRestoreYieldController() {
let sliceStartedAt = monotonicNow();
return async (iteration: number, force = false) => {
if (!force && iteration % RESTORE_CHECK_INTERVAL !== 0) return;
if (!force && monotonicNow() - sliceStartedAt < RESTORE_SYNC_BUDGET_MS) return;
await yieldToEventLoop();
sliceStartedAt = monotonicNow();
};
}
/**
* Upgrade a large pre-checkpoint transfer history without monopolizing the
* renderer. The final bounded prune still uses the canonical domain function;
* this function only performs its expensive legacy directory normalization
* and manifest hashing in cooperative slices first.
*/
export async function restoreSftpTransferHistoryCooperatively(
raw: string,
now = Date.now(),
): Promise<CooperativeTransferHistoryRestoreResult> {
// Let first paint and store subscribers attach before parsing a legacy blob.
await yieldToEventLoop();
let parsed: { version?: unknown; tasks?: unknown };
try {
parsed = JSON.parse(raw) as { version?: unknown; tasks?: unknown };
} catch {
return { valid: false, tasks: [] };
}
if (parsed.version !== SFTP_TRANSFER_CENTER_VERSION || !Array.isArray(parsed.tasks)) {
return { valid: false, tasks: [] };
}
const maybeYield = createRestoreYieldController();
const restored: TransferTask[] = [];
for (let index = 0; index < parsed.tasks.length; index += 1) {
const task = sanitizeSftpTransferTask(parsed.tasks[index]);
if (task) restored.push(task);
await maybeYield(index);
}
const unfinishedDirectoryParents: TransferTask[] = [];
const childrenByParent = new Map<string, TransferTask[]>();
for (let index = 0; index < restored.length; index += 1) {
const task = restored[index];
if (task.isDirectory && !task.parentTaskId && !TERMINAL_STATUSES.has(task.status)) {
unfinishedDirectoryParents.push(task);
}
if (task.parentTaskId) {
const children = childrenByParent.get(task.parentTaskId) ?? [];
children.push(task);
childrenByParent.set(task.parentTaskId, children);
}
await maybeYield(index);
}
const compactedChildIds = new Set<string>();
const parentUpdates = new Map<string, TransferTask>();
const normalizedChildUpdates = new Map<string, TransferTask>();
for (let parentIndex = 0; parentIndex < unfinishedDirectoryParents.length; parentIndex += 1) {
const parent = unfinishedDirectoryParents[parentIndex];
let parentChildren = childrenByParent.get(parent.id) ?? [];
const needsLegacyNormalization = !isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint)
&& parentChildren.some((child) => (
!Number.isSafeInteger(child.directoryEntryIndex)
|| !/^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
));
if (needsLegacyNormalization) {
parentChildren = [...parentChildren]
.sort((left, right) => compareDirectoryTraversalPaths(left.sourcePath, right.sourcePath));
await maybeYield(parentIndex, parentChildren.length > RESTORE_CHECK_INTERVAL);
for (let childIndex = 0; childIndex < parentChildren.length; childIndex += 1) {
const child = parentChildren[childIndex];
const normalized: TransferTask = {
...child,
directoryEntryIndex: childIndex,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: child.sourcePath,
targetPath: child.targetPath,
size: child.totalBytes,
lastModified: child.sourceLastModified,
}),
};
parentChildren[childIndex] = normalized;
normalizedChildUpdates.set(normalized.id, normalized);
await maybeYield(childIndex);
}
}
const checkpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint)
? { ...parent.directoryResumeCheckpoint }
: createEmptyDirectoryResumeCheckpoint();
const initialCoveredEntries = checkpoint.coveredEntries;
const childrenByIndex = new Map<number, TransferTask>();
for (let childIndex = 0; childIndex < parentChildren.length; childIndex += 1) {
const child = parentChildren[childIndex];
if (
Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? -1) >= 0
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
&& !childrenByIndex.has(child.directoryEntryIndex!)
) {
childrenByIndex.set(child.directoryEntryIndex!, child);
}
await maybeYield(childIndex);
}
let hashIterations = 0;
const manifest = createDirectoryManifestAccumulator(checkpoint);
while (childrenByIndex.has(checkpoint.coveredEntries)) {
const child = childrenByIndex.get(checkpoint.coveredEntries)!;
manifest.append(child.directoryEntryIdentity!);
checkpoint.coveredEntries += 1;
hashIterations += 1;
await maybeYield(hashIterations);
}
checkpoint.manifestHash = manifest.digest();
let newlyCompacted = 0;
for (let childIndex = 0; childIndex < parentChildren.length; childIndex += 1) {
const child = parentChildren[childIndex];
if (
child.status === "completed"
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
) {
compactedChildIds.add(child.id);
newlyCompacted += 1;
}
await maybeYield(childIndex);
}
if (checkpoint.coveredEntries !== initialCoveredEntries || newlyCompacted > 0) {
checkpoint.completedEntries = Math.min(
checkpoint.coveredEntries,
checkpoint.completedEntries + newlyCompacted,
);
parentUpdates.set(parent.id, {
...parent,
directoryResumeCheckpoint: checkpoint,
transferredBytes: Math.max(parent.transferredBytes, checkpoint.completedEntries),
});
}
}
const compacted: TransferTask[] = [];
for (let index = 0; index < restored.length; index += 1) {
const task = restored[index];
if (!compactedChildIds.has(task.id)) {
compacted.push(parentUpdates.get(task.id) ?? normalizedChildUpdates.get(task.id) ?? task);
}
await maybeYield(index);
}
return {
valid: true,
tasks: pruneSftpTransferHistory(compacted, now),
};
}

View File

@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import {
isTransferWalkInFlight,
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} from "./transferWalkRegistry";
import { transferRuntime } from "./transferRuntime";
import { finishTransferTask, runTrackedTransferAttempt } from "./useSftpTransfers.ts";
const makeTask = (status: TransferTask["status"] = "transferring"): TransferTask => ({
id: "directory-1",
fileName: "folder",
sourcePath: "/source/folder",
targetPath: "/target/folder",
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 3,
transferredBytes: 3,
speed: 1,
startTime: 1,
isDirectory: true,
progressMode: "files",
resumable: true,
retryable: true,
});
test("a throwing completion handler does not leave the transfer marked in flight", async () => {
const inFlight = new Set<string>();
const completionHandler = async () => {
throw new Error("completion callback failed");
};
await assert.rejects(
runTrackedTransferAttempt(inFlight, "transfer-1", async () => {
await completionHandler();
return "completed";
}),
/completion callback failed/,
);
assert.equal(inFlight.has("transfer-1"), false);
let reruns = 0;
const result = await runTrackedTransferAttempt(inFlight, "transfer-1", async () => {
reruns += 1;
return "completed";
});
assert.equal(result, "completed");
assert.equal(reruns, 1);
assert.equal(inFlight.has("transfer-1"), false);
});
test("directory completion reaches the global store while its walk is still active", (t) => {
const ownerId = "completion-test";
const task = {
...makeTask(),
id: "directory-complete",
totalBytes: 0,
transferredBytes: 0,
};
resetTransferWalkRegistryForTests();
sftpTransferCenterStore.publishOwner(ownerId, [task]);
registerTransferWalk(task.id);
let mirroredTask: TransferTask | undefined;
t.after(() => {
unregisterTransferWalk(task.id);
resetTransferWalkRegistryForTests();
sftpTransferCenterStore.dismiss(task.id);
});
const status = finishTransferTask(
task,
{ partialFailure: false, cancelled: false, endTime: Date.now() },
() => {
transferRuntime.patchTask(task.id, { totalBytes: 3, transferredBytes: 3 });
},
(canonicalTask) => { mirroredTask = canonicalTask; },
);
assert.equal(status, "completed");
assert.equal(isTransferWalkInFlight(task.id), true);
assert.equal(sftpTransferCenterStore.getTask(task.id)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(task.id)?.transferredBytes, 3);
assert.equal(sftpTransferCenterStore.getTask(task.id)?.totalBytes, 3);
assert.equal(mirroredTask?.totalBytes, 3);
assert.equal(sftpTransferCenterStore.getSnapshot().activeCount, 0);
});
test("partial failure and late cancellation keep their existing terminal behavior", () => {
const failedUpdates: Array<Partial<TransferTask>> = [];
const failedStatus = finishTransferTask(
{ ...makeTask(), transferredBytes: 2 },
{ partialFailure: true, cancelled: false, endTime: 456 },
() => {},
(task) => {
failedUpdates.push({
status: task.status,
error: task.error,
retryable: task.retryable,
endTime: task.endTime,
transferredBytes: task.transferredBytes,
speed: task.speed,
});
},
);
assert.equal(failedStatus, "failed");
assert.deepEqual(failedUpdates, [{
status: "failed",
error: "Some files failed to transfer",
retryable: false,
endTime: 456,
transferredBytes: 2,
speed: 0,
}]);
let cancelledUpdates: Partial<TransferTask> | undefined;
const cancelledStatus = finishTransferTask(
makeTask("cancelled"),
{ partialFailure: false, cancelled: false, endTime: 789 },
() => {},
(task) => {
cancelledUpdates = {
status: task.status,
error: task.error,
endTime: task.endTime,
speed: task.speed,
};
},
);
assert.equal(cancelledStatus, "cancelled");
assert.deepEqual(cancelledUpdates, {
status: "cancelled",
error: undefined,
endTime: 789,
speed: 0,
});
});

View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isTransferOrRootPauseLatched,
isTransferPauseLatched,
latchTransferPause,
latchTransferPauseTree,
listTransferPauseLatchesForTests,
releaseTransferPause,
releaseTransferPauseTree,
resetTransferPauseLatchesForTests,
waitUntilTransferPauseReleased,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
test("latch and release are process-global and wake waiters", async () => {
resetTransferPauseLatchesForTests();
latchTransferPause("parent");
assert.equal(isTransferPauseLatched("parent"), true);
let released = false;
const waiter = waitUntilTransferPauseReleased("parent").then(() => {
released = true;
});
await new Promise((r) => setTimeout(r, 5));
assert.equal(released, false);
releaseTransferPause("parent");
await waiter;
assert.equal(released, true);
assert.equal(isTransferPauseLatched("parent"), false);
});
test("root latch blocks child wait and tree release clears both", async () => {
resetTransferPauseLatchesForTests();
latchTransferPauseTree("dir", ["child-a", "child-b"]);
assert.equal(isTransferOrRootPauseLatched("dir", "child-a"), true);
assert.deepEqual(listTransferPauseLatchesForTests(), ["child-a", "child-b", "dir"]);
let done = false;
const waiter = waitWhileTransferOrRootPaused("dir", "child-a").then(() => {
done = true;
});
await new Promise((r) => setTimeout(r, 5));
assert.equal(done, false);
releaseTransferPauseTree("dir", ["child-a", "child-b"]);
await waiter;
assert.equal(done, true);
assert.deepEqual(listTransferPauseLatchesForTests(), []);
});
test("idempotent release does not throw", () => {
resetTransferPauseLatchesForTests();
releaseTransferPause("missing");
latchTransferPause("x");
releaseTransferPause("x");
releaseTransferPause("x");
assert.equal(isTransferPauseLatched("x"), false);
});
test("releasing only the parent leaves child latches stuck (documents the bug we fixed)", () => {
resetTransferPauseLatchesForTests();
latchTransferPauseTree("dir", ["c1", "c2"]);
// Wrong: only parent (the old resume path).
releaseTransferPause("dir");
assert.equal(isTransferPauseLatched("dir"), false);
assert.equal(isTransferOrRootPauseLatched("dir", "c1"), true, "child latch still blocks the walk");
// Right: full tree.
releaseTransferPauseTree("dir", ["c1", "c2"]);
assert.equal(isTransferOrRootPauseLatched("dir", "c1"), false);
});

View File

@@ -0,0 +1,95 @@
/**
* Process-wide pause latches for SFTP transfers.
*
* Must outlive any React owner (SFTP panel / vault page). Directory walks and
* stream workers wait on these latches; the global transfer center sets them
* on Pause and clears them on Resume whether or not a panel is mounted.
*/
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);
if (barrier) return barrier;
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
barrier = { promise, resolve };
barriers.set(taskId, barrier);
return barrier;
}
export function isTransferPauseLatched(taskId: string): boolean {
return pausedIds.has(taskId);
}
export function isTransferOrRootPauseLatched(rootTaskId: string, taskId?: string): boolean {
return pausedIds.has(rootTaskId) || (!!taskId && pausedIds.has(taskId));
}
/** Latch pause for a task id. Idempotent; creates a waiter barrier. */
export function latchTransferPause(taskId: string): void {
pausedIds.add(taskId);
ensureBarrier(taskId);
}
/** Release pause and wake every waiter. Idempotent. */
export function releaseTransferPause(taskId: string): void {
pausedIds.delete(taskId);
const barrier = barriers.get(taskId);
if (!barrier) return;
barriers.delete(taskId);
barrier.resolve();
}
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 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);
}
export async function waitUntilTransferPauseReleased(taskId: string): Promise<void> {
while (pausedIds.has(taskId)) {
const barrier = ensureBarrier(taskId);
await barrier.promise;
}
}
export async function waitWhileTransferOrRootPaused(
rootTaskId: string,
taskId?: string,
): Promise<void> {
while (isTransferOrRootPauseLatched(rootTaskId, taskId)) {
const latchId = pausedIds.has(rootTaskId) ? rootTaskId : (taskId as string);
await waitUntilTransferPauseReleased(latchId);
}
}
/** Test helper — clear all latches between cases. */
export function resetTransferPauseLatchesForTests(): void {
const ids = [...pausedIds];
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
return [...pausedIds].sort();
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
hasNewSourceFingerprint,
resolveDurableCheckpointBytes,
shouldApplyTransferProgress,
} from "./transferProgressMetadata";
test("source fingerprint metadata changes bypass ordinary progress throttling", () => {
assert.equal(hasNewSourceFingerprint("sha256:old", "sha256:new"), true);
assert.equal(hasNewSourceFingerprint("sha256:same", "sha256:same"), false);
assert.equal(shouldApplyTransferProgress({
elapsedMs: 10,
transferred: 20,
total: 100,
incomingSourceFingerprint: "sha256:new",
}), true);
assert.equal(shouldApplyTransferProgress({
elapsedMs: 10,
transferred: 20,
total: 100,
}), false);
// Below the UI floor (400ms) ordinary ticks stay suppressed.
assert.equal(shouldApplyTransferProgress({
elapsedMs: 250,
transferred: 20,
total: 100,
}), false);
assert.equal(shouldApplyTransferProgress({
elapsedMs: 400,
transferred: 20,
total: 100,
}), true);
// Completion always paints even inside the throttle window.
assert.equal(shouldApplyTransferProgress({
elapsedMs: 10,
transferred: 100,
total: 100,
}), true);
});
test("durable checkpoint prefers contiguous bridge offset over high-water transferred", () => {
// Soft-drain: transferred=3MB, contiguous hole-free offset still 1MB.
assert.equal(resolveDurableCheckpointBytes({
transferred: 3 * 1024 * 1024,
previousCheckpoint: 512 * 1024,
incomingCheckpoint: 1024 * 1024,
status: "pausing",
}), 1024 * 1024);
// While paused, missing contiguous field must not advance from high-water.
assert.equal(resolveDurableCheckpointBytes({
transferred: 9_000_000,
previousCheckpoint: 1000,
status: "paused",
}), 1000);
// Active stream without explicit contiguous still uses transferred.
assert.equal(resolveDurableCheckpointBytes({
transferred: 500,
previousCheckpoint: 100,
status: "transferring",
}), 500);
});

View File

@@ -0,0 +1,57 @@
export function hasNewSourceFingerprint(
current: string | undefined,
incoming: string | undefined,
): incoming is string {
return typeof incoming === "string" && incoming.length > 0 && incoming !== current;
}
/** UI progress floor: match/stay above main-process IPC throttle so we do not
* re-render React + transfer-center faster than the bridge fans out events. */
export const TRANSFER_PROGRESS_UI_MIN_MS = 400;
export function shouldApplyTransferProgress({
elapsedMs,
transferred,
total,
currentSourceFingerprint,
incomingSourceFingerprint,
}: {
elapsedMs: number;
transferred: number;
total: number;
currentSourceFingerprint?: string;
incomingSourceFingerprint?: string;
}): boolean {
return elapsedMs >= TRANSFER_PROGRESS_UI_MIN_MS
|| (total > 0 && transferred >= total)
|| hasNewSourceFingerprint(currentSourceFingerprint, incomingSourceFingerprint);
}
/**
* Soft-drain concurrent transfers report high-water `transferred` ahead of the
* contiguous durable offset in `checkpointBytes`. Resume/restart must never
* claim past a sparse hole — always prefer the bridge contiguous checkpoint.
*/
export function resolveDurableCheckpointBytes(params: {
transferred: number;
previousCheckpoint?: number;
incomingCheckpoint?: number;
status?: string;
}): number {
const incoming = Number(params.incomingCheckpoint);
if (Number.isFinite(incoming) && incoming >= 0) {
return Math.max(0, Math.trunc(incoming));
}
const previous = Number(params.previousCheckpoint);
const prev = Number.isFinite(previous) && previous >= 0 ? Math.trunc(previous) : 0;
// While pausing/paused, late high-water progress without a contiguous field
// must not advance the resume offset past the last durable value.
if (params.status === "pausing" || params.status === "paused") {
return prev;
}
const transferred = Number(params.transferred);
if (Number.isFinite(transferred) && transferred >= 0) {
return Math.max(prev, Math.trunc(transferred));
}
return prev;
}

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isTransientTransferError,
runWithTransferRetry,
} from "./transferRetry";
test("isTransientTransferError covers session and network blips", () => {
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
assert.equal(isTransientTransferError(new Error("Connection reset")), true);
assert.equal(isTransientTransferError(new Error("ECONNRESET")), true);
assert.equal(isTransientTransferError(new Error("Permission denied")), false);
assert.equal(isTransientTransferError(new Error("No such file")), false);
});
test("runWithTransferRetry succeeds after a single transient failure", async () => {
let attempts = 0;
const result = await runWithTransferRetry(async () => {
attempts += 1;
if (attempts === 1) throw new Error("SFTP session not found");
return "ok";
}, { retries: 1, delayMs: 0 });
assert.equal(result, "ok");
assert.equal(attempts, 2);
});
test("runWithTransferRetry does not retry permanent errors", async () => {
let attempts = 0;
await assert.rejects(
() => runWithTransferRetry(async () => {
attempts += 1;
throw new Error("Permission denied");
}, { retries: 2, delayMs: 0 }),
/Permission denied/,
);
assert.equal(attempts, 1);
});
test("runWithTransferRetry does not retry cancel", async () => {
let attempts = 0;
await assert.rejects(
() => runWithTransferRetry(async () => {
attempts += 1;
throw new Error("Transfer cancelled");
}, { retries: 2, delayMs: 0 }),
/Transfer cancelled/,
);
assert.equal(attempts, 1);
});

View File

@@ -0,0 +1,68 @@
import { isSessionError } from "./errors";
/**
* Errors that are worth an automatic one-shot retry (WinSCP/FileZilla-style
* "network blip" recovery). Permanent failures (auth, permission, missing
* path) should not be retried.
*/
export function isTransientTransferError(err: unknown): boolean {
if (isSessionError(err)) return true;
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("econnreset")
|| msg.includes("etimedout")
|| msg.includes("econnrefused")
|| msg.includes("epipe")
|| msg.includes("socket hang up")
|| msg.includes("network")
|| msg.includes("temporarily unavailable")
|| msg.includes("try again")
|| msg.includes("broken pipe")
);
}
export function isTransferCancelledError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return msg.includes("transfer cancelled") || msg.includes("transfer canceled");
}
export interface RunWithTransferRetryOptions {
retries?: number;
delayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean;
onRetry?: (err: unknown, attempt: number) => void;
}
/**
* Run an async transfer step with a small number of automatic retries for
* transient network / session failures.
*/
export async function runWithTransferRetry<T>(
work: (attempt: number) => Promise<T>,
options: RunWithTransferRetryOptions = {},
): Promise<T> {
const retries = Math.max(0, options.retries ?? 1);
const delayMs = Math.max(0, options.delayMs ?? 400);
const shouldRetry = options.shouldRetry ?? ((err, attempt) => (
attempt < retries
&& !isTransferCancelledError(err)
&& isTransientTransferError(err)
));
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await work(attempt);
} catch (err) {
lastError = err;
if (!shouldRetry(err, attempt)) throw err;
options.onRetry?.(err, attempt + 1);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs * (attempt + 1)));
}
}
}
throw lastError;
}

View File

@@ -0,0 +1,318 @@
/**
* TransferRuntime unification tests — drive the shipped entry surface.
* Covers: no-owner control, post-teardown pause/resume with live walk,
* single resume API soft vs hard strategy selection.
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
import {
createTransferRuntime,
resetTransferRuntimeRunsForTests,
} from "./transferRuntime";
import {
isTransferCancelledFlag,
markTransferCancelledTree,
resetTransferCancelLatchesForTests,
} from "./transferCancelLatch";
import {
bumpTransferControlEpoch,
isTransferControlEpochCurrent,
resetTransferControlEpochsForTests,
} from "./transferControlEpoch";
import {
isTransferPauseLatched,
resetTransferPauseLatchesForTests,
} from "./transferPauseLatch";
import {
isTransferWalkInFlight,
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} from "./transferWalkRegistry";
function makeTask(
id: string,
status: TransferTask["status"] = "transferring",
extras: Partial<TransferTask> = {},
): TransferTask {
return {
id,
fileName: `${id}.bin`,
sourcePath: `/src/${id}`,
targetPath: `/dst/${id}`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
totalBytes: 100,
transferredBytes: 10,
speed: 1,
startTime: 1,
isDirectory: false,
resumable: true,
ownerId: extras.ownerId ?? "panel-closed",
...extras,
};
}
function installBridge(t: test.TestContext, handlers: {
pauseTransfer?: (id: string) => Promise<{ success: boolean; reason?: string; checkpointBytes?: number }>;
resumeTransfer?: (id: string) => Promise<{ success: boolean; reason?: string }>;
cancelTransfer?: (id: string) => Promise<unknown>;
clearPendingTransferCancel?: (id: string) => Promise<void>;
}) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: handlers },
});
}
function resetGlobals() {
resetTransferCancelLatchesForTests();
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
resetTransferControlEpochsForTests();
resetTransferRuntimeRunsForTests();
}
test("runtime pause/resume works with no registered panel owner", async (t) => {
resetGlobals();
const pauseCalls: string[] = [];
const resumeCalls: string[] = [];
installBridge(t, {
pauseTransfer: async (id) => {
pauseCalls.push(id);
return { success: true, checkpointBytes: 40, lifecycleEpoch: 1 };
},
resumeTransfer: async (id) => {
resumeCalls.push(id);
return { success: true, lifecycleEpoch: 2 };
},
});
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
// No registerOwner — panel never mounted / already torn down.
runtime.enqueue([makeTask("live-file", "transferring", { transferredBytes: 40 })]);
await runtime.pause("live-file");
assert.equal(runtime.getTask("live-file")?.status, "paused");
assert.equal(isTransferPauseLatched("live-file"), true);
assert.ok(pauseCalls.includes("live-file"));
await runtime.resume("live-file");
assert.equal(runtime.getTask("live-file")?.status, "transferring");
assert.equal(isTransferPauseLatched("live-file"), false);
assert.ok(resumeCalls.includes("live-file"));
// Bridge-aligned epoch (not control-plane bumps).
assert.equal(runtime.getTask("live-file")?.lifecycleEpoch, 2);
resetGlobals();
});
test("runtime soft-resumes live walk after owner teardown simulation (no dedicated re-entry)", async (t) => {
resetGlobals();
const resumeCalls: string[] = [];
let dedicatedHandlerCalls = 0;
installBridge(t, {
pauseTransfer: async () => ({ success: true, checkpointBytes: 5 }),
resumeTransfer: async (id) => {
resumeCalls.push(id);
return { success: true };
},
});
const store = createSftpTransferCenterStore();
store.setDedicatedResumeHandler(async () => {
dedicatedHandlerCalls += 1;
return { success: false, error: "should not hard-reconnect live walk" };
});
const runtime = createTransferRuntime(store);
// Simulate panel started a walk then unmounted: walk still in-flight, no owner.
registerTransferWalk("folder");
runtime.enqueue([
makeTask("folder", "transferring", {
isDirectory: true,
progressMode: "files",
totalBytes: 3,
transferredBytes: 1,
ownerId: "gone-panel",
}),
makeTask("child", "transferring", { parentTaskId: "folder", ownerId: "gone-panel" }),
]);
await runtime.pause("folder");
assert.equal(runtime.getTask("folder")?.status, "paused");
assert.equal(isTransferPauseLatched("folder"), true);
assert.equal(isTransferWalkInFlight("folder"), true);
await runtime.resume("folder");
assert.equal(runtime.getTask("folder")?.status, "transferring");
assert.equal(isTransferPauseLatched("folder"), false);
assert.equal(dedicatedHandlerCalls, 0, "live walk must soft-resume only");
assert.ok(resumeCalls.length >= 0); // bridge may resume children
unregisterTransferWalk("folder");
resetGlobals();
});
test("runtime resume chooses hard reconnect when walk is dead and reconnectRequired", async (t) => {
resetGlobals();
installBridge(t, {
resumeTransfer: async () => ({ success: false, reason: "not active" }),
clearPendingTransferCancel: async () => {},
});
const store = createSftpTransferCenterStore();
let dedicatedCalls = 0;
store.setDedicatedResumeHandler(async (task) => {
dedicatedCalls += 1;
assert.equal(task.id, "dead-file");
// Simulate successful hard reconnect completion.
store.patchTask(task.id, {
status: "completed",
transferredBytes: 100,
reconnectRequired: false,
ownerId: "dedicated-resume",
});
return { success: true };
});
const runtime = createTransferRuntime(store);
// Dead walk (not registered), reconnect required — single resume API.
assert.equal(runtime.isWalkInFlight("dead-file"), false);
runtime.enqueue([makeTask("dead-file", "interrupted", {
reconnectRequired: true,
checkpointBytes: 50,
transferredBytes: 50,
sourceHostId: "host-a",
})]);
await runtime.resume("dead-file");
assert.equal(dedicatedCalls, 1, "dead walk must use hard reconnect under same resume entry");
assert.equal(runtime.getTask("dead-file")?.status, "completed");
resetGlobals();
});
test("runWalk registers process-global walk and survives without a panel owner", async () => {
resetGlobals();
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
let sawInFlight = false;
let steps = 0;
const run = runtime.runWalk("walk-1", async () => {
sawInFlight = runtime.isWalkInFlight("walk-1");
steps += 1;
// Mid-walk pause via runtime (no owner).
runtime.enqueue([makeTask("walk-1", "transferring")]);
await runtime.pause("walk-1");
assert.equal(isTransferPauseLatched("walk-1"), true);
await runtime.resume("walk-1");
assert.equal(isTransferPauseLatched("walk-1"), false);
steps += 1;
});
assert.equal(runtime.isWalkInFlight("walk-1"), true);
await run;
assert.equal(sawInFlight, true);
assert.equal(steps, 2);
assert.equal(runtime.isWalkInFlight("walk-1"), false);
resetGlobals();
});
test("runWalk keeps tree controls live until settle and then releases parent and children", async () => {
resetGlobals();
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
runtime.enqueue([
makeTask("settling-root", "transferring", { isDirectory: true, progressMode: "files" }),
makeTask("settling-child", "transferring", { parentTaskId: "settling-root" }),
]);
let markEntered!: () => void;
let finishRun!: () => void;
const entered = new Promise<void>((resolve) => { markEntered = resolve; });
const finish = new Promise<void>((resolve) => { finishRun = resolve; });
let rootEpoch = 0;
let childEpoch = 0;
const run = runtime.runWalk("settling-root", async () => {
markTransferCancelledTree("settling-root", ["settling-child"]);
rootEpoch = bumpTransferControlEpoch("settling-root");
childEpoch = bumpTransferControlEpoch("settling-child");
// Renderer lifecycle paint can become terminal before final callbacks and
// resource release finish. Store cleanup must wait for runWalk settlement.
store.patchTask("settling-root", { status: "completed", endTime: Date.now() });
markEntered();
await finish;
});
await entered;
assert.equal(isTransferCancelledFlag("settling-root"), true);
assert.equal(isTransferCancelledFlag("settling-child"), true);
assert.equal(isTransferControlEpochCurrent("settling-root", rootEpoch), true);
assert.equal(isTransferControlEpochCurrent("settling-child", childEpoch), true);
finishRun();
await run;
assert.equal(isTransferCancelledFlag("settling-root"), false);
assert.equal(isTransferCancelledFlag("settling-child"), false);
assert.equal(isTransferControlEpochCurrent("settling-root", rootEpoch), false);
assert.equal(isTransferControlEpochCurrent("settling-child", childEpoch), false);
resetGlobals();
});
test("publishOwner cannot strip runtime-owned live rows after panel empty publish", () => {
resetGlobals();
const store = createSftpTransferCenterStore();
registerTransferWalk("keep-me");
store.publishOwner("panel-a", [makeTask("keep-me", "transferring", { ownerId: "panel-a" })]);
// Panel "unmounts" and publishes empty list — live walk row must remain.
store.publishOwner("panel-a", []);
const row = store.getSnapshot().tasks.find((task) => task.id === "keep-me");
assert.ok(row, "runtime-owned live task must not be dropped by empty panel publish");
assert.equal(row?.status, "transferring");
unregisterTransferWalk("keep-me");
resetGlobals();
});
test("soft resume bridge epoch beats sticky panel paused merge", async (t) => {
resetGlobals();
installBridge(t, {
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 2 }),
resumeTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
});
const store = createSftpTransferCenterStore();
const runtime = createTransferRuntime(store);
registerTransferWalk("sticky");
runtime.enqueue([makeTask("sticky", "transferring", { ownerId: "panel-a", lifecycleEpoch: 1 })]);
await runtime.pause("sticky");
assert.equal(runtime.getTask("sticky")?.lifecycleEpoch, 2);
await runtime.resume("sticky");
const resumed = runtime.getTask("sticky");
assert.equal(resumed?.status, "transferring");
assert.equal(resumed?.lifecycleEpoch, 3, "bridge resume epoch must win over pause epoch");
// Stale panel re-publish of paused at older epoch must not win.
store.publishOwner("panel-a", [{
...makeTask("sticky", "paused", { ownerId: "panel-a", lifecycleEpoch: 2 }),
}]);
assert.equal(store.getSnapshot().tasks.find((t) => t.id === "sticky")?.status, "transferring");
unregisterTransferWalk("sticky");
resetGlobals();
});

View File

@@ -0,0 +1,132 @@
/**
* Process-level TransferRuntime — single control/execution surface for SFTP
* bulk transfers.
*
* Contract (one entry for all callers — Global Center, panel queue, tests):
* start / enqueue — register work in the store (no panel ownership required)
* pause / resume / cancel — process-global; soft control never needs a mounted owner
* subscribe / getSnapshot — observe runtime state
* runWalk — execute a directory/file walk that outlives React unmount
*
* Soft pause/resume of a live walk vs hard dedicated reconnect on dead/reconnect
* rows are internal strategies of `resume`. Callers do not pick owner vs orphan
* APIs. Panel hooks are enqueue + view only.
*/
import type { TransferTask } from "../../../domain/models";
import {
sftpTransferCenterStore,
type SftpTransferCenterSnapshot,
} from "../sftpTransferCenterStore";
import {
isTransferWalkInFlight,
registerTransferWalk,
unregisterTransferWalk,
} from "./transferWalkRegistry";
import { settleTransferCancelTree } from "./transferCancelLatch";
import { settleTransferControlEpochTree } from "./transferControlEpoch";
export type TransferRuntimeSnapshot = SftpTransferCenterSnapshot;
export type TransferWalkRunner = () => Promise<void>;
export interface TransferRuntime {
/** Observe store/runtime snapshot changes. */
subscribe(listener: () => void): () => void;
getSnapshot(): TransferRuntimeSnapshot;
getTask(taskId: string): TransferTask | undefined;
/**
* Insert or replace task rows (enqueue). Does not require a panel owner.
* ownerId on the task is an origin label only, not control authority.
*/
enqueue(tasks: readonly TransferTask[]): void;
/** Patch a single task from the runtime writer path (progress / lifecycle). */
patchTask(taskId: string, updates: Partial<TransferTask>): void;
/** Soft-pause a live walk (process-global; no owner required). */
pause(taskId: string): Promise<void>;
/**
* Resume: soft-unlatch when walk is in-flight; hard dedicated reconnect when
* walk is dead / reconnectRequired. Single external operation.
*/
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
/**
* Run a transfer walk process-globally. Registers the walk before `runner`
* starts and always unregisters on settle — survives panel unmount.
* Concurrent starts on the same id no-op (existing walk wins).
*/
runWalk(rootTaskId: string, runner: TransferWalkRunner): Promise<void>;
isWalkInFlight(rootTaskId: string): boolean;
}
const inFlightRunPromises = new Map<string, Promise<void>>();
export function createTransferRuntime(
store: typeof sftpTransferCenterStore = sftpTransferCenterStore,
): TransferRuntime {
return {
subscribe(listener) {
return store.subscribe(listener);
},
getSnapshot() {
return store.getSnapshot();
},
getTask(taskId) {
return store.getSnapshot().tasks.find((task) => task.id === taskId);
},
enqueue(tasks) {
store.upsertTasks(tasks);
},
patchTask(taskId, updates) {
store.patchTask(taskId, updates);
},
pause(taskId) {
return store.pause(taskId);
},
resume(taskId) {
return store.resume(taskId);
},
cancel(taskId) {
return store.cancel(taskId);
},
async runWalk(rootTaskId, runner) {
const existing = inFlightRunPromises.get(rootTaskId);
if (existing || isTransferWalkInFlight(rootTaskId)) {
if (existing) await existing;
return;
}
registerTransferWalk(rootTaskId);
const run = (async () => {
try {
await runner();
} finally {
const childIds = store.getSnapshot().tasks
.filter((task) => task.parentTaskId === rootTaskId)
.map((task) => task.id);
const relatedChildIds = settleTransferCancelTree(rootTaskId, childIds);
settleTransferControlEpochTree(rootTaskId, relatedChildIds);
unregisterTransferWalk(rootTaskId);
if (inFlightRunPromises.get(rootTaskId) === run) {
inFlightRunPromises.delete(rootTaskId);
}
}
})();
inFlightRunPromises.set(rootTaskId, run);
await run;
},
isWalkInFlight(rootTaskId) {
return isTransferWalkInFlight(rootTaskId) || inFlightRunPromises.has(rootTaskId);
},
};
}
/** Process-global singleton used by UI and panel hooks. */
export const transferRuntime = createTransferRuntime();
/** Test helper — clear in-flight run bookkeeping (walk registry is separate). */
export function resetTransferRuntimeRunsForTests(): void {
inFlightRunPromises.clear();
}

View File

@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
function child(): TransferTask {
return {
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
};
}
for (const status of ["completed", "failed", "cancelled"] as const) {
test(`settlement observation retains exact ${status} before history pruning`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
const observation = store.observeTaskSettlement(task);
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
assert.equal(observation.read()?.status, status);
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
observation.dispose();
assert.equal(observation.read(), undefined);
});
}
test("another file reusing an id and index is not completion evidence", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
assert.equal(observation.read(), undefined);
observation.dispose();
});
test("a disposed observer receives no later completion", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
observation.dispose();
store.patchTask(task.id, { status: "completed" });
assert.equal(observation.read(), undefined);
});
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
store.upsertTasks([task]);
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
assert.equal(store.admitTaskRun(next), "ready");
assert.equal(store.getTask(task.id)?.status, "transferring");
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
});
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
test(`dispatch cannot revive a retained ${status} row`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([
{ ...task, id: "root", parentTaskId: undefined },
{ ...task, status },
]);
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
assert.equal(store.getTask(task.id)?.status, status);
});
}
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), lifecycleEpoch: 9 };
store.upsertTasks([task]);
const before = store.getTask(task.id);
assert.equal(store.admitTaskRun(task), "ready");
assert.equal(store.getTask(task.id), before);
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
});
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const };
store.upsertTasks([task]);
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
latchTransferPause("root");
assert.equal(store.admitTaskRun(task), "paused");
resetTransferPauseLatchesForTests();
markTransferCancelledTree("root", [task.id]);
assert.equal(store.admitTaskRun(task), "cancelled");
assert.equal(store.getTask(task.id)?.status, "failed");
});
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
// Attach rejection handling before the cancellation event is delivered.
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.status, "paused");
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
if (nextStatus === "cancelled") {
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
markTransferCancelledTree(task.id, []);
t.after(() => settleTransferCancelTree(task.id, []));
} else store.patchTask(task.id, { status: nextStatus });
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
});
}
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "completed" }]);
t.after(() => store.dismiss(task.id));
const start = async () => { assert.fail("completed transfer must not restart"); };
await runTransferAndWaitForOwner(task, start, () => false);
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
latchTransferPause(task.id);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: "completed" });
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
// An unrelated lifecycle publication captures the previous failed row while waiting.
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
t.after(() => store.dismiss("unrelated-admission"));
resetTransferPauseLatchesForTests();
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
});
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
const paused = store.getTask(task.id)!;
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
assert.equal(store.admitTaskRun(task, paused), "ready");
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
const currentPause = store.getTask(task.id)!;
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});

View File

@@ -0,0 +1,152 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { FileConflict, TransferStatus, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
import type { TransferResult } from "./useSftpTransfers.types";
interface UseSftpTransferTaskOpsParams {
cancelledTasksRef: MutableRefObject<Set<string>>;
activeChildIdsRef: MutableRefObject<Map<string, Set<string>>>;
transfersRef: MutableRefObject<TransferTask[]>;
completionHandlersRef: MutableRefObject<Map<string, (result: TransferResult) => void | Promise<void>>>;
setConflicts: Dispatch<SetStateAction<FileConflict[]>>;
setTransfers: Dispatch<SetStateAction<TransferTask[]>>;
releasePausedTransfer?: (taskId: string) => void;
cleanupTaskArtifacts?: (task: TransferTask) => void | Promise<void>;
}
export function useSftpTransferTaskOps({
cancelledTasksRef,
activeChildIdsRef,
transfersRef,
completionHandlersRef,
setConflicts,
setTransfers,
releasePausedTransfer,
cleanupTaskArtifacts,
}: UseSftpTransferTaskOpsParams) {
const completeCancelledTask = useCallback(
async (task: TransferTask) => {
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
},
[completionHandlersRef],
);
const cancelBackendTransfers = useCallback(async (transferIds: string[]) => {
const idsToCancel = new Set<string>();
const currentTransfers = transfersRef.current;
for (const transferId of transferIds) {
idsToCancel.add(transferId);
const trackedChildren = activeChildIdsRef.current.get(transferId);
if (trackedChildren) {
for (const childId of trackedChildren) {
idsToCancel.add(childId);
cancelledTasksRef.current.add(childId);
}
}
for (const transfer of currentTransfers) {
if (
transfer.parentTaskId === transferId &&
(transfer.status === "transferring" || transfer.status === "pending")
) {
idsToCancel.add(transfer.id);
cancelledTasksRef.current.add(transfer.id);
}
}
}
const bridge = netcattyBridge.get();
const cancelTransferAtBackend = bridge?.cancelTransfer;
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
await Promise.all(
Array.from(idsToCancel).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");
}
}),
);
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
const markBatchStopped = useCallback(
async (task: TransferTask) => {
const batchId = task.batchId;
// Stop the whole unfinished batch, including siblings already waiting on
// conflict resolution (attention), not only pending/transferring rows.
const isUnfinished = (status: TransferTask["status"]) =>
!["completed", "cancelled", "failed"].includes(status);
const affected = transfersRef.current.filter((candidate) =>
candidate.id === task.id ||
(!!batchId && candidate.batchId === batchId && isUnfinished(candidate.status)),
);
for (const candidate of affected) {
cancelledTasksRef.current.add(candidate.id);
globalSftpTransferScheduler.cancel(candidate.id);
releasePausedTransfer?.(candidate.id);
}
const affectedIds = new Set(affected.map((candidate) => candidate.id));
for (const candidate of transfersRef.current) {
if (candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)) {
cancelledTasksRef.current.add(candidate.id);
globalSftpTransferScheduler.cancel(candidate.id);
releasePausedTransfer?.(candidate.id);
affectedIds.add(candidate.id);
}
}
const nextTransfers = transfersRef.current
.filter((candidate) => !(candidate.parentTaskId && affectedIds.has(candidate.parentTaskId)))
.map((candidate) =>
affectedIds.has(candidate.id)
? { ...candidate, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
: candidate,
);
transfersRef.current = nextTransfers;
setTransfers(nextTransfers);
setConflicts((prev) => prev.filter((conflict) => !affectedIds.has(conflict.transferId) && (!batchId || conflict.batchId !== batchId)));
await cancelBackendTransfers([...affectedIds]);
for (const candidate of affected) {
try {
await cleanupTaskArtifacts?.(candidate);
} catch {
// best-effort
}
await completeCancelledTask(candidate);
}
},
[
cancelBackendTransfers,
cancelledTasksRef,
cleanupTaskArtifacts,
completeCancelledTask,
releasePausedTransfer,
setConflicts,
setTransfers,
transfersRef,
],
);
return { completeCancelledTask, cancelBackendTransfers, markBatchStopped };
}

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isTransferWalkInFlight,
listTransferWalksForTests,
registerTransferWalk,
resetTransferWalkRegistryForTests,
unregisterTransferWalk,
} from "./transferWalkRegistry";
test("walk registry is process-global and survives logical unmount", () => {
resetTransferWalkRegistryForTests();
registerTransferWalk("folder-1");
assert.equal(isTransferWalkInFlight("folder-1"), true);
assert.deepEqual(listTransferWalksForTests(), ["folder-1"]);
// Simulate panel unmount: registry must still report the walk so soft-resume
// does not start a second dedicated processTransfer.
assert.equal(isTransferWalkInFlight("folder-1"), true);
unregisterTransferWalk("folder-1");
assert.equal(isTransferWalkInFlight("folder-1"), false);
});

View File

@@ -0,0 +1,29 @@
/**
* Process-global registry of live processTransfer walks (directory or file).
*
* Survives SFTP panel / terminal-tab unmount so the global transfer center can
* soft-resume a still-running walk instead of starting a second dedicated walk.
*/
const inFlightRootIds = new Set<string>();
export function registerTransferWalk(rootTaskId: string): void {
inFlightRootIds.add(rootTaskId);
}
export function unregisterTransferWalk(rootTaskId: string): void {
inFlightRootIds.delete(rootTaskId);
}
export function isTransferWalkInFlight(rootTaskId: string): boolean {
return inFlightRootIds.has(rootTaskId);
}
/** Test helper. */
export function resetTransferWalkRegistryForTests(): void {
inFlightRootIds.clear();
}
export function listTransferWalksForTests(): string[] {
return [...inFlightRootIds].sort();
}

View File

@@ -0,0 +1,119 @@
import { Host, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
connection: SftpConnection | null;
files: SftpFileEntry[];
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
transferMutationToken: number;
}
export interface SftpHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
}
export interface SftpHostKeyVerificationState {
hostKeyInfo: SftpHostKeyInfo;
progressLogs: string[];
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
activeTabId: string | null;
}
// Constants for empty placeholder pane IDs
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (
id?: string,
showHiddenFiles = false,
): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
transferMutationToken: 0,
});
// File watch event types
export interface FileWatchSyncedEvent {
watchId: string;
localPath: string;
remotePath: string;
bytesWritten: number;
}
export interface FileWatchErrorEvent {
watchId: string;
localPath: string;
remotePath: string;
error: string;
}
export interface SftpStateOptions {
transferOwnerId?: string;
canPrepareTransferAdoption?: boolean;
/**
* When false the side panel is retained-but-hidden (closed during transfer).
* Progress must not force React state paints for the hidden tree.
*/
surfaceVisible?: boolean;
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* When false, park (soft-close) browse SFTP channels so transfer-pool
* sessions stay independent. Defaults to true (interactive).
*/
interactive?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
/**
* Resolve a live terminal session id for a vault host so transfer-pool opens
* can reuse that SSH transport (openSftpForSession) instead of a cold connect.
* When `host` is provided, only return a session whose live endpoint matches.
*/
resolveTransferSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* Resolve a live terminal session id for restoring parked browse sessions.
* This keeps side-panel tab switches on the already-authenticated SSH transport.
*/
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* @deprecated Transfer channels no longer park independently. SSH keep-alive
* is controlled by sshTransportIdleTtlMs in settings.
*/
transferPoolIdleTtlMs?: number;
}

View File

@@ -0,0 +1,164 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpPane } from "./types";
import {
assertUploadEndpointUnchanged,
captureUploadEndpoint,
resolveUploadTargetPane,
} from "./uploadTargetPin";
const pane = (overrides: Partial<SftpPane> & { id: string; connectionId: string; hostId?: string }): SftpPane => ({
id: overrides.id,
files: [],
selectedFiles: new Set(),
filter: "",
loading: false,
reconnecting: false,
error: null,
showHiddenFiles: false,
filenameEncoding: "auto",
connectionLogs: [],
transferMutationToken: 0,
connection: {
id: overrides.connectionId,
hostId: overrides.hostId ?? "host-a",
hostLabel: "A",
isLocal: false,
status: "connected",
currentPath: "/home",
},
...overrides,
});
test("resolveUploadTargetPane prefers tabId over active pane", () => {
const pinned = pane({ id: "tab-1", connectionId: "conn-1", hostId: "host-a" });
const active = pane({ id: "tab-2", connectionId: "conn-2", hostId: "host-b" });
const resolved = resolveUploadTargetPane({
side: "left",
tabId: "tab-1",
connectionId: "conn-stale",
getActivePane: () => active,
getPaneByTabId: (id) => (id === "tab-1" ? pinned : null),
getPaneByConnectionId: () => null,
});
assert.equal(resolved.id, "tab-1");
assert.equal(resolved.connection?.id, "conn-1");
});
test("resolveUploadTargetPane falls back to connectionId then active", () => {
const byConn = pane({ id: "tab-c", connectionId: "conn-c" });
const active = pane({ id: "tab-a", connectionId: "conn-a" });
assert.equal(
resolveUploadTargetPane({
side: "left",
connectionId: "conn-c",
getActivePane: () => active,
getPaneByTabId: () => null,
getPaneByConnectionId: (id) => (id === "conn-c" ? byConn : null),
}).id,
"tab-c",
);
assert.equal(
resolveUploadTargetPane({
side: "left",
getActivePane: () => active,
getPaneByTabId: () => null,
getPaneByConnectionId: () => null,
}).id,
"tab-a",
);
});
test("assertUploadEndpointUnchanged rejects host switch on same tab", () => {
const map = new Map<string, string>([["conn-1", "key-a"]]);
const expected = captureUploadEndpoint(
pane({ id: "t", connectionId: "conn-1", hostId: "host-a" }).connection!,
map,
);
assert.throws(
() => assertUploadEndpointUnchanged(
pane({ id: "t", connectionId: "conn-2", hostId: "host-b" }).connection!,
expected,
map,
),
/Upload target changed/,
);
});
test("assertUploadEndpointUnchanged allows same-host reconnect with new connection id", () => {
const map = new Map<string, string>([
["conn-old", "key-a"],
["conn-new", "key-a"],
]);
const expected = captureUploadEndpoint(
pane({ id: "t", connectionId: "conn-old", hostId: "host-a" }).connection!,
map,
);
assert.doesNotThrow(() => assertUploadEndpointUnchanged(
pane({ id: "t", connectionId: "conn-new", hostId: "host-a" }).connection!,
expected,
map,
));
});
test("a strict upload pin rejects a same-endpoint connection replacement", () => {
const map = new Map<string, string>([
["conn-old", "key-a"],
["conn-new", "key-a"],
]);
const expected = captureUploadEndpoint(
pane({ id: "t", connectionId: "conn-old", hostId: "host-a" }).connection!,
map,
{ pinConnectionId: true },
);
assert.throws(() => assertUploadEndpointUnchanged(
pane({ id: "t", connectionId: "conn-new", hostId: "host-a" }).connection!,
expected,
map,
), /Upload target changed/);
});
test("a slow strict upload probe cannot continue after its tab is rebound", async () => {
const map = new Map<string, string>([
["conn-old", "key-a"],
["conn-new", "key-a"],
]);
let livePane = pane({ id: "t", connectionId: "conn-old", hostId: "host-a" });
const expected = captureUploadEndpoint(livePane.connection!, map, {
pinConnectionId: true,
});
let resolveProbe!: () => void;
const probe = new Promise<void>((resolve) => { resolveProbe = resolve; });
let uploadCreated = false;
const start = async () => {
await probe;
assertUploadEndpointUnchanged(livePane.connection!, expected, map);
uploadCreated = true;
};
const pending = start();
livePane = pane({ id: "t", connectionId: "conn-new", hostId: "host-a" });
resolveProbe();
await assert.rejects(pending, /Upload target changed/);
assert.equal(uploadCreated, false);
});
test("carried endpoint pin rejects a retargeted tab mid multi-folder upload", () => {
// Simulate paste-time pin for host-a; later folder call resolves tab on host-b.
const mapAtPaste = new Map<string, string>([["conn-1", "key-a"]]);
const pastePin = captureUploadEndpoint(
pane({ id: "tab-1", connectionId: "conn-1", hostId: "host-a" }).connection!,
mapAtPaste,
);
const mapAfterRetarget = new Map<string, string>([["conn-2", "key-b"]]);
assert.throws(
() => assertUploadEndpointUnchanged(
pane({ id: "tab-1", connectionId: "conn-2", hostId: "host-b" }).connection!,
pastePin,
mapAfterRetarget,
),
/Upload target changed/,
);
});

View File

@@ -0,0 +1,92 @@
import type { SftpPane } from "./types";
export type UploadEndpointPin = {
isLocal: boolean;
hostId: string | null;
cacheKey: string | null;
/** When present, reconnecting the same tab is still a target change. */
connectionId?: string;
};
export function captureUploadEndpoint(
connection: NonNullable<SftpPane["connection"]>,
connectionCacheKeyMap: Map<string, string>,
options?: { pinConnectionId?: boolean },
): UploadEndpointPin {
return {
isLocal: connection.isLocal,
hostId: connection.isLocal ? null : (connection.hostId ?? null),
cacheKey: connection.isLocal
? "local"
: (connectionCacheKeyMap.get(connection.id) ?? null),
...(options?.pinConnectionId ? { connectionId: connection.id } : undefined),
};
}
export function assertUploadEndpointUnchanged(
connection: NonNullable<SftpPane["connection"]>,
expected: UploadEndpointPin,
connectionCacheKeyMap: Map<string, string>,
): void {
if (expected.connectionId && connection.id !== expected.connectionId) {
throw new Error("Upload target changed before the transfer started");
}
if (connection.isLocal !== expected.isLocal) {
throw new Error("Upload target changed before the transfer started");
}
if (connection.isLocal) return;
if ((connection.hostId ?? null) !== expected.hostId) {
throw new Error("Upload target changed before the transfer started");
}
if (expected.cacheKey) {
const liveKey = connectionCacheKeyMap.get(connection.id) ?? null;
// Same-host reconnect re-stamps the key; a different endpoint must stop.
if (liveKey && liveKey !== expected.cacheKey) {
throw new Error("Upload target changed before the transfer started");
}
}
}
/**
* Resolve the pane that should receive an external upload.
* Prefer stable tabId (survives reconnect) over connectionId, then active pane.
*/
export function resolveUploadTargetPane(params: {
side: "left" | "right";
tabId?: string;
connectionId?: string;
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByTabId: (tabId: string) => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
}): SftpPane {
const {
side,
tabId,
connectionId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
} = params;
if (tabId) {
const pane = getPaneByTabId(tabId);
if (!pane?.connection) {
throw new Error("Upload target connection is no longer available");
}
return pane;
}
if (connectionId) {
const pane = getPaneByConnectionId(connectionId);
if (!pane?.connection) {
throw new Error("Upload target connection is no longer available");
}
return pane;
}
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
return pane;
}

View File

@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createUploadTaskCallbacks } from "./uploadTaskCallbacks";
test("upload task callbacks write through the transfer store without page callbacks", () => {
const upserts: TransferTask[][] = [];
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const dismissed: string[] = [];
const store = {
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: (taskId: string) => dismissed.push(taskId),
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
targetHostId: "host-1",
store,
});
callbacks.onTaskCreated?.({
id: "upload-1",
fileName: "file.bin",
displayName: "file.bin",
sourcePath: "/local/file.bin",
totalBytes: 100,
isDirectory: false,
});
callbacks.onTaskProgress?.("upload-1", {
transferred: 40,
total: 100,
speed: 20,
percent: 40,
checkpointBytes: 32,
phase: "transferring",
});
callbacks.onTaskCompleted?.("upload-1", 100);
assert.equal(upserts.length, 1);
assert.equal(upserts[0][0].ownerId, "owner-1");
assert.equal(upserts[0][0].id, "upload-1");
assert.deepEqual(patches[0], {
taskId: "upload-1",
updates: {
transferredBytes: 40,
totalBytes: 100,
checkpointBytes: 32,
speed: 20,
phase: "transferring",
resumable: undefined,
pauseUnavailableReason: undefined,
},
});
assert.equal(patches[1].taskId, "upload-1");
assert.equal(patches[1].updates.status, "completed");
assert.equal(patches[1].updates.transferredBytes, 100);
assert.deepEqual(dismissed, []);
});
test("progress promotes pending scanning folder rows into transferring", () => {
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const liveTask: TransferTask = {
id: "folder-1",
fileName: "docs",
sourcePath: "local",
targetPath: "/remote/docs",
sourceConnectionId: "external",
targetConnectionId: "connection-1",
direction: "upload",
status: "pending",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
phase: "scanning",
};
const store = {
upsertTasks: () => {},
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onTaskProgress?.("folder-1", {
transferred: 12,
total: 400,
speed: 0,
percent: 3,
phase: "transferring",
});
assert.equal(patches[0].taskId, "folder-1");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches[0].updates.transferredBytes, 12);
assert.equal(patches[0].updates.totalBytes, 400);
assert.equal(patches[0].updates.phase, "transferring");
});
test("scanning callbacks expose live file counts in files progress mode", () => {
const upserts: TransferTask[][] = [];
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const store = {
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onScanningStart?.("scan-1", { label: "docs" });
callbacks.onScanningProgress?.("scan-1", {
fileCount: 1284,
directoryCount: 40,
entryCount: 1324,
label: "docs",
});
assert.equal(upserts[0][0].fileName, "docs");
assert.equal(upserts[0][0].phase, "scanning");
assert.equal(upserts[0][0].progressMode, "files");
assert.equal(upserts[0][0].status, "pending");
assert.deepEqual(patches[0], {
taskId: "scan-1",
updates: {
// Found files live in totalBytes; completed stays 0 during scan.
transferredBytes: 0,
totalBytes: 1284,
progressMode: "files",
phase: "scanning",
fileName: "docs",
},
});
// Display contract: 0 done · N found (not N done · N found).
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
const completed = patches[0].updates.transferredBytes ?? 0;
assert.equal(completed, 0);
assert.equal(discovered, 1284);
});

View File

@@ -0,0 +1,185 @@
import type { TransferTask, TransferStatus } from "../../../domain/models";
import type { UploadCallbacks, UploadTaskInfo } from "../../../lib/uploadService";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { joinPath } from "./utils";
type UploadTransferStore = Pick<
typeof sftpTransferCenterStore,
"upsertTasks" | "patchTask" | "dismiss"
> & Partial<Pick<typeof sftpTransferCenterStore, "getTask">>;
interface UploadTaskCallbacksParams {
ownerId: string;
connectionId: string;
targetPath: string;
targetHostId?: string;
targetHostLabel?: string;
targetConnectionKey?: string;
store?: UploadTransferStore;
}
export const createUploadTaskCallbacks = ({
ownerId,
connectionId,
targetPath,
targetHostId,
targetHostLabel,
targetConnectionKey,
store = sftpTransferCenterStore,
}: UploadTaskCallbacksParams): UploadCallbacks => ({
onScanningStart: (taskId: string, info?: { label?: string }) => {
store.upsertTasks([{
id: taskId,
ownerId,
fileName: info?.label?.trim() || "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetHostLabel,
sourceHostLabel: "Local",
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
// Progressive discovery: totalBytes = found so far, completed stays 0
// until real transfers start (UI: "0 done · N found").
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "files",
origin: "drag-drop",
background: false,
resumable: true,
phase: "scanning",
}]);
},
onScanningProgress: (taskId: string, progress) => {
store.patchTask(taskId, {
// Found count is the growing total; nothing is completed during scan.
totalBytes: Math.max(0, progress.fileCount),
transferredBytes: 0,
progressMode: "files",
phase: "scanning",
...(progress.label?.trim() ? { fileName: progress.label.trim() } : null),
});
},
onScanningEnd: (taskId: string) => {
store.dismiss(taskId);
},
onTaskCreated: (task: UploadTaskInfo) => {
store.upsertTasks([{
id: task.id,
ownerId,
fileName: task.displayName,
sourcePath: task.sourcePath ?? "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetHostLabel,
sourceHostLabel: "Local",
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
origin: "drag-drop",
background: false,
resumable: true,
phase: "transferring",
controlKind: task.controlKind,
}]);
},
onTaskProgress: (taskId: string, progress) => {
const durableCheckpoint = Number.isFinite(Number(progress.checkpointBytes))
? Math.max(0, Math.trunc(Number(progress.checkpointBytes)))
: progress.transferred;
// Progressive folder walks keep the scanning row as pending until real
// progress arrives. Promote pending/queued → transferring so the panel
// leaves "Waiting..." and matches the transfer-center live state.
const current = "getTask" in store && typeof store.getTask === "function"
? store.getTask(taskId)
: undefined;
const shouldPromote =
!!current
&& (current.status === "pending" || current.status === "queued")
&& current.reconnectRequired !== true
&& (
progress.phase === "scanning"
|| progress.phase === "transferring"
|| progress.transferred > 0
|| progress.total > 0
);
// Only patch fingerprint/checkpoint while paused — do not keep animating
// high-water transferred after the user hit Pause.
const isPausedLike = current?.status === "paused" || current?.status === "pausing";
if (isPausedLike) {
store.patchTask(taskId, {
checkpointBytes: durableCheckpoint,
resumable: progress.resumable,
pauseUnavailableReason: progress.pauseUnavailableReason,
...("sourceFingerprint" in progress && progress.sourceFingerprint
? { sourceFingerprint: progress.sourceFingerprint as string }
: null),
});
return;
}
store.patchTask(taskId, {
transferredBytes: progress.transferred,
totalBytes: progress.total,
// Soft-drain high-water transferred must not become the resume offset.
checkpointBytes: durableCheckpoint,
speed: progress.speed,
phase: progress.phase,
resumable: progress.resumable,
pauseUnavailableReason: progress.pauseUnavailableReason,
...(shouldPromote ? { status: "transferring" as TransferStatus } : null),
// Durable pause identity may arrive on a forced progress event while
// status is already paused — keep it for restart/resume safety.
...("sourceFingerprint" in progress && progress.sourceFingerprint
? { sourceFingerprint: progress.sourceFingerprint as string }
: null),
});
},
onTaskNameUpdate: (taskId: string, value: string) => {
const separator = value.lastIndexOf("|");
const phase = separator >= 0 ? value.slice(separator + 1) : "transferring";
store.patchTask(taskId, {
phase: phase === "compressed" ? "transferring" : phase as TransferTask["phase"],
});
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
store.patchTask(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
phase: undefined,
});
},
onTaskFailed: (taskId: string, error: string) => {
store.patchTask(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
phase: undefined,
});
},
onTaskCancelled: (taskId: string) => {
store.patchTask(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
},
});

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { usePendingSftpUploadRebind } from "./usePendingSftpUploadRebind";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
test("a repeated drop can share a slow strict connect without losing completion", async () => {
let resolveConnect!: () => void;
const sharedConnect = new Promise<void>((resolve) => {
resolveConnect = resolve;
});
let latest: ReturnType<typeof usePendingSftpUploadRebind> | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
latest = usePendingSftpUploadRebind();
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
act(() => {
latest!.start({
requestId: "drop-1",
previousConnectionId: "old-connection",
connect: () => sharedConnect,
});
latest!.start({
requestId: "drop-2",
previousConnectionId: "connecting-connection",
connect: () => sharedConnect,
});
latest!.bindTarget("drop-2", {
tabId: "new-tab",
connectionId: "new-connection",
});
});
assert.equal(latest!.startedRequestIdRef.current, "drop-2");
assert.equal(latest!.settledRequestId, null);
assert.deepEqual(latest!.barrierRef.current, {
requestId: "drop-2",
previousConnectionId: "connecting-connection",
targetTabId: "new-tab",
targetConnectionId: "new-connection",
});
await act(async () => {
resolveConnect();
await sharedConnect;
});
assert.equal(latest!.settledRequestId, "drop-2");
act(() => renderer!.unmount());
});

View File

@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
export interface PendingSftpUploadRebindBarrier {
requestId: string;
previousConnectionId: string | null;
targetTabId?: string;
targetConnectionId?: string;
}
export interface PendingSftpUploadRebindTarget {
tabId: string;
connectionId: string;
}
interface StartPendingSftpUploadRebindParams {
requestId: string;
previousConnectionId: string | null;
connect: () => Promise<void>;
}
/**
* Tracks the strict SFTP connect attempt owned by the latest terminal drop.
* A later drop may share the same in-flight connect promise, so completion is
* tracked by request generation instead of relying only on connection-id churn.
*/
export function usePendingSftpUploadRebind() {
const startedRequestIdRef = useRef<string | null>(null);
const barrierRef = useRef<PendingSftpUploadRebindBarrier | null>(null);
const generationRef = useRef(0);
const [settledRequestId, setSettledRequestId] = useState<string | null>(null);
const start = useCallback((params: StartPendingSftpUploadRebindParams) => {
const generation = generationRef.current + 1;
generationRef.current = generation;
startedRequestIdRef.current = params.requestId;
barrierRef.current = {
requestId: params.requestId,
previousConnectionId: params.previousConnectionId,
};
setSettledRequestId(null);
const settle = () => {
if (
generationRef.current === generation
&& startedRequestIdRef.current === params.requestId
) {
setSettledRequestId(params.requestId);
}
};
void params.connect().then(settle, settle);
}, []);
const bindTarget = useCallback((
requestId: string,
target: PendingSftpUploadRebindTarget,
) => {
const barrier = barrierRef.current;
if (
!barrier
|| barrier.requestId !== requestId
|| startedRequestIdRef.current !== requestId
) return;
barrierRef.current = {
...barrier,
targetTabId: target.tabId,
targetConnectionId: target.connectionId,
};
}, []);
const clearBarrier = useCallback((requestId?: string) => {
if (requestId && barrierRef.current?.requestId !== requestId) return;
barrierRef.current = null;
}, []);
const reset = useCallback(() => {
generationRef.current += 1;
startedRequestIdRef.current = null;
barrierRef.current = null;
setSettledRequestId(null);
}, []);
return {
barrierRef,
bindTarget,
clearBarrier,
reset,
settledRequestId,
start,
startedRequestIdRef,
};
}

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React, { Suspense } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpBrowseConnectionLifecycle } from "./useSftpBrowseConnectionLifecycle";
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
test("an uncommitted hidden render cannot invalidate live browse connections", async () => {
const never = new Promise<void>(() => {});
let lifecycleRef: ReturnType<typeof useSftpBrowseConnectionLifecycle> | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe({ interactive }: { interactive: boolean }) {
const nextRef = useSftpBrowseConnectionLifecycle(interactive);
if (!interactive) throw never;
lifecycleRef = nextRef;
return null;
}
await act(async () => {
renderer = create(React.createElement(
Suspense,
{ fallback: null },
React.createElement(Probe, { interactive: true }),
));
});
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
await act(async () => {
renderer!.update(React.createElement(
Suspense,
{ fallback: null },
React.createElement(Probe, { interactive: false }),
));
});
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
act(() => renderer!.unmount());
});

View File

@@ -0,0 +1,26 @@
import { useLayoutEffect, useRef } from "react";
import type { MutableRefObject } from "react";
export interface SftpBrowseConnectionLifecycle {
generation: number;
interactive: boolean;
}
export function useSftpBrowseConnectionLifecycle(
interactive: boolean,
): MutableRefObject<SftpBrowseConnectionLifecycle> {
const lifecycleRef = useRef<SftpBrowseConnectionLifecycle>({
generation: 0,
interactive,
});
useLayoutEffect(() => {
if (lifecycleRef.current.interactive === interactive) return;
lifecycleRef.current = {
generation: lifecycleRef.current.generation + 1,
interactive,
};
}, [interactive]);
return lifecycleRef;
}

View File

@@ -0,0 +1,772 @@
// Created: 2026-07-21
// Purpose: Verify SFTP prefers live terminal session reuse before fresh auth.
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
import {
applyToLiveSftpTabSide,
beginSftpTabConnectRequest,
buildSftpConnectInFlightKey,
buildSftpHomeDirCandidates,
clearSftpConnectInFlightForTab,
closeSftpTabLifecycle,
runSftpConnectOnceByKey,
createSftpConnectionId,
createPinnedReconnectSideResolver,
finishSftpTabConnectRequest,
invalidateSftpTabConnectRequest,
isSftpHostKeySessionCurrent,
isSftpTabConnectRequestCurrent,
openSftpConnectionOnce,
openSftpWithSessionPreference,
rejectHostKeyVerificationRequest,
registerOpenedSftpSession,
resolveSftpPaneEndpointKey,
resolveSftpReconnectAttempt,
takeSftpConnectionMetadataForClose,
releaseSftpConnectionMetadata,
resolvePinnedReconnectSide,
resolveSftpReconnectOptions,
resolveSftpReconnectSchedule,
resolveSftpReconnectHost,
runSftpTabDisconnectIfLatest,
settleFailedSftpConnectIfCurrent,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
});
test("connection ids stay unique even when connects start in the same millisecond", () => {
const ids = ["uuid-a", "uuid-b"];
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
});
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
const inFlight = new Map<string, Promise<void>>();
let runs = 0;
let releaseFirst: (() => void) | undefined;
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
});
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
});
assert.equal(runs, 1);
releaseFirst?.();
await Promise.all([first, second]);
assert.equal(runs, 1);
assert.equal(inFlight.size, 0);
});
test("disconnect detaches only that tab's in-flight connects", () => {
const pending = Promise.resolve();
const inFlight = new Map<string, Promise<void>>([
[`tab-a\u0000host-a\u0000`, pending],
[`tab-b\u0000host-a\u0000`, pending],
]);
clearSftpConnectInFlightForTab(inFlight, "tab-a");
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-a\u0000"]);
});
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
const first = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-a",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
const second = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-b",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
assert.notEqual(first, second);
});
test("moving an in-flight reconnect does not create a second connection", () => {
const base = {
tabId: "moving-tab",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
};
assert.equal(
buildSftpConnectInFlightKey({ ...base, side: "left" }),
buildSftpConnectInFlightKey({ ...base, side: "right" }),
);
});
test("the newest request for a moved tab wins after an older close finishes", () => {
const requests = new Map<string, symbol>();
const oldRequest = beginSftpTabConnectRequest(requests, "tab-moving");
const newRequest = beginSftpTabConnectRequest(requests, "tab-moving");
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", oldRequest), false);
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
finishSftpTabConnectRequest(requests, "tab-moving", oldRequest);
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
invalidateSftpTabConnectRequest(requests, "tab-moving");
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), false);
});
test("a slow disconnect cannot clear a newer connection on the same tab", async () => {
const requests = new Map<string, symbol>();
let releaseClose: (() => void) | undefined;
let clears = 0;
const disconnect = runSftpTabDisconnectIfLatest({
requests,
tabId: "tab-a",
disconnect: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
clear: () => {
clears += 1;
},
});
await Promise.resolve();
const newerConnect = beginSftpTabConnectRequest(requests, "tab-a");
finishSftpTabConnectRequest(requests, "tab-a", newerConnect);
releaseClose?.();
assert.equal(await disconnect, false);
assert.equal(clears, 0);
});
test("a slow disconnect clears a tab after it moves to the other pane", async () => {
const requests = new Map<string, symbol>();
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-moving" }];
let rightTabs: ReadonlyArray<{ id: string }> = [];
let releaseClose: (() => void) | undefined;
let clearedSide: "left" | "right" | null = null;
const disconnect = runSftpTabDisconnectIfLatest({
requests,
tabId: "tab-moving",
disconnect: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
clear: () => {
applyToLiveSftpTabSide({
requestedSide: "left",
tabId: "tab-moving",
leftTabs,
rightTabs,
apply: (side) => {
clearedSide = side;
},
});
},
});
await Promise.resolve();
leftTabs = [];
rightTabs = [{ id: "tab-moving" }];
releaseClose?.();
assert.equal(await disconnect, true);
assert.equal(clearedSide, "right");
});
test("closing a tab removes it before a slow connection release", async () => {
const requests = new Map<string, symbol>();
beginSftpTabConnectRequest(requests, "tab-a");
const pending = Promise.resolve();
const inFlight = new Map<string, Promise<void>>([
["tab-a\u0000host-a", pending],
["tab-b\u0000host-b", pending],
]);
const connectedHosts = new Map<string, Host | "local">([["tab-a", "local"]]);
let releaseClose: (() => void) | undefined;
let closedSide: "left" | "right" | null = null;
const closing = closeSftpTabLifecycle({
requestedSide: "left",
tabId: "tab-a",
leftTabs: [{ id: "tab-a" }],
rightTabs: [],
connectRequests: requests,
connectInFlight: inFlight,
connectedHosts,
closeTab: (side) => {
closedSide = side;
},
releaseConnection: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
});
assert.equal(closedSide, "left");
assert.equal(requests.has("tab-a"), false);
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-b"]);
assert.equal(connectedHosts.has("tab-a"), false);
releaseClose?.();
await closing;
});
test("a slow failed-request cleanup cannot overwrite a newer successful connection", async () => {
let current = true;
let releaseClose: (() => void) | undefined;
let failuresWritten = 0;
const settlement = settleFailedSftpConnectIfCurrent({
isCurrent: () => current,
close: async () => new Promise<void>((resolve) => {
releaseClose = resolve;
}),
updateFailure: () => {
failuresWritten += 1;
},
});
await Promise.resolve();
current = false;
releaseClose?.();
assert.equal(await settlement, false);
assert.equal(failuresWritten, 0);
});
test("stale host-key prompts do not belong to a replacement connection", () => {
const requests = new Map<string, symbol>();
const oldToken = beginSftpTabConnectRequest(requests, "tab-a");
const oldOwner = { tabId: "tab-a", connectRequestToken: oldToken };
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), true);
beginSftpTabConnectRequest(requests, "tab-a");
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), false);
});
test("reconnect state only follows the endpoint that originally failed", () => {
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: true,
previousConnectionKey: "host-a",
targetConnectionKey: "host-a",
}), true);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: true,
previousConnectionKey: "host-a",
targetConnectionKey: "host-b",
}), false);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: true,
initialPath: "/srv/app",
previousPaneReconnecting: false,
previousConnectionKey: null,
targetConnectionKey: "host-a",
}), true);
});
test("reconnect keeps its endpoint identity after released connection metadata is gone", () => {
const hostA = {
id: "host-a",
label: "A",
hostname: "a.example",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
const hostB = {
id: "host-b",
label: "B",
hostname: "b.example",
port: 22,
username: "bob",
protocol: "ssh",
} as Host;
const pane = {
id: "tab-a",
reconnecting: true,
connection: {
id: "released-connection-a",
hostId: hostA.id,
hostLabel: hostA.label,
isLocal: false,
status: "disconnected",
currentPath: "/srv/app",
},
} as SftpPane;
const previousConnectionKey = resolveSftpPaneEndpointKey({
connection: pane.connection,
cachedConnectionKey: null,
connectedHost: hostA,
});
const hostAKey = resolveSftpPaneEndpointKey({
connection: pane.connection,
cachedConnectionKey: null,
connectedHost: hostA,
});
const hostBKey = resolveSftpPaneEndpointKey({
connection: {
...pane.connection!,
hostId: hostB.id,
},
cachedConnectionKey: null,
connectedHost: hostB,
});
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: pane.reconnecting,
previousConnectionKey,
targetConnectionKey: hostAKey!,
}), true);
assert.equal(resolveSftpReconnectAttempt({
isPinnedBackgroundReconnect: false,
previousPaneReconnecting: pane.reconnecting,
previousConnectionKey,
targetConnectionKey: hostBKey!,
}), false);
});
test("forced terminal rebinds never merge into an older in-flight route", () => {
const base = {
side: "left" as const,
tabId: "tab-1",
targetConnectionKey: "host-key",
initialPath: "/srv/app",
};
const oldRoute = buildSftpConnectInFlightKey(base);
const newRouteDrop = buildSftpConnectInFlightKey({
...base,
connectRequestKey: "drop-route-b",
});
assert.notEqual(oldRoute, newRouteDrop);
});
const openOptions = {
sessionId: "sftp-request-1",
hostname: "192.168.9.138",
username: "zhlrs",
port: 22,
} as NetcattySSHOptions;
test("openSftpWithSessionPreference opens session-backed SFTP before authing again", async () => {
const calls: string[] = [];
let expectedEndpoint: NetcattySSHOptions | undefined;
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, endpoint?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
expectedEndpoint = endpoint;
return "session-backed-sftp";
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions,
});
assert.equal(sftpId, "session-backed-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(expectedEndpoint, openOptions);
});
test("openSftpWithSessionPreference falls back to normal SFTP when session reuse fails", async () => {
const calls: string[] = [];
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string) => {
calls.push(`openForSession:${sessionId}`);
throw new Error("channel unavailable");
},
openSftp: async (options: NetcattySSHOptions) => {
calls.push(`openSftp:${options.sessionId}`);
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions,
});
assert.equal(sftpId, "fresh-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
});
test("strict source-session reuse never dials a different route after reuse fails", async () => {
const calls: string[] = [];
let receivedOptions: NetcattySSHOptions | undefined;
await assert.rejects(
openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
receivedOptions = options;
throw new Error("channel unavailable");
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
requireSourceSessionReuse: true,
openOptions,
}),
/channel unavailable/,
);
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(receivedOptions?.requireExactSourceSession, true);
});
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
const calls: string[] = [];
let passedOptions: NetcattySSHOptions | undefined;
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
passedOptions = options;
return "sudo-session-backed-sftp";
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions: {
...openOptions,
sudo: true,
password: "sudo-pass",
},
});
assert.equal(sftpId, "sudo-session-backed-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(passedOptions?.sudo, true);
assert.equal(passedOptions?.password, "sudo-pass");
});
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
const calls: string[] = [];
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async () => {
calls.push("openForSession");
return "session-backed-sftp";
},
openSftp: async (options: NetcattySSHOptions) => {
calls.push(`openSftp:${options.sessionId}`);
return "fresh-sftp";
},
},
sourceSessionId: undefined,
openOptions,
});
assert.equal(sftpId, "fresh-sftp");
assert.deepEqual(calls, ["openSftp:sftp-request-1"]);
});
test("one connect attempt never repeats a failed fresh SFTP dial after reuse fails", async () => {
const calls: string[] = [];
await assert.rejects(
openSftpConnectionOnce({
bridge: {
openSftpForSession: async () => {
calls.push("openForSession");
throw new Error("shared channel unavailable");
},
openSftp: async () => {
calls.push("openSftp");
throw new Error("authentication failed");
},
},
sourceSessionId: "ssh-session-1",
openOptions,
}),
/authentication failed/,
);
assert.deepEqual(calls, ["openForSession", "openSftp"]);
});
test("closing connections releases their session and cache-key metadata", () => {
const sessions = new Map<string, string>();
const cacheKeys = new Map<string, string>();
const cleared: string[] = [];
for (let index = 0; index < 1_000; index += 1) {
const connectionId = `connection-${index}`;
sessions.set(connectionId, `sftp-${index}`);
cacheKeys.set(connectionId, `endpoint-${index}`);
assert.equal(takeSftpConnectionMetadataForClose({
connectionId,
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: (id) => { cleared.push(id); },
}), `sftp-${index}`);
}
assert.equal(sessions.size, 0);
assert.equal(cacheKeys.size, 0);
assert.equal(cleared.length, 1_000);
});
test("release metadata closes the backend before a failed connection is retained as an error", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "endpoint-1"]]);
const closed: string[] = [];
const cleared: string[] = [];
await releaseSftpConnectionMetadata({
connectionId: "connection-1",
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: (id) => { cleared.push(id); },
closeSftp: async (id) => { closed.push(id); },
});
assert.deepEqual(closed, ["sftp-1"]);
assert.deepEqual(cleared, ["connection-1"]);
assert.equal(sessions.size, 0);
assert.equal(cacheKeys.size, 0);
});
test("a connection that finishes after its owner unmounts is closed instead of registered", async () => {
const sftpSessions = new Map<string, string>();
const closed: string[] = [];
const notified: string[] = [];
const disposedRef = { current: true };
const registered = await registerOpenedSftpSession({
disposedRef,
connectionId: "connection-late",
sftpId: "sftp-late",
sftpSessions,
closeSftp: async (sftpId) => { closed.push(sftpId); },
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.equal(registered, false);
assert.equal(sftpSessions.size, 0);
assert.deepEqual(closed, ["sftp-late"]);
assert.deepEqual(notified, ["sftp-late"]);
});
test("a connection that finishes after browse parking is closed instead of registered", async () => {
const sftpSessions = new Map<string, string>();
const closed: string[] = [];
const lifecycle = { generation: 1, interactive: true };
const openedGeneration = lifecycle.generation;
lifecycle.generation += 1;
lifecycle.interactive = false;
const registered = await registerOpenedSftpSession({
disposedRef: { current: false },
canRegister: () => (
lifecycle.interactive && lifecycle.generation === openedGeneration
),
connectionId: "connection-parked",
sftpId: "sftp-parked",
sftpSessions,
closeSftp: async (sftpId) => { closed.push(sftpId); },
});
assert.equal(registered, false);
assert.equal(sftpSessions.size, 0);
assert.deepEqual(closed, ["sftp-parked"]);
});
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "cache-1"]]);
const notified: string[] = [];
await releaseSftpConnectionMetadata({
connectionId: "connection-1",
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: () => {},
closeSftp: async () => {},
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.deepEqual(notified, ["sftp-1"]);
});
test("resolvePinnedReconnectSide follows a tab moved to the other side", () => {
assert.equal(
resolvePinnedReconnectSide("left", "tab-1", [], [{ id: "tab-1" }]),
"right",
);
assert.equal(
resolvePinnedReconnectSide("right", "tab-1", [{ id: "tab-1" }], []),
"left",
);
assert.equal(
resolvePinnedReconnectSide("left", "tab-1", [{ id: "tab-1" }], []),
"left",
);
assert.equal(
resolvePinnedReconnectSide("left", undefined, [], [{ id: "tab-1" }]),
"left",
);
assert.throws(
() => resolvePinnedReconnectSide("left", "gone", [], []),
/SFTP tab is no longer available/,
);
});
test("rejectHostKeyVerificationRequest rejects an orphaned verification", () => {
const responses: Array<[string, boolean, boolean]> = [];
rejectHostKeyVerificationRequest({
respondHostKeyVerification: async (requestId, accept, addToKnownHosts) => {
responses.push([requestId, accept, addToKnownHosts]);
return { success: true };
},
}, "hostkey-1");
assert.deepEqual(responses, [["hostkey-1", false, false]]);
});
test("a newly allocated forced-connect tab follows moves across async boundaries", async () => {
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-1" }];
let rightTabs: ReadonlyArray<{ id: string }> = [];
const resolveSide = createPinnedReconnectSideResolver(
"left",
"tab-1",
() => leftTabs,
() => rightTabs,
);
assert.equal(resolveSide(), "left");
await Promise.resolve();
leftTabs = [];
rightTabs = [{ id: "tab-1" }];
assert.equal(resolveSide(), "right");
rightTabs = [];
assert.equal(resolveSide(), "right");
});
test("a just-allocated connect tab uses its known side until React state commits", () => {
let leftTabs: ReadonlyArray<{ id: string }> = [];
let rightTabs: ReadonlyArray<{ id: string }> = [];
const resolveSide = createPinnedReconnectSideResolver(
"left",
"new-tab",
() => leftTabs,
() => rightTabs,
);
assert.equal(resolveSide(), "left");
leftTabs = [{ id: "new-tab" }];
assert.equal(resolveSide(), "left");
leftTabs = [];
rightTabs = [{ id: "new-tab" }];
assert.equal(resolveSide(), "right");
});
test("reconnect uses the active tab host instead of the side's previous host", () => {
const hostA = {
id: "host-a",
label: "A",
hostname: "a.example",
port: 22,
username: "alice",
protocol: "ssh",
} as Host;
const hostB = {
id: "host-b",
label: "B",
hostname: "b.example",
port: 22,
username: "bob",
protocol: "ssh",
} as Host;
const activePane = {
id: "tab-a-moved-right",
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
},
} as SftpPane;
assert.equal(resolveSftpReconnectHost({
pane: activePane,
lastHost: hostB,
connectedHostByTabId: new Map([[activePane.id, hostA]]),
hosts: [hostA, hostB],
}), hostA);
});
test("a reconnecting tab moved to the other pane still schedules recovery", () => {
const movedPane = {
id: "tab-a",
reconnecting: true,
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
},
} as SftpPane;
assert.deepEqual(resolveSftpReconnectSchedule({
requestedSide: "left",
pane: movedPane,
leftTabs: [],
rightTabs: [movedPane],
}), { side: "right", tabId: "tab-a" });
});
test("automatic reconnect retries the source session then allows a fresh open", () => {
const pane = {
id: "tab-a",
connection: {
id: "connection-a",
hostId: "host-a",
hostLabel: "A",
isLocal: false,
status: "disconnected",
currentPath: "/home/alice",
sourceSessionId: "ssh-a",
},
} as SftpPane;
assert.deepEqual(resolveSftpReconnectOptions(pane), {
tabId: "tab-a",
sourceSessionId: "ssh-a",
});
assert.equal(
"requireSourceSessionReuse" in resolveSftpReconnectOptions(pane),
false,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from "react";
import { STORAGE_KEY_SFTP_DIRECTORIES_FIRST } from "../../../infrastructure/config/storageKeys";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../../infrastructure/persistence/localStorageAdapter";
export const useSftpDirectoriesFirst = () => {
const [directoriesFirst, setDirectoriesFirst] = useState(
() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST) ?? true,
);
useEffect(() => {
const syncDirectoriesFirst = (event: Event) => {
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_DIRECTORIES_FIRST) return;
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_DIRECTORIES_FIRST) return;
setDirectoriesFirst(
localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST) ?? true,
);
};
window.addEventListener("storage", syncDirectoriesFirst);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncDirectoriesFirst);
return () => {
window.removeEventListener("storage", syncDirectoriesFirst);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncDirectoriesFirst);
};
}, []);
const toggleDirectoriesFirst = useCallback(() => {
setDirectoriesFirst((current) => {
const next = !current;
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_DIRECTORIES_FIRST, next);
return next;
});
}, []);
return { directoriesFirst, toggleDirectoriesFirst };
};

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize, formatDate } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
return getMockLocalFiles(path);
}
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
owner: f.owner,
};
});
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
if (!rawFiles) return [];
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[],
);
return {
listLocalFiles,
listRemoteFiles,
};
};

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More