Compare commits

...
Author SHA1 Message Date
bincxz b8bc61562d fix(sync): share auto unlock retry generation across consumers 2026-09-07 05:29:44 +08:00
bincxz 7a677afd9c fix(sync): retry completed auto unlock after consumers remount 2026-09-07 05:26:45 +08:00
bincxz e66ece365d fix(sync): distinguish stale unlocks from invalid passwords 2026-09-07 05:14:35 +08:00
bincxz b9d24020c8 test(sync): cover pending unlock owner handoff 2026-09-07 02:36:55 +08:00
netcatty-bot aa50aa8113 fix: address Codex review on PR #3302 2026-09-06 18:09:23 +00:00
netcatty-bot dab4fe6904 fix: address Codex review on PR #3302 2026-09-06 17:41:43 +00:00
netcatty-bot 48b1afde85 fix: address Codex review on PR #3302 2026-09-06 17:11:58 +00:00
netcatty-bot f359e6c42b fix: address Codex review on PR #3302 2026-09-06 16:46:59 +00:00
bincxz 6de510c0c7 fix(sync): retain deliberate locks across consumer remounts 2026-09-07 00:14:49 +08:00
bincxz cee77d5dd8 fix(sync): preserve deliberate locks after peer password events 2026-09-06 23:39:33 +08:00
bincxz 62391fd98f fix(sync): retain deliberate locks after availability notifications 2026-09-06 23:22:49 +08:00
bincxz f9e3999c66 fix(sync): retry peer unlock when the shared password becomes available 2026-09-06 23:20:20 +08:00
cabdcdf31c fix(logs): adaptive light/dark theme for HTML session logs (#3264)
* fix(#3263): automated AI fix

* fix(logs): keep adaptive HTML colors readable

* fix(logs): adapt extended foreground colors to light pages

* perf(logs): calculate light foreground colors without iteration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-06 15:11:13 +08:00
陈大猫andnetcatty-bot e2a9e54a90 fix(sftp): preserve concurrent files during local publication (#3286)
* fix(sftp): preserve concurrent files during local publication

* fix(sftp): preserve metadata during fallback restoration

* fix: address Codex review on PR #3286

* fix(sftp): prepare timestamps before publishing local downloads

* fix: address Codex review on PR #3286

* fix(sftp): preserve file access through restricted-mode publication

* fix(sftp): retry timestamps when preparation fails

* fix(sftp): verify unreadable destination recovery before replacement

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-06 14:23:15 +08:00
陈大猫 c9d22b3b4d fix(sftp): preserve latest pause across delayed transfer controls (#3284)
* fix(sftp): preserve newer pause decisions across delayed transfer controls

* fix(sftp): stop obsolete recovery after held transfers settle

* fix(sftp): treat superseded cross-window controls as no-ops

* fix(sftp): preserve latest folder control in pause watcher

* fix(sftp): reconcile superseded controls across windows

* test(sftp): expect authoritative action on superseded pause

* fix(sftp): distinguish mixed resume outcomes and IPC failures

* docs(sftp): publish audit findings and validation boundaries

* docs(sftp): record final publication and restart validation

* fix(sftp): reflect partial resumes and reconcile remote watcher controls

* fix(sftp): preserve resolved resume failures in partial folders
2026-09-06 14:13:49 +08:00
陈大猫 2dea078c6d fix(sftp): preserve shared SSH transport when channel opens abort (#3288) 2026-09-06 13:29:08 +08:00
陈大猫 446e82180b fix(sftp): settle folder children before history compaction removes their rows (#3287)
* fix(sftp): observe folder child completion before history compaction

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

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

* test(sftp): keep independent audit regressions mergeable

* fix(sftp): resume retained pauses without bypassing newer controls
2026-09-06 13:29:05 +08:00
陈大猫 2eaddf0baf fix(sftp): refuse ambiguous or replaced resume endpoints (#3285) 2026-09-06 13:29:02 +08:00
38 changed files with 3510 additions and 482 deletions

No files matched your search

@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
protocol: "ssh",
} as Host);
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const originalGet = netcattyBridge.get;
let abort = false;
let running: Promise<unknown> | undefined;
let childId: string | undefined;
const parent: TransferTask = {
id: "superseded-compacted-folder", fileName: "folder",
sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
targetHostId: "h1", direction: "upload", status: "transferring",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
t.after(async () => {
abort = true;
await running;
netcattyBridge.get = originalGet;
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
sftpTransferCenterStore.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
const persisted: TransferTask = {
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
}],
mkdirSftp: async () => {},
statLocal: async () => {
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
return { size: 10, lastModified: 1 };
},
startStreamTransfer: async (options: {
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
}) => {
childId = options.transferId;
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "started", transferId: childId, lifecycleEpoch: 0,
parentTaskId: options.parentTaskId,
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
if (newerPause) {
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
assert.equal(childId, undefined, "newer paused child must not start");
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
return;
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.equal((result as { success: boolean }).success, true);
});
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
resolveDirectoryResumeTargetRoot({
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint prefers id then label", () => {
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
});
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
resetDedicatedSessionOpenGateForTests();
}
});
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const opened: string[] = [];
let uploads = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
});
const hosts = scenario === "deleted-id"
? [host("different-id", "production", "different.example")]
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
const result = await resumeTransferWithDedicatedSession({
id: "wrong-host-resume", fileName: "deployment.zip",
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
sourceConnectionId: "local", targetConnectionId: "right-expired",
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
targetHostLabel: "production", direction: "upload", status: "interrupted",
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
}, {hosts, keys: [], identities: []});
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
assert.equal(uploads, 0);
});
}
@@ -1,3 +1,4 @@
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
}
/**
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
* label/hostname when the id is stale after vault edits or older task records.
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
hostLabel?: string,
): Host | null {
if (hostId) {
const byId = hosts.find((host) => host.id === hostId);
if (byId) return byId;
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
return hosts.find((host) => {
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
}) ?? null;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
// Fresh recovery authorizes only pauses already present at its entry. Keep
// row references so a newer cross-window pause invalidates that permission.
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
.map((child) => [child.id, child]));
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await bridge.startStreamTransfer!({
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -51,6 +51,46 @@ 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();
@@ -245,3 +285,218 @@ 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,6 +20,7 @@ import {
isTransferControlEpochCurrent,
} from "./transferControlEpoch";
import {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
@@ -28,6 +29,9 @@ 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"];
@@ -39,12 +43,67 @@ 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>,
@@ -170,14 +229,23 @@ 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) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
return { success: false, reason: "Pause superseded by resume" };
}
@@ -186,6 +254,9 @@ 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" };
}
@@ -200,17 +271,22 @@ 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) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
return { id, result };
}));
if (!pauseStillCurrent()) return;
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return;
}
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!after || after.status === "cancelled") return;
if (after.status !== "paused" && after.status !== "pausing") return;
@@ -259,6 +335,10 @@ 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);
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
if (userAlreadyResumed) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
bridgeResults: pauseResults.map((row) => row.result),
});
for (const id of rollback.bridgeIdsToResume) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
const hard = pauseResults.find(({ result }) =>
result && !result.success && !isBenignPauseMiss(result.reason),
)?.result;
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
bumpTransferControlEpoch(taskId);
const resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, releaseIds);
for (const id of [taskId, ...releaseIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
try { bridge = host.getBridge(); } catch { bridge = undefined; }
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
const results = await Promise.all(resumeIds.map(async (id) =>
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
));
const failedIds = new Set<string>();
const results = await Promise.all(resumeIds.map(async (id) => {
try {
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
} catch (error) {
// Rejections must pass the same stale-control check as ordinary failures.
failedIds.add(id);
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
}
}));
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return { handled: true };
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
results.forEach((result, index) => {
// A missing stream can belong to queued directory work. Verification and
// other explicit failures still own a paused stream and must remain held.
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
});
if (results.some(wasSuperseded)) {
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
}
const successIds = resumeIds.filter((_, index) => results[index]?.success);
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
if (failedIds.size > 0) {
// A partial resume stays visibly active; hold only failed children. Do not
// hide successfully running streams behind a paused root barrier.
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
for (const id of heldIds) {
latchTransferPauseTree(id, []);
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
if (!effectiveRunning) return { handled: false, reason: failureReason };
}
if (results.some(wasSuperseded) && failedIds.size === 0) {
return { handled: true };
}
const walkAlive = isTransferWalkInFlight(taskId);
// Directory walk can continue after unlatch without bridge resume on every child.
// Single-file must not claim success when every bridge resume fails (stuck bar).
if (successIds.length === 0) {
if (!effectiveRunning) {
if (task.isDirectory && walkAlive) {
host.setTasks(paintTreeStatus(
host.getTasks(),
@@ -491,6 +605,12 @@ 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) {
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
return {
...candidate,
status: "transferring" as const,
error: undefined,
error: failureReason,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
@@ -41,6 +41,106 @@ 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;
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
let childId: string | undefined;
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<number> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "live-compacted-owner", cancelledTasksRef,
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
sftpTransferCenterStore.dismiss(root.id);
(globalThis as { window?: unknown }).window = previousWindow;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+29 -29
View File
@@ -1,3 +1,5 @@
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";
import {
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
}
// Await the invoke result — cancel resolves with { error } and may
// not fire onComplete/onError after preload clears listeners.
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
const transferPromise = runTransferAndWaitForOwner(
task,
() => netcattyBridge.require().startStreamTransfer!(options),
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
);
// Streams that arm after the parent pause round never receive the
// initial pauseTransfer. Keep pausing while the folder is latched.
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
)
) {
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
const childEpochAtAttempt = getTransferControlEpoch(task.id);
try {
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
// Resume won while we were awaiting pause — undo.
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.
if (
result?.success
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& !isPauseLatched(rootTaskId, task.id)
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
try {
await netcattyBridge.get()?.resumeTransfer?.(task.id);
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
function child(): TransferTask {
return {
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
};
}
for (const status of ["completed", "failed", "cancelled"] as const) {
test(`settlement observation retains exact ${status} before history pruning`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
const observation = store.observeTaskSettlement(task);
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
assert.equal(observation.read()?.status, status);
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
observation.dispose();
assert.equal(observation.read(), undefined);
});
}
test("another file reusing an id and index is not completion evidence", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
assert.equal(observation.read(), undefined);
observation.dispose();
});
test("a disposed observer receives no later completion", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
observation.dispose();
store.patchTask(task.id, { status: "completed" });
assert.equal(observation.read(), undefined);
});
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
store.upsertTasks([task]);
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
assert.equal(store.admitTaskRun(next), "ready");
assert.equal(store.getTask(task.id)?.status, "transferring");
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
});
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
test(`dispatch cannot revive a retained ${status} row`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([
{ ...task, id: "root", parentTaskId: undefined },
{ ...task, status },
]);
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
assert.equal(store.getTask(task.id)?.status, status);
});
}
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), lifecycleEpoch: 9 };
store.upsertTasks([task]);
const before = store.getTask(task.id);
assert.equal(store.admitTaskRun(task), "ready");
assert.equal(store.getTask(task.id), before);
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
});
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const };
store.upsertTasks([task]);
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
latchTransferPause("root");
assert.equal(store.admitTaskRun(task), "paused");
resetTransferPauseLatchesForTests();
markTransferCancelledTree("root", [task.id]);
assert.equal(store.admitTaskRun(task), "cancelled");
assert.equal(store.getTask(task.id)?.status, "failed");
});
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
// Attach rejection handling before the cancellation event is delivered.
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.status, "paused");
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
if (nextStatus === "cancelled") {
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
markTransferCancelledTree(task.id, []);
t.after(() => settleTransferCancelTree(task.id, []));
} else store.patchTask(task.id, { status: nextStatus });
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
});
}
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "completed" }]);
t.after(() => store.dismiss(task.id));
const start = async () => { assert.fail("completed transfer must not restart"); };
await runTransferAndWaitForOwner(task, start, () => false);
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
latchTransferPause(task.id);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: "completed" });
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
// An unrelated lifecycle publication captures the previous failed row while waiting.
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
t.after(() => store.dismiss("unrelated-admission"));
resetTransferPauseLatchesForTests();
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
});
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
const paused = store.getTask(task.id)!;
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
assert.equal(store.admitTaskRun(task, paused), "ready");
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
const currentPause = store.getTask(task.id)!;
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
@@ -0,0 +1,41 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
task: TransferTask,
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task);
const result = await start();
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} finally {
observation.dispose();
}
}
@@ -3762,3 +3762,108 @@ 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");
});
}
+112 -37
View File
@@ -28,6 +28,7 @@ import {
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import {
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./sftp/transferCancelLatch";
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -398,6 +403,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
&& expected.targetPath === candidate.targetPath
&& expected.parentTaskId === candidate.parentTaskId
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -609,6 +623,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// live renderer walk keeps its controls until TransferRuntime.runWalk
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
const retainedIds = new Set(tasks.map((task) => task.id));
@@ -1307,6 +1329,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
if (parent?.status === "cancelled") return "cancelled";
const index = tasks.findIndex((task) => task.id === incoming.id);
const existing = index < 0 ? undefined : tasks[index];
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
if (existing?.status === "completed") {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
&& existing?.status === "paused"
&& (parent?.status === "pending" || parent?.status === "transferring");
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|| parent?.status === "paused" || parent?.status === "pausing"
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
if (parent?.status === "completed") return "conflict";
if (!existing) return "ready";
if (existing.status === "transferring"
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
// Explicit retry may leave failed/interrupted and replace its manifest identity.
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
const next = tasks.slice();
next[index] = {
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
error: undefined,
endTime: undefined,
reconnectRequired: false,
}),
};
tasks = next;
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
},
dispose() {
disposed = true;
observer.settled = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
};
},
getTask(taskId) {
return tasks.find((task) => task.id === taskId);
},
@@ -1671,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).
bumpTransferControlEpoch(taskId);
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
@@ -1693,64 +1779,53 @@ 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 {
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;
});
// 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);
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;
@@ -0,0 +1,65 @@
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();
}
});
+9 -36
View File
@@ -6,6 +6,7 @@
* 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,
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
getConvergentSyncLocalConfigSnapshot,
);
// 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]);
useCloudSyncAutoUnlock({
securityState: state.securityState,
masterKeyIdentity: state.masterKeyConfig
? JSON.stringify(state.masterKeyConfig)
: null,
manager,
bridge: netcattyBridge.get(),
});
useEffect(() => {
if (state.securityState !== 'UNLOCKED') return;
@@ -0,0 +1,253 @@
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(); }
});
+115
View File
@@ -0,0 +1,115 @@
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]);
}
+24
View File
@@ -0,0 +1,24 @@
# Folder completion after same-ID ownership handoff
Both normal directory transfer and dedicated directory recovery can receive a
superseded stream result: another invocation now owns the same child transfer.
The old caller must wait for that owner rather than report premature completion.
Previously it polled the visible child row. Completed rows are compacted into
parent checkpoints, so a completion arriving before the superseded reply removes
the row first. Polling then waits forever; a stale panel row can mask this further.
Two regressions use the actual React directory hook / dedicated recovery entrypoint
and actual store compaction. Each records one completed file in the parent while
the transfer operation remains pending on the baseline.
The fix registers a bounded settlement observation before starting the stream.
The store captures terminal state for exact observed file identities before
history compaction. One shared helper waits for the actual owner in both paths,
then releases its observation on success, failure or cancellation. There is no
persistent per-file tombstone list and no inference that a missing row means
success. Reused IDs with different indexed file identities are not evidence.
This addresses a separately reproduced folder-never-settles condition relevant
to #2568 and #3155. It does not establish the original reporters' precise cause.
Large-history recovery follow-up: stream lifecycle events carry the current child hierarchy identity. Before dispatch, the store admits the explicit retry into the current row without a full history compaction, so a batched old failed row cannot reject the new completion. Admission distinguishes pause waiting, cancellation, identity conflict and exact prior completion; active lifecycle epochs and newer pause/cancel intent remain protected.
@@ -0,0 +1,43 @@
# Local transfer publication safety audit
## Confirmed defect
The completed download moves an existing destination to a backup, checks that
its pathname is unoccupied, then renames the prepared download into place.
Another program can save to that pathname between the check and rename. The
rename overwrites those bytes and successful cleanup deletes the original
backup. Backup restoration and post-publication rollback have equivalent races.
Real filesystem regressions inject concurrent creation at the actual final
publication and restoration boundary. Both lose concurrent contents on the
baseline. An explicitly absent destination can also appear after validation and
be incorrectly moved aside. These tests fail before the change.
## Contract and design
- Prepared data and an original backup remain private sibling files.
- Publication and backup restoration use the same no-overwrite primitive.
- On hardlink-capable filesystems, linking publishes complete bytes atomically.
- On filesystems without that operation, exclusive open plus writes through the
owned handle preserves compatibility without overwriting another destination.
This fallback exposes partial contents during copying; it does not promise
atomic visibility. It never deletes the destination pathname on failure.
- Failed fallback copies retain complete prepared data and original backup, with
their locations in the error. A concurrent replacement is never removed.
- Successful publication is the commit boundary. Cancellation checked before it
restores the original where possible. Cancellation arriving after publication
does not attempt unsafe pathname-based rollback.
- A validated absent target is distinct from an omitted validation callback.
- This change does not claim to prevent another program writing through an
already-open handle or provide durable power-loss transactions.
## Validation
The focused tests cover publication and restoration boundary races, absent
validation, normal mode-preserving replacement, early/late cancellation,
unsupported-hardlink fallback and write failure with concurrent replacement.
Real unsupported-filesystem hardware has not been exercised; the fallback uses
real files with hardlink capability failure injected.
Related audit themes: #3186 replacement attributes and #3213 interrupted recovery.
These reports do not establish the cause of this separately reproduced defect.
@@ -0,0 +1,41 @@
# SFTP restart recovery must not guess another server
Audit baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
## Confirmed defect
`resolveHostForTransferEndpoint` used a display-name/hostname search when a
recorded host id no longer existed, and returned the first match when multiple
hosts shared a name. Dedicated resume used those credentials to open the target
and start uploading. A saved task for a deleted server could therefore send
its source file to an unrelated same-named server. Both endpoint directions and
folder recovery use this resolver.
Two regressions invoke `resumeTransferWithDedicatedSession`, with the real
resolver, scheduler, credential construction, and resume path but instrumented
bridge I/O. Before the fix, both selected an unrelated endpoint and called
`startStreamTransfer`: a deleted recorded id with one same-named replacement,
and a legacy task without an id with two same-named hosts. Both now stop before
opening or uploading.
## Fix contract
- A recorded host id is authoritative; absence must not fall back to a name.
- Legacy tasks without an id may resolve a name only if exactly one host matches.
- Preserve exact-id recovery, unique-name legacy recovery, and a still-live
original session when vault credentials are absent.
- Never select an arbitrary member of an ambiguous name match.
This does not freeze future edits to the connection options of an existing host
id; a persisted immutable endpoint snapshot would be a separate schema and
migration change. It also does not claim to explain all historical restart
failures from issue #3213 or #2638.
## Verification
`node --test --import tsx application/state/sftp/dedicatedTransferResume.test.ts`:
39 passed, including both regressions and existing single-file/folder restart,
remote-to-remote, source-validation, and live-session fallback tests.
Broader SFTP audit remains in progress in the sibling `codex/sftp-transfer-audit`
worktree; its first control-ordering fix is PR #3284.
@@ -0,0 +1,20 @@
# Shared SSH connection cancellation audit
Cancelling a transfer while its isolated SFTP channel is opening calls the
bounded-open abort path. Previously that path ended/destroyed the physical SSH
transport. Production pooled SFTP sessions normally share a terminal SSH
connection, so cancelling one transfer can disconnect terminals, browsing and
other transfers. The same teardown happened on the channel-open deadline.
The real startTransfer/cancelTransfer path reproduces physical end/destroy calls;
its old test double lacked those methods and could not detect the problem.
The repair rejects the abandoned caller promptly, retains the shared transport,
closes a channel arriving after abandonment, and blocks further channel-open
allocation on that transport while any abandoned request remains unresolved.
This bounds retry accumulation without queuing controls behind a transfer.
Healthy parallel opens remain allowed; callback settlement or transport closure
releases the abandoned-open bookkeeping. Existing active channels remain usable.
This separately reproduced mechanism is relevant to the connection-loss themes
in #2973 and #2832, but does not establish the cause of their VPN/jump-host reports.
@@ -0,0 +1,248 @@
# 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.
+42 -5
View File
@@ -1,6 +1,30 @@
"use strict";
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
// may own unrelated terminals. Block only additional opens until its callback
// or transport closure settles; retain no unbounded retry queue.
const abandonedOpens = new WeakMap();
function abandonOpen(client, token) {
let state = abandonedOpens.get(client);
if (!state) {
state = { tokens: new Set(), onClose: null };
state.onClose = () => {
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
state.tokens.clear();
};
abandonedOpens.set(client, state);
client.once?.("close", state.onClose);
}
state.tokens.add(token);
}
function settleAbandonedOpen(client, token) {
const state = abandonedOpens.get(client);
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
abandonedOpens.delete(client);
client.removeListener?.("close", state.onClose);
}
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
@@ -24,6 +48,12 @@ function openBoundedSftpChannel(sshClient, options = {}) {
return Promise.resolve(null);
}
const signal = options.signal || null;
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
if (abandonedOpens.has(sshClient)) {
const error = new Error("A previous SFTP channel open is still pending on this connection");
error.code = "SFTP_CHANNEL_OPEN_PENDING";
return Promise.reject(error);
}
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const timeoutMs = Math.max(
@@ -33,13 +63,15 @@ function openBoundedSftpChannel(sshClient, options = {}) {
return new Promise((resolve, reject) => {
let settled = false;
let requestPending = false;
const token = Symbol("sftp-open");
let timer = null;
const cleanup = () => {
if (timer) clearTimeoutFn(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, channel = null, { invalidateTransport = false } = {}) => {
const finish = (error, channel = null, { abandon = false } = {}) => {
if (settled) {
closeSftpChannel(channel);
return false;
@@ -47,13 +79,13 @@ function openBoundedSftpChannel(sshClient, options = {}) {
settled = true;
cleanup();
if (error && channel) closeSftpChannel(channel);
if (invalidateTransport) invalidateSshTransport(sshClient);
if (abandon && requestPending) abandonOpen(sshClient, token);
if (error) reject(error);
else resolve(channel);
return true;
};
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
invalidateTransport: true,
abandon: true,
});
if (signal?.aborted) {
@@ -66,15 +98,20 @@ function openBoundedSftpChannel(sshClient, options = {}) {
timer = setTimeoutFn(() => {
const error = new Error(`SFTP channel open timed out after ${timeoutMs}ms`);
error.code = "SFTP_CHANNEL_OPEN_TIMEOUT";
finish(error, null, { invalidateTransport: true });
finish(error, null, { abandon: true });
}, timeoutMs);
try {
requestPending = true;
sshClient.sftp((error, channel) => {
requestPending = false;
settleAbandonedOpen(sshClient, token);
if (error) finish(error, channel || null);
else finish(null, channel || null);
});
} catch (error) {
requestPending = false;
settleAbandonedOpen(sshClient, token);
finish(error);
}
});
+73 -23
View File
@@ -46,7 +46,7 @@ test("bounded SFTP open times out and closes a late channel", async () => {
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
assert.equal(invalidations, 1);
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
const channel = createChannel();
@@ -72,7 +72,7 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
assert.equal([...timers.active][0].hasRef(), true);
controller.abort(new Error("cancelled"));
await assert.rejects(result, /cancelled/);
assert.equal(invalidations, 1);
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
@@ -82,28 +82,44 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
assert.equal(timers.active.size, 0);
});
test("repeated unresponsive SFTP opens cannot retain channel callbacks", async () => {
const sshClient = {
destroyed: false,
pendingChannelCallbacks: [],
sftp(callback) {
if (this.destroyed) throw new Error("Not connected");
this.pendingChannelCallbacks.push(callback);
},
end() {},
destroy() {
this.destroyed = true;
this.pendingChannelCallbacks.length = 0;
},
};
for (const reason of ["timeout", "cancel"]) {
test(`abandoned ${reason} opens cannot accumulate requests on a shared transport`, async () => {
const callbacks = [];
let physicalCloses = 0;
const sshClient = new EventEmitter();
sshClient.sftp = callback => { callbacks.push(callback); };
sshClient.end = sshClient.destroy = () => { physicalCloses++; };
const controller = new AbortController();
const opening = openBoundedSftpChannel(sshClient, { timeoutMs: 5, signal: controller.signal });
if (reason === "cancel") controller.abort(new Error("cancelled"));
await assert.rejects(opening);
for (let attempt = 0; attempt < 3; attempt++) {
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
}
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
for (let attempt = 0; attempt < 3; attempt += 1) {
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 2 }),
/timed out|Not connected/,
);
}
assert.equal(sshClient.pendingChannelCallbacks.length, 0);
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2, "a settled abandoned request must release admission");
const healthy = createChannel();
callbacks[1](null, healthy);
assert.equal(await next, healthy);
assert.equal(physicalCloses, 0);
});
}
test("healthy SFTP channel openings remain parallel", async () => {
const callbacks = [];
const sshClient = { sftp(callback) { callbacks.push(callback); } };
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2);
const channels = [createChannel(), createChannel()];
callbacks.forEach((callback, index) => callback(null, channels[index]));
assert.deepEqual(await Promise.all([first, second]), channels);
});
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
@@ -131,3 +147,37 @@ test("bounded SFTP open converts synchronous setup errors into rejections", asyn
const sshClient = { sftp() { throw new Error("sync failure"); } };
await assert.rejects(openBoundedSftpChannel(sshClient), /sync failure/);
});
test("transport closure releases abandoned-open bookkeeping without a late callback", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
assert.equal(sshClient.listenerCount("close"), 1);
sshClient.emit("close");
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const channel = createChannel();
callbacks[1](null, channel);
assert.equal(await next, channel);
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0);
});
test("all abandoned parallel opens must settle before admitting a new one", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
await Promise.all([assert.rejects(first), assert.rejects(second)]);
assert.equal(sshClient.listenerCount("close"), 1);
callbacks[0](new Error("first failed"));
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
callbacks[1](new Error("second failed"));
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient);
callbacks[2](null, createChannel());
await next;
});
+73
View File
@@ -0,0 +1,73 @@
"use strict";
const fs = require("node:fs");
// Publish a prepared regular file without replacing any destination entry.
// Hardlinks make complete bytes visible atomically. FAT-like filesystems use an
// exclusively created handle instead: partial contents can be visible there,
// but no pathname cleanup may remove a concurrent writer's replacement.
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
// verify an open destination handle before later metadata stamping.
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
assertNotCancelled();
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) throw error;
}
let input;
let output;
let failure;
let publishedIdentity;
try {
input = preparedHandle ?? await fs.promises.open(source, "r");
const stat = await input.stat();
assertNotCancelled();
output = await fs.promises.open(target, "wx", stat.mode & 0o7777);
const buffer = Buffer.allocUnsafe(1024 * 1024);
let position = 0;
while (position < stat.size) {
assertNotCancelled();
const { bytesRead } = await input.read(buffer, 0, Math.min(buffer.length, stat.size - position), position);
if (!bytesRead) throw new Error("Prepared local file ended before publication completed");
let written = 0;
while (written < bytesRead) {
assertNotCancelled();
const { bytesWritten } = await output.write(buffer, written, bytesRead - written, position + written);
if (!bytesWritten) throw new Error("Local publication made no write progress");
written += bytesWritten;
}
position += bytesRead;
}
assertNotCancelled();
// Restore metadata after writes (which may clear special permission bits),
// through the owned handle rather than a potentially replaced pathname.
await output.chmod(stat.mode & 0o7777);
await output.utimes(stat.atime, stat.mtime);
const ownedStat = await output.stat();
const targetStat = await fs.promises.lstat(target);
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
throw new Error("Local download target changed during replacement");
}
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
} catch (error) {
failure = error;
} finally {
for (const handle of [output, preparedHandle ? undefined : input]) {
try { await handle?.close(); } catch (error) { failure ??= error; }
}
}
if (failure) {
// Once exclusive creation succeeded, leave the partial destination intact.
// The caller must retain its complete prepared file and any original backup.
if (output) failure.localPublicationIncomplete = true;
throw failure;
}
return publishedIdentity;
}
module.exports = { publishLocalFileExclusive };
+93 -6
View File
@@ -77,7 +77,41 @@ 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);
@@ -86,23 +120,76 @@ 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: #1e1e1e;
color: #d4d4d4;
font-family: 'JetBrains Mono', 'SF Mono', Monaco, Menlo, monospace;
background: var(--term-default-bg);
color: var(--term-default-fg);
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono',
Consolas, Monaco, Menlo, monospace;
font-size: 13px;
line-height: 1.4;
line-height: 1.5;
padding: 20px;
white-space: pre-wrap;
word-wrap: break-word;
}
.header {
border-bottom: 1px solid #444;
border-bottom: 1px solid var(--term-border);
padding-bottom: 10px;
margin-bottom: 20px;
color: #888;
color: var(--term-header-fg);
}
</style>
</head>
@@ -919,3 +919,45 @@ 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,11 +755,10 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
}),
/cancel|abort/i,
);
assert.equal(renameCalls >= 3, true, "the backup should be restored after cancellation");
assert.deepEqual(fs.readFileSync(localPath), original);
});
it("downloadSftpToLocal rolls back a published file when cancellation wins the final rename", async (t) => {
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
const controller = new AbortController();
const downloaded = Buffer.from("new-downloaded-content");
const backend = {
@@ -778,26 +777,27 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
const original = Buffer.from("existing-local-content");
fs.writeFileSync(localPath, original);
const originalRename = fs.promises.rename;
let renameCalls = 0;
fs.promises.rename = async (...args) => {
await originalRename(...args);
renameCalls += 1;
if (renameCalls === 3) controller.abort();
const originalLink = fs.promises.link;
let published = false;
fs.promises.link = async (...args) => {
await originalLink(...args);
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
published = true;
controller.abort();
}
};
t.after(() => { fs.promises.rename = originalRename; });
t.after(() => { fs.promises.link = originalLink; });
await assert.rejects(
await assert.doesNotReject(
() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-final-rename-abort",
remotePath: "/remote/file.bin",
localPath,
abortSignal: controller.signal,
}),
/cancel|abort/i,
);
assert.equal(renameCalls >= 4, true, "the published file should be rolled back to the backup");
assert.deepEqual(fs.readFileSync(localPath), original);
assert.equal(published, true);
assert.deepEqual(fs.readFileSync(localPath), downloaded);
});
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
@@ -820,20 +820,20 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
fs.writeFileSync(localPath, original);
const originalRename = fs.promises.rename;
let renameCalls = 0;
const originalLink = fs.promises.link;
let backupPath = null;
fs.promises.rename = async (...args) => {
renameCalls += 1;
if (renameCalls === 4) {
const error = new Error("injected backup restore failure");
error.code = "EIO";
throw error;
}
await originalRename(...args);
if (renameCalls === 2) backupPath = args[1];
if (renameCalls === 3) controller.abort();
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
backupPath = args[1];
controller.abort();
}
};
t.after(() => { fs.promises.rename = originalRename; });
fs.promises.link = async (...args) => {
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
return originalLink(...args);
};
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -843,7 +843,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
abortSignal: controller.signal,
}),
(error) => {
assert.match(error.message, /Could not restore the original file/);
assert.match(error.message, /Recovery files preserved/);
assert.match(error.message, /Backup:/);
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
return true;
@@ -877,11 +877,6 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
let backupPath = null;
fs.promises.rename = async (...args) => {
renameCalls += 1;
if (renameCalls === 3) {
const error = new Error("injected backup restore failure");
error.code = "EIO";
throw error;
}
await originalRename(...args);
if (renameCalls === 1) readyPath = args[1];
if (renameCalls === 2) {
@@ -889,7 +884,12 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
controller.abort();
}
};
t.after(() => { fs.promises.rename = originalRename; });
const originalLink = fs.promises.link;
fs.promises.link = async (...args) => {
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
return originalLink(...args);
};
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -899,7 +899,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
abortSignal: controller.signal,
}),
(error) => {
assert.match(error.message, /Could not restore the original file/);
assert.match(error.message, /Recovery files preserved/);
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
return true;
+30 -8
View File
@@ -12,6 +12,9 @@
*/
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 = [
@@ -35,6 +38,11 @@ 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 = [[]];
@@ -307,17 +315,17 @@ class TerminalTextRenderer {
} else if (code === 27) {
this.style.inverse = false;
} else if (code >= 30 && code <= 37) {
this.style.fg = BASIC_COLORS[code - 30];
this.style.fg = ansiColorVar(code - 30);
} else if (code === 39) {
this.style.fg = null;
} else if (code >= 40 && code <= 47) {
this.style.bg = BASIC_COLORS[code - 40];
this.style.bg = ansiColorVar(code - 40);
} else if (code === 49) {
this.style.bg = null;
} else if (code >= 90 && code <= 97) {
this.style.fg = BRIGHT_COLORS[code - 90];
this.style.fg = ansiColorVar(8 + code - 90);
} else if (code >= 100 && code <= 107) {
this.style.bg = BRIGHT_COLORS[code - 100];
this.style.bg = ansiColorVar(8 + code - 100);
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
const color = colorFromAnsi256(codes[i + 2]);
if (color) {
@@ -585,8 +593,23 @@ function getTrimmedLineLength(line) {
function styleToCss(style) {
if (!style) return "";
const declarations = [];
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
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})`;
}
if (fg) declarations.push(`color: ${fg}`);
if (bg) declarations.push(`background-color: ${bg}`);
if (style.bold) declarations.push("font-weight: 700");
@@ -608,8 +631,7 @@ function stylesEqual(a, b) {
function colorFromAnsi256(value) {
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
if (value < 8) return BASIC_COLORS[value];
if (value < 16) return BRIGHT_COLORS[value - 8];
if (value < 16) return ansiColorVar(value);
if (value < 232) {
const n = value - 16;
const r = Math.floor(n / 36);
+42 -2
View File
@@ -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*#cd3131[^"]*"[^>]*>shell-after/.test(html), false);
assert.match(html, /color:\s*#cd3131[^"]*"[^>]*>before/);
assert.equal(/color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>shell-after/.test(html), false);
assert.match(html, /color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>before/);
});
test("RIS rebases screen so later cursor-home does not overwrite history", () => {
@@ -248,3 +248,43 @@ 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/);
});
+237 -221
View File
@@ -950,219 +950,151 @@ function stableLocalFileIdentity(statLike) {
}
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
const assertNotCancelled = typeof options.assertNotCancelled === "function"
? options.assertNotCancelled
: () => {};
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
const assertNotCancelled = options.assertNotCancelled || (() => {});
const token = crypto.randomUUID().replace(/-/g, "");
const targetDir = path.dirname(targetPath);
const targetBase = path.basename(targetPath);
const readyPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.ready`);
const backupPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.backup`);
let preparedPath = stagedPath;
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
const readyPath = `${base}.ready`;
const backupPath = `${base}.backup`;
const restoreProbePath = `${base}.restore-check`;
let backedUp = false;
let published = false;
let publishedIdentity = null;
let committed = false;
let keepRecoveryFiles = false;
let preparedHandle;
let originalHandle;
let restoreProbeCreated = false;
let localMtimePrepared = false;
try {
assertNotCancelled();
try {
await fs.promises.rename(stagedPath, readyPath);
} catch (err) {
if (err?.code !== "EXDEV") throw err;
await fs.promises.copyFile(stagedPath, readyPath);
preparedPath = readyPath;
} catch (error) {
if (error?.code !== "EXDEV") throw error;
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
}
if (preparedPath !== readyPath) preparedPath = readyPath;
// Stamp the private prepared file before applying possibly unreadable
// destination permissions. Publication carries these times to the target.
const mtimeMs = Number(options.sourceSoftIdentity?.mtimeMs);
if (Number.isFinite(mtimeMs) && mtimeMs >= 1000) {
const when = new Date(Math.floor(mtimeMs / 1000) * 1000);
await awaitBestEffortBounded(
fs.promises.utimes(readyPath, when, when), 15_000, "Prepared destination utimes",
).then(() => { localMtimePrepared = true; }).catch(() => {});
}
// Keep read access to our private bytes before destination permissions
// can remove it; the no-hardlink fallback copies through this handle.
preparedHandle = await fs.promises.open(readyPath, "r");
let appliedMode = null;
let targetStable = false;
let expectedStableIdentity = null;
let validatedTarget;
let stable = false;
for (let attempt = 0; attempt < 3; attempt += 1) {
const validatedTarget = typeof options.validateTarget === "function"
validatedTarget = typeof options.validateTarget === "function"
? await options.validateTarget()
: null;
const existingMode = Number.isInteger(validatedTarget?.existingMode)
: undefined;
const mode = Number.isInteger(validatedTarget?.existingMode)
? validatedTarget.existingMode & 0o7777
: Number.isInteger(options.existingMode)
? options.existingMode & 0o7777
: null;
if (existingMode !== null && existingMode !== appliedMode) {
await fs.promises.chmod(readyPath, existingMode);
appliedMode = existingMode;
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
if (mode !== null && mode !== appliedMode) {
await fs.promises.chmod(readyPath, mode);
appliedMode = mode;
continue;
}
expectedStableIdentity = validatedTarget?.stableIdentity
|| (validatedTarget?.targetIdentity
? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":")
: null);
targetStable = true;
stable = true;
break;
}
if (!targetStable) {
throw new Error("Local download target kept changing before replacement");
}
if (!stable) throw new Error("Local download target kept changing before replacement");
assertNotCancelled();
try {
await fs.promises.rename(targetPath, backupPath);
backedUp = true;
} catch (err) {
if (err?.code !== "ENOENT") throw err;
}
// Another process may have replaced the destination between validateTarget
// and rename. Re-check the moved backup before publishing the download.
if (backedUp && expectedStableIdentity) {
let backupStat;
const expectedAbsent = validatedTarget?.targetIdentity === "missing" || validatedTarget?.targetIdentity === null;
const expectedIdentity = validatedTarget?.stableIdentity
|| (validatedTarget?.targetIdentity ? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":") : null);
if (!expectedAbsent) {
try {
backupStat = await fs.promises.lstat(backupPath);
} catch (err) {
throw new Error(
`Local download target disappeared during replacement: ${targetPath}`,
{ cause: err },
);
originalHandle = await fs.promises.open(targetPath, "r");
} catch (error) {
if (error?.code === "EACCES" || error?.code === "EPERM") {
// An unreadable original cannot use copy-based recovery. Verify the
// non-overwriting alternative before moving its only visible name.
try {
await fs.promises.link(targetPath, restoreProbePath);
restoreProbeCreated = true;
await fs.promises.unlink(restoreProbePath);
restoreProbeCreated = false;
} catch (restoreError) {
throw new Error("Cannot safely replace unreadable local destination: hardlink recovery unavailable", { cause: restoreError });
}
} else if (error?.code !== "ENOENT") throw error;
}
if (!backupStat.isFile() || stableLocalFileIdentity(backupStat) !== expectedStableIdentity) {
// If another writer already recreated targetPath, do not restore the
// mismatched backup over it — leave both intact and fail closed.
let targetOccupied = false;
try {
await fs.promises.lstat(targetPath);
targetOccupied = true;
} catch (err) {
if (err?.code !== "ENOENT") throw err;
}
if (targetOccupied) {
const conflict = new Error("Local download target changed during replacement");
conflict.leaveConcurrentTarget = true;
conflict.remoteBackupPath = backupPath;
backedUp = false;
throw conflict;
}
try {
await fs.promises.rename(backupPath, targetPath);
backedUp = false;
} catch (restoreErr) {
const recoveryFailure = new Error(
`Could not restore the original file after a concurrent replacement was detected. `
+ `Backup: ${backupPath}; target: ${targetPath}`,
{ cause: restoreErr },
);
recoveryFailure.recoveryFailed = true;
throw recoveryFailure;
}
assertNotCancelled();
try {
await fs.promises.rename(targetPath, backupPath);
backedUp = true;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
if (backedUp && expectedIdentity) {
const stat = await fs.promises.lstat(backupPath);
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
throw new Error("Local download target changed during replacement");
}
}
// After the backup move, a concurrent writer may recreate targetPath. Never
// clobber that file: leave it in place, keep the original in backupPath for
// recovery, and fail closed before publishing the download.
if (backedUp) {
let recreated = false;
try {
await fs.promises.lstat(targetPath);
recreated = true;
} catch (err) {
if (err?.code !== "ENOENT") throw err;
}
if (recreated) {
const conflict = new Error("Local download target changed during replacement");
conflict.leaveConcurrentTarget = true;
conflict.remoteBackupPath = backupPath;
// Prevent the catch path from renaming backup over the concurrent file.
backedUp = false;
throw conflict;
}
}
assertNotCancelled();
// Capture identity of the ready file before publish so rollback can refuse
// to clobber a concurrent replacement of the published target.
let publishedIdentity = null;
try {
publishedIdentity = stableLocalFileIdentity(await fs.promises.lstat(readyPath));
} catch {
publishedIdentity = null;
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("Local download target changed during replacement", { cause: error });
}
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
throw error;
}
await fs.promises.rename(readyPath, targetPath);
published = true;
assertNotCancelled();
options.onCommit?.();
// Publication is the commit boundary. A late cancel must not perform a
// check-then-unlink rollback against a name another process may now own.
committed = true;
// Hand the published inode identity to the caller for descriptor-based
// metadata stamping after publication.
options.onCommit?.(publishedIdentity, localMtimePrepared);
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
if (stagedPath !== readyPath) await fs.promises.unlink(stagedPath).catch(() => {});
} catch (err) {
let restoreError = null;
if (err?.leaveConcurrentTarget) {
// Caller already decided not to touch a concurrent target/backup pair.
} else if (backedUp) {
let targetMatchesPublished = false;
let targetMissing = false;
await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(stagedPath).catch(() => {});
} catch (error) {
if (committed) throw error;
if (backedUp && !keepRecoveryFiles) {
try {
const targetStat = await fs.promises.lstat(targetPath);
targetMatchesPublished = !!(
published
&& publishedIdentity
&& stableLocalFileIdentity(targetStat) === publishedIdentity
);
} catch (statErr) {
if (statErr?.code === "ENOENT") targetMissing = true;
else restoreError = statErr;
}
if (!restoreError) {
if (published && !targetMissing && !targetMatchesPublished) {
// Concurrent writer replaced our published file — keep both.
err.leaveConcurrentTarget = true;
err.remoteBackupPath = backupPath;
backedUp = false;
} else {
if (published && targetMatchesPublished) {
await fs.promises.unlink(targetPath).catch(() => {});
}
if (targetMissing || targetMatchesPublished || !published) {
try {
// Only restore when the path is free or still holds our publish.
if (!published) {
let occupied = false;
try {
await fs.promises.lstat(targetPath);
occupied = true;
} catch (occErr) {
if (occErr?.code !== "ENOENT") throw occErr;
}
if (occupied) {
err.leaveConcurrentTarget = true;
err.remoteBackupPath = backupPath;
backedUp = false;
} else {
await fs.promises.rename(backupPath, targetPath);
backedUp = false;
}
} else {
await fs.promises.rename(backupPath, targetPath);
backedUp = false;
}
} catch (recoveryErr) {
restoreError = recoveryErr;
}
}
// The pathname may have changed between open and rename. Never copy
// an earlier inode over the original actually moved into the backup.
let restoreHandle;
if (originalHandle) {
const [heldStat, backupStat] = await Promise.all([
originalHandle.stat(), fs.promises.lstat(backupPath),
]);
if (stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(backupStat)) restoreHandle = originalHandle;
}
}
} else if (published) {
try {
const targetStat = await fs.promises.lstat(targetPath);
if (publishedIdentity && stableLocalFileIdentity(targetStat) === publishedIdentity) {
await fs.promises.unlink(targetPath);
}
} catch (recoveryErr) {
if (recoveryErr?.code !== "ENOENT") restoreError = recoveryErr;
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
await fs.promises.unlink(backupPath).catch(() => {});
backedUp = false;
} catch (restoreError) {
keepRecoveryFiles = true;
error.cause ??= restoreError;
}
}
if (restoreError) {
const recoveryFailure = new Error(
`Could not restore the original file after replacement failed. `
+ `Backup: ${backupPath}; prepared replacement: ${readyPath}; `
+ `original staged path: ${stagedPath}; target: ${targetPath}`,
{ cause: restoreError },
if (keepRecoveryFiles) {
const failure = new Error(
`${error.message}. Recovery files preserved. Backup: ${backedUp ? backupPath : "none"}; `
+ `prepared replacement: ${readyPath}; target: ${targetPath}`,
{ cause: error },
);
recoveryFailure.recoveryFailed = true;
throw recoveryFailure;
failure.recoveryFailed = true;
if (backedUp) failure.remoteBackupPath = backupPath;
throw failure;
}
await fs.promises.unlink(readyPath).catch(() => {});
throw err;
throw error;
} finally {
await preparedHandle?.close().catch(() => {});
await originalHandle?.close().catch(() => {});
if (restoreProbeCreated) await fs.promises.unlink(restoreProbePath).catch(() => {});
}
}
@@ -1207,11 +1139,30 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
: 15_000;
if (transfer.targetType === "local" && transfer.targetPath) {
await awaitBestEffortBounded(
fs.promises.utimes(transfer.targetPath, when, when),
mtimeTimeoutMs,
"Destination utimes",
);
if (transfer.localMtimePrepared) return;
// Verify and stamp the same open file. A later pathname replacement
// must never receive metadata belonging to this completed transfer.
await awaitBestEffortBounded((async () => {
let handle;
try {
handle = await fs.promises.open(transfer.targetPath, "r");
} catch (error) {
if (error?.code !== "EACCES" && error?.code !== "EPERM") throw error;
// O_WRONLY neither creates nor truncates a write-only destination.
handle = await fs.promises.open(transfer.targetPath, fs.constants.O_WRONLY);
}
try {
const currentStat = await handle.stat();
const publishedIdentity = transfer.publishedLocalIdentity;
const expectedIdentity = typeof publishedIdentity === "string"
? publishedIdentity : stableLocalFileIdentity(publishedIdentity);
if (!currentStat.isFile()
|| (expectedIdentity && stableLocalFileIdentity(currentStat) !== expectedIdentity)) return;
await handle.utimes(when, when);
} finally {
await handle.close();
}
})(), mtimeTimeoutMs, "Destination utimes");
return;
}
@@ -6082,6 +6033,7 @@ async function startTransferNow(event, payload, onProgress) {
} = await inspectLocalPromotionTarget(targetPath);
if (transfer.cancelled) throw new Error("Transfer cancelled");
await promoteLocalTransfer(downloadTargetPath, promotionTargetPath, {
sourceSoftIdentity: transfer.sourceSoftIdentity,
existingMode,
async validateTarget() {
const latestTarget = await inspectLocalPromotionTarget(targetPath);
@@ -6096,8 +6048,10 @@ async function startTransferNow(event, payload, onProgress) {
assertNotCancelled() {
if (transfer.cancelled) throw new Error("Transfer cancelled");
},
onCommit() {
onCommit(publishedIdentity, localMtimePrepared) {
transfer.completionCommitted = true;
transfer.localMtimePrepared = localMtimePrepared;
transfer.publishedLocalIdentity = publishedIdentity ?? null;
},
});
transfer.stagedLocalPath = null;
@@ -6185,11 +6139,14 @@ async function startTransferNow(event, payload, onProgress) {
});
if (transfer.resumable && transfer.stagedLocalPath) {
await promoteLocalTransfer(transfer.stagedLocalPath, targetPath, {
sourceSoftIdentity: transfer.sourceSoftIdentity,
assertNotCancelled() {
if (transfer.cancelled) throw new Error("Transfer cancelled");
},
onCommit() {
onCommit(publishedIdentity, localMtimePrepared) {
transfer.completionCommitted = true;
transfer.localMtimePrepared = localMtimePrepared;
transfer.publishedLocalIdentity = publishedIdentity ?? null;
},
});
transfer.stagedLocalPath = null;
@@ -6792,7 +6749,13 @@ async function pauseTransfer(_event, payload) {
) {
return { success: false, reason: "This transfer cannot be paused yet" };
}
if (transfer.pauseOperation) return transfer.pauseOperation;
// 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.paused && transfer.lifecycleState === "paused") {
const result = {
success: true,
@@ -6844,7 +6807,7 @@ async function pauseTransfer(_event, payload) {
if (usesContiguousRangeCheckpoint) {
await transfer.waitForPause();
if (!transfer.paused || transfer.pauseSuperseded) {
return { success: false, reason: "Pause was superseded by resume" };
return { success: false, superseded: true, supersededBy: "resume", 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.
@@ -6959,7 +6922,7 @@ async function pauseTransfer(_event, payload) {
return { success: false, reason: "Transfer is no longer active" };
}
if (!transfer.paused || transfer.pauseSuperseded) {
return { success: false, reason: "Pause was superseded by resume" };
return { success: false, superseded: true, supersededBy: "resume", 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
@@ -7014,6 +6977,20 @@ 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 { }
@@ -7023,6 +7000,8 @@ 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";
@@ -7057,7 +7036,7 @@ async function resumeTransfer(_event, payload) {
await transfer.captureSourceFingerprint?.();
}
if (!transfer.sourceFingerprint) {
return { success: false, reason: "Could not verify the source file for resume" };
return invalidResumeResult("Could not verify the source file for resume");
}
await transfer.verifySourceFingerprint(transfer.sourceFingerprint);
}
@@ -7065,12 +7044,11 @@ async function resumeTransfer(_event, payload) {
if (transfer.cancelled || activeTransfers.get(payload?.transferId) !== transfer) {
return { success: false, reason: "Transfer is no longer active" };
}
return {
success: false,
reason: error?.message || "Could not verify the source file for resume",
};
return invalidResumeResult(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.
@@ -7081,15 +7059,14 @@ async function resumeTransfer(_event, payload) {
{ maxWaitMs: RESUME_RANGE_SETTLE_MS },
);
if (!settled?.ok) {
return {
success: false,
reason: settled?.reason || "The current file is still finishing. Try resume again.",
};
return invalidResumeResult(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;
@@ -7264,6 +7241,37 @@ 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);
@@ -7289,6 +7297,7 @@ 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
@@ -7314,20 +7323,22 @@ function registerHandlers(ipcMain, options = {}) {
ipcMain.handle("netcatty:transfer:cancel", (event, payload) => (
cancelQueuedTransfer(payload?.transferId)
? { success: true }
: workerRequest(event, "netcatty:transfer:cancel", payload)
: withWorkerControl(payload?.transferId, "cancel", () => workerRequest(event, "netcatty:transfer:cancel", payload))
));
ipcMain.handle("netcatty:transfer:pause", async (event, payload) => {
const queued = pauseQueuedTransfer(payload?.transferId);
if (queued) return queued;
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
broadcastGlobalTransferEvent({
type: "pausing",
transferId: payload?.transferId,
lifecycleEpoch,
lifecycleState: "pausing",
return withWorkerControl(payload?.transferId, "pause", async (isCurrent, superseded) => {
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({
@@ -7351,6 +7362,7 @@ function registerHandlers(ipcMain, options = {}) {
});
return { ...result, lifecycleEpoch };
} catch (error) {
if (!isCurrent()) throw error;
const rollbackEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
broadcastGlobalTransferEvent({
type: "resumed",
@@ -7360,24 +7372,28 @@ function registerHandlers(ipcMain, options = {}) {
});
throw error;
}
});
});
ipcMain.handle("netcatty:transfer:resume", async (event, payload) => {
const queuedResume = resumeQueuedTransfer(payload?.transferId);
if (queuedResume) return queuedResume;
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;
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;
});
});
ipcMain.handle("netcatty:transfer:prioritize", (event, payload) => (
prioritizeQueuedTransfer(payload?.transferId)
@@ -299,3 +299,85 @@ 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");
});
}
@@ -0,0 +1,385 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const bridge = require("./transferBridge.cjs");
const temp = require("./tempDirBridge.cjs");
for (const restore of [false, true]) {
test(`local ${restore ? "restore" : "publish"} never overwrites a last-moment concurrent file`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-race")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original");
let injected = false;
for (const method of ["rename", "link"]) {
const original = fs.promises[method];
t.after(() => { fs.promises[method] = original; });
fs.promises[method] = async (...args) => {
if (!injected && args[1] === target && String(args[0]).endsWith(restore ? ".backup" : ".ready")) {
injected = true;
fs.writeFileSync(target, "concurrent");
}
return original.apply(fs.promises, args);
};
}
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target,
restore ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}));
assert.equal(injected, true);
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
const backup = fs.readdirSync(root).find(name => name.endsWith(".backup"));
assert.ok(backup, "retain original for recovery");
assert.equal(fs.readFileSync(path.join(root, backup), "utf8"), "original");
});
}
test("an explicitly absent destination is never moved aside if another writer creates it", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-absent")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
validateTarget: async () => {
fs.writeFileSync(target, "concurrent");
return { targetIdentity: "missing", existingMode: null };
},
}));
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
});
for (const failCopy of [false, true]) {
test(`exclusive copy fallback ${failCopy ? "retains recovery files after a write failure" : "publishes complete bytes without hardlinks"}`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-fallback")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
const payload = Buffer.alloc(2 * 1024 * 1024 + 7, 173);
fs.writeFileSync(staged, payload);
fs.writeFileSync(target, "original");
const link = fs.promises.link;
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
t.after(() => { fs.promises.link = link; });
if (failCopy) {
const open = fs.promises.open;
fs.promises.open = async (...args) => {
const handle = await open.apply(fs.promises, args);
if (args[0] === target && args[1] === "wx") {
handle.write = async () => {
// A replacement entry must survive even when the owned old handle fails.
fs.unlinkSync(target);
fs.writeFileSync(target, "concurrent");
throw new Error("disk write failed");
};
}
return handle;
};
t.after(() => { fs.promises.open = open; });
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target), /Recovery files preserved/);
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
const names = fs.readdirSync(root);
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
} else {
await bridge._promoteLocalTransferForTests(staged, target);
assert.deepEqual(fs.readFileSync(target), payload);
assert.deepEqual(fs.readdirSync(root), ["target"]);
}
});
}
test("cancellation before publication restores the original destination", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-cancel")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original");
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
assertNotCancelled: () => { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
}), /cancelled/);
assert.equal(fs.readFileSync(target, "utf8"), "original");
});
for (const interruption of ["cancel", "replace", "restore-replace"]) {
test(`fallback ${interruption} during copying preserves complete recovery files`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mid-copy")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
const payload = Buffer.alloc(3 * 1024 * 1024, 42);
fs.writeFileSync(staged, payload);
fs.writeFileSync(target, "original");
const link = fs.promises.link;
const open = fs.promises.open;
let cancelled = false;
let writes = 0;
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
fs.promises.open = async (...args) => {
const handle = await open.apply(fs.promises, args);
if (args[0] === target && args[1] === "wx") {
const write = handle.write.bind(handle);
handle.write = async (...writeArgs) => {
const result = await write(...writeArgs);
if (++writes === 1) {
if (interruption === "cancel") cancelled = true;
else { fs.unlinkSync(target); fs.writeFileSync(target, "concurrent"); }
}
return result;
};
}
return handle;
};
t.after(() => { fs.promises.link = link; fs.promises.open = open; });
let committed = false;
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
...(interruption === "restore-replace" ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}),
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
onCommit() { committed = true; },
}), /Recovery files preserved/);
assert.equal(committed, false);
if (interruption === "cancel") assert.equal(writes, 1);
else assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
const names = fs.readdirSync(root);
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
});
}
test("cancelled replacement restores original timestamps through the copy fallback", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restore-times")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original", { mode: 0o640 });
const atime = new Date("2019-01-02T00:00:00Z");
const mtime = new Date("2020-02-03T00:00:00Z");
fs.utimesSync(target, atime, mtime);
const link = fs.promises.link;
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
t.after(() => { fs.promises.link = link; });
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
assertNotCancelled() { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
}), /cancelled/);
const stat = fs.statSync(target);
assert.equal(stat.mtimeMs, mtime.getTime());
assert.equal(stat.atimeMs, atime.getTime());
assert.equal(fs.readFileSync(target, "utf8"), "original");
});
for (const replaceAfterCommit of [false, true]) {
test(`local publication prepares timestamps before restrictive permissions${replaceAfterCommit ? " and leaves post-commit replacement alone" : ""}`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mtime")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
const transfer = {
targetType: "local", targetPath: target,
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
};
await bridge._promoteLocalTransferForTests(staged, target, {
existingMode: 0,
sourceSoftIdentity: transfer.sourceSoftIdentity,
onCommit(identity) {
transfer.publishedLocalIdentity = identity;
transfer.localMtimePrepared = true;
if (replaceAfterCommit) {
fs.renameSync(target, path.join(root, "published"));
fs.writeFileSync(target, "concurrent");
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
}
},
});
await bridge._preserveTransferredDestinationMtimeForTests(transfer);
const stat = fs.statSync(target);
assert.equal(Math.floor(stat.mtimeMs / 1000), replaceAfterCommit ? 1_600_000_000 : 1_700_000_000);
if (!replaceAfterCommit) assert.equal(stat.mode & 0o777, 0);
});
}
test("fallback close failure retains the original backup and prepared replacement", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-close")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original");
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
const open = fs.promises.open;
t.mock.method(fs.promises, "open", async (...args) => {
const handle = await open(...args);
if (args[0] === target && args[1] === "wx") {
const close = handle.close.bind(handle);
handle.close = async () => { await close(); throw new Error("close failed"); };
}
return handle;
});
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target), (error) => {
assert.equal(error.recoveryFailed, true);
assert.equal(fs.readFileSync(error.remoteBackupPath, "utf8"), "original");
return true;
});
const ready = fs.readdirSync(root).find((name) => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(root, ready), "utf8"), "download");
});
for (const mode of [0o200, 0]) {
test(`copy fallback publishes prepared bytes with destination mode ${mode.toString(8)}`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mode")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
await bridge._promoteLocalTransferForTests(staged, target, {
existingMode: mode, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
});
const stat = fs.statSync(target);
assert.equal(stat.mode & 0o777, mode);
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
fs.chmodSync(target, 0o600);
assert.equal(fs.readFileSync(target, "utf8"), "download");
});
}
test("direct local timestamp preservation supports a write-only target", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "target");
fs.writeFileSync(target, "download");
fs.chmodSync(target, 0o200);
await bridge._preserveTransferredDestinationMtimeForTests({
targetType: "local", targetPath: target,
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
});
const stat = fs.statSync(target);
assert.equal(stat.mode & 0o777, 0o200);
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
});
test("write-only timestamp fallback leaves a later pathname replacement unchanged", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly-race")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const target = path.join(root, "target");
const published = path.join(root, "published");
fs.writeFileSync(target, "download");
fs.chmodSync(target, 0o200);
let replaced = false;
const open = fs.promises.open;
t.mock.method(fs.promises, "open", async (...args) => {
if (args[0] === target && args[1] === "r") {
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
}
const handle = await open(...args);
if (args[0] === target && args[1] === fs.constants.O_WRONLY) {
const utimes = handle.utimes.bind(handle);
handle.utimes = async (...times) => {
fs.renameSync(target, published);
fs.writeFileSync(target, "concurrent");
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
replaced = true;
return utimes(...times);
};
}
return handle;
});
await bridge._preserveTransferredDestinationMtimeForTests({
targetType: "local", targetPath: target,
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
});
assert.equal(replaced, true);
assert.equal(Math.floor(fs.statSync(target).mtimeMs / 1000), 1_600_000_000);
assert.equal(Math.floor(fs.statSync(published).mtimeMs / 1000), 1_700_000_000);
});
test("copy fallback refuses to move an unreadable existing destination", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restrictive")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
const payload = Buffer.alloc(1024 * 1024 + 3, 7);
fs.writeFileSync(staged, payload);
fs.writeFileSync(target, "original", { mode: 0o200 });
const link = fs.promises.link;
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
t.after(() => { fs.promises.link = link; });
const open = fs.promises.open;
t.mock.method(fs.promises, "open", async (...args) => {
if (args[0] === target && args[1] === "r") throw Object.assign(new Error("read access denied"), { code: "EACCES" });
return open(...args);
});
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, { existingMode: 0o200 }), /hardlink recovery unavailable/);
const stat = fs.statSync(target);
assert.equal(stat.mode & 0o777, 0o200);
assert.equal(stat.size, "original".length);
fs.chmodSync(target, 0o600); // grant readback for verification
assert.equal(fs.readFileSync(target, "utf8"), "original");
assert.deepEqual(fs.readdirSync(root), ["target"]);
});
for (const mode of [0o200, 0]) {
test(`unreadable original remains at destination when safe restoration is unavailable: ${mode.toString(8)}`, async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-unreadable")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original");
fs.chmodSync(target, mode);
const open = fs.promises.open;
t.mock.method(fs.promises, "open", async (...args) => {
if (args[1] === "r" && (args[0] === target || String(args[0]).endsWith(".backup"))) {
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
}
return open(...args);
});
let cancelled = false;
const rename = fs.promises.rename;
t.mock.method(fs.promises, "rename", async (...args) => {
const result = await rename(...args);
if (args[0] === target) cancelled = true;
return result;
});
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
existingMode: mode,
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
}));
assert.equal(fs.existsSync(target), true, "unrestorable original must not be moved away");
assert.equal(fs.statSync(target).mode & 0o777, mode);
fs.chmodSync(target, 0o600);
assert.equal(fs.readFileSync(target, "utf8"), "original");
});
}
test("copy recovery retains original read access after its permissions become restrictive", async (t) => {
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-held-handle")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, "staged");
const target = path.join(root, "target");
fs.writeFileSync(staged, "download");
fs.writeFileSync(target, "original");
let cancelled = false;
const rename = fs.promises.rename;
t.mock.method(fs.promises, "rename", async (...args) => {
const result = await rename(...args);
if (args[0] === target) {
fs.chmodSync(args[1], 0);
cancelled = true;
}
return result;
});
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
}), /Transfer cancelled/);
assert.equal(fs.statSync(target).mode & 0o777, 0);
fs.chmodSync(target, 0o600);
assert.equal(fs.readFileSync(target, "utf8"), "original");
assert.deepEqual(fs.readdirSync(root), ["target"]);
});
+304 -47
View File
@@ -1607,6 +1607,125 @@ 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 () => {
@@ -1766,6 +1885,8 @@ 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();
@@ -10532,6 +10653,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
let delayedOpen = null;
let abandonedChannelClosed = false;
let openCalls = 0;
let physicalEndCalls = 0;
let physicalDestroyCalls = 0;
const abandonedSftp = createFastSftp({
end() {
abandonedChannelClosed = true;
@@ -10551,6 +10674,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
return Promise.resolve({ size: 10 });
},
client: {
end() { physicalEndCalls++; },
destroy() { physicalDestroyCalls++; },
sftp(callback) {
openCalls += 1;
if (openCalls === 1) {
@@ -10588,6 +10713,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
const cancelled = await cancelledPromise;
assert.equal(cancelled.error, "Transfer cancelled");
assert.equal(abandonedChannelClosed, true);
assert.equal(physicalEndCalls, 0, "cancelling one download must preserve the shared SSH connection");
assert.equal(physicalDestroyCalls, 0, "terminal and browsing channels must not be destroyed");
const next = await Promise.race([
start("download-after-cancel"),
@@ -12269,55 +12396,31 @@ test("local promotion does not clobber a target recreated after backup", async (
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
});
test("local promotion rollback does not clobber a concurrent post-publish target", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-postpub-"));
t.after(async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => {
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`);
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const stagedPath = path.join(tempDir, "download.staged");
const targetPath = path.join(tempDir, "target.bin");
const original = Buffer.from("original-target-content");
const concurrent = Buffer.from("post-publish-concurrent");
const downloaded = Buffer.from("downloaded-replacement");
await fs.promises.writeFile(targetPath, original);
await fs.promises.writeFile(stagedPath, downloaded);
const originalStat = await fs.promises.lstat(targetPath);
await assert.rejects(
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
async validateTarget() {
return {
existingMode: null,
stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat),
targetIdentity: [
originalStat.dev,
originalStat.ino,
originalStat.size,
originalStat.mtimeMs,
originalStat.ctimeMs,
].join(":"),
};
},
assertNotCancelled() {
// After ready is published onto targetPath, simulate concurrent replace + cancel.
try {
if (fs.readFileSync(targetPath).equals(downloaded)) {
fs.writeFileSync(targetPath, concurrent);
throw new Error("Transfer cancelled");
}
} catch (err) {
if (String(err.message || err).includes("cancelled")) throw err;
}
},
}),
/cancelled/i,
);
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
assert.ok(backupName, "original should remain in backup");
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
await fs.promises.writeFile(targetPath, "original");
await fs.promises.writeFile(stagedPath, "download");
let cancelled = false;
let committed = false;
const link = fs.promises.link;
t.after(() => { fs.promises.link = link; });
fs.promises.link = async (...args) => {
await link.apply(fs.promises, args);
if (args[1] === targetPath && String(args[0]).endsWith(".ready")) {
await fs.promises.unlink(targetPath);
await fs.promises.writeFile(targetPath, "concurrent");
cancelled = true;
}
};
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
onCommit() { committed = true; },
});
assert.equal(committed, true);
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent");
});
test("local promotion succeeds when backup still matches validated identity", async (t) => {
@@ -12376,6 +12479,109 @@ test("preserveTransferredDestinationMtime stamps local targets from sourceSoftId
assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
});
test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const targetPath = path.join(tempDir, "copied.bin");
await fs.promises.writeFile(targetPath, Buffer.from("concurrent"));
const before = await fs.promises.stat(targetPath);
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
// Identity recorded at publication no longer matches the pathname's file.
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local",
targetPath,
publishedLocalIdentity: "999999:999999:7",
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
});
const after = await fs.promises.stat(targetPath);
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
// A vanished destination is likewise left alone.
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local",
targetPath: path.join(tempDir, "missing.bin"),
publishedLocalIdentity: "999999:999999:7",
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
});
});
test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const targetPath = path.join(tempDir, "copied.bin");
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
const publishedStat = await fs.promises.lstat(targetPath);
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local",
targetPath,
publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat),
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
});
const after = await fs.promises.stat(targetPath);
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
});
test("local publication returns an identity usable for timestamp preservation", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-"));
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
const stagedPath = path.join(tempDir, "stage");
const targetPath = path.join(tempDir, "target");
await fs.promises.writeFile(stagedPath, "payload");
let publishedLocalIdentity;
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
onCommit(identity) { publishedLocalIdentity = identity; },
});
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local", targetPath, publishedLocalIdentity,
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
});
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000);
});
test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-"));
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
const targetPath = path.join(tempDir, "target");
const oldPath = path.join(tempDir, "published");
await fs.promises.writeFile(targetPath, "payload");
const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath));
let replaced = false;
const replace = async () => {
if (replaced) return;
replaced = true;
await fs.promises.rename(targetPath, oldPath);
await fs.promises.writeFile(targetPath, "concurrent");
await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000);
};
const originalUtimes = fs.promises.utimes;
const originalOpen = fs.promises.open;
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
if (name === targetPath) await replace();
return originalUtimes(name, ...args);
});
t.mock.method(fs.promises, "open", async (...args) => {
const handle = await originalOpen(...args);
if (args[0] === targetPath) {
const utimes = handle.utimes.bind(handle);
handle.utimes = async (...times) => { await replace(); return utimes(...times); };
}
return handle;
});
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local", targetPath, publishedLocalIdentity,
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
});
assert.equal(replaced, true);
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
});
test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => {
const hangingClient = {
async chmod() {
@@ -12751,3 +12957,54 @@ test("in-place isolated OPEN keeps path gate until late callback after channel e
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close"));
});
test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const targetPath = path.join(tempDir, "copied.bin");
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
await fs.promises.chmod(targetPath, 0o200);
const sourceMtimeMs = 1_700_000_000_000;
await transferBridge._preserveTransferredDestinationMtimeForTests({
targetType: "local",
targetPath,
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
});
const after = await fs.promises.stat(targetPath);
assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000);
assert.equal(after.mode & 0o777, 0o200);
});
for (const failureMode of ["rejection", "timeout"]) {
test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-"));
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
const sourcePath = path.join(tempDir, "source");
const targetPath = path.join(tempDir, "target");
await fs.promises.writeFile(sourcePath, "payload");
await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000);
let preparationFailed = false;
const utimes = fs.promises.utimes;
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
if (String(name).endsWith(".ready")) {
preparationFailed = true;
if (failureMode === "timeout") return new Promise(() => {});
throw Object.assign(new Error("transient metadata failure"), { code: "EIO" });
}
return utimes(name, ...args);
});
transferBridge.init({ sftpClients: new Map() });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: "local-mtime-retry", sourcePath, targetPath,
sourceType: "local", targetType: "local", totalBytes: 7, resumable: true,
});
assert.equal(result.error, undefined);
assert.equal(preparationFailed, true);
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload");
});
}
+7
View File
@@ -789,6 +789,13 @@ 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();
}
+5
View File
@@ -1176,6 +1176,11 @@ 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: () =>
@@ -0,0 +1,17 @@
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,13 +920,34 @@ 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,
this.state.masterKeyConfig
configAtStart
);
if (!unlockedKey) {
return false;
// 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;
}
this.state.unlockedKey = unlockedKey;
@@ -0,0 +1,30 @@
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);
});
+3 -1
View File
@@ -92,6 +92,8 @@ 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;
@@ -100,7 +102,7 @@ declare global {
lifecycleEpoch?: number;
reason?: string;
}>;
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number }>;
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number; superseded?: boolean; supersededBy?: "pause" | "resume" | "cancel" }>;
prioritizeTransfer?(transferId: string): Promise<{ success: boolean }>;
setGlobalTransferConcurrency?(limit: number): Promise<{ success: boolean; limit: number }>;
cleanupTransferArtifacts?(payload: {
+1
View File
@@ -102,6 +102,7 @@ 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)