mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f776f23fa7 | ||
|
|
8528969824 | ||
|
|
d6fede5f84 | ||
|
|
c1dcb6cea6 | ||
|
|
8ff7459db0 |
No files matched your search
@@ -199,11 +199,10 @@ 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 requires recorded id and supports unique legacy names", () => {
|
||||
test("resolveHostForTransferEndpoint prefers id then label", () => {
|
||||
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"), null);
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -2101,33 +2100,3 @@ 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);
|
||||
});
|
||||
}
|
||||
@@ -96,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
* 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.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -105,16 +105,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
const matches = hosts.filter((host) => {
|
||||
return hosts.find((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
|
||||
@@ -51,46 +51,6 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
|
||||
};
|
||||
}
|
||||
|
||||
test("an older pause response must not resume a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
let calls = 0;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
|
||||
pauseTransfer: async () => {
|
||||
backendPaused = true;
|
||||
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
|
||||
return { success: true, lifecycleEpoch: 2 };
|
||||
},
|
||||
resumeTransfer: async () => {
|
||||
backendPaused = false;
|
||||
return { success: true, lifecycleEpoch: 3 };
|
||||
},
|
||||
}));
|
||||
const first = softPauseTransfer(host, "overlapping-pause");
|
||||
await softPauseTransfer(host, "overlapping-pause");
|
||||
finishFirstPause({ success: true, lifecycleEpoch: 1 });
|
||||
await first;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
|
||||
});
|
||||
|
||||
test("a delayed resume response must not repaint a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
|
||||
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
|
||||
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
|
||||
}));
|
||||
const resume = softResumeTransfer(host, "resume-then-pause");
|
||||
await softPauseTransfer(host, "resume-then-pause");
|
||||
finishResume({ success: true, lifecycleEpoch: 2 });
|
||||
await resume;
|
||||
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
|
||||
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
@@ -285,218 +245,3 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
});
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
for (const newerLocalPause of [false, true]) {
|
||||
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
|
||||
let pauses = 0;
|
||||
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
|
||||
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
|
||||
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(initial, () => ({
|
||||
pauseTransfer: () => ++pauses === 1
|
||||
? new Promise((resolve) => { finish = resolve; })
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
|
||||
}));
|
||||
const pending = softPauseTransfer(host, id);
|
||||
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
|
||||
if (newerLocalPause) await softPauseTransfer(host, id);
|
||||
finish({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await pending;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"] as const) {
|
||||
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: boolean }) => void;
|
||||
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
|
||||
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
|
||||
const id = getTasks()[0].id;
|
||||
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
|
||||
// A global event from another window changes lifecycle, not this window's local control epoch.
|
||||
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
|
||||
finish({ success: false, superseded: true });
|
||||
const result = await operation;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 8);
|
||||
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
|
||||
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
|
||||
});
|
||||
}
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `remote-pause-${isDirectory}`;
|
||||
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
|
||||
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(tasks, () => ({
|
||||
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
|
||||
}));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(isTransferPauseLatched(id), true);
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
test("a child-only remote resume does not release the folder pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const { host } = createHost([
|
||||
{ ...makeTask("mixed-root"), isDirectory: true },
|
||||
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
|
||||
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
|
||||
], () => ({ pauseTransfer: async id => id === "mixed-one"
|
||||
? { success: false, superseded: true, supersededBy: "resume" }
|
||||
: { success: true } }));
|
||||
await softPauseTransfer(host, "mixed-root");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched("mixed-root"), true);
|
||||
assert.equal(isTransferPauseLatched("mixed-two"), true);
|
||||
});
|
||||
|
||||
test("directory resume joins successful and remotely resumed children", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "mixed-success-resume";
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
|
||||
? { success: true, lifecycleEpoch: 3 }
|
||||
: { success: false, superseded: true, supersededBy: "resume" } }));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
});
|
||||
|
||||
for (const newerPause of [false, true]) {
|
||||
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `rejected-folder-resume-${newerPause}`;
|
||||
registerTransferWalk(id);
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
|
||||
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerPause) await softPauseTransfer(host, id);
|
||||
rejectResume(new Error("resume transport disconnected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerPause);
|
||||
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
|
||||
assert.equal(backendPaused, true);
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
|
||||
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `partial-reject-${newerResume}`;
|
||||
const successfulId = `${id}-one`;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let successfulBackendPaused = true;
|
||||
let round = 0;
|
||||
let rollbackCalls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: async childId => {
|
||||
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
|
||||
if (round > 0) return { success: true };
|
||||
return new Promise((_, reject) => { rejectResume = reject; });
|
||||
},
|
||||
pauseTransfer: async childId => {
|
||||
rollbackCalls++;
|
||||
if (childId === successfulId) successfulBackendPaused = true;
|
||||
return { success: true };
|
||||
},
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) { round++; await softResumeTransfer(host, id); }
|
||||
rejectResume(new Error("second child IPC rejected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
|
||||
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
|
||||
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
const rejected = getTasks().find(task => task.id === `${id}-two`);
|
||||
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
|
||||
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const resolvedFailure of [false, true]) {
|
||||
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "remote-partial-reject";
|
||||
const runningId = `${id}-one`;
|
||||
const rejectedId = `${id}-two`;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(runningId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => {
|
||||
if (childId === rejectedId) {
|
||||
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
|
||||
throw new Error("sibling resume rejected");
|
||||
}
|
||||
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
|
||||
return { success: false, superseded: true, supersededBy: "resume" };
|
||||
} }));
|
||||
assert.equal((await softResumeTransfer(host, id)).handled, true);
|
||||
assert.equal(getTasks()[0].status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
|
||||
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
|
||||
assert.equal(isTransferPauseLatched(rejectedId), true);
|
||||
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `resolved-verification-${newerResume}`;
|
||||
registerTransferWalk(id);
|
||||
let finish!: (value: { success: boolean; reason: string }) => void;
|
||||
let calls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) await softResumeTransfer(host, id);
|
||||
finish({ success: false, reason: "Could not verify the source file for resume" });
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerResume);
|
||||
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
|
||||
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(id), !newerResume);
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
} from "./transferControlEpoch";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -29,9 +28,6 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
export type TransferControlBridge = {
|
||||
pauseTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: TransferTask["resumeStage"];
|
||||
@@ -43,67 +39,12 @@ export type TransferControlBridge = {
|
||||
}>;
|
||||
resumeTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
lifecycleEpoch?: number;
|
||||
}>;
|
||||
cancelTransfer?: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
|
||||
return result?.superseded === true;
|
||||
}
|
||||
|
||||
type SupersededControlResult = {
|
||||
success?: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
};
|
||||
|
||||
export function reconcileSupersededControls(
|
||||
host: TransferControlHost,
|
||||
taskId: string,
|
||||
childIds: string[],
|
||||
backendIds: string[],
|
||||
results: ReadonlyArray<SupersededControlResult | undefined>,
|
||||
epoch: number,
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
const task = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
|
||||
if (action === "resume") releaseTransferPauseTree(id, descendants);
|
||||
else latchTransferPauseTree(id, descendants);
|
||||
for (const affectedId of [id, ...descendants]) {
|
||||
try {
|
||||
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
|
||||
else globalSftpTransferScheduler.pause(affectedId);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
const decisions = results.map((result) => result?.superseded
|
||||
? result.supersededBy
|
||||
: result?.success ? requestedAction : undefined);
|
||||
// A child-only resume does not establish a folder-wide decision. Require all
|
||||
// relevant children to agree, or an already-authoritative resumed root row.
|
||||
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
|
||||
apply(taskId, childIds, "resume");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
|
||||
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
|
||||
apply(taskId, childIds, "pause");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
|
||||
} else if (task.status === "transferring" && decisions.includes("resume")) {
|
||||
apply(taskId, [], "resume");
|
||||
}
|
||||
decisions.forEach((action, index) => {
|
||||
if (action) apply(backendIds[index], [], action);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
|
||||
function maxBridgeLifecycleEpoch(
|
||||
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
|
||||
@@ -229,23 +170,14 @@ export async function softPauseTransfer(
|
||||
}
|
||||
|
||||
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
// An obsolete pause can be superseded by another pause or cancellation,
|
||||
// not only by resume. Compensate only while the tree still wants to run.
|
||||
const undoObsoletePause = async (id: string) => {
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
let result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
const maxAttempts = task.isDirectory ? 4 : 16;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (wasSuperseded(result)) return result;
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
if (result.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
return { success: false, reason: "Pause superseded by resume" };
|
||||
}
|
||||
@@ -254,9 +186,6 @@ export async function softPauseTransfer(
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
return { success: false, reason: "Pause superseded by newer control" };
|
||||
}
|
||||
result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
}
|
||||
@@ -271,22 +200,17 @@ export async function softPauseTransfer(
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
const result = await pauseOne(id);
|
||||
if (wasSuperseded(result)) return { id, result };
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
const userResumed = !pauseStillCurrent()
|
||||
|| !live
|
||||
|| (live.status !== "paused" && live.status !== "pausing");
|
||||
if (userResumed) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
return { id, result };
|
||||
}));
|
||||
if (!pauseStillCurrent()) return;
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return;
|
||||
}
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!after || after.status === "cancelled") return;
|
||||
if (after.status !== "paused" && after.status !== "pausing") return;
|
||||
@@ -335,10 +259,6 @@ export async function softPauseTransfer(
|
||||
id,
|
||||
result: await pauseOne(id),
|
||||
})));
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return "noop";
|
||||
}
|
||||
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (afterLivePause?.status === "cancelled") {
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
@@ -351,7 +271,7 @@ export async function softPauseTransfer(
|
||||
if (userAlreadyResumed) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -364,7 +284,7 @@ export async function softPauseTransfer(
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -447,9 +367,8 @@ export async function softPauseTransfer(
|
||||
bridgeResults: pauseResults.map((row) => row.result),
|
||||
});
|
||||
for (const id of rollback.bridgeIdsToResume) {
|
||||
await undoObsoletePause(id);
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
|
||||
const hard = pauseResults.find(({ result }) =>
|
||||
result && !result.success && !isBenignPauseMiss(result.reason),
|
||||
)?.result;
|
||||
@@ -509,7 +428,7 @@ export async function softResumeTransfer(
|
||||
: [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
|
||||
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
|
||||
const resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, releaseIds);
|
||||
for (const id of [taskId, ...releaseIds]) {
|
||||
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
|
||||
@@ -519,50 +438,17 @@ export async function softResumeTransfer(
|
||||
try { bridge = host.getBridge(); } catch { bridge = undefined; }
|
||||
|
||||
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
const failedIds = new Set<string>();
|
||||
const results = await Promise.all(resumeIds.map(async (id) => {
|
||||
try {
|
||||
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
|
||||
} catch (error) {
|
||||
// Rejections must pass the same stale-control check as ordinary failures.
|
||||
failedIds.add(id);
|
||||
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
|
||||
}
|
||||
}));
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
|
||||
));
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
|
||||
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
|
||||
if (after?.status === "cancelled") return { handled: true };
|
||||
|
||||
results.forEach((result, index) => {
|
||||
// A missing stream can belong to queued directory work. Verification and
|
||||
// other explicit failures still own a paused stream and must remain held.
|
||||
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
|
||||
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
|
||||
});
|
||||
if (results.some(wasSuperseded)) {
|
||||
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
|
||||
}
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
|
||||
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
|
||||
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
|
||||
if (failedIds.size > 0) {
|
||||
// A partial resume stays visibly active; hold only failed children. Do not
|
||||
// hide successfully running streams behind a paused root barrier.
|
||||
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
|
||||
for (const id of heldIds) {
|
||||
latchTransferPauseTree(id, []);
|
||||
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!effectiveRunning) return { handled: false, reason: failureReason };
|
||||
}
|
||||
if (results.some(wasSuperseded) && failedIds.size === 0) {
|
||||
return { handled: true };
|
||||
}
|
||||
const walkAlive = isTransferWalkInFlight(taskId);
|
||||
// Directory walk can continue after unlatch without bridge resume on every child.
|
||||
// Single-file must not claim success when every bridge resume fails (stuck bar).
|
||||
if (!effectiveRunning) {
|
||||
if (successIds.length === 0) {
|
||||
if (task.isDirectory && walkAlive) {
|
||||
host.setTasks(paintTreeStatus(
|
||||
host.getTasks(),
|
||||
@@ -605,12 +491,6 @@ export async function softResumeTransfer(
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (failedIds.has(candidate.id)) {
|
||||
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
|
||||
}
|
||||
const candidateResult = results[resumeIds.indexOf(candidate.id)];
|
||||
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
|
||||
|
||||
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
|
||||
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
|
||||
if (candidate.id === taskId) {
|
||||
@@ -622,7 +502,7 @@ export async function softResumeTransfer(
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: failureReason,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
|
||||
@@ -41,106 +41,6 @@ const rootTask = (): TransferTask => ({
|
||||
progressMode: "files",
|
||||
});
|
||||
|
||||
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
|
||||
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
|
||||
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
|
||||
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
const pausedTasksRef = { current: new Set<string>() };
|
||||
let pauseCalls = 0;
|
||||
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
|
||||
let finishTransfer!: (value: { error?: string }) => void;
|
||||
let pauseStarted!: () => void;
|
||||
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
|
||||
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
|
||||
let resumeCalls = 0;
|
||||
let childId = "";
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
// Reproduce a stream arming during the parent's initial pause round.
|
||||
bumpTransferControlEpoch(root.id);
|
||||
latchTransferPauseTree(root.id, [childId]);
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
|
||||
return transferGate;
|
||||
},
|
||||
pauseTransfer: () => {
|
||||
pauseCalls++;
|
||||
pauseStarted();
|
||||
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
|
||||
},
|
||||
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<unknown> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
|
||||
pausedTasksRef,
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
await pauseGate;
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
|
||||
// Another window's resumed event updates rows without bumping this local epoch.
|
||||
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
|
||||
finishPause({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
|
||||
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(isTransferPauseLatched(childId), false);
|
||||
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(pausedTasksRef.current.has(childId), false);
|
||||
return;
|
||||
}
|
||||
bumpTransferControlEpoch(root.id);
|
||||
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
|
||||
else {
|
||||
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
|
||||
resetTransferPauseLatchesForTests();
|
||||
}
|
||||
finishPause({ success: true });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
|
||||
} finally {
|
||||
pausedTasksRef.current.clear();
|
||||
resetTransferPauseLatchesForTests();
|
||||
finishPause?.({ success: true });
|
||||
finishTransfer({});
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
resetTransferControlEpochsForTests();
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
@@ -433,33 +432,12 @@ export function useSftpDirectoryTransferOps({
|
||||
)
|
||||
) {
|
||||
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
|
||||
const childEpochAtAttempt = getTransferControlEpoch(task.id);
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
|
||||
if (
|
||||
result?.superseded && result.supersededBy === "resume"
|
||||
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
|
||||
reconcileSupersededControls({
|
||||
getTasks: () => transfersRef.current,
|
||||
setTasks: (next) => setTransfers(next),
|
||||
getBridge: () => netcattyBridge.get(),
|
||||
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
|
||||
epochAtAttempt, "pause", rootTaskId);
|
||||
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
|
||||
pausedTasksRef.current.delete(task.id);
|
||||
break;
|
||||
}
|
||||
// Compensate only if the newest decision still wants this file running.
|
||||
// A new pause or cancellation also changes the epoch.
|
||||
// Resume won while we were awaiting pause — undo.
|
||||
if (
|
||||
result?.success
|
||||
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& !isPauseLatched(rootTaskId, task.id)
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.resumeTransfer?.(task.id);
|
||||
|
||||
@@ -3762,108 +3762,3 @@ test("hard directory resume failure falls back to bounded fresh retry history",
|
||||
assert.equal(retainedParent?.checkpointBytes, 0);
|
||||
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
|
||||
});
|
||||
|
||||
|
||||
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => resumeGate,
|
||||
} } });
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask("held-file", "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: true };
|
||||
});
|
||||
const first = store.resume("held-file");
|
||||
await startGate;
|
||||
await store.pause("held-file");
|
||||
const second = store.resume("held-file");
|
||||
await store.pause("held-file");
|
||||
releaseResume({ success: true, lifecycleEpoch: 2 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
|
||||
assert.equal(row?.lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
for (const outcome of ["rejected", "stream-gone"] as const) {
|
||||
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let resolveResume!: (result: { success: boolean; reason: string }) => void;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let resumeCalls = 0;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
|
||||
resolveResume = resolve;
|
||||
rejectResume = reject;
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => ++resumeCalls === 1
|
||||
? resumeGate
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
|
||||
} } });
|
||||
const id = `held-file-${outcome}`;
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask(id, "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask(id, { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
});
|
||||
const first = store.resume(id);
|
||||
await startGate;
|
||||
await store.pause(id);
|
||||
const second = store.resume(id);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (outcome === "stream-gone") {
|
||||
resolveResume({ success: false, reason: "Transfer is no longer active" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await store.pause(id);
|
||||
if (outcome === "rejected") {
|
||||
rejectResume(new Error("worker channel unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
|
||||
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
|
||||
});
|
||||
}
|
||||
@@ -1757,7 +1757,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
@@ -1779,53 +1779,64 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
|
||||
));
|
||||
const after = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return existing;
|
||||
// Only rejoin when at least one backend stream actually resumed.
|
||||
// Empty/all-fail must not paint transferring over a dead held run.
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
if (successIds.length > 0) {
|
||||
const resumed = new Set(successIds);
|
||||
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
|
||||
// if omitted so main-process progress is not stale-dropped.
|
||||
let bridgeEpoch: number | undefined;
|
||||
for (let index = 0; index < results.length; index += 1) {
|
||||
if (!results[index]?.success) continue;
|
||||
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
|
||||
if (!Number.isFinite(epoch)) continue;
|
||||
bridgeEpoch = bridgeEpoch === undefined
|
||||
? (epoch as number)
|
||||
: Math.max(bridgeEpoch, epoch as number);
|
||||
}
|
||||
tasks = tasks.map((candidate) => {
|
||||
if (candidate.id === taskId || resumed.has(candidate.id)) {
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
lifecycleEpoch: bridgeEpoch,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
|
||||
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
|
||||
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
t.after(() => {
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
let sharedPassword: string | undefined;
|
||||
let clears = 0;
|
||||
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
|
||||
cloudSyncGetSessionPassword: async () => sharedPassword,
|
||||
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
|
||||
} });
|
||||
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
|
||||
const manager = getCloudSyncManager();
|
||||
await manager.setupMasterKey('old-fixture-password');
|
||||
manager.lock();
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
const { useCloudSync } = await import('./useCloudSync');
|
||||
let current!: ReturnType<typeof useCloudSync>;
|
||||
const Probe = () => { current = useCloudSync(); return null; };
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const originalUnlock = EncryptionService.unlockMasterKey;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
try {
|
||||
await renderer.render(<Probe />);
|
||||
await flushEffects();
|
||||
EncryptionService.unlockMasterKey = async (...args) => {
|
||||
started();
|
||||
await gate;
|
||||
return originalUnlock(...args);
|
||||
};
|
||||
sharedPassword = 'old-fixture-password';
|
||||
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
|
||||
await entered;
|
||||
const state = Reflect.get(manager, 'state') as SyncManagerState;
|
||||
state.masterKeyConfig = newConfig;
|
||||
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
|
||||
sharedPassword = 'new-fixture-password';
|
||||
release();
|
||||
const error = await result;
|
||||
assert.equal(clears, 0, 'stale work must not erase the new shared password');
|
||||
assert.equal(sharedPassword, 'new-fixture-password');
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /changed while unlocking/i);
|
||||
} finally {
|
||||
release();
|
||||
EncryptionService.unlockMasterKey = originalUnlock;
|
||||
await renderer.unmount();
|
||||
manager.destroy();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,6 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
@@ -267,14 +266,42 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (!password) return;
|
||||
|
||||
const ok = await manager.unlock(password);
|
||||
if (!ok) {
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
function tree(owner: boolean) {
|
||||
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
|
||||
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
|
||||
React.createElement(Consumer, { key: 'peer' }));
|
||||
}
|
||||
try {
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.ok(pending.length > 0);
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-only']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let cleared = 0;
|
||||
let currentIdentity = 'old-key';
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: Array<[string, string]> = [];
|
||||
const manager = {
|
||||
unlock: async (value: string) => {
|
||||
unlocked.push([currentIdentity, value]);
|
||||
return currentIdentity === 'new-key';
|
||||
},
|
||||
};
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
// The password notification overtakes the config event: the peer is
|
||||
// still locked on the old config when the new password arrives.
|
||||
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
|
||||
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
|
||||
// The config event arrives afterwards; the retained password must
|
||||
// still be available to auto-unlock the rotated key.
|
||||
currentIdentity = 'new-key';
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [
|
||||
['old-key', 'rotation-password'],
|
||||
['new-key', 'rotation-password'],
|
||||
]);
|
||||
assert.equal(cleared, 0);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed auto-unlock does not undo a later deliberate lock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let calls = 0;
|
||||
const manager = { unlock: async () => { calls++; return true; } };
|
||||
let onAvailable: (() => void) | undefined;
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => 'fixture-only',
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
|
||||
};
|
||||
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
|
||||
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1);
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let reads = 0;
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
|
||||
try {
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.ok(reads > 0);
|
||||
assert.deepEqual(unlocked, []);
|
||||
await renderer.render(null);
|
||||
await flushEffects();
|
||||
password = 'fixture-shared-while-absent';
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('remount during an active derivation does not start a duplicate unlock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let finish!: (value: boolean) => void;
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await runWithAct(async () => finish(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(React.StrictMode, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('password notification and a later peer mount share the same attempt', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return false; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = (late: boolean) => React.createElement(React.Fragment, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
|
||||
late ? React.createElement(Consumer, { key: 'late' }) : null);
|
||||
try {
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 0);
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'new consumers share the notification generation');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AutoUnlockManager {
|
||||
unlock(password: string): Promise<boolean>;
|
||||
}
|
||||
interface AutoUnlockBridge {
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
}
|
||||
|
||||
// A renderer can mount several consumers of the same manager. Remember the
|
||||
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
|
||||
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Dedupe attempts per manager (not per hook instance): every consumer of the
|
||||
// same manager observes the same events, and without sharing they would each
|
||||
// run the expensive PBKDF2 derivation inside manager.unlock.
|
||||
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
|
||||
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
|
||||
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Mounted consumers register a retry trigger here so an attempt can be handed
|
||||
// off when its owner unmounts (see the cleanup below).
|
||||
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
|
||||
|
||||
/** Retry a locked peer window when the setting window finishes sharing its key. */
|
||||
export function useCloudSyncAutoUnlock(input: {
|
||||
securityState: string;
|
||||
masterKeyIdentity: string | null;
|
||||
manager: AutoUnlockManager;
|
||||
bridge: AutoUnlockBridge | null | undefined;
|
||||
}) {
|
||||
const { securityState, masterKeyIdentity, manager, bridge } = input;
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
|
||||
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
|
||||
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
|
||||
setRetryRevision(value => value + 1);
|
||||
}), [bridge, manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterKeyIdentity) return;
|
||||
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
|
||||
if (securityState === 'UNLOCKED') {
|
||||
unlockedIdentityByManager.set(manager, masterKeyIdentity);
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
return;
|
||||
}
|
||||
if (securityState !== 'LOCKED') return;
|
||||
// Once this key has been unlocked, a later lock is intentional. A peer
|
||||
// sharing its password must not undo it, regardless of notification order.
|
||||
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
|
||||
// Register before the dedup check: a consumer that only observes an
|
||||
// in-flight attempt must still be reachable for an ownership handoff.
|
||||
let listeners = retryListenersByManager.get(manager);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
retryListenersByManager.set(manager, listeners);
|
||||
}
|
||||
const notifyRetry = () => setRetryRevision(value => value + 1);
|
||||
listeners.add(notifyRetry);
|
||||
const removeListener = () => {
|
||||
listeners.delete(notifyRetry);
|
||||
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
};
|
||||
if (attemptedAttemptByManager.get(manager) === attempt) {
|
||||
return removeListener;
|
||||
}
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
completedAttemptByManager.delete(manager);
|
||||
let handedOff = false;
|
||||
let cancelled = false;
|
||||
let awaitingPassword = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
awaitingPassword = false;
|
||||
if (cancelled || !password) return;
|
||||
// A failed attempt must not clear the shared password: the config and
|
||||
// the password travel over unordered channels (localStorage vs IPC),
|
||||
// so this attempt may have raced a master-key rotation and the
|
||||
// password may match the config that is still in flight. The attempt
|
||||
// is deduped per [identity, revision], so keeping the password lets
|
||||
// the arriving config trigger a retry instead of stranding the vault.
|
||||
await manager.unlock(password);
|
||||
} catch {
|
||||
// Explicit sync actions surface errors; keep auto-unlock silent.
|
||||
} finally {
|
||||
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
completedAttemptByManager.set(manager, attempt);
|
||||
// Availability events can arrive while no consumer is mounted.
|
||||
// A future mount must read again after this attempt has settled.
|
||||
if (listeners.size === 0) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListener();
|
||||
// React StrictMode can immediately remount the same effect.
|
||||
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
handedOff = true;
|
||||
attemptedAttemptByManager.set(manager, null);
|
||||
// Hand the attempt off: this consumer's in-flight password request is
|
||||
// discarded as cancelled, but other mounted consumers already returned
|
||||
// at the dedup check above, so they must be nudged to retry.
|
||||
for (const notify of listeners) notify();
|
||||
}
|
||||
};
|
||||
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Shared SSH connection cancellation audit
|
||||
|
||||
Cancelling a transfer while its isolated SFTP channel is opening calls the
|
||||
bounded-open abort path. Previously that path ended/destroyed the physical SSH
|
||||
transport. Production pooled SFTP sessions normally share a terminal SSH
|
||||
connection, so cancelling one transfer can disconnect terminals, browsing and
|
||||
other transfers. The same teardown happened on the channel-open deadline.
|
||||
|
||||
The real startTransfer/cancelTransfer path reproduces physical end/destroy calls;
|
||||
its old test double lacked those methods and could not detect the problem.
|
||||
|
||||
The repair rejects the abandoned caller promptly, retains the shared transport,
|
||||
closes a channel arriving after abandonment, and blocks further channel-open
|
||||
allocation on that transport while any abandoned request remains unresolved.
|
||||
This bounds retry accumulation without queuing controls behind a transfer.
|
||||
Healthy parallel opens remain allowed; callback settlement or transport closure
|
||||
releases the abandoned-open bookkeeping. Existing active channels remain usable.
|
||||
|
||||
This separately reproduced mechanism is relevant to the connection-loss themes
|
||||
in #2973 and #2832, but does not establish the cause of their VPN/jump-host reports.
|
||||
@@ -1,248 +0,0 @@
|
||||
# SFTP transfer audit (September 2026)
|
||||
|
||||
Baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
|
||||
Status: scoped audit completed, with five independently reproduced defects
|
||||
fixed and submitted. This ledger distinguishes verified paths from reporter
|
||||
environments unavailable to this audit.
|
||||
|
||||
## Completion requirements
|
||||
|
||||
Review the global transfer center, live and restored execution, admission and
|
||||
connection ownership, pause/resume/cancel ordering, durable checkpoints, source
|
||||
and destination integrity, folder replacement/traversal, history retention,
|
||||
transfer responsiveness, and architectural duplication. Cross-reference actual
|
||||
issue reports and mature open-source clients. Reproduce material findings before
|
||||
fixing them. Submit verified fixes as PRs and retain evidence for unresolved
|
||||
reporter-specific conditions.
|
||||
|
||||
## Confirmed finding: stale transfer control can defeat a newer pause
|
||||
|
||||
Priority: high. The user can pause a transfer and have an older request silently
|
||||
restart it, or see the state return to transferring while the pause latch remains
|
||||
set. File transfers and folder children share the affected control path.
|
||||
|
||||
Reproductions (each failed on the baseline before the implementation change):
|
||||
|
||||
- Two overlapping pauses: the first reply incorrectly compensates by calling
|
||||
resume, even though the second pause is still intended.
|
||||
- Resume reply delayed until after a newer pause: old success overwrites the row
|
||||
and its lifecycle epoch.
|
||||
- Dedicated single-file recovery held for the stream lifetime: its duplicated
|
||||
soft-resume implementation independently has the same stale reply problem.
|
||||
- Pipelined upload: resume waits for outstanding writes, receives a newer pause,
|
||||
then resumes anyway when draining finishes.
|
||||
- Worker reply fan-out: old successful resume, failed pause, or rejected pause
|
||||
emits a new resumed event after a later pause succeeded.
|
||||
|
||||
Fix contract: the newest pause/cancel decision wins over older replies; no late
|
||||
result can revive a terminal task. Keep checkpoints and source verification.
|
||||
Do not serialize controls behind a stream lifetime. Reuse the shared soft-resume
|
||||
path for held single-file recovery instead of maintaining another state writer.
|
||||
|
||||
Verification so far:
|
||||
|
||||
- Original selected SFTP suite: 747 passed.
|
||||
- Initial fixes with the three new regressions: 750 passed.
|
||||
- Store and control suite after dedicated-path consolidation: 107 passed.
|
||||
- Worker fan-out suite including three out-of-order cases: 9 passed.
|
||||
- Loopback SSH/SFTP: 12 files of 128 MiB, two file jobs concurrently, each starts
|
||||
from a 32 MiB saved checkpoint and pauses/resumes during the remaining download.
|
||||
All final SHA-256 digests match. Pause acknowledgements 6-15 ms, resume checks
|
||||
1391-1493 ms. These are fixture measurements, not WAN throughput claims.
|
||||
- Independent review found another held-run failure path: rejection or a dead
|
||||
stream response followed by a newer pause during wind-down could start a fresh
|
||||
resume. Two regressions reproduced it; shared rejection handling and epoch
|
||||
checks after wind-down now pass (109 store/control tests).
|
||||
- First full suite: 11377 passed, 7 plugin archive failures traced to missing
|
||||
worktree-local nested dependency (yauzl 3.x). Reinstalled with npm ci; all 29
|
||||
plugin CLI tests then pass. Full rerun passed: 11386 passed, 0 failed,
|
||||
18 skipped.
|
||||
- GitHub review found a cross-window gap: the obsolete worker result was a hard
|
||||
failure to a renderer whose local control epoch had not changed. Explicit
|
||||
superseded outcomes now bypass rollback, compensation and dedicated recovery,
|
||||
including rejected worker requests. Updated focused suite: 122 passed.
|
||||
- Production build and lint pass after that follow-up; two independent reviewers
|
||||
found no actionable follow-up issues.
|
||||
- Browser fixture exercised the actual transfer-center component/store with
|
||||
simulated transport: pause all, resume all, individual pause, paused filter,
|
||||
and cancel. State/buttons matched; no browser console errors. This is not a
|
||||
full Electron connection-path test.
|
||||
- Separate real loopback SSH/SFTP audit experiments killed the transferring child
|
||||
process with SIGKILL and resumed the saved checkpoint in a fresh child process.
|
||||
Both 32 MiB download and upload completed with matching SHA-256. Persisted
|
||||
checkpoints were 30670848 and 14024704 bytes respectively. This validates the
|
||||
transfer engine and disk staging across process death, not the renderer's
|
||||
automatic history restoration or a real VPN/jump-host environment.
|
||||
|
||||
## Submitted fixes
|
||||
|
||||
| PR | Confirmed failure | Architectural change |
|
||||
| --- | --- | --- |
|
||||
| [3284](https://github.com/binaricat/Netcatty/pull/3284) | Late pause/resume replies revive a newer paused/cancelled task or overwrite its visible state, including cross-window and folder watcher paths | Shared held-file resume handling; obsolete controls carry the winning action to reconcile local barriers; compensation checks current intent rather than assuming every epoch change means resume. |
|
||||
| [3285](https://github.com/binaricat/Netcatty/pull/3285) | Deleted recorded host or duplicate legacy display names can resume an upload against another saved server | Exact host-ID recovery; unique-match-only legacy resolution; preserve live-session recovery. |
|
||||
| [3286](https://github.com/binaricat/Netcatty/pull/3286) | Publication/restoration can overwrite a concurrently saved local file; rollback can delete a replacement | One exclusive publication helper, a clear commit boundary, preserved recovery artifacts on conflicts or incomplete fallback copying. |
|
||||
| [3287](https://github.com/binaricat/Netcatty/pull/3287) | Folder stays active after child completion was compacted out of visible history | One bounded settlement observer/helper for both live transfer and recovery; no persistent tombstone history. |
|
||||
| [3288](https://github.com/binaricat/Netcatty/pull/3288) | Cancelling or timing out channel initialization disconnects shared SSH users | Channel cancellation is separated from shared-transport ownership; abandoned initialization is bounded until settlement. |
|
||||
|
||||
Additional engine experiments used actual loopback SSH/SFTP, killed the child
|
||||
process, and resumed in a fresh process for remote-to-remote transfers. Both the
|
||||
download phase and upload phase passed final 32 MiB SHA-256 comparison. The
|
||||
upload-phase checkpoint was 16777216 bytes. These complement direct upload and
|
||||
download recovery, not full-app history restoration or reporter confirmation.
|
||||
|
||||
## Historical issue evidence fetched in this audit
|
||||
|
||||
| Issue | Reported condition | Audit treatment |
|
||||
| --- | --- | --- |
|
||||
| [3213](https://github.com/binaricat/Netcatty/issues/3213) | macOS, 10+ files of 100-200 MB; pause/resume and force-quit recovery unreliable | Control ordering reproduced separately; source direction and server still absent from report. Do not claim reporter confirmation. |
|
||||
| [3155](https://github.com/binaricat/Netcatty/issues/3155) | Windows, many-file transfer freezes; no count or logs | Recheck bounded discovery, scheduling, publication and history work. |
|
||||
| [2973](https://github.com/binaricat/Netcatty/issues/2973) | VPN uploads disconnect SSH and SFTP; transfer spinner continues; later inode VPN report | Check transport loss and settlement. Network/security cause not established by the available logs. |
|
||||
| [3186](https://github.com/binaricat/Netcatty/issues/3186) | Replacement changes permissions on 1.1.82 | Check mode/owner behavior on each replacement path; existing bot explanations are not proof. |
|
||||
| [3149](https://github.com/binaricat/Netcatty/issues/3149) | Windows proxy + terminal drag-upload reports No such file | Check target pinning, path encoding, session and retry behavior. |
|
||||
| [2832](https://github.com/binaricat/Netcatty/issues/2832) | Browsing works through VPN/jump host, transfers wait indefinitely, cancel works | Check dedicated connection admission/authentication/timeout. |
|
||||
| [2568](https://github.com/binaricat/Netcatty/issues/2568) | Folder copy reaches 100% but remains active; pause ineffective | Check parent settlement and directory checkpoints. |
|
||||
| [2458](https://github.com/binaricat/Netcatty/issues/2458) | Windows 1 GiB upload continues after Pause/Pause all from both terminal sidebar and SFTP tab | Motivates transport-plus-visible-state regressions; current delayed-control defects independently reproduced. |
|
||||
| [3031](https://github.com/binaricat/Netcatty/issues/3031) | macOS jump-host/proxy drag upload: no such file | Missing full error, protocol, target path and direct-connect comparison prevent attribution. |
|
||||
| [2556](https://github.com/binaricat/Netcatty/issues/2556) | Windows download of 1.9 GiB from local Linux VM; separate many-small-files progress complaint | Preserve verification correctness; distinguish network payload from verification and incremental discovery. No comparative reporter throughput available. |
|
||||
| [2886](https://github.com/binaricat/Netcatty/issues/2886) | sudo terminal drop denied while SFTP upload succeeds | Existing terminal fallback fix is separate; contradictory bot explanations are not evidence of identity or permission correctness. |
|
||||
| [2638](https://github.com/binaricat/Netcatty/issues/2638) | Recovery after network failure | Verify restore end to end; UI availability alone is insufficient. |
|
||||
|
||||
Issue state (open/closed) and automated comments do not substitute for runtime
|
||||
proof. Initial title search hit its 100-result cap. Expanded SFTP search returned
|
||||
308 issue matches, including reports without SFTP in the title. The reports
|
||||
listed above were read as representative symptom clusters; this was not a claim
|
||||
to have investigated all 308 matching issues individually.
|
||||
|
||||
## External reference points
|
||||
|
||||
- [Tabby SFTP implementation](https://github.com/Eugeny/tabby/blob/master/tabby-ssh/src/session/sftp.ts):
|
||||
stream transfer and temporary upload destination before rename. Useful as a
|
||||
separation-of-concerns comparison; this file does not establish durable
|
||||
restart recovery and must not be treated as a complete replacement design.
|
||||
- [WinSCP resume documentation](https://winscp.net/eng/docs/resume): partial-file
|
||||
discovery and temporary filenames support interruption recovery; temporary
|
||||
creation can be unavailable under some permission layouts.
|
||||
- [Electerm transfer implementation](https://github.com/electerm/electerm/blob/master/src/app/server/transfer.js):
|
||||
separates a per-transfer object from queue/UI state, opens a separate SFTP
|
||||
channel on the existing SSH connection when available, and uses 32 KiB chunks
|
||||
with 64 requests. Its live pause flag stops scheduling additional reads. Its
|
||||
ordinary transfer opens the destination with `w`; it is not evidence for
|
||||
durable checkpoint recovery. Retain Netcatty's staging and contiguous-offset
|
||||
protections when simplifying ownership.
|
||||
- [Electerm action store](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transports-action-store.jsx)
|
||||
counts pending initializations toward admission; its
|
||||
[mutation queue](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transfer-queue.jsx)
|
||||
distinguishes completion of a state update from completion of transfer I/O.
|
||||
This supports keeping control requests independent of long-lived transfer runs.
|
||||
|
||||
## Coverage and practical limits
|
||||
|
||||
| Area | Evidence and result |
|
||||
| --- | --- |
|
||||
| Transfer controls and global center | Actual component/store browser interactions; delayed replies, direct/worker, folder watcher and cross-window action regressions. PR 3284 fixes confirmed ordering failures, including root-state reconciliation and failed resume settlement. |
|
||||
| Upload/download and remote-to-remote durability | Four real SSH/SFTP fresh-process recovery experiments, including both remote-to-remote phases, all compare final bytes by SHA-256. Existing transfer tests cover changed source, sparse ranges, cancellation and publication. |
|
||||
| Local/SCP publication | Real filesystem conflict regressions and SCP abort tests; one shared no-overwrite publication helper. Copy fallback retains mode/timestamps and recovery files on failure. Actual FAT/exFAT hardware was unavailable. |
|
||||
| Folder traversal and final state | Discovery concurrency, replacement/rollback, manifest, pause latch, skip/conflict and history tests; actual live/recovery entrypoints reproduce compacted-child hang and pass after PR 3287. |
|
||||
| History, restart and ownership | Store history/large-manifest and dedicated recovery tests; PR 3285 rejects missing or ambiguous saved host identity. Full Electron quit/relaunch with restored user credentials was not exercised; engine-process recovery was. Editing endpoint details under an unchanged saved host ID remains a documented identity-model limitation. |
|
||||
| Connection sharing and cleanup | Connection pool, lease and initialization tests; real delayed SFTP OPEN followed by cancellation leaves browsing alive and closes the late channel after PR 3288. VPN/MFA/security-product and original jump-host environments were unavailable. |
|
||||
| Responsiveness | Actual browser controls, list virtualization, bounded directory discovery and 50k history cases; scheduler smoke figures below are diagnostic, not end-user throughput or Windows runtime proof. |
|
||||
|
||||
No additional data-loss defect was established by these checks. User reports of
|
||||
VPN-specific hangs, proxy drag-upload path errors and Windows throughput/freezes
|
||||
still require their original environments and discriminating logs. They are not
|
||||
marked resolved solely because a related mechanism was repaired here.
|
||||
|
||||
## Validation summary
|
||||
|
||||
Each fix received two independent local reviews. Review-discovered gaps were
|
||||
corrected and checked again. PR 3284 latest focused control/worker tests: 35 pass;
|
||||
two actual direct-resume tests include the full undrained timeout and pass.
|
||||
PR 3285 full suite: 11,379 pass, 18 skip. PR 3286 full suite before its metadata
|
||||
follow-up: 11,386 pass, 18 skip; latest publication/abort tests: 34 pass.
|
||||
PR 3287 serial full suite: 11,383 pass, 18 skip; production build passes.
|
||||
PR 3288 real SSH cancellation experiment and nine channel tests pass; its full
|
||||
suite has 11,380 passes and one unrelated terminal write-queue timing failure,
|
||||
independently reproduced on the unchanged baseline. That entire test file passes
|
||||
alone (81 tests). A separate integration worktree combines all five branches;
|
||||
one test-only insertion conflict was resolved by retaining both regressions.
|
||||
The folder test was subsequently relocated in its own PR; a fresh merge-tree
|
||||
check confirms it combines with the other control tests without a conflict.
|
||||
|
||||
## Architectural assessment
|
||||
|
||||
The staged-file and contiguous-checkpoint design is worth retaining. A simpler
|
||||
client that writes directly into the destination is not an equivalent safety
|
||||
reference. The most important simplification is ownership: a long-lived stream
|
||||
must not block its own control requests, and one authoritative lifecycle must
|
||||
inform every window. PR 3284 removes a duplicate state-writing recovery path
|
||||
and makes obsolete controls explicit.
|
||||
|
||||
Publication is a second useful module boundary. PR 3286 replaces repeated
|
||||
check/rename/check/rollback branches with one no-overwrite operation shared by
|
||||
publication and restoration. On hardlink-capable filesystems its commit is
|
||||
atomic; the documented exclusive-copy fallback trades atomic visibility for
|
||||
compatibility while preserving recoverable data and cancellation.
|
||||
|
||||
The current transfer list virtualizes beyond 20 visible tasks, directory listing
|
||||
has a tree-wide concurrency gate, and history migration yields cooperatively.
|
||||
A scheduler-only smoke workload of 1000 and 10000 immediately completing jobs
|
||||
finished in 87 ms and 4362 ms, with maximum timer gaps of 15 ms and 111 ms during
|
||||
other validation activity. The queue still scans for eligibility/priority/fairness;
|
||||
this is a follow-up performance lead, not proof of the Windows freeze reports or
|
||||
a standalone throughput benchmark. Folder fan-out bounds ordinary queue growth.
|
||||
|
||||
Do not weaken full saved-prefix verification merely to improve resume timings.
|
||||
The loopback experiments include extra verification reads; those bytes are not
|
||||
payload throughput. A future optimization needs proof that changed sources and
|
||||
out-of-order durable ranges still cannot produce mixed file contents.
|
||||
|
||||
PR 3287 makes final transfer ownership independent of visible history retention:
|
||||
observers capture terminal settlement before compaction and are disposed after
|
||||
the waiting invocation exits. PR 3288 keeps per-channel cancellation from
|
||||
claiming ownership of a shared SSH connection. These targeted boundaries reduce
|
||||
duplicated policy without replacing the working durable-transfer engine.
|
||||
|
||||
Combined-engine live checks also passed: shared-connection cancellation retains
|
||||
working browsing and closes the late channel; a 32 MiB download killed at a
|
||||
16809984-byte checkpoint resumes in a new process to the expected SHA-256. An
|
||||
initial 8 MiB fixture completed before an intermediate checkpoint was sampled;
|
||||
the larger paced fixture supplies the intended process-death evidence.
|
||||
|
||||
## Final combined validation
|
||||
|
||||
The combined full suite passed: 11,439 passed, 0 failed, 18 skipped. Lint and
|
||||
production build passed. This run includes all five fixes and the failed-child
|
||||
admission follow-up. A final control-aggregation follow-up then corrected mixed
|
||||
success/superseded resume outcomes and current IPC rejection handling; both
|
||||
independent reviewers approved it. After bringing that follow-up and the
|
||||
test-only relocation into the integration checkout, all 202 affected control,
|
||||
store, folder, observation, recovery and worker tests passed. Final lint/build
|
||||
are recorded in the PR descriptions. No production changes were made in the
|
||||
integration checkout; the only merge edits preserve independent regression tests.
|
||||
|
||||
The initial combined run stopped after an older exact-result assertion rejected
|
||||
the newly structured superseded response and left its fixture alive. The
|
||||
assertion was updated, its fixture completed, and the successful full run above
|
||||
supersedes that aborted run. Tests were not removed or weakened.
|
||||
|
||||
## Delivery follow-ups
|
||||
|
||||
The remote publication branch received an automated patch during final review.
|
||||
It was fetched and checked rather than assuming earlier validation covered it.
|
||||
Two actual filesystem regressions showed lost normal mtime preservation and a
|
||||
remaining final-path stamping race. The repair prepares staged timestamps before
|
||||
publication and restrictive chmod, skips post-commit stamping of that pathname,
|
||||
and uses a verified file handle on non-promoted local paths. Fallback close
|
||||
errors also retain recovery artifacts. Publication/bridge/SCP checks: 31 pass;
|
||||
independent targeted review: 10 pass.
|
||||
|
||||
Fresh dedicated recovery now recognizes only the exact unchanged paused child
|
||||
rows captured at its entry as old pauses eligible to resume. A newer row,
|
||||
pausing state, root/child latch or cancellation still blocks admission. This
|
||||
avoids a 4096-row batching deadlock without removing later pause protection.
|
||||
Actual batched paused-history and later-pause regressions pass; 163 relevant
|
||||
tests and an independent 60-test review pass.
|
||||
|
||||
The delivery rerun includes all follow-ups above: 11,452 tests passed, 0 failed,
|
||||
18 skipped (182.9 seconds, four test processes). This is the final combined
|
||||
full-suite result and supersedes the earlier intermediate counts.
|
||||
@@ -1,30 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
// 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 { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
|
||||
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -48,12 +24,6 @@ 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(
|
||||
@@ -63,15 +33,13 @@ 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, { abandon = false } = {}) => {
|
||||
const finish = (error, channel = null, { invalidateTransport = false } = {}) => {
|
||||
if (settled) {
|
||||
closeSftpChannel(channel);
|
||||
return false;
|
||||
@@ -79,13 +47,13 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error && channel) closeSftpChannel(channel);
|
||||
if (abandon && requestPending) abandonOpen(sshClient, token);
|
||||
if (invalidateTransport) invalidateSshTransport(sshClient);
|
||||
if (error) reject(error);
|
||||
else resolve(channel);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
|
||||
abandon: true,
|
||||
invalidateTransport: true,
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -98,20 +66,15 @@ 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, { abandon: true });
|
||||
finish(error, null, { invalidateTransport: true });
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
requestPending = true;
|
||||
sshClient.sftp((error, channel) => {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
if (error) finish(error, channel || null);
|
||||
else finish(null, channel || null);
|
||||
});
|
||||
} catch (error) {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test("bounded SFTP open times out and closes a late channel", async () => {
|
||||
assert.equal(timers.active.size, 1);
|
||||
assert.equal([...timers.active][0].hasRef(), true);
|
||||
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
|
||||
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(invalidations, 1);
|
||||
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, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(timers.active.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
|
||||
@@ -82,44 +82,28 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
|
||||
assert.equal(timers.active.size, 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");
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
|
||||
@@ -147,37 +131,3 @@ 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;
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
"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 };
|
||||
@@ -77,41 +77,7 @@ function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
|
||||
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
|
||||
}
|
||||
|
||||
function buildExtendedForegroundPalette(htmlContent) {
|
||||
const colors = new Set(Array.from(
|
||||
htmlContent.matchAll(/var\(--term-custom-([a-f0-9]{6}), #[a-f0-9]{6}\)/g),
|
||||
(match) => match[1],
|
||||
));
|
||||
const light = [];
|
||||
const dark = [];
|
||||
for (const hex of colors) {
|
||||
const channels = hex.match(/../g).map((channel) => parseInt(channel, 16));
|
||||
const linearChannels = channels.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
const luminance = linearChannels[0] * 0.2126 + linearChannels[1] * 0.7152 + linearChannels[2] * 0.0722;
|
||||
const maxLuminance = 1.05 / 4.5 - 0.05;
|
||||
let adjusted = channels;
|
||||
// A single linear-light scaling reaches the contrast target without an
|
||||
// iterative search for every color on each streaming snapshot.
|
||||
if (luminance > maxLuminance) {
|
||||
const scale = maxLuminance / luminance;
|
||||
adjusted = linearChannels.map((channel) => {
|
||||
const value = channel * scale;
|
||||
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
|
||||
return Math.floor(srgb * 255);
|
||||
});
|
||||
}
|
||||
const lightHex = adjusted.map((channel) => channel.toString(16).padStart(2, "0")).join("");
|
||||
light.push(`--term-custom-${hex}: #${lightHex};`);
|
||||
dark.push(`--term-custom-${hex}: #${hex};`);
|
||||
}
|
||||
return { light: light.join("\n "), dark: dark.join("\n ") };
|
||||
}
|
||||
|
||||
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
const extendedPalette = buildExtendedForegroundPalette(htmlContent);
|
||||
const dateStr = new Date(timestamp).toLocaleString();
|
||||
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
|
||||
const safeDateStr = escapeHtml(dateStr);
|
||||
@@ -120,76 +86,23 @@ function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Session Log - ${safeHostLabel}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* Day palette (default): readable on light browser/OS themes. */
|
||||
--term-default-bg: #ffffff;
|
||||
--term-default-fg: #24292f;
|
||||
--term-header-fg: #6a737d;
|
||||
--term-border: #d0d7de;
|
||||
/* ANSI palette (basic 0-7, bright 8-15) tuned for the light background. */
|
||||
--ansi-0: #000000;
|
||||
--ansi-1: #cd3131;
|
||||
--ansi-2: #008000;
|
||||
--ansi-3: #747700;
|
||||
--ansi-4: #0451a5;
|
||||
--ansi-5: #bc05bc;
|
||||
--ansi-6: #007c9b;
|
||||
--ansi-7: #555555;
|
||||
--ansi-8: #666666;
|
||||
--ansi-9: #cd3131;
|
||||
--ansi-10: #008500;
|
||||
--ansi-11: #797600;
|
||||
--ansi-12: #0451a5;
|
||||
--ansi-13: #bc05bc;
|
||||
--ansi-14: #007c9b;
|
||||
--ansi-15: #767676;
|
||||
${extendedPalette.light}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--term-default-bg: #1e1e1e;
|
||||
--term-default-fg: #d4d4d4;
|
||||
--term-header-fg: #888888;
|
||||
--term-border: #444444;
|
||||
--ansi-0: #000000;
|
||||
--ansi-1: #cd3131;
|
||||
--ansi-2: #0dbc79;
|
||||
--ansi-3: #e5e510;
|
||||
--ansi-4: #2472c8;
|
||||
--ansi-5: #bc3fbc;
|
||||
--ansi-6: #11a8cd;
|
||||
--ansi-7: #e5e5e5;
|
||||
--ansi-8: #666666;
|
||||
--ansi-9: #f14c4c;
|
||||
--ansi-10: #23d18b;
|
||||
--ansi-11: #f5f543;
|
||||
--ansi-12: #3b8eea;
|
||||
--ansi-13: #d670d6;
|
||||
--ansi-14: #29b8db;
|
||||
--ansi-15: #ffffff;
|
||||
${extendedPalette.dark}
|
||||
}
|
||||
}
|
||||
body {
|
||||
background: var(--term-default-bg);
|
||||
color: var(--term-default-fg);
|
||||
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono',
|
||||
Consolas, Monaco, Menlo, monospace;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Monaco, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
padding: 20px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 1px solid var(--term-border);
|
||||
border-bottom: 1px solid #444;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--term-header-fg);
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -919,45 +919,3 @@ test("manual session log rejects renderer-supplied filePath without selection to
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("html wrapper adapts to light/dark browser color scheme", () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const html = bridge.wrapTerminalHtmlContent("hello", "host-1", 0);
|
||||
// Day palette is the default; dark applies only via prefers-color-scheme.
|
||||
assert.match(html, /--term-default-bg:\s*#ffffff/);
|
||||
assert.match(html, /color-scheme:\s*light dark/);
|
||||
assert.match(html, /<meta name="color-scheme" content="light dark">/);
|
||||
assert.match(html, /@media \(prefers-color-scheme: dark\)/);
|
||||
assert.match(html, /--term-default-bg:\s*#1e1e1e/);
|
||||
// Fonts remain monospace with a platform-appropriate stack.
|
||||
assert.match(html, /font-family:[^;]*ui-monospace[^;]*monospace/);
|
||||
});
|
||||
|
||||
test("all light-mode ANSI foregrounds remain readable on the default background", () => {
|
||||
const { terminalDataToHtml } = loadBridgeWithDialog({});
|
||||
let data = "";
|
||||
for (let index = 0; index < 256; index++) data += `\x1b[38;5;${index}mcolor ${index}\x1b[0m\n`;
|
||||
data += "\x1b[38;2;255;255;255mwhite\x1b[0m\n\x1b[38;2;255;255;0myellow\x1b[0m\n\x1b[38;2;18;52;86mdark\x1b[0m";
|
||||
const html = terminalDataToHtml(data, "host", 0);
|
||||
const [lightCss, darkCss] = html.split("@media");
|
||||
const luminance = (hex) => {
|
||||
const channels = hex.match(/[a-f0-9]{2}/gi).map((channel) => {
|
||||
const value = parseInt(channel, 16) / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
const background = luminance(lightCss.match(/--term-default-bg:\s*(#[a-f0-9]{6})/i)[1]);
|
||||
const palette = [...lightCss.matchAll(/--(ansi-\d+|term-custom-[a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)];
|
||||
assert.equal(palette.length, 257);
|
||||
for (const [, index, color] of palette) {
|
||||
const foreground = luminance(color);
|
||||
const contrast = (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
|
||||
assert.ok(contrast >= 4.5, `ANSI ${index} (${color}) contrast ${contrast.toFixed(2)} is below 4.5:1`);
|
||||
}
|
||||
for (const [, hex, color] of lightCss.matchAll(/--term-custom-([a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)) {
|
||||
assert.ok(darkCss.includes(`--term-custom-${hex}: #${hex};`));
|
||||
if (1.05 / (luminance(hex) + 0.05) >= 4.5) assert.equal(color, `#${hex}`);
|
||||
}
|
||||
assert.match(html, /color: var\(--term-custom-ffffff, #ffffff\)[^>]*>white/);
|
||||
});
|
||||
@@ -755,10 +755,11 @@ 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 keeps the committed download when cancellation arrives after publication", async (t) => {
|
||||
it("downloadSftpToLocal rolls back a published file when cancellation wins the final rename", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
@@ -777,27 +778,26 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
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();
|
||||
}
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) controller.abort();
|
||||
};
|
||||
t.after(() => { fs.promises.link = originalLink; });
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
await assert.doesNotReject(
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-final-rename-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(published, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
assert.equal(renameCalls >= 4, true, "the published file should be rolled back to the backup");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
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;
|
||||
const originalLink = fs.promises.link;
|
||||
let renameCalls = 0;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
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();
|
||||
};
|
||||
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; });
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
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, /Recovery files preserved/);
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Backup:/);
|
||||
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
||||
return true;
|
||||
@@ -877,6 +877,11 @@ 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) {
|
||||
@@ -884,12 +889,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
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; });
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
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, /Recovery files preserved/);
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
return true;
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
*/
|
||||
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
// ANSI palette colors are emitted as CSS variables (with hex fallbacks) so the
|
||||
// wrapping HTML page can provide light and dark palettes. Indexes 0-7 are the
|
||||
// basic colors, 8-15 the bright colors.
|
||||
const DEFAULT_FOREGROUND = "#d4d4d4";
|
||||
const DEFAULT_BACKGROUND = "#1e1e1e";
|
||||
const BASIC_COLORS = [
|
||||
@@ -38,11 +35,6 @@ const BRIGHT_COLORS = [
|
||||
"#ffffff",
|
||||
];
|
||||
|
||||
function ansiColorVar(index) {
|
||||
const fallback = index < 8 ? BASIC_COLORS[index] : BRIGHT_COLORS[index - 8];
|
||||
return `var(--ansi-${index}, ${fallback})`;
|
||||
}
|
||||
|
||||
class TerminalTextRenderer {
|
||||
constructor(options = {}) {
|
||||
this.lines = [[]];
|
||||
@@ -315,17 +307,17 @@ class TerminalTextRenderer {
|
||||
} else if (code === 27) {
|
||||
this.style.inverse = false;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
this.style.fg = ansiColorVar(code - 30);
|
||||
this.style.fg = BASIC_COLORS[code - 30];
|
||||
} else if (code === 39) {
|
||||
this.style.fg = null;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
this.style.bg = ansiColorVar(code - 40);
|
||||
this.style.bg = BASIC_COLORS[code - 40];
|
||||
} else if (code === 49) {
|
||||
this.style.bg = null;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
this.style.fg = ansiColorVar(8 + code - 90);
|
||||
this.style.fg = BRIGHT_COLORS[code - 90];
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
this.style.bg = ansiColorVar(8 + code - 100);
|
||||
this.style.bg = BRIGHT_COLORS[code - 100];
|
||||
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
|
||||
const color = colorFromAnsi256(codes[i + 2]);
|
||||
if (color) {
|
||||
@@ -593,23 +585,8 @@ function getTrimmedLineLength(line) {
|
||||
function styleToCss(style) {
|
||||
if (!style) return "";
|
||||
const declarations = [];
|
||||
let fg = style.inverse
|
||||
? (style.bg || `var(--term-default-bg, ${DEFAULT_BACKGROUND})`)
|
||||
: style.fg;
|
||||
let bg = style.inverse
|
||||
? (style.fg || `var(--term-default-fg, ${DEFAULT_FOREGROUND})`)
|
||||
: style.bg;
|
||||
// Theme the normal page foreground, but preserve the original color pair
|
||||
// when output supplies a background (including a foreground inverted into
|
||||
// one). Darkening foregrounds independently can make these runs unreadable.
|
||||
if (style.bg || (style.inverse && style.fg)) {
|
||||
const originalColor = (color) => color?.replace(/var\(--[\w-]+, (#[a-f0-9]{6})\)/g, "$1");
|
||||
fg = originalColor(fg) || DEFAULT_FOREGROUND;
|
||||
bg = originalColor(bg);
|
||||
} else if (fg?.startsWith("#")) {
|
||||
// The wrapper supplies a readable light variant for extended/true colors.
|
||||
fg = `var(--term-custom-${fg.slice(1)}, ${fg})`;
|
||||
}
|
||||
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
|
||||
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
|
||||
if (fg) declarations.push(`color: ${fg}`);
|
||||
if (bg) declarations.push(`background-color: ${bg}`);
|
||||
if (style.bold) declarations.push("font-weight: 700");
|
||||
@@ -631,7 +608,8 @@ function stylesEqual(a, b) {
|
||||
|
||||
function colorFromAnsi256(value) {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
||||
if (value < 16) return ansiColorVar(value);
|
||||
if (value < 8) return BASIC_COLORS[value];
|
||||
if (value < 16) return BRIGHT_COLORS[value - 8];
|
||||
if (value < 232) {
|
||||
const n = value - 16;
|
||||
const r = Math.floor(n / 36);
|
||||
|
||||
@@ -214,8 +214,8 @@ test("RIS resets SGR so post-reset text is not colored from pre-entry style", ()
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /shell-after/);
|
||||
// Pre-entry red SGR must not wrap post-RIS shell text.
|
||||
assert.equal(/color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>shell-after/.test(html), false);
|
||||
assert.match(html, /color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>before/);
|
||||
assert.equal(/color:\s*#cd3131[^"]*"[^>]*>shell-after/.test(html), false);
|
||||
assert.match(html, /color:\s*#cd3131[^"]*"[^>]*>before/);
|
||||
});
|
||||
|
||||
test("RIS rebases screen so later cursor-home does not overwrite history", () => {
|
||||
@@ -248,43 +248,3 @@ test("split C1 CSI alternate-screen enter still omits TUI paint", () => {
|
||||
renderer.feed("\x9b?1049l$ done\n");
|
||||
assert.equal(renderer.finish(), "before\n$ done");
|
||||
});
|
||||
|
||||
test("SGR colors are emitted as themeable CSS variables with hex fallbacks", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[31mred \x1b[91mbright-red \x1b[44mbg-blue \x1b[0mplain\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
// Basic / bright / background colors map to --ansi-N variables so the
|
||||
// wrapping HTML page can swap palettes for light and dark themes.
|
||||
assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>red/);
|
||||
assert.match(html, /color:\s*var\(--ansi-9, #f14c4c\)[^"]*"[^>]*>bright-red/);
|
||||
assert.match(html, /color:\s*#f14c4c; background-color:\s*#2472c8/);
|
||||
});
|
||||
|
||||
test("256-color base and cube foregrounds use themeable variables", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[38;5;1mbase\x1b[0m \x1b[38;5;208mcube\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>base/);
|
||||
assert.match(html, /color:\s*var\(--term-custom-ff8700, #ff8700\)[^"]*"[^>]*>cube/);
|
||||
});
|
||||
|
||||
test("inverse text falls back to themeable default background/foreground", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[7minversed\x1b[0m\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(
|
||||
html,
|
||||
/color:\s*var\(--term-default-bg, #1e1e1e\); background-color:\s*var\(--term-default-fg, #d4d4d4\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit backgrounds preserve original foreground/background pairs across themes", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[37;41mERROR\x1b[0m \x1b[40mTEXT\x1b[0m \x1b[38;5;15;48;5;1mindexed\x1b[0m \x1b[31;7minverse\x1b[0m \x1b[48;2;0;0;0mRGB background\x1b[0m");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /color: #e5e5e5; background-color: #cd3131[^>]*>ERROR/);
|
||||
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>TEXT/);
|
||||
assert.match(html, /color: #ffffff; background-color: #cd3131[^>]*>indexed/);
|
||||
assert.match(html, /color: #1e1e1e; background-color: #cd3131[^>]*>inverse/);
|
||||
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>RGB background/);
|
||||
});
|
||||
+227
-243
@@ -950,151 +950,219 @@ function stableLocalFileIdentity(statLike) {
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
|
||||
const assertNotCancelled = options.assertNotCancelled || (() => {});
|
||||
const assertNotCancelled = typeof options.assertNotCancelled === "function"
|
||||
? options.assertNotCancelled
|
||||
: () => {};
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
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`;
|
||||
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;
|
||||
let backedUp = false;
|
||||
let committed = false;
|
||||
let keepRecoveryFiles = false;
|
||||
let preparedHandle;
|
||||
let originalHandle;
|
||||
let restoreProbeCreated = false;
|
||||
let localMtimePrepared = false;
|
||||
let published = false;
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(stagedPath, readyPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") throw error;
|
||||
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
if (err?.code !== "EXDEV") throw err;
|
||||
await fs.promises.copyFile(stagedPath, 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");
|
||||
if (preparedPath !== readyPath) preparedPath = readyPath;
|
||||
let appliedMode = null;
|
||||
let validatedTarget;
|
||||
let stable = false;
|
||||
let targetStable = false;
|
||||
let expectedStableIdentity = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
validatedTarget = typeof options.validateTarget === "function"
|
||||
const validatedTarget = typeof options.validateTarget === "function"
|
||||
? await options.validateTarget()
|
||||
: undefined;
|
||||
const mode = Number.isInteger(validatedTarget?.existingMode)
|
||||
: null;
|
||||
const existingMode = Number.isInteger(validatedTarget?.existingMode)
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
if (mode !== null && mode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, mode);
|
||||
appliedMode = mode;
|
||||
: Number.isInteger(options.existingMode)
|
||||
? options.existingMode & 0o7777
|
||||
: null;
|
||||
if (existingMode !== null && existingMode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, existingMode);
|
||||
appliedMode = existingMode;
|
||||
continue;
|
||||
}
|
||||
stable = true;
|
||||
expectedStableIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity
|
||||
? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":")
|
||||
: null);
|
||||
targetStable = true;
|
||||
break;
|
||||
}
|
||||
if (!stable) throw new Error("Local download target kept changing before replacement");
|
||||
assertNotCancelled();
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (!targetStable) {
|
||||
throw new Error("Local download target kept changing before replacement");
|
||||
}
|
||||
if (backedUp && expectedIdentity) {
|
||||
const stat = await fs.promises.lstat(backupPath);
|
||||
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
|
||||
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;
|
||||
try {
|
||||
backupStat = await fs.promises.lstat(backupPath);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Local download target disappeared during replacement: ${targetPath}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
}
|
||||
assertNotCancelled();
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
// 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(() => {});
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (error) {
|
||||
if (committed) throw error;
|
||||
if (backedUp && !keepRecoveryFiles) {
|
||||
// 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 {
|
||||
// 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;
|
||||
}
|
||||
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
|
||||
await fs.promises.unlink(backupPath).catch(() => {});
|
||||
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;
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
throw conflict;
|
||||
}
|
||||
}
|
||||
if (keepRecoveryFiles) {
|
||||
const failure = new Error(
|
||||
`${error.message}. Recovery files preserved. Backup: ${backedUp ? backupPath : "none"}; `
|
||||
+ `prepared replacement: ${readyPath}; target: ${targetPath}`,
|
||||
{ cause: error },
|
||||
assertNotCancelled();
|
||||
// Capture identity of the ready file before publish so rollback can refuse
|
||||
// to clobber a concurrent replacement of the published target.
|
||||
try {
|
||||
publishedIdentity = stableLocalFileIdentity(await fs.promises.lstat(readyPath));
|
||||
} catch {
|
||||
publishedIdentity = null;
|
||||
}
|
||||
await fs.promises.rename(readyPath, targetPath);
|
||||
published = true;
|
||||
assertNotCancelled();
|
||||
options.onCommit?.();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
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 },
|
||||
);
|
||||
failure.recoveryFailed = true;
|
||||
if (backedUp) failure.remoteBackupPath = backupPath;
|
||||
throw failure;
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
}
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await preparedHandle?.close().catch(() => {});
|
||||
await originalHandle?.close().catch(() => {});
|
||||
if (restoreProbeCreated) await fs.promises.unlink(restoreProbePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1139,30 +1207,11 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
: 15_000;
|
||||
|
||||
if (transfer.targetType === "local" && transfer.targetPath) {
|
||||
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");
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(transfer.targetPath, when, when),
|
||||
mtimeTimeoutMs,
|
||||
"Destination utimes",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6033,7 +6082,6 @@ 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);
|
||||
@@ -6048,10 +6096,8 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
onCommit() {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6139,14 +6185,11 @@ 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(publishedIdentity, localMtimePrepared) {
|
||||
onCommit() {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6749,13 +6792,7 @@ async function pauseTransfer(_event, payload) {
|
||||
) {
|
||||
return { success: false, reason: "This transfer cannot be paused yet" };
|
||||
}
|
||||
// A repeated pause is still a newer user decision: invalidate any resume
|
||||
// waiting for verification or outstanding ranges, even if already paused.
|
||||
transfer.pauseRequestToken = Symbol("pause");
|
||||
if (transfer.pauseOperation) {
|
||||
transfer.pauseSuperseded = false;
|
||||
return transfer.pauseOperation;
|
||||
}
|
||||
if (transfer.pauseOperation) return transfer.pauseOperation;
|
||||
if (transfer.paused && transfer.lifecycleState === "paused") {
|
||||
const result = {
|
||||
success: true,
|
||||
@@ -6807,7 +6844,7 @@ async function pauseTransfer(_event, payload) {
|
||||
if (usesContiguousRangeCheckpoint) {
|
||||
await transfer.waitForPause();
|
||||
if (!transfer.paused || transfer.pauseSuperseded) {
|
||||
return { success: false, superseded: true, supersededBy: "resume", reason: "Pause was superseded by resume" };
|
||||
return { success: false, reason: "Pause was superseded by resume" };
|
||||
}
|
||||
// Concurrent path already tracks contiguous durable bytes — do not spend
|
||||
// hundreds of ms waiting for writeStream drain before acknowledging pause.
|
||||
@@ -6922,7 +6959,7 @@ async function pauseTransfer(_event, payload) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
if (!transfer.paused || transfer.pauseSuperseded) {
|
||||
return { success: false, superseded: true, supersededBy: "resume", reason: "Pause was superseded by resume" };
|
||||
return { success: false, reason: "Pause was superseded by resume" };
|
||||
}
|
||||
// Confirm pause as soon as soft-drain + durable checkpoint are ready.
|
||||
// Source identity (remote sample reads on download) used to block this IPC
|
||||
@@ -6977,20 +7014,6 @@ async function resumeTransfer(_event, payload) {
|
||||
reason: transfer.pauseUnavailableReason || "This transfer cannot be resumed safely",
|
||||
};
|
||||
}
|
||||
const pauseRequestToken = transfer.pauseRequestToken;
|
||||
const invalidResumeResult = (reason) => ({
|
||||
success: false, reason,
|
||||
...(transfer.pauseRequestToken !== pauseRequestToken ? { superseded: true, supersededBy: "pause" } : {}),
|
||||
});
|
||||
const resumeInvalidReason = () => {
|
||||
if (activeTransfers.get(payload?.transferId) !== transfer || transfer.cancelled) {
|
||||
return "Transfer is no longer active";
|
||||
}
|
||||
if (transfer.pauseRequestToken !== pauseRequestToken) {
|
||||
return "Resume was superseded by a newer pause";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (transfer.pauseOperation) {
|
||||
transfer.pauseSuperseded = true;
|
||||
try { transfer.cancelPauseWait?.(); } catch { }
|
||||
@@ -7000,8 +7023,6 @@ async function resumeTransfer(_event, payload) {
|
||||
if (currentTransfer !== transfer || transfer.cancelled) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
const initialInvalidReason = resumeInvalidReason();
|
||||
if (initialInvalidReason) return invalidResumeResult(initialInvalidReason);
|
||||
// Already flowing (e.g. double-click resume): do not pipe() again.
|
||||
if (!transfer.paused) {
|
||||
transfer.lifecycleState = "transferring";
|
||||
@@ -7036,7 +7057,7 @@ async function resumeTransfer(_event, payload) {
|
||||
await transfer.captureSourceFingerprint?.();
|
||||
}
|
||||
if (!transfer.sourceFingerprint) {
|
||||
return invalidResumeResult("Could not verify the source file for resume");
|
||||
return { success: false, reason: "Could not verify the source file for resume" };
|
||||
}
|
||||
await transfer.verifySourceFingerprint(transfer.sourceFingerprint);
|
||||
}
|
||||
@@ -7044,11 +7065,12 @@ async function resumeTransfer(_event, payload) {
|
||||
if (transfer.cancelled || activeTransfers.get(payload?.transferId) !== transfer) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
return invalidResumeResult(error?.message || "Could not verify the source file for resume");
|
||||
return {
|
||||
success: false,
|
||||
reason: error?.message || "Could not verify the source file for resume",
|
||||
};
|
||||
}
|
||||
}
|
||||
const verifiedInvalidReason = resumeInvalidReason();
|
||||
if (verifiedInvalidReason) return invalidResumeResult(verifiedInvalidReason);
|
||||
// Soft-drained concurrent pause may leave a sparse tail past the contiguous
|
||||
// checkpoint. Wait with Resume's own budget, then single-flight truncate so
|
||||
// background settle cannot race new writes after unpause.
|
||||
@@ -7059,14 +7081,15 @@ async function resumeTransfer(_event, payload) {
|
||||
{ maxWaitMs: RESUME_RANGE_SETTLE_MS },
|
||||
);
|
||||
if (!settled?.ok) {
|
||||
return invalidResumeResult(settled?.reason || "The current file is still finishing. Try resume again.");
|
||||
return {
|
||||
success: false,
|
||||
reason: settled?.reason || "The current file is still finishing. Try resume again.",
|
||||
};
|
||||
}
|
||||
if (transfer.cancelled || activeTransfers.get(payload?.transferId) !== transfer) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
}
|
||||
const finalInvalidReason = resumeInvalidReason();
|
||||
if (finalInvalidReason) return invalidResumeResult(finalInvalidReason);
|
||||
transfer.paused = false;
|
||||
transfer.pauseSuperseded = false;
|
||||
transfer.lifecycleEpoch += 1;
|
||||
@@ -7241,37 +7264,6 @@ function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
|
||||
function registerHandlers(ipcMain, options = {}) {
|
||||
const terminalWorkerManager = options.terminalWorkerManager || null;
|
||||
if (terminalWorkerManager) {
|
||||
// Control replies can arrive out of order across windows. Only the latest
|
||||
// request may publish lifecycle state; retain tokens only while in flight.
|
||||
const workerControlRequests = new Map();
|
||||
const withWorkerControl = async (transferId, action, work) => {
|
||||
const token = Symbol("control");
|
||||
const state = workerControlRequests.get(transferId) || { token, action, pending: 0 };
|
||||
state.token = token;
|
||||
state.action = action;
|
||||
state.pending += 1;
|
||||
workerControlRequests.set(transferId, state);
|
||||
const isCurrent = () => workerControlRequests.get(transferId) === state && state.token === token;
|
||||
const superseded = () => ({ success: false, superseded: true, supersededBy: state.action });
|
||||
try {
|
||||
const result = await work(isCurrent, superseded);
|
||||
if (isCurrent() && !result?.success && !result?.superseded) {
|
||||
if (action === "pause") state.action = "resume";
|
||||
else if (action === "resume") state.action = "pause";
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return superseded();
|
||||
if (action === "pause") state.action = "resume";
|
||||
else if (action === "resume") state.action = "pause";
|
||||
throw error;
|
||||
} finally {
|
||||
state.pending -= 1;
|
||||
if (state.pending === 0 && workerControlRequests.get(transferId) === state) {
|
||||
workerControlRequests.delete(transferId);
|
||||
}
|
||||
}
|
||||
};
|
||||
const nextWorkerLifecycleEpoch = (transferId, suggestedEpoch) => {
|
||||
const entry = workerTransferLifecycleEpochs.get(transferId);
|
||||
const current = Math.max(0, Number(entry?.epoch) || 0);
|
||||
@@ -7297,7 +7289,6 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
&& workerTransferLifecycleEpochs.get(payload.transferId) === lifecycleEntry
|
||||
) {
|
||||
workerTransferLifecycleEpochs.delete(payload.transferId);
|
||||
workerControlRequests.delete(payload.transferId);
|
||||
}
|
||||
};
|
||||
// Renderer (or outer main) already admitted — skip a second queue so
|
||||
@@ -7323,22 +7314,20 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
ipcMain.handle("netcatty:transfer:cancel", (event, payload) => (
|
||||
cancelQueuedTransfer(payload?.transferId)
|
||||
? { success: true }
|
||||
: withWorkerControl(payload?.transferId, "cancel", () => workerRequest(event, "netcatty:transfer:cancel", payload))
|
||||
: workerRequest(event, "netcatty:transfer:cancel", payload)
|
||||
));
|
||||
ipcMain.handle("netcatty:transfer:pause", async (event, payload) => {
|
||||
const queued = pauseQueuedTransfer(payload?.transferId);
|
||||
if (queued) return queued;
|
||||
return withWorkerControl(payload?.transferId, "pause", async (isCurrent, superseded) => {
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "pausing",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "pausing",
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "pausing",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "pausing",
|
||||
});
|
||||
try {
|
||||
const result = await workerRequest(event, "netcatty:transfer:pause", payload);
|
||||
if (!isCurrent()) return superseded();
|
||||
if (!result?.success) {
|
||||
const rollbackEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
@@ -7362,7 +7351,6 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
} catch (error) {
|
||||
if (!isCurrent()) throw error;
|
||||
const rollbackEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
@@ -7372,28 +7360,24 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcMain.handle("netcatty:transfer:resume", async (event, payload) => {
|
||||
const queuedResume = resumeQueuedTransfer(payload?.transferId);
|
||||
if (queuedResume) return queuedResume;
|
||||
return withWorkerControl(payload?.transferId, "resume", async (isCurrent, superseded) => {
|
||||
const result = await workerRequest(event, "netcatty:transfer:resume", payload);
|
||||
if (!isCurrent()) return superseded();
|
||||
if (result?.success) {
|
||||
// Normalize into main-process epoch space (may advance past worker-local).
|
||||
// Soft-resume UI must stamp THIS epoch or later worker progress is stale.
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId, result.lifecycleEpoch);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "transferring",
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const result = await workerRequest(event, "netcatty:transfer:resume", payload);
|
||||
if (result?.success) {
|
||||
// Normalize into main-process epoch space (may advance past worker-local).
|
||||
// Soft-resume UI must stamp THIS epoch or later worker progress is stale.
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId, result.lifecycleEpoch);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "transferring",
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:transfer:prioritize", (event, payload) => (
|
||||
prioritizeQueuedTransfer(payload?.transferId)
|
||||
|
||||
@@ -299,85 +299,3 @@ test("worker-backed pause and resume fan authoritative lifecycle to every window
|
||||
delete require.cache[require.resolve(bridgePath)];
|
||||
}
|
||||
});
|
||||
|
||||
for (const latestAction of ["pause", "resume", "cancel"]) {
|
||||
test(`stale worker pause retains latest ${latestAction} after that newer request has settled`, async (t) => {
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let finishFirst;
|
||||
const firstGate = new Promise((resolve) => { finishFirst = resolve; });
|
||||
let requests = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
||||
});
|
||||
const payload = { transferId: `worker-last-action-${latestAction}` };
|
||||
const first = handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
await handlers.get(`netcatty:transfer:${latestAction}`)(null, payload);
|
||||
finishFirst({ success: true });
|
||||
const stale = await first;
|
||||
assert.equal(stale.superseded, true);
|
||||
assert.equal(stale.supersededBy, latestAction, "completed newer request must retain intent for older pending replies");
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["resume-success", "pause-success", "pause-failure", "pause-error", "resume-error"]) {
|
||||
test(`worker ${outcome} arriving after a newer pause cannot broadcast resumed`, async (t) => {
|
||||
const sent = [];
|
||||
const restore = withElectronVersionStub();
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === "electron") return { BrowserWindow: { getAllWindows: () => [{
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: (_channel, payload) => sent.push(payload) },
|
||||
}] } };
|
||||
return originalLoad(request, parent, isMain);
|
||||
};
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { Module._load = originalLoad; restore(); delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let resolveFirst;
|
||||
let rejectFirst;
|
||||
const firstGate = new Promise((resolve, reject) => { resolveFirst = resolve; rejectFirst = reject; });
|
||||
let requests = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
||||
});
|
||||
const payload = { transferId: `worker-order-${outcome}` };
|
||||
const channel = outcome.startsWith("resume") ? "resume" : "pause";
|
||||
const first = handlers.get(`netcatty:transfer:${channel}`)(null, payload).catch(() => null);
|
||||
await handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
const afterPause = sent.length;
|
||||
if (outcome.endsWith("error")) rejectFirst(new Error("channel closed"));
|
||||
else resolveFirst({ success: outcome.endsWith("success"), reason: "pause unavailable" });
|
||||
assert.deepEqual(await first, { success: false, superseded: true, supersededBy: "pause" });
|
||||
assert.equal(sent.slice(afterPause).some((event) => event.type === "resumed"), false);
|
||||
assert.equal(sent.at(-1).type, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"]) {
|
||||
test(`superseded result follows rollback after latest ${action} fails`, async (t) => {
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let finish;
|
||||
let calls = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; })
|
||||
: Promise.resolve({ success: false, reason: "unavailable" }) },
|
||||
});
|
||||
const payload = { transferId: `rollback-${action}` };
|
||||
const older = handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
await handlers.get(`netcatty:transfer:${action}`)(null, payload);
|
||||
finish({ success: true });
|
||||
assert.equal((await older).supersededBy, action === "pause" ? "resume" : "pause");
|
||||
});
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
"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"]);
|
||||
});
|
||||
@@ -1607,125 +1607,6 @@ test("pause soft-drains concurrent ranges but resume waits before truncating", a
|
||||
assert.equal(durableBytes, payload.length);
|
||||
});
|
||||
|
||||
for (const settleWrites of [true, false]) {
|
||||
test(`a newer pause supersedes resume while concurrent writes are draining: settle=${settleWrites}`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-repause-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Several MB so concurrent fanout stays saturated while we hold writes.
|
||||
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65);
|
||||
const localPath = path.join(tempDir, "upload.bin");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
let holdWrites = true;
|
||||
const pendingWrites = [];
|
||||
let durableBytes = 0;
|
||||
const truncateCalls = [];
|
||||
const fastSftp = createFastSftp({
|
||||
open(_remotePath, _flags, callback) {
|
||||
callback(null, Buffer.from("remote-handle"));
|
||||
},
|
||||
write(_handle, _buffer, _offset, length, position, callback) {
|
||||
const complete = () => {
|
||||
durableBytes = Math.max(durableBytes, position + length);
|
||||
callback(null);
|
||||
};
|
||||
if (holdWrites) pendingWrites.push({ position, complete });
|
||||
else setImmediate(complete);
|
||||
},
|
||||
close(_handle, callback) {
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
const client = {
|
||||
sftp: createFastSftp({}),
|
||||
stat() {
|
||||
return Promise.resolve({ size: durableBytes });
|
||||
},
|
||||
truncate(_remotePath, size) {
|
||||
truncateCalls.push(size);
|
||||
durableBytes = size;
|
||||
return Promise.resolve();
|
||||
},
|
||||
rename() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
callback(null, fastSftp);
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const running = transferBridge.startTransfer(
|
||||
{ sender: createSender() },
|
||||
{
|
||||
transferId: "upload-resume-repause",
|
||||
sourcePath: localPath,
|
||||
targetPath: "/tmp/upload-soft.bin",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: true,
|
||||
},
|
||||
);
|
||||
|
||||
const readyDeadline = Date.now() + 1000;
|
||||
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes");
|
||||
|
||||
const started = Date.now();
|
||||
// Hold writes so active ranges never drain — soft-drain must still resolve.
|
||||
const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
||||
const elapsed = Date.now() - started;
|
||||
assert.equal(paused.success, true);
|
||||
// Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain.
|
||||
assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`);
|
||||
|
||||
const outOfOrderWrite = pendingWrites.pop();
|
||||
assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint");
|
||||
outOfOrderWrite.complete();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let resumeSettled = false;
|
||||
const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-resume-repause" })
|
||||
.then((result) => {
|
||||
resumeSettled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const truncatedBeforeDrain = truncateCalls.length > 0;
|
||||
const resumedBeforeDrain = resumeSettled;
|
||||
|
||||
const newerPause = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
||||
assert.equal(newerPause.success, true);
|
||||
if (settleWrites) {
|
||||
holdWrites = false;
|
||||
for (const { complete } of pendingWrites.splice(0)) complete();
|
||||
}
|
||||
const resumeResult = await resuming;
|
||||
holdWrites = false;
|
||||
for (const { complete } of pendingWrites.splice(0)) complete();
|
||||
await transferBridge.cancelTransfer(null, { transferId: "upload-resume-repause" });
|
||||
await running;
|
||||
assert.equal(resumeResult.success, false, "old resume must not restart writes after a newer pause");
|
||||
if (settleWrites) assert.match(resumeResult.reason, /superseded.*pause/i);
|
||||
assert.equal(resumeResult.superseded, true, "direct callers need a structured superseded result too");
|
||||
assert.equal(resumeResult.supersededBy, "pause");
|
||||
assert.equal(truncatedBeforeDrain, false);
|
||||
assert.equal(resumedBeforeDrain, false);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test("cancelling resume during soft-drain does not truncate the staged file", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-cancel-"));
|
||||
t.after(async () => {
|
||||
@@ -1885,8 +1766,6 @@ test("resuming while a fast pause is pending settles the pause request", async (
|
||||
assert.deepEqual(await pausing, {
|
||||
success: false,
|
||||
reason: "Pause was superseded by resume",
|
||||
superseded: true,
|
||||
supersededBy: "resume",
|
||||
});
|
||||
|
||||
finishWrite();
|
||||
@@ -10653,8 +10532,6 @@ 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;
|
||||
@@ -10674,8 +10551,6 @@ 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) {
|
||||
@@ -10713,8 +10588,6 @@ 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"),
|
||||
@@ -12396,31 +12269,55 @@ 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 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 }));
|
||||
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 });
|
||||
});
|
||||
|
||||
const stagedPath = path.join(tempDir, "download.staged");
|
||||
const targetPath = path.join(tempDir, "target.bin");
|
||||
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");
|
||||
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);
|
||||
});
|
||||
|
||||
test("local promotion succeeds when backup still matches validated identity", async (t) => {
|
||||
@@ -12479,109 +12376,6 @@ 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() {
|
||||
@@ -12957,54 +12751,3 @@ 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");
|
||||
});
|
||||
|
||||
}
|
||||
@@ -789,13 +789,6 @@ function createBridgeRegistrar(context) {
|
||||
cloudSyncSessionPassword = typeof password === "string" && password.length ? password : null;
|
||||
if (cloudSyncSessionPassword) {
|
||||
persistCloudSyncPassword(cloudSyncSessionPassword);
|
||||
// Peer renderers may have observed MASTER_KEY_CONFIG before the
|
||||
// settings renderer finished unlocking and shared this password.
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send("netcatty:cloudSync:session:passwordAvailable");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearPersistedCloudSyncPassword();
|
||||
}
|
||||
|
||||
@@ -1176,11 +1176,6 @@ function createPreloadApi(ctx) {
|
||||
// Cloud sync session (in-memory only, shared across windows)
|
||||
cloudSyncSetSessionPassword: (password) =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:setPassword", password),
|
||||
onCloudSyncSessionPasswordAvailable: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
},
|
||||
cloudSyncGetSessionPassword: () =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:getPassword"),
|
||||
cloudSyncClearSessionPassword: () =>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createPreloadApi } = require('./api.cjs');
|
||||
|
||||
test('password-availability subscription exposes no IPC payload and unsubscribes', () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const calls = [];
|
||||
const unsubscribe = api.onCloudSyncSessionPasswordAvailable((...args) => calls.push(args));
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', { sender: 'private' }, 'must-not-forward');
|
||||
assert.deepEqual(calls, [[]]);
|
||||
unsubscribe();
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', {});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(ipcRenderer.listenerCount('netcatty:cloudSync:session:passwordAvailable'), 0);
|
||||
});
|
||||
@@ -920,34 +920,13 @@ export async function unlockImpl(this: any,password: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The master key configuration can rotate while the derivation below is
|
||||
// in flight (e.g. a peer window re-encrypts with the same password and
|
||||
// the storage event lands mid-await). Capture the config and security
|
||||
// generation up front so a late-completing call cannot install a key
|
||||
// derived from a stale configuration over the active one.
|
||||
const configAtStart = this.state.masterKeyConfig;
|
||||
const generationAtStart = this.getSyncSecurityGeneration?.();
|
||||
|
||||
const unlockedKey = await EncryptionService.unlockMasterKey(
|
||||
password,
|
||||
configAtStart
|
||||
this.state.masterKeyConfig
|
||||
);
|
||||
|
||||
// Superseded derivations are not password failures. Callers discard the
|
||||
// shared session password only after false, so report stale work as an
|
||||
// error before interpreting either a valid or invalid old-config result.
|
||||
if (
|
||||
this.state.masterKeyConfig !== configAtStart
|
||||
|| (generationAtStart !== undefined
|
||||
&& this.getSyncSecurityGeneration?.() !== generationAtStart)
|
||||
|| (this.state.securityState !== 'LOCKED' && this.state.securityState !== 'UNLOCKED')
|
||||
) {
|
||||
throw new Error('Vault changed while unlocking. Try again.');
|
||||
}
|
||||
if (!unlockedKey) return false;
|
||||
if (this.state.securityState === 'UNLOCKED') {
|
||||
// Another unlock with the same config won the race; nothing to install.
|
||||
return true;
|
||||
if (!unlockedKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state.unlockedKey = unlockedKey;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { unlockImpl } from './stateAndSecurityMethods';
|
||||
|
||||
for (const scenario of ['rotated-valid-password', 'rotated-invalid-old-password', 'locked-during-unlock'] as const) {
|
||||
test(`unlock rejects a superseded request instead of reporting a bad password: ${scenario}`, async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig('old-fixture-password');
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
let generation = 0;
|
||||
const manager = {
|
||||
state: { masterKeyConfig: oldConfig, securityState: 'LOCKED', unlockedKey: null },
|
||||
getSyncSecurityGeneration: () => generation,
|
||||
};
|
||||
const pending = unlockImpl.call(manager,
|
||||
scenario === 'rotated-invalid-old-password' ? 'new-fixture-password' : 'old-fixture-password');
|
||||
if (scenario !== 'locked-during-unlock') manager.state.masterKeyConfig = newConfig;
|
||||
generation += 1;
|
||||
await assert.rejects(pending, /changed while unlocking/i);
|
||||
assert.equal(manager.state.securityState, 'LOCKED');
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
}
|
||||
|
||||
test('a wrong password for the current configuration remains an ordinary verification failure', async () => {
|
||||
const config = await EncryptionService.createMasterKeyConfig('correct-fixture-password');
|
||||
const manager = { state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null } };
|
||||
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
+1
-3
@@ -92,8 +92,6 @@ declare global {
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
|
||||
pauseTransfer?(transferId: string): Promise<{
|
||||
success: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: 'direct' | 'download' | 'upload';
|
||||
downloadCheckpointBytes?: number;
|
||||
@@ -102,7 +100,7 @@ declare global {
|
||||
lifecycleEpoch?: number;
|
||||
reason?: string;
|
||||
}>;
|
||||
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number; superseded?: boolean; supersededBy?: "pause" | "resume" | "cancel" }>;
|
||||
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number }>;
|
||||
prioritizeTransfer?(transferId: string): Promise<{ success: boolean }>;
|
||||
setGlobalTransferConcurrency?(limit: number): Promise<{ success: boolean; limit: number }>;
|
||||
cleanupTransferArtifacts?(payload: {
|
||||
|
||||
-1
@@ -102,7 +102,6 @@ declare global {
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
|
||||
Reference in new issue
Block a user