[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
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:
33
application/state/sftp/bookmarkHelpers.test.ts
Normal file
33
application/state/sftp/bookmarkHelpers.test.ts
Normal 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);
|
||||
});
|
||||
49
application/state/sftp/bookmarkHelpers.ts
Normal file
49
application/state/sftp/bookmarkHelpers.ts
Normal 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
|
||||
));
|
||||
}
|
||||
131
application/state/sftp/browseSessionLifecycle.test.ts
Normal file
131
application/state/sftp/browseSessionLifecycle.test.ts
Normal 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"]);
|
||||
});
|
||||
113
application/state/sftp/browseSessionLifecycle.ts
Normal file
113
application/state/sftp/browseSessionLifecycle.ts
Normal 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];
|
||||
}
|
||||
35
application/state/sftp/columnLayout.ts
Normal file
35
application/state/sftp/columnLayout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
31
application/state/sftp/compressedUploadControl.test.ts
Normal file
31
application/state/sftp/compressedUploadControl.test.ts
Normal 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" });
|
||||
});
|
||||
16
application/state/sftp/compressedUploadControl.ts
Normal file
16
application/state/sftp/compressedUploadControl.ts
Normal 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." };
|
||||
}
|
||||
40
application/state/sftp/compressedUploadSession.ts
Normal file
40
application/state/sftp/compressedUploadSession.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
166
application/state/sftp/dedicatedStreamSessionPolicy.test.ts
Normal file
166
application/state/sftp/dedicatedStreamSessionPolicy.test.ts
Normal 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" },
|
||||
);
|
||||
});
|
||||
2133
application/state/sftp/dedicatedTransferResume.test.ts
Normal file
2133
application/state/sftp/dedicatedTransferResume.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1369
application/state/sftp/dedicatedTransferResume.ts
Normal file
1369
application/state/sftp/dedicatedTransferResume.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
application/state/sftp/directDownloadRuntime.test.ts
Normal file
100
application/state/sftp/directDownloadRuntime.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
51
application/state/sftp/directoryListingCache.test.ts
Normal file
51
application/state/sftp/directoryListingCache.test.ts
Normal 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);
|
||||
});
|
||||
69
application/state/sftp/directoryListingCache.ts
Normal file
69
application/state/sftp/directoryListingCache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
139
application/state/sftp/directoryReplacePromotion.test.ts
Normal file
139
application/state/sftp/directoryReplacePromotion.test.ts
Normal 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/);
|
||||
});
|
||||
132
application/state/sftp/directoryReplacePromotion.ts
Normal file
132
application/state/sftp/directoryReplacePromotion.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
65
application/state/sftp/downloadTransferTask.test.ts
Normal file
65
application/state/sftp/downloadTransferTask.test.ts
Normal 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");
|
||||
});
|
||||
59
application/state/sftp/downloadTransferTask.ts
Normal file
59
application/state/sftp/downloadTransferTask.ts
Normal 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" };
|
||||
}
|
||||
260
application/state/sftp/ensureRemoteSftpSession.test.ts
Normal file
260
application/state/sftp/ensureRemoteSftpSession.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
155
application/state/sftp/ensureRemoteSftpSession.ts
Normal file
155
application/state/sftp/ensureRemoteSftpSession.ts
Normal 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;
|
||||
}
|
||||
66
application/state/sftp/errors.test.ts
Normal file
66
application/state/sftp/errors.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
21
application/state/sftp/errors.ts
Normal file
21
application/state/sftp/errors.ts
Normal 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";
|
||||
186
application/state/sftp/externalDragDropRetry.test.ts
Normal file
186
application/state/sftp/externalDragDropRetry.test.ts
Normal 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");
|
||||
});
|
||||
267
application/state/sftp/externalDragDropRetry.ts
Normal file
267
application/state/sftp/externalDragDropRetry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
28
application/state/sftp/externalEditTempRetention.test.ts
Normal file
28
application/state/sftp/externalEditTempRetention.test.ts
Normal 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);
|
||||
});
|
||||
59
application/state/sftp/externalEditTempRetention.ts
Normal file
59
application/state/sftp/externalEditTempRetention.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
181
application/state/sftp/externalFileWatchLifecycle.test.ts
Normal file
181
application/state/sftp/externalFileWatchLifecycle.test.ts
Normal 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");
|
||||
});
|
||||
104
application/state/sftp/externalFileWatchLifecycle.ts
Normal file
104
application/state/sftp/externalFileWatchLifecycle.ts
Normal 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 };
|
||||
}
|
||||
63
application/state/sftp/externalUploadCancel.test.ts
Normal file
63
application/state/sftp/externalUploadCancel.test.ts
Normal 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\)/);
|
||||
}
|
||||
});
|
||||
39
application/state/sftp/externalUploadRuntime.test.ts
Normal file
39
application/state/sftp/externalUploadRuntime.test.ts
Normal 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);
|
||||
});
|
||||
54
application/state/sftp/externalUploadRuntime.ts
Normal file
54
application/state/sftp/externalUploadRuntime.ts
Normal 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();
|
||||
}
|
||||
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
45
application/state/sftp/globalSftpBookmarks.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
522
application/state/sftp/globalSftpTransferControl.test.ts
Normal file
522
application/state/sftp/globalSftpTransferControl.test.ts
Normal 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");
|
||||
});
|
||||
683
application/state/sftp/globalSftpTransferControl.ts
Normal file
683
application/state/sftp/globalSftpTransferControl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
171
application/state/sftp/globalTransferScheduler.test.ts
Normal file
171
application/state/sftp/globalTransferScheduler.test.ts
Normal 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");
|
||||
});
|
||||
156
application/state/sftp/globalTransferScheduler.ts
Normal file
156
application/state/sftp/globalTransferScheduler.ts
Normal 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;
|
||||
93
application/state/sftp/localSftpBookmarks.ts
Normal file
93
application/state/sftp/localSftpBookmarks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
454
application/state/sftp/mockLocalFiles.ts
Normal file
454
application/state/sftp/mockLocalFiles.ts
Normal 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] || [];
|
||||
}
|
||||
169
application/state/sftp/openTransferSftpSession.dedicated.test.ts
Normal file
169
application/state/sftp/openTransferSftpSession.dedicated.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
174
application/state/sftp/pauseTransferOutcome.test.ts
Normal file
174
application/state/sftp/pauseTransferOutcome.test.ts
Normal 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/);
|
||||
});
|
||||
121
application/state/sftp/pauseTransferOutcome.ts
Normal file
121
application/state/sftp/pauseTransferOutcome.ts
Normal 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 };
|
||||
}
|
||||
124
application/state/sftp/sftpClipboardStore.ts
Normal file
124
application/state/sftp/sftpClipboardStore.ts
Normal 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
|
||||
);
|
||||
};
|
||||
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal file
56
application/state/sftp/sftpConnectStartPath.test.ts
Normal 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 ");
|
||||
});
|
||||
44
application/state/sftp/sftpConnectStartPath.ts
Normal file
44
application/state/sftp/sftpConnectStartPath.ts
Normal 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 ?? "/",
|
||||
};
|
||||
}
|
||||
124
application/state/sftp/sftpDialogActionStore.ts
Normal file
124
application/state/sftp/sftpDialogActionStore.ts
Normal 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]);
|
||||
};
|
||||
28
application/state/sftp/sftpDualPaneOpenStore.test.ts
Normal file
28
application/state/sftp/sftpDualPaneOpenStore.test.ts
Normal 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();
|
||||
});
|
||||
45
application/state/sftp/sftpDualPaneOpenStore.ts
Normal file
45
application/state/sftp/sftpDualPaneOpenStore.ts
Normal 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;
|
||||
}
|
||||
22
application/state/sftp/sftpFilterFocusStore.ts
Normal file
22
application/state/sftp/sftpFilterFocusStore.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
54
application/state/sftp/sftpFocusStore.ts
Normal file
54
application/state/sftp/sftpFocusStore.ts
Normal 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
|
||||
);
|
||||
};
|
||||
70
application/state/sftp/sftpHostViewModeStore.ts
Normal file
70
application/state/sftp/sftpHostViewModeStore.ts
Normal 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 };
|
||||
}
|
||||
67
application/state/sftp/sftpListDensityStore.ts
Normal file
67
application/state/sftp/sftpListDensityStore.ts
Normal 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 };
|
||||
}
|
||||
16
application/state/sftp/sftpPaneViewModeStore.test.ts
Normal file
16
application/state/sftp/sftpPaneViewModeStore.test.ts
Normal 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);
|
||||
});
|
||||
13
application/state/sftp/sftpPaneViewModeStore.ts
Normal file
13
application/state/sftp/sftpPaneViewModeStore.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
152
application/state/sftp/sftpReopenLocation.test.ts
Normal file
152
application/state/sftp/sftpReopenLocation.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
66
application/state/sftp/sftpReopenLocation.ts
Normal file
66
application/state/sftp/sftpReopenLocation.ts
Normal 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;
|
||||
}
|
||||
153
application/state/sftp/sftpTreeSelectionStore.ts
Normal file
153
application/state/sftp/sftpTreeSelectionStore.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
57
application/state/sftp/sharedRemoteHostCache.test.ts
Normal file
57
application/state/sftp/sharedRemoteHostCache.test.ts
Normal 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);
|
||||
});
|
||||
94
application/state/sftp/sharedRemoteHostCache.ts
Normal file
94
application/state/sftp/sharedRemoteHostCache.ts
Normal 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),
|
||||
});
|
||||
66
application/state/sftp/transferCancelLatch.test.ts
Normal file
66
application/state/sftp/transferCancelLatch.test.ts
Normal 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);
|
||||
});
|
||||
66
application/state/sftp/transferCancelLatch.ts
Normal file
66
application/state/sftp/transferCancelLatch.ts
Normal 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();
|
||||
}
|
||||
194
application/state/sftp/transferConcurrency.test.ts
Normal file
194
application/state/sftp/transferConcurrency.test.ts
Normal 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"]);
|
||||
});
|
||||
128
application/state/sftp/transferConcurrency.ts
Normal file
128
application/state/sftp/transferConcurrency.ts
Normal 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;
|
||||
}
|
||||
117
application/state/sftp/transferConflictLifecycle.test.ts
Normal file
117
application/state/sftp/transferConflictLifecycle.test.ts
Normal 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);
|
||||
});
|
||||
131
application/state/sftp/transferConflictLifecycle.ts
Normal file
131
application/state/sftp/transferConflictLifecycle.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
112
application/state/sftp/transferConflictOps.ts
Normal file
112
application/state/sftp/transferConflictOps.ts
Normal 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 };
|
||||
}
|
||||
551
application/state/sftp/transferConnectionPool.test.ts
Normal file
551
application/state/sftp/transferConnectionPool.test.ts
Normal 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);
|
||||
});
|
||||
563
application/state/sftp/transferConnectionPool.ts
Normal file
563
application/state/sftp/transferConnectionPool.ts
Normal 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";
|
||||
}
|
||||
70
application/state/sftp/transferControlEpoch.test.ts
Normal file
70
application/state/sftp/transferControlEpoch.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
43
application/state/sftp/transferControlEpoch.ts
Normal file
43
application/state/sftp/transferControlEpoch.ts
Normal 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;
|
||||
}
|
||||
406
application/state/sftp/transferDirectoryOps.discovery.test.tsx
Normal file
406
application/state/sftp/transferDirectoryOps.discovery.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
1032
application/state/sftp/transferDirectoryOps.ts
Normal file
1032
application/state/sftp/transferDirectoryOps.ts
Normal file
File diff suppressed because it is too large
Load Diff
202
application/state/sftp/transferHistoryRestoreMigration.ts
Normal file
202
application/state/sftp/transferHistoryRestoreMigration.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
143
application/state/sftp/transferInFlightCleanup.test.ts
Normal file
143
application/state/sftp/transferInFlightCleanup.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
73
application/state/sftp/transferPauseLatch.test.ts
Normal file
73
application/state/sftp/transferPauseLatch.test.ts
Normal 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);
|
||||
});
|
||||
95
application/state/sftp/transferPauseLatch.ts
Normal file
95
application/state/sftp/transferPauseLatch.ts
Normal 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();
|
||||
}
|
||||
65
application/state/sftp/transferProgressMetadata.test.ts
Normal file
65
application/state/sftp/transferProgressMetadata.test.ts
Normal 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);
|
||||
});
|
||||
57
application/state/sftp/transferProgressMetadata.ts
Normal file
57
application/state/sftp/transferProgressMetadata.ts
Normal 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;
|
||||
}
|
||||
51
application/state/sftp/transferRetry.test.ts
Normal file
51
application/state/sftp/transferRetry.test.ts
Normal 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);
|
||||
});
|
||||
68
application/state/sftp/transferRetry.ts
Normal file
68
application/state/sftp/transferRetry.ts
Normal 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;
|
||||
}
|
||||
318
application/state/sftp/transferRuntime.test.ts
Normal file
318
application/state/sftp/transferRuntime.test.ts
Normal 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();
|
||||
});
|
||||
132
application/state/sftp/transferRuntime.ts
Normal file
132
application/state/sftp/transferRuntime.ts
Normal 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();
|
||||
}
|
||||
171
application/state/sftp/transferSettlementObservation.test.ts
Normal file
171
application/state/sftp/transferSettlementObservation.test.ts
Normal 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");
|
||||
});
|
||||
152
application/state/sftp/transferTaskOps.ts
Normal file
152
application/state/sftp/transferTaskOps.ts
Normal 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 };
|
||||
}
|
||||
22
application/state/sftp/transferWalkRegistry.test.ts
Normal file
22
application/state/sftp/transferWalkRegistry.test.ts
Normal 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);
|
||||
});
|
||||
29
application/state/sftp/transferWalkRegistry.ts
Normal file
29
application/state/sftp/transferWalkRegistry.ts
Normal 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();
|
||||
}
|
||||
119
application/state/sftp/types.ts
Normal file
119
application/state/sftp/types.ts
Normal 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;
|
||||
}
|
||||
164
application/state/sftp/uploadTargetPin.test.ts
Normal file
164
application/state/sftp/uploadTargetPin.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
92
application/state/sftp/uploadTargetPin.ts
Normal file
92
application/state/sftp/uploadTargetPin.ts
Normal 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;
|
||||
}
|
||||
154
application/state/sftp/uploadTaskCallbacks.test.ts
Normal file
154
application/state/sftp/uploadTaskCallbacks.test.ts
Normal 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);
|
||||
});
|
||||
185
application/state/sftp/uploadTaskCallbacks.ts
Normal file
185
application/state/sftp/uploadTaskCallbacks.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
65
application/state/sftp/usePendingSftpUploadRebind.test.ts
Normal file
65
application/state/sftp/usePendingSftpUploadRebind.test.ts
Normal 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());
|
||||
});
|
||||
91
application/state/sftp/usePendingSftpUploadRebind.ts
Normal file
91
application/state/sftp/usePendingSftpUploadRebind.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
26
application/state/sftp/useSftpBrowseConnectionLifecycle.ts
Normal file
26
application/state/sftp/useSftpBrowseConnectionLifecycle.ts
Normal 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;
|
||||
}
|
||||
772
application/state/sftp/useSftpConnections.test.ts
Normal file
772
application/state/sftp/useSftpConnections.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
1463
application/state/sftp/useSftpConnections.ts
Normal file
1463
application/state/sftp/useSftpConnections.ts
Normal file
File diff suppressed because it is too large
Load Diff
39
application/state/sftp/useSftpDirectoriesFirst.ts
Normal file
39
application/state/sftp/useSftpDirectoriesFirst.ts
Normal 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 };
|
||||
};
|
||||
66
application/state/sftp/useSftpDirectoryListing.ts
Normal file
66
application/state/sftp/useSftpDirectoryListing.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
1906
application/state/sftp/useSftpExternalOperations.ts
Normal file
1906
application/state/sftp/useSftpExternalOperations.ts
Normal file
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
Reference in New Issue
Block a user