Compare commits

...
Author SHA1 Message Date
bincxz a5bbaa1723 fix(sftp): verify unreadable destination recovery before replacement 2026-09-06 14:05:25 +08:00
bincxz 7639bd83d5 fix(sftp): retry timestamps when preparation fails 2026-09-06 13:48:34 +08:00
bincxz a0c31a3b24 merge: retain safe local publication permissions handling 2026-09-06 13:42:18 +08:00
bincxz 5a030a91ae Merge remote-tracking branch 'origin/main' into codex/sftp-local-publish-safety 2026-09-06 13:38:14 +08:00
bincxz 3eeece75a4 fix(sftp): preserve file access through restricted-mode publication 2026-09-06 13:38:14 +08:00
netcatty-bot 5d6c487fc6 fix: address Codex review on PR #3286 2026-09-06 05:30:58 +00:00
陈大猫 2dea078c6d fix(sftp): preserve shared SSH transport when channel opens abort (#3288) 2026-09-06 13:29:08 +08:00
陈大猫 446e82180b fix(sftp): settle folder children before history compaction removes their rows (#3287)
* fix(sftp): observe folder child completion before history compaction

* fix(sftp): carry current child identity through recovery events

* fix(sftp): admit recovered children before waiting for completion

* test(sftp): keep independent audit regressions mergeable

* fix(sftp): resume retained pauses without bypassing newer controls
2026-09-06 13:29:05 +08:00
陈大猫 2eaddf0baf fix(sftp): refuse ambiguous or replaced resume endpoints (#3285) 2026-09-06 13:29:02 +08:00
bincxz ec1868a069 fix(sftp): prepare timestamps before publishing local downloads 2026-09-06 13:12:13 +08:00
netcatty-bot 7b32ec68fd fix: address Codex review on PR #3286 2026-09-06 05:01:54 +00:00
bincxz eb9e4ee4be fix(sftp): preserve metadata during fallback restoration 2026-09-06 12:24:46 +08:00
bincxz de4412c9d6 fix(sftp): preserve concurrent files during local publication 2026-09-06 12:02:05 +08:00
18 changed files with 1590 additions and 343 deletions

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;
}
});
+6 -28
View File
@@ -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);
},
+24
View File
@@ -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.
+42 -5
View File
@@ -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);
}
});
+73 -23
View File
@@ -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;
});
+73
View File
@@ -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
View File
@@ -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"]);
});
+183 -47
View File
@@ -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");
});
}