mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 08:42:45 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bbaa1723 | ||
|
|
7639bd83d5 | ||
|
|
a0c31a3b24 | ||
|
|
5a030a91ae | ||
|
|
3eeece75a4 | ||
|
|
5d6c487fc6 | ||
|
|
2dea078c6d | ||
|
|
446e82180b | ||
|
|
2eaddf0baf | ||
|
|
ec1868a069 | ||
|
|
7b32ec68fd | ||
|
|
eb9e4ee4be | ||
|
|
de4412c9d6 |
No files matched your search
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
protocol: "ssh",
|
||||
} as Host);
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
const batchExistingIdentity = retainedStatus !== undefined;
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const originalGet = netcattyBridge.get;
|
||||
let abort = false;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let childId: string | undefined;
|
||||
const parent: TransferTask = {
|
||||
id: "superseded-compacted-folder", fileName: "folder",
|
||||
sourcePath: "/local/folder", targetPath: "/remote/folder",
|
||||
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
|
||||
targetHostId: "h1", direction: "upload", status: "transferring",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
|
||||
isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await running;
|
||||
netcattyBridge.get = originalGet;
|
||||
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(parent.id);
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
const persisted: TransferTask = {
|
||||
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
|
||||
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
|
||||
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
};
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
// Exercise the real large-history batching branch with one relevant retained row.
|
||||
const childBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => id === persisted.id,
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => [{
|
||||
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
|
||||
}],
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => {
|
||||
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
|
||||
return { size: 10, lastModified: 1 };
|
||||
},
|
||||
startStreamTransfer: async (options: {
|
||||
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
|
||||
}) => {
|
||||
childId = options.transferId;
|
||||
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "started", transferId: childId, lifecycleEpoch: 0,
|
||||
parentTaskId: options.parentTaskId,
|
||||
directoryEntryIndex: options.directoryEntryIndex,
|
||||
directoryEntryIdentity: options.directoryEntryIdentity,
|
||||
});
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
});
|
||||
running = resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
|
||||
onChildUpdate: (child) => batchExistingIdentity
|
||||
? childBatcher.push(child)
|
||||
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
if (newerPause) {
|
||||
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
|
||||
assert.equal(childId, undefined, "newer paused child must not start");
|
||||
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
|
||||
return;
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
|
||||
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.equal((result as { success: boolean }).success, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
|
||||
assert.equal(
|
||||
resolveDirectoryResumeTargetRoot({
|
||||
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
|
||||
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
|
||||
});
|
||||
|
||||
test("resolveHostForTransferEndpoint prefers id then label", () => {
|
||||
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
|
||||
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
|
||||
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const opened: string[] = [];
|
||||
let uploads = 0;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
|
||||
closeSftp: async () => {},
|
||||
statLocal: async () => ({ size: 100, lastModified: 123 }),
|
||||
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
|
||||
});
|
||||
const hosts = scenario === "deleted-id"
|
||||
? [host("different-id", "production", "different.example")]
|
||||
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
|
||||
const result = await resumeTransferWithDedicatedSession({
|
||||
id: "wrong-host-resume", fileName: "deployment.zip",
|
||||
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
|
||||
sourceConnectionId: "local", targetConnectionId: "right-expired",
|
||||
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
|
||||
targetHostLabel: "production", direction: "upload", status: "interrupted",
|
||||
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
|
||||
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
|
||||
}, {hosts, keys: [], identities: []});
|
||||
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
|
||||
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
|
||||
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
|
||||
* label/hostname when the id is stale after vault edits or older task records.
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
return hosts.find((host) => {
|
||||
const matches = hosts.filter((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
}) ?? null;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
onProgress?: (progress: DedicatedResumeProgress) => void,
|
||||
options?: DedicatedResumeOptions,
|
||||
): Promise<DedicatedResumeResult> {
|
||||
// Fresh recovery authorizes only pauses already present at its entry. Keep
|
||||
// row references so a newer cross-window pause invalidates that permission.
|
||||
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
|
||||
.map((child) => [child.id, child]));
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { success: false, error: "Transfer bridge unavailable" };
|
||||
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
|
||||
const streamResult = await bridge.startStreamTransfer!({
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -233,3 +233,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
(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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
@@ -412,7 +413,11 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
// Await the invoke result — cancel resolves with { error } and may
|
||||
// not fire onComplete/onError after preload clears listeners.
|
||||
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
|
||||
const transferPromise = runTransferAndWaitForOwner(
|
||||
task,
|
||||
() => netcattyBridge.require().startStreamTransfer!(options),
|
||||
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
|
||||
);
|
||||
// Streams that arm after the parent pause round never receive the
|
||||
// initial pauseTransfer. Keep pausing while the folder is latched.
|
||||
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
|
||||
@@ -450,33 +455,6 @@ export function useSftpDirectoryTransferOps({
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
task: TransferTask,
|
||||
start: () => Promise<StreamResult>,
|
||||
shouldAbort: () => boolean,
|
||||
pausedAtResume?: TransferTask,
|
||||
): Promise<StreamResult> {
|
||||
// Register before admission/start: an owner may finish while dispatch waits for resume.
|
||||
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
try {
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
|
||||
if (admission === "cancelled") throw new Error("Transfer cancelled");
|
||||
if (admission === "completed" || observation.read()?.status === "completed") return {};
|
||||
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
|
||||
if (admission === "ready") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
// Admission begins a new attempt; discard any previous failed settlement.
|
||||
observation.dispose();
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
const result = await start();
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const latest = observation.read();
|
||||
if (latest?.status === "completed") return { ...result, superseded: false };
|
||||
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
|
||||
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} finally {
|
||||
observation.dispose();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import {
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./sftp/transferCancelLatch";
|
||||
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
|
||||
getBadgeSnapshot(): { count: number; hasAttention: boolean };
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
getTask(taskId: string): TransferTask | undefined;
|
||||
/** Observe exact task settlement before completed child rows are compacted. */
|
||||
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
|
||||
/** Publish an explicit dispatch identity without flushing large child-history batches. */
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
getOwnerTasks(ownerId: string): TransferTask[];
|
||||
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
|
||||
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
|
||||
@@ -398,6 +403,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
const progressListeners = new Set<Listener>();
|
||||
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
|
||||
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
|
||||
expected.id === candidate.id
|
||||
&& expected.sourcePath === candidate.sourcePath
|
||||
&& expected.targetPath === candidate.targetPath
|
||||
&& expected.parentTaskId === candidate.parentTaskId
|
||||
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
|
||||
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
|
||||
);
|
||||
const refreshBadgeSnapshot = () => {
|
||||
const next = buildBadgeSnapshot(tasks);
|
||||
if (
|
||||
@@ -609,6 +623,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// live renderer walk keeps its controls until TransferRuntime.runWalk
|
||||
// settles; everything else can release its whole tree here.
|
||||
settleFinishedTransferControlState(tasks, tasks);
|
||||
if (settlementObservers.size > 0) {
|
||||
for (const task of tasks) {
|
||||
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
const beforePrune = tasks;
|
||||
tasks = pruneSftpTransferHistory(tasks);
|
||||
const retainedIds = new Set(tasks.map((task) => task.id));
|
||||
@@ -1307,6 +1329,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
|
||||
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
|
||||
if (parent?.status === "cancelled") return "cancelled";
|
||||
const index = tasks.findIndex((task) => task.id === incoming.id);
|
||||
const existing = index < 0 ? undefined : tasks[index];
|
||||
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|
||||
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
|
||||
if (existing?.status === "cancelled") return "cancelled";
|
||||
if (existing?.status === "completed") {
|
||||
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
|
||||
}
|
||||
const resumesUnchangedPause = existing === pausedAtResume
|
||||
&& existing?.status === "paused"
|
||||
&& (parent?.status === "pending" || parent?.status === "transferring");
|
||||
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|
||||
|| parent?.status === "paused" || parent?.status === "pausing"
|
||||
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
|
||||
if (parent?.status === "completed") return "conflict";
|
||||
if (!existing) return "ready";
|
||||
if (existing.status === "transferring"
|
||||
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
|
||||
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
|
||||
// Explicit retry may leave failed/interrupted and replace its manifest identity.
|
||||
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
|
||||
const next = tasks.slice();
|
||||
next[index] = {
|
||||
...existing,
|
||||
directoryEntryIndex: incoming.directoryEntryIndex,
|
||||
directoryEntryIdentity: incoming.directoryEntryIdentity,
|
||||
...(existing.status === "transferring" ? {} : {
|
||||
status: "transferring" as const,
|
||||
lifecycleEpoch: undefined,
|
||||
error: undefined,
|
||||
endTime: undefined,
|
||||
reconnectRequired: false,
|
||||
}),
|
||||
};
|
||||
tasks = next;
|
||||
emitProgress(false);
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected) {
|
||||
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
|
||||
const observers = settlementObservers.get(expected.id) ?? new Set();
|
||||
observers.add(observer);
|
||||
settlementObservers.set(expected.id, observers);
|
||||
let disposed = false;
|
||||
return {
|
||||
read() {
|
||||
if (disposed) return undefined;
|
||||
if (observer.settled) return observer.settled;
|
||||
const current = tasks.find((task) => task.id === expected.id);
|
||||
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
observer.settled = undefined;
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) settlementObservers.delete(expected.id);
|
||||
},
|
||||
};
|
||||
},
|
||||
getTask(taskId) {
|
||||
return tasks.find((task) => task.id === taskId);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Folder completion after same-ID ownership handoff
|
||||
|
||||
Both normal directory transfer and dedicated directory recovery can receive a
|
||||
superseded stream result: another invocation now owns the same child transfer.
|
||||
The old caller must wait for that owner rather than report premature completion.
|
||||
Previously it polled the visible child row. Completed rows are compacted into
|
||||
parent checkpoints, so a completion arriving before the superseded reply removes
|
||||
the row first. Polling then waits forever; a stale panel row can mask this further.
|
||||
|
||||
Two regressions use the actual React directory hook / dedicated recovery entrypoint
|
||||
and actual store compaction. Each records one completed file in the parent while
|
||||
the transfer operation remains pending on the baseline.
|
||||
|
||||
The fix registers a bounded settlement observation before starting the stream.
|
||||
The store captures terminal state for exact observed file identities before
|
||||
history compaction. One shared helper waits for the actual owner in both paths,
|
||||
then releases its observation on success, failure or cancellation. There is no
|
||||
persistent per-file tombstone list and no inference that a missing row means
|
||||
success. Reused IDs with different indexed file identities are not evidence.
|
||||
|
||||
This addresses a separately reproduced folder-never-settles condition relevant
|
||||
to #2568 and #3155. It does not establish the original reporters' precise cause.
|
||||
|
||||
Large-history recovery follow-up: stream lifecycle events carry the current child hierarchy identity. Before dispatch, the store admits the explicit retry into the current row without a full history compaction, so a batched old failed row cannot reject the new completion. Admission distinguishes pause waiting, cancellation, identity conflict and exact prior completion; active lifecycle epochs and newer pause/cancel intent remain protected.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Local transfer publication safety audit
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
The completed download moves an existing destination to a backup, checks that
|
||||
its pathname is unoccupied, then renames the prepared download into place.
|
||||
Another program can save to that pathname between the check and rename. The
|
||||
rename overwrites those bytes and successful cleanup deletes the original
|
||||
backup. Backup restoration and post-publication rollback have equivalent races.
|
||||
|
||||
Real filesystem regressions inject concurrent creation at the actual final
|
||||
publication and restoration boundary. Both lose concurrent contents on the
|
||||
baseline. An explicitly absent destination can also appear after validation and
|
||||
be incorrectly moved aside. These tests fail before the change.
|
||||
|
||||
## Contract and design
|
||||
|
||||
- Prepared data and an original backup remain private sibling files.
|
||||
- Publication and backup restoration use the same no-overwrite primitive.
|
||||
- On hardlink-capable filesystems, linking publishes complete bytes atomically.
|
||||
- On filesystems without that operation, exclusive open plus writes through the
|
||||
owned handle preserves compatibility without overwriting another destination.
|
||||
This fallback exposes partial contents during copying; it does not promise
|
||||
atomic visibility. It never deletes the destination pathname on failure.
|
||||
- Failed fallback copies retain complete prepared data and original backup, with
|
||||
their locations in the error. A concurrent replacement is never removed.
|
||||
- Successful publication is the commit boundary. Cancellation checked before it
|
||||
restores the original where possible. Cancellation arriving after publication
|
||||
does not attempt unsafe pathname-based rollback.
|
||||
- A validated absent target is distinct from an omitted validation callback.
|
||||
- This change does not claim to prevent another program writing through an
|
||||
already-open handle or provide durable power-loss transactions.
|
||||
|
||||
## Validation
|
||||
|
||||
The focused tests cover publication and restoration boundary races, absent
|
||||
validation, normal mode-preserving replacement, early/late cancellation,
|
||||
unsupported-hardlink fallback and write failure with concurrent replacement.
|
||||
Real unsupported-filesystem hardware has not been exercised; the fallback uses
|
||||
real files with hardlink capability failure injected.
|
||||
|
||||
Related audit themes: #3186 replacement attributes and #3213 interrupted recovery.
|
||||
These reports do not establish the cause of this separately reproduced defect.
|
||||
@@ -0,0 +1,41 @@
|
||||
# SFTP restart recovery must not guess another server
|
||||
|
||||
Audit baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
`resolveHostForTransferEndpoint` used a display-name/hostname search when a
|
||||
recorded host id no longer existed, and returned the first match when multiple
|
||||
hosts shared a name. Dedicated resume used those credentials to open the target
|
||||
and start uploading. A saved task for a deleted server could therefore send
|
||||
its source file to an unrelated same-named server. Both endpoint directions and
|
||||
folder recovery use this resolver.
|
||||
|
||||
Two regressions invoke `resumeTransferWithDedicatedSession`, with the real
|
||||
resolver, scheduler, credential construction, and resume path but instrumented
|
||||
bridge I/O. Before the fix, both selected an unrelated endpoint and called
|
||||
`startStreamTransfer`: a deleted recorded id with one same-named replacement,
|
||||
and a legacy task without an id with two same-named hosts. Both now stop before
|
||||
opening or uploading.
|
||||
|
||||
## Fix contract
|
||||
|
||||
- A recorded host id is authoritative; absence must not fall back to a name.
|
||||
- Legacy tasks without an id may resolve a name only if exactly one host matches.
|
||||
- Preserve exact-id recovery, unique-name legacy recovery, and a still-live
|
||||
original session when vault credentials are absent.
|
||||
- Never select an arbitrary member of an ambiguous name match.
|
||||
|
||||
This does not freeze future edits to the connection options of an existing host
|
||||
id; a persisted immutable endpoint snapshot would be a separate schema and
|
||||
migration change. It also does not claim to explain all historical restart
|
||||
failures from issue #3213 or #2638.
|
||||
|
||||
## Verification
|
||||
|
||||
`node --test --import tsx application/state/sftp/dedicatedTransferResume.test.ts`:
|
||||
39 passed, including both regressions and existing single-file/folder restart,
|
||||
remote-to-remote, source-validation, and live-session fallback tests.
|
||||
|
||||
Broader SFTP audit remains in progress in the sibling `codex/sftp-transfer-audit`
|
||||
worktree; its first control-ordering fix is PR #3284.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Shared SSH connection cancellation audit
|
||||
|
||||
Cancelling a transfer while its isolated SFTP channel is opening calls the
|
||||
bounded-open abort path. Previously that path ended/destroyed the physical SSH
|
||||
transport. Production pooled SFTP sessions normally share a terminal SSH
|
||||
connection, so cancelling one transfer can disconnect terminals, browsing and
|
||||
other transfers. The same teardown happened on the channel-open deadline.
|
||||
|
||||
The real startTransfer/cancelTransfer path reproduces physical end/destroy calls;
|
||||
its old test double lacked those methods and could not detect the problem.
|
||||
|
||||
The repair rejects the abandoned caller promptly, retains the shared transport,
|
||||
closes a channel arriving after abandonment, and blocks further channel-open
|
||||
allocation on that transport while any abandoned request remains unresolved.
|
||||
This bounds retry accumulation without queuing controls behind a transfer.
|
||||
Healthy parallel opens remain allowed; callback settlement or transport closure
|
||||
releases the abandoned-open bookkeeping. Existing active channels remain usable.
|
||||
|
||||
This separately reproduced mechanism is relevant to the connection-loss themes
|
||||
in #2973 and #2832, but does not establish the cause of their VPN/jump-host reports.
|
||||
@@ -1,6 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
|
||||
// may own unrelated terminals. Block only additional opens until its callback
|
||||
// or transport closure settles; retain no unbounded retry queue.
|
||||
const abandonedOpens = new WeakMap();
|
||||
|
||||
function abandonOpen(client, token) {
|
||||
let state = abandonedOpens.get(client);
|
||||
if (!state) {
|
||||
state = { tokens: new Set(), onClose: null };
|
||||
state.onClose = () => {
|
||||
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
};
|
||||
abandonedOpens.set(client, state);
|
||||
client.once?.("close", state.onClose);
|
||||
}
|
||||
state.tokens.add(token);
|
||||
}
|
||||
|
||||
function settleAbandonedOpen(client, token) {
|
||||
const state = abandonedOpens.get(client);
|
||||
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
|
||||
abandonedOpens.delete(client);
|
||||
client.removeListener?.("close", state.onClose);
|
||||
}
|
||||
|
||||
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -24,6 +48,12 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (abandonedOpens.has(sshClient)) {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
const timeoutMs = Math.max(
|
||||
@@ -33,13 +63,15 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let requestPending = false;
|
||||
const token = Symbol("sftp-open");
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error, channel = null, { invalidateTransport = false } = {}) => {
|
||||
const finish = (error, channel = null, { abandon = false } = {}) => {
|
||||
if (settled) {
|
||||
closeSftpChannel(channel);
|
||||
return false;
|
||||
@@ -47,13 +79,13 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error && channel) closeSftpChannel(channel);
|
||||
if (invalidateTransport) invalidateSshTransport(sshClient);
|
||||
if (abandon && requestPending) abandonOpen(sshClient, token);
|
||||
if (error) reject(error);
|
||||
else resolve(channel);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
|
||||
invalidateTransport: true,
|
||||
abandon: true,
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -66,15 +98,20 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
timer = setTimeoutFn(() => {
|
||||
const error = new Error(`SFTP channel open timed out after ${timeoutMs}ms`);
|
||||
error.code = "SFTP_CHANNEL_OPEN_TIMEOUT";
|
||||
finish(error, null, { invalidateTransport: true });
|
||||
finish(error, null, { abandon: true });
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
requestPending = true;
|
||||
sshClient.sftp((error, channel) => {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
if (error) finish(error, channel || null);
|
||||
else finish(null, channel || null);
|
||||
});
|
||||
} catch (error) {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test("bounded SFTP open times out and closes a late channel", async () => {
|
||||
assert.equal(timers.active.size, 1);
|
||||
assert.equal([...timers.active][0].hasRef(), true);
|
||||
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(timers.active.size, 0);
|
||||
|
||||
const channel = createChannel();
|
||||
@@ -72,7 +72,7 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
|
||||
assert.equal([...timers.active][0].hasRef(), true);
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(result, /cancelled/);
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(timers.active.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
|
||||
@@ -82,28 +82,44 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
|
||||
assert.equal(timers.active.size, 0);
|
||||
});
|
||||
|
||||
test("repeated unresponsive SFTP opens cannot retain channel callbacks", async () => {
|
||||
const sshClient = {
|
||||
destroyed: false,
|
||||
pendingChannelCallbacks: [],
|
||||
sftp(callback) {
|
||||
if (this.destroyed) throw new Error("Not connected");
|
||||
this.pendingChannelCallbacks.push(callback);
|
||||
},
|
||||
end() {},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.pendingChannelCallbacks.length = 0;
|
||||
},
|
||||
};
|
||||
for (const reason of ["timeout", "cancel"]) {
|
||||
test(`abandoned ${reason} opens cannot accumulate requests on a shared transport`, async () => {
|
||||
const callbacks = [];
|
||||
let physicalCloses = 0;
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = callback => { callbacks.push(callback); };
|
||||
sshClient.end = sshClient.destroy = () => { physicalCloses++; };
|
||||
const controller = new AbortController();
|
||||
const opening = openBoundedSftpChannel(sshClient, { timeoutMs: 5, signal: controller.signal });
|
||||
if (reason === "cancel") controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(opening);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
}
|
||||
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
|
||||
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 2 }),
|
||||
/timed out|Not connected/,
|
||||
);
|
||||
}
|
||||
assert.equal(sshClient.pendingChannelCallbacks.length, 0);
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
assert.equal(callbacks.length, 2, "a settled abandoned request must release admission");
|
||||
const healthy = createChannel();
|
||||
callbacks[1](null, healthy);
|
||||
assert.equal(await next, healthy);
|
||||
assert.equal(physicalCloses, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("healthy SFTP channel openings remain parallel", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = { sftp(callback) { callbacks.push(callback); } };
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
assert.equal(callbacks.length, 2);
|
||||
const channels = [createChannel(), createChannel()];
|
||||
callbacks.forEach((callback, index) => callback(null, channels[index]));
|
||||
assert.deepEqual(await Promise.all([first, second]), channels);
|
||||
});
|
||||
|
||||
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
|
||||
@@ -131,3 +147,37 @@ test("bounded SFTP open converts synchronous setup errors into rejections", asyn
|
||||
const sshClient = { sftp() { throw new Error("sync failure"); } };
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), /sync failure/);
|
||||
});
|
||||
|
||||
test("transport closure releases abandoned-open bookkeeping without a late callback", async () => {
|
||||
const sshClient = new EventEmitter();
|
||||
const callbacks = [];
|
||||
sshClient.sftp = callback => callbacks.push(callback);
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
sshClient.emit("close");
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
const channel = createChannel();
|
||||
callbacks[1](null, channel);
|
||||
assert.equal(await next, channel);
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0);
|
||||
});
|
||||
|
||||
test("all abandoned parallel opens must settle before admitting a new one", async () => {
|
||||
const sshClient = new EventEmitter();
|
||||
const callbacks = [];
|
||||
sshClient.sftp = callback => callbacks.push(callback);
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
|
||||
callbacks[1](new Error("second failed"));
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient);
|
||||
callbacks[2](null, createChannel());
|
||||
await next;
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
// Publish a prepared regular file without replacing any destination entry.
|
||||
// Hardlinks make complete bytes visible atomically. FAT-like filesystems use an
|
||||
// exclusively created handle instead: partial contents can be visible there,
|
||||
// but no pathname cleanup may remove a concurrent writer's replacement.
|
||||
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
|
||||
// verify an open destination handle before later metadata stamping.
|
||||
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.link(source, target);
|
||||
// The hardlink shares the published inode; stat either name.
|
||||
const linkedStat = await fs.promises.lstat(source);
|
||||
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
|
||||
} catch (error) {
|
||||
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) throw error;
|
||||
}
|
||||
|
||||
let input;
|
||||
let output;
|
||||
let failure;
|
||||
let publishedIdentity;
|
||||
try {
|
||||
input = preparedHandle ?? await fs.promises.open(source, "r");
|
||||
const stat = await input.stat();
|
||||
assertNotCancelled();
|
||||
output = await fs.promises.open(target, "wx", stat.mode & 0o7777);
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let position = 0;
|
||||
while (position < stat.size) {
|
||||
assertNotCancelled();
|
||||
const { bytesRead } = await input.read(buffer, 0, Math.min(buffer.length, stat.size - position), position);
|
||||
if (!bytesRead) throw new Error("Prepared local file ended before publication completed");
|
||||
let written = 0;
|
||||
while (written < bytesRead) {
|
||||
assertNotCancelled();
|
||||
const { bytesWritten } = await output.write(buffer, written, bytesRead - written, position + written);
|
||||
if (!bytesWritten) throw new Error("Local publication made no write progress");
|
||||
written += bytesWritten;
|
||||
}
|
||||
position += bytesRead;
|
||||
}
|
||||
assertNotCancelled();
|
||||
// Restore metadata after writes (which may clear special permission bits),
|
||||
// through the owned handle rather than a potentially replaced pathname.
|
||||
await output.chmod(stat.mode & 0o7777);
|
||||
await output.utimes(stat.atime, stat.mtime);
|
||||
const ownedStat = await output.stat();
|
||||
const targetStat = await fs.promises.lstat(target);
|
||||
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
for (const handle of [output, preparedHandle ? undefined : input]) {
|
||||
try { await handle?.close(); } catch (error) { failure ??= error; }
|
||||
}
|
||||
}
|
||||
if (failure) {
|
||||
// Once exclusive creation succeeded, leave the partial destination intact.
|
||||
// The caller must retain its complete prepared file and any original backup.
|
||||
if (output) failure.localPublicationIncomplete = true;
|
||||
throw failure;
|
||||
}
|
||||
return publishedIdentity;
|
||||
}
|
||||
|
||||
module.exports = { publishLocalFileExclusive };
|
||||
@@ -755,11 +755,10 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 3, true, "the backup should be restored after cancellation");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal rolls back a published file when cancellation wins the final rename", async (t) => {
|
||||
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
@@ -778,26 +777,27 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) controller.abort();
|
||||
const originalLink = fs.promises.link;
|
||||
let published = false;
|
||||
fs.promises.link = async (...args) => {
|
||||
await originalLink(...args);
|
||||
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
|
||||
published = true;
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
t.after(() => { fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
await assert.doesNotReject(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-final-rename-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 4, true, "the published file should be rolled back to the backup");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
assert.equal(published, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
|
||||
@@ -820,20 +820,20 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
const originalLink = fs.promises.link;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 4) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 2) backupPath = args[1];
|
||||
if (renameCalls === 3) controller.abort();
|
||||
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -843,7 +843,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, /Backup:/);
|
||||
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
||||
return true;
|
||||
@@ -877,11 +877,6 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
@@ -889,7 +884,12 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
const originalLink = fs.promises.link;
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -899,7 +899,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
return true;
|
||||
|
||||
+145
-188
@@ -950,219 +950,151 @@ function stableLocalFileIdentity(statLike) {
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const assertNotCancelled = typeof options.assertNotCancelled === "function"
|
||||
? options.assertNotCancelled
|
||||
: () => {};
|
||||
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
|
||||
const assertNotCancelled = options.assertNotCancelled || (() => {});
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
const targetDir = path.dirname(targetPath);
|
||||
const targetBase = path.basename(targetPath);
|
||||
const readyPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.ready`);
|
||||
const backupPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.backup`);
|
||||
let preparedPath = stagedPath;
|
||||
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
|
||||
const readyPath = `${base}.ready`;
|
||||
const backupPath = `${base}.backup`;
|
||||
const restoreProbePath = `${base}.restore-check`;
|
||||
let backedUp = false;
|
||||
let published = false;
|
||||
let publishedIdentity = null;
|
||||
let committed = false;
|
||||
let keepRecoveryFiles = false;
|
||||
let preparedHandle;
|
||||
let originalHandle;
|
||||
let restoreProbeCreated = false;
|
||||
let localMtimePrepared = false;
|
||||
try {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(stagedPath, readyPath);
|
||||
} catch (err) {
|
||||
if (err?.code !== "EXDEV") throw err;
|
||||
await fs.promises.copyFile(stagedPath, readyPath);
|
||||
preparedPath = readyPath;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") throw error;
|
||||
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
|
||||
}
|
||||
if (preparedPath !== readyPath) preparedPath = readyPath;
|
||||
// Stamp the private prepared file before applying possibly unreadable
|
||||
// destination permissions. Publication carries these times to the target.
|
||||
const mtimeMs = Number(options.sourceSoftIdentity?.mtimeMs);
|
||||
if (Number.isFinite(mtimeMs) && mtimeMs >= 1000) {
|
||||
const when = new Date(Math.floor(mtimeMs / 1000) * 1000);
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(readyPath, when, when), 15_000, "Prepared destination utimes",
|
||||
).then(() => { localMtimePrepared = true; }).catch(() => {});
|
||||
}
|
||||
// Keep read access to our private bytes before destination permissions
|
||||
// can remove it; the no-hardlink fallback copies through this handle.
|
||||
preparedHandle = await fs.promises.open(readyPath, "r");
|
||||
let appliedMode = null;
|
||||
let targetStable = false;
|
||||
let expectedStableIdentity = null;
|
||||
let validatedTarget;
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const validatedTarget = typeof options.validateTarget === "function"
|
||||
validatedTarget = typeof options.validateTarget === "function"
|
||||
? await options.validateTarget()
|
||||
: null;
|
||||
const existingMode = Number.isInteger(validatedTarget?.existingMode)
|
||||
: undefined;
|
||||
const mode = Number.isInteger(validatedTarget?.existingMode)
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode)
|
||||
? options.existingMode & 0o7777
|
||||
: null;
|
||||
if (existingMode !== null && existingMode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, existingMode);
|
||||
appliedMode = existingMode;
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
if (mode !== null && mode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, mode);
|
||||
appliedMode = mode;
|
||||
continue;
|
||||
}
|
||||
expectedStableIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity
|
||||
? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":")
|
||||
: null);
|
||||
targetStable = true;
|
||||
stable = true;
|
||||
break;
|
||||
}
|
||||
if (!targetStable) {
|
||||
throw new Error("Local download target kept changing before replacement");
|
||||
}
|
||||
if (!stable) throw new Error("Local download target kept changing before replacement");
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
// Another process may have replaced the destination between validateTarget
|
||||
// and rename. Re-check the moved backup before publishing the download.
|
||||
if (backedUp && expectedStableIdentity) {
|
||||
let backupStat;
|
||||
const expectedAbsent = validatedTarget?.targetIdentity === "missing" || validatedTarget?.targetIdentity === null;
|
||||
const expectedIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity ? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":") : null);
|
||||
if (!expectedAbsent) {
|
||||
try {
|
||||
backupStat = await fs.promises.lstat(backupPath);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Local download target disappeared during replacement: ${targetPath}`,
|
||||
{ cause: err },
|
||||
);
|
||||
originalHandle = await fs.promises.open(targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
||||
// An unreadable original cannot use copy-based recovery. Verify the
|
||||
// non-overwriting alternative before moving its only visible name.
|
||||
try {
|
||||
await fs.promises.link(targetPath, restoreProbePath);
|
||||
restoreProbeCreated = true;
|
||||
await fs.promises.unlink(restoreProbePath);
|
||||
restoreProbeCreated = false;
|
||||
} catch (restoreError) {
|
||||
throw new Error("Cannot safely replace unreadable local destination: hardlink recovery unavailable", { cause: restoreError });
|
||||
}
|
||||
} else if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (!backupStat.isFile() || stableLocalFileIdentity(backupStat) !== expectedStableIdentity) {
|
||||
// If another writer already recreated targetPath, do not restore the
|
||||
// mismatched backup over it — leave both intact and fail closed.
|
||||
let targetOccupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
targetOccupied = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (targetOccupied) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
} catch (restoreErr) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after a concurrent replacement was detected. `
|
||||
+ `Backup: ${backupPath}; target: ${targetPath}`,
|
||||
{ cause: restoreErr },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
}
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (backedUp && expectedIdentity) {
|
||||
const stat = await fs.promises.lstat(backupPath);
|
||||
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
}
|
||||
// After the backup move, a concurrent writer may recreate targetPath. Never
|
||||
// clobber that file: leave it in place, keep the original in backupPath for
|
||||
// recovery, and fail closed before publishing the download.
|
||||
if (backedUp) {
|
||||
let recreated = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
recreated = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (recreated) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
// Prevent the catch path from renaming backup over the concurrent file.
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
}
|
||||
assertNotCancelled();
|
||||
// Capture identity of the ready file before publish so rollback can refuse
|
||||
// to clobber a concurrent replacement of the published target.
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
publishedIdentity = stableLocalFileIdentity(await fs.promises.lstat(readyPath));
|
||||
} catch {
|
||||
publishedIdentity = null;
|
||||
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error("Local download target changed during replacement", { cause: error });
|
||||
}
|
||||
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.rename(readyPath, targetPath);
|
||||
published = true;
|
||||
assertNotCancelled();
|
||||
options.onCommit?.();
|
||||
// Publication is the commit boundary. A late cancel must not perform a
|
||||
// check-then-unlink rollback against a name another process may now own.
|
||||
committed = true;
|
||||
// Hand the published inode identity to the caller for descriptor-based
|
||||
// metadata stamping after publication.
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
|
||||
if (stagedPath !== readyPath) await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (err) {
|
||||
let restoreError = null;
|
||||
if (err?.leaveConcurrentTarget) {
|
||||
// Caller already decided not to touch a concurrent target/backup pair.
|
||||
} else if (backedUp) {
|
||||
let targetMatchesPublished = false;
|
||||
let targetMissing = false;
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (error) {
|
||||
if (committed) throw error;
|
||||
if (backedUp && !keepRecoveryFiles) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
targetMatchesPublished = !!(
|
||||
published
|
||||
&& publishedIdentity
|
||||
&& stableLocalFileIdentity(targetStat) === publishedIdentity
|
||||
);
|
||||
} catch (statErr) {
|
||||
if (statErr?.code === "ENOENT") targetMissing = true;
|
||||
else restoreError = statErr;
|
||||
}
|
||||
if (!restoreError) {
|
||||
if (published && !targetMissing && !targetMatchesPublished) {
|
||||
// Concurrent writer replaced our published file — keep both.
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
if (published && targetMatchesPublished) {
|
||||
await fs.promises.unlink(targetPath).catch(() => {});
|
||||
}
|
||||
if (targetMissing || targetMatchesPublished || !published) {
|
||||
try {
|
||||
// Only restore when the path is free or still holds our publish.
|
||||
if (!published) {
|
||||
let occupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
occupied = true;
|
||||
} catch (occErr) {
|
||||
if (occErr?.code !== "ENOENT") throw occErr;
|
||||
}
|
||||
if (occupied) {
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
restoreError = recoveryErr;
|
||||
}
|
||||
}
|
||||
// The pathname may have changed between open and rename. Never copy
|
||||
// an earlier inode over the original actually moved into the backup.
|
||||
let restoreHandle;
|
||||
if (originalHandle) {
|
||||
const [heldStat, backupStat] = await Promise.all([
|
||||
originalHandle.stat(), fs.promises.lstat(backupPath),
|
||||
]);
|
||||
if (stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(backupStat)) restoreHandle = originalHandle;
|
||||
}
|
||||
}
|
||||
} else if (published) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
if (publishedIdentity && stableLocalFileIdentity(targetStat) === publishedIdentity) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
if (recoveryErr?.code !== "ENOENT") restoreError = recoveryErr;
|
||||
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
|
||||
await fs.promises.unlink(backupPath).catch(() => {});
|
||||
backedUp = false;
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
}
|
||||
}
|
||||
if (restoreError) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after replacement failed. `
|
||||
+ `Backup: ${backupPath}; prepared replacement: ${readyPath}; `
|
||||
+ `original staged path: ${stagedPath}; target: ${targetPath}`,
|
||||
{ cause: restoreError },
|
||||
if (keepRecoveryFiles) {
|
||||
const failure = new Error(
|
||||
`${error.message}. Recovery files preserved. Backup: ${backedUp ? backupPath : "none"}; `
|
||||
+ `prepared replacement: ${readyPath}; target: ${targetPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
failure.recoveryFailed = true;
|
||||
if (backedUp) failure.remoteBackupPath = backupPath;
|
||||
throw failure;
|
||||
}
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
throw err;
|
||||
throw error;
|
||||
} finally {
|
||||
await preparedHandle?.close().catch(() => {});
|
||||
await originalHandle?.close().catch(() => {});
|
||||
if (restoreProbeCreated) await fs.promises.unlink(restoreProbePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,11 +1139,30 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
: 15_000;
|
||||
|
||||
if (transfer.targetType === "local" && transfer.targetPath) {
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(transfer.targetPath, when, when),
|
||||
mtimeTimeoutMs,
|
||||
"Destination utimes",
|
||||
);
|
||||
if (transfer.localMtimePrepared) return;
|
||||
// Verify and stamp the same open file. A later pathname replacement
|
||||
// must never receive metadata belonging to this completed transfer.
|
||||
await awaitBestEffortBounded((async () => {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.promises.open(transfer.targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code !== "EACCES" && error?.code !== "EPERM") throw error;
|
||||
// O_WRONLY neither creates nor truncates a write-only destination.
|
||||
handle = await fs.promises.open(transfer.targetPath, fs.constants.O_WRONLY);
|
||||
}
|
||||
try {
|
||||
const currentStat = await handle.stat();
|
||||
const publishedIdentity = transfer.publishedLocalIdentity;
|
||||
const expectedIdentity = typeof publishedIdentity === "string"
|
||||
? publishedIdentity : stableLocalFileIdentity(publishedIdentity);
|
||||
if (!currentStat.isFile()
|
||||
|| (expectedIdentity && stableLocalFileIdentity(currentStat) !== expectedIdentity)) return;
|
||||
await handle.utimes(when, when);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
})(), mtimeTimeoutMs, "Destination utimes");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6082,6 +6033,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
} = await inspectLocalPromotionTarget(targetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
await promoteLocalTransfer(downloadTargetPath, promotionTargetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
existingMode,
|
||||
async validateTarget() {
|
||||
const latestTarget = await inspectLocalPromotionTarget(targetPath);
|
||||
@@ -6096,8 +6048,10 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6185,11 +6139,14 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
if (transfer.resumable && transfer.stagedLocalPath) {
|
||||
await promoteLocalTransfer(transfer.stagedLocalPath, targetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const bridge = require("./transferBridge.cjs");
|
||||
const temp = require("./tempDirBridge.cjs");
|
||||
|
||||
for (const restore of [false, true]) {
|
||||
test(`local ${restore ? "restore" : "publish"} never overwrites a last-moment concurrent file`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let injected = false;
|
||||
for (const method of ["rename", "link"]) {
|
||||
const original = fs.promises[method];
|
||||
t.after(() => { fs.promises[method] = original; });
|
||||
fs.promises[method] = async (...args) => {
|
||||
if (!injected && args[1] === target && String(args[0]).endsWith(restore ? ".backup" : ".ready")) {
|
||||
injected = true;
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
}
|
||||
return original.apply(fs.promises, args);
|
||||
};
|
||||
}
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target,
|
||||
restore ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}));
|
||||
assert.equal(injected, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const backup = fs.readdirSync(root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup, "retain original for recovery");
|
||||
assert.equal(fs.readFileSync(path.join(root, backup), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("an explicitly absent destination is never moved aside if another writer creates it", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-absent")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
validateTarget: async () => {
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
return { targetIdentity: "missing", existingMode: null };
|
||||
},
|
||||
}));
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
for (const failCopy of [false, true]) {
|
||||
test(`exclusive copy fallback ${failCopy ? "retains recovery files after a write failure" : "publishes complete bytes without hardlinks"}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-fallback")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(2 * 1024 * 1024 + 7, 173);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
if (failCopy) {
|
||||
const open = fs.promises.open;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
handle.write = async () => {
|
||||
// A replacement entry must survive even when the owned old handle fails.
|
||||
fs.unlinkSync(target);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
throw new Error("disk write failed");
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.open = open; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target), /Recovery files preserved/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
} else {
|
||||
await bridge._promoteLocalTransferForTests(staged, target);
|
||||
assert.deepEqual(fs.readFileSync(target), payload);
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("cancellation before publication restores the original destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-cancel")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled: () => { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const interruption of ["cancel", "replace", "restore-replace"]) {
|
||||
test(`fallback ${interruption} during copying preserves complete recovery files`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mid-copy")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(3 * 1024 * 1024, 42);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
const open = fs.promises.open;
|
||||
let cancelled = false;
|
||||
let writes = 0;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const write = handle.write.bind(handle);
|
||||
handle.write = async (...writeArgs) => {
|
||||
const result = await write(...writeArgs);
|
||||
if (++writes === 1) {
|
||||
if (interruption === "cancel") cancelled = true;
|
||||
else { fs.unlinkSync(target); fs.writeFileSync(target, "concurrent"); }
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.link = link; fs.promises.open = open; });
|
||||
let committed = false;
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
...(interruption === "restore-replace" ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}),
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
}), /Recovery files preserved/);
|
||||
assert.equal(committed, false);
|
||||
if (interruption === "cancel") assert.equal(writes, 1);
|
||||
else assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("cancelled replacement restores original timestamps through the copy fallback", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restore-times")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original", { mode: 0o640 });
|
||||
const atime = new Date("2019-01-02T00:00:00Z");
|
||||
const mtime = new Date("2020-02-03T00:00:00Z");
|
||||
fs.utimesSync(target, atime, mtime);
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mtimeMs, mtime.getTime());
|
||||
assert.equal(stat.atimeMs, atime.getTime());
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const replaceAfterCommit of [false, true]) {
|
||||
test(`local publication prepares timestamps before restrictive permissions${replaceAfterCommit ? " and leaves post-commit replacement alone" : ""}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mtime")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
const transfer = {
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
};
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: 0,
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
onCommit(identity) {
|
||||
transfer.publishedLocalIdentity = identity;
|
||||
transfer.localMtimePrepared = true;
|
||||
if (replaceAfterCommit) {
|
||||
fs.renameSync(target, path.join(root, "published"));
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
}
|
||||
},
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests(transfer);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), replaceAfterCommit ? 1_600_000_000 : 1_700_000_000);
|
||||
if (!replaceAfterCommit) assert.equal(stat.mode & 0o777, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("fallback close failure retains the original backup and prepared replacement", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-close")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => { await close(); throw new Error("close failed"); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target), (error) => {
|
||||
assert.equal(error.recoveryFailed, true);
|
||||
assert.equal(fs.readFileSync(error.remoteBackupPath, "utf8"), "original");
|
||||
return true;
|
||||
});
|
||||
const ready = fs.readdirSync(root).find((name) => name.endsWith(".ready"));
|
||||
assert.equal(fs.readFileSync(path.join(root, ready), "utf8"), "download");
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`copy fallback publishes prepared bytes with destination mode ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mode")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, mode);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
});
|
||||
}
|
||||
|
||||
test("direct local timestamp preservation supports a write-only target", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("write-only timestamp fallback leaves a later pathname replacement unchanged", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
const published = path.join(root, "published");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
let replaced = false;
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === fs.constants.O_WRONLY) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => {
|
||||
fs.renameSync(target, published);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
replaced = true;
|
||||
return utimes(...times);
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor(fs.statSync(target).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(Math.floor(fs.statSync(published).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("copy fallback refuses to move an unreadable existing destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restrictive")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(1024 * 1024 + 3, 7);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original", { mode: 0o200 });
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
return open(...args);
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, { existingMode: 0o200 }), /hardlink recovery unavailable/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(stat.size, "original".length);
|
||||
fs.chmodSync(target, 0o600); // grant readback for verification
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`unreadable original remains at destination when safe restoration is unavailable: ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-unreadable")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
fs.chmodSync(target, mode);
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[1] === "r" && (args[0] === target || String(args[0]).endsWith(".backup"))) {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
return open(...args);
|
||||
});
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) cancelled = true;
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode,
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}));
|
||||
assert.equal(fs.existsSync(target), true, "unrestorable original must not be moved away");
|
||||
assert.equal(fs.statSync(target).mode & 0o777, mode);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("copy recovery retains original read access after its permissions become restrictive", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-held-handle")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) {
|
||||
fs.chmodSync(args[1], 0);
|
||||
cancelled = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}), /Transfer cancelled/);
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
@@ -10532,6 +10532,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
let delayedOpen = null;
|
||||
let abandonedChannelClosed = false;
|
||||
let openCalls = 0;
|
||||
let physicalEndCalls = 0;
|
||||
let physicalDestroyCalls = 0;
|
||||
const abandonedSftp = createFastSftp({
|
||||
end() {
|
||||
abandonedChannelClosed = true;
|
||||
@@ -10551,6 +10553,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
return Promise.resolve({ size: 10 });
|
||||
},
|
||||
client: {
|
||||
end() { physicalEndCalls++; },
|
||||
destroy() { physicalDestroyCalls++; },
|
||||
sftp(callback) {
|
||||
openCalls += 1;
|
||||
if (openCalls === 1) {
|
||||
@@ -10588,6 +10592,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
const cancelled = await cancelledPromise;
|
||||
assert.equal(cancelled.error, "Transfer cancelled");
|
||||
assert.equal(abandonedChannelClosed, true);
|
||||
assert.equal(physicalEndCalls, 0, "cancelling one download must preserve the shared SSH connection");
|
||||
assert.equal(physicalDestroyCalls, 0, "terminal and browsing channels must not be destroyed");
|
||||
|
||||
const next = await Promise.race([
|
||||
start("download-after-cancel"),
|
||||
@@ -12269,55 +12275,31 @@ test("local promotion does not clobber a target recreated after backup", async (
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
});
|
||||
|
||||
test("local promotion rollback does not clobber a concurrent post-publish target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-postpub-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`);
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "download.staged");
|
||||
const targetPath = path.join(tempDir, "target.bin");
|
||||
const original = Buffer.from("original-target-content");
|
||||
const concurrent = Buffer.from("post-publish-concurrent");
|
||||
const downloaded = Buffer.from("downloaded-replacement");
|
||||
await fs.promises.writeFile(targetPath, original);
|
||||
await fs.promises.writeFile(stagedPath, downloaded);
|
||||
const originalStat = await fs.promises.lstat(targetPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
async validateTarget() {
|
||||
return {
|
||||
existingMode: null,
|
||||
stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat),
|
||||
targetIdentity: [
|
||||
originalStat.dev,
|
||||
originalStat.ino,
|
||||
originalStat.size,
|
||||
originalStat.mtimeMs,
|
||||
originalStat.ctimeMs,
|
||||
].join(":"),
|
||||
};
|
||||
},
|
||||
assertNotCancelled() {
|
||||
// After ready is published onto targetPath, simulate concurrent replace + cancel.
|
||||
try {
|
||||
if (fs.readFileSync(targetPath).equals(downloaded)) {
|
||||
fs.writeFileSync(targetPath, concurrent);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err.message || err).includes("cancelled")) throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
/cancelled/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
|
||||
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
|
||||
assert.ok(backupName, "original should remain in backup");
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
await fs.promises.writeFile(targetPath, "original");
|
||||
await fs.promises.writeFile(stagedPath, "download");
|
||||
let cancelled = false;
|
||||
let committed = false;
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
fs.promises.link = async (...args) => {
|
||||
await link.apply(fs.promises, args);
|
||||
if (args[1] === targetPath && String(args[0]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
});
|
||||
assert.equal(committed, true);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
test("local promotion succeeds when backup still matches validated identity", async (t) => {
|
||||
@@ -12376,6 +12358,109 @@ test("preserveTransferredDestinationMtime stamps local targets from sourceSoftId
|
||||
assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("concurrent"));
|
||||
const before = await fs.promises.stat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
// Identity recorded at publication no longer matches the pathname's file.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
|
||||
// A vanished destination is likewise left alone.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath: path.join(tempDir, "missing.bin"),
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
const publishedStat = await fs.promises.lstat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat),
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("local publication returns an identity usable for timestamp preservation", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "stage");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(stagedPath, "payload");
|
||||
let publishedLocalIdentity;
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
onCommit(identity) { publishedLocalIdentity = identity; },
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
const oldPath = path.join(tempDir, "published");
|
||||
await fs.promises.writeFile(targetPath, "payload");
|
||||
const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath));
|
||||
let replaced = false;
|
||||
const replace = async () => {
|
||||
if (replaced) return;
|
||||
replaced = true;
|
||||
await fs.promises.rename(targetPath, oldPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000);
|
||||
};
|
||||
const originalUtimes = fs.promises.utimes;
|
||||
const originalOpen = fs.promises.open;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (name === targetPath) await replace();
|
||||
return originalUtimes(name, ...args);
|
||||
});
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
if (args[0] === targetPath) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => { await replace(); return utimes(...times); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
});
|
||||
|
||||
test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => {
|
||||
const hangingClient = {
|
||||
async chmod() {
|
||||
@@ -12751,3 +12836,54 @@ test("in-place isolated OPEN keeps path gate until late callback after channel e
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close"));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
await fs.promises.chmod(targetPath, 0o200);
|
||||
const sourceMtimeMs = 1_700_000_000_000;
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000);
|
||||
assert.equal(after.mode & 0o777, 0o200);
|
||||
});
|
||||
|
||||
for (const failureMode of ["rejection", "timeout"]) {
|
||||
test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const sourcePath = path.join(tempDir, "source");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(sourcePath, "payload");
|
||||
await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000);
|
||||
let preparationFailed = false;
|
||||
const utimes = fs.promises.utimes;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (String(name).endsWith(".ready")) {
|
||||
preparationFailed = true;
|
||||
if (failureMode === "timeout") return new Promise(() => {});
|
||||
throw Object.assign(new Error("transient metadata failure"), { code: "EIO" });
|
||||
}
|
||||
return utimes(name, ...args);
|
||||
});
|
||||
transferBridge.init({ sftpClients: new Map() });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "local-mtime-retry", sourcePath, targetPath,
|
||||
sourceType: "local", targetType: "local", totalBytes: 7, resumable: true,
|
||||
});
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(preparationFailed, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload");
|
||||
});
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user