[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
411
electron/bridges/fileWatcherBridge.test.cjs
Normal file
411
electron/bridges/fileWatcherBridge.test.cjs
Normal file
@@ -0,0 +1,411 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const EventEmitter = require("node:events");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
|
||||
function createHarness(t) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-filewatch-test-"));
|
||||
const localPath = path.join(tempDir, "edit.txt");
|
||||
fs.writeFileSync(localPath, "initial");
|
||||
|
||||
const listenersByPath = new Map();
|
||||
const originalWatchFile = fs.watchFile;
|
||||
const originalUnwatchFile = fs.unwatchFile;
|
||||
fs.watchFile = (filePath, _options, listener) => {
|
||||
const listeners = listenersByPath.get(filePath) ?? new Set();
|
||||
listeners.add(listener);
|
||||
listenersByPath.set(filePath, listeners);
|
||||
};
|
||||
fs.unwatchFile = (filePath, listener) => {
|
||||
const listeners = listenersByPath.get(filePath);
|
||||
if (!listeners) return;
|
||||
if (typeof listener === "function") listeners.delete(listener);
|
||||
else listeners.clear();
|
||||
if (listeners.size === 0) listenersByPath.delete(filePath);
|
||||
};
|
||||
|
||||
const handlers = new Map();
|
||||
fileWatcherBridge.init({
|
||||
sftpClients: new Map(),
|
||||
electronModule: {},
|
||||
transferBridge: require("./transferBridge.cjs"),
|
||||
});
|
||||
fileWatcherBridge.registerHandlers({
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
});
|
||||
|
||||
const sender = new EventEmitter();
|
||||
sender.id = Math.floor(Math.random() * 1_000_000) + 1;
|
||||
sender.isDestroyed = () => false;
|
||||
sender.send = () => {};
|
||||
const event = { sender };
|
||||
|
||||
t.after(() => {
|
||||
fileWatcherBridge.stopWatchersForSession(`sftp-${sender.id}`, false);
|
||||
fs.watchFile = originalWatchFile;
|
||||
fs.unwatchFile = originalUnwatchFile;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
return {
|
||||
event,
|
||||
handlers,
|
||||
listenersByPath,
|
||||
localPath,
|
||||
sender,
|
||||
sftpId: `sftp-${sender.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
test("opening the same external edit twice reuses one polling watcher", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const payload = {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
};
|
||||
|
||||
const first = await harness.handlers.get("netcatty:filewatch:start")(harness.event, payload);
|
||||
const second = await harness.handlers.get("netcatty:filewatch:start")(harness.event, payload);
|
||||
const listed = await harness.handlers.get("netcatty:filewatch:list")(harness.event, {});
|
||||
|
||||
assert.equal(second.watchId, first.watchId);
|
||||
assert.equal(harness.listenersByPath.get(harness.localPath)?.size, 1);
|
||||
assert.equal(listed.length, 1);
|
||||
});
|
||||
|
||||
test("concurrent identical starts still install only one polling watcher", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const payload = {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/concurrent.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
harness.handlers.get("netcatty:filewatch:start")(harness.event, payload),
|
||||
harness.handlers.get("netcatty:filewatch:start")(harness.event, payload),
|
||||
]);
|
||||
|
||||
assert.equal(second.watchId, first.watchId);
|
||||
assert.equal(harness.listenersByPath.get(harness.localPath)?.size, 1);
|
||||
});
|
||||
|
||||
test("stopping one watch removes only its listener from a shared local path", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const first = await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/first.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const second = await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/second.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
assert.notEqual(first.watchId, second.watchId);
|
||||
assert.equal(harness.listenersByPath.get(harness.localPath)?.size, 2);
|
||||
|
||||
await harness.handlers.get("netcatty:filewatch:stop")(harness.event, { watchId: first.watchId });
|
||||
const listed = await harness.handlers.get("netcatty:filewatch:list")(harness.event, {});
|
||||
|
||||
assert.equal(harness.listenersByPath.get(harness.localPath)?.size, 1);
|
||||
assert.deepEqual(listed.map((watch) => watch.watchId), [second.watchId]);
|
||||
});
|
||||
|
||||
test("destroying the owner window releases its external file watches", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
harness.sender.emit("destroyed");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const listed = await harness.handlers.get("netcatty:filewatch:list")(harness.event, {});
|
||||
assert.equal(listed.length, 0);
|
||||
assert.equal(harness.listenersByPath.has(harness.localPath), false);
|
||||
});
|
||||
|
||||
test("closing an SFTP session releases every watcher for that session", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const stoppedEvents = [];
|
||||
harness.sender.send = (channel, payload) => {
|
||||
if (channel === "netcatty:filewatch:stopped") stoppedEvents.push(payload);
|
||||
};
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/first.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/second.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
fileWatcherBridge.stopWatchersForSession(harness.sftpId, false);
|
||||
|
||||
const listed = await harness.handlers.get("netcatty:filewatch:list")(harness.event, {});
|
||||
assert.equal(listed.length, 0);
|
||||
assert.equal(harness.listenersByPath.has(harness.localPath), false);
|
||||
assert.equal(stoppedEvents.length, 2);
|
||||
assert.deepEqual(new Set(stoppedEvents.map((event) => event.sftpId)), new Set([harness.sftpId]));
|
||||
});
|
||||
|
||||
test("worker proxy releases renderer-owned watchers when its window is destroyed", async () => {
|
||||
const handlers = new Map();
|
||||
const requests = [];
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
requests.push({ channel, payload, options });
|
||||
return Promise.resolve(channel === "netcatty:filewatch:start" ? { watchId: "worker-watch" } : null);
|
||||
},
|
||||
};
|
||||
fileWatcherBridge.registerHandlers({
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
}, { terminalWorkerManager });
|
||||
const sender = new EventEmitter();
|
||||
sender.id = 72;
|
||||
const event = { sender };
|
||||
|
||||
await handlers.get("netcatty:filewatch:start")(event, {
|
||||
localPath: "/tmp/edit.txt",
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: "sftp-worker",
|
||||
});
|
||||
sender.emit("destroyed");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(requests.at(-1), {
|
||||
channel: "netcatty:filewatch:releaseOwner",
|
||||
payload: { webContentsId: 72, cleanupTempFiles: true },
|
||||
options: { webContentsId: 72 },
|
||||
});
|
||||
});
|
||||
|
||||
test("session close cancels a watch whose initial stat is still pending", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const originalStat = fs.promises.stat;
|
||||
let finishStat;
|
||||
fs.promises.stat = () => new Promise((resolve) => {
|
||||
finishStat = () => resolve({ mtimeMs: 1, size: 7 });
|
||||
});
|
||||
t.after(() => { fs.promises.stat = originalStat; });
|
||||
|
||||
const starting = harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/pending.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
fileWatcherBridge.stopWatchersForSession(harness.sftpId, true);
|
||||
finishStat();
|
||||
|
||||
await assert.rejects(starting, /cancelled|closed/i);
|
||||
const listed = await harness.handlers.get("netcatty:filewatch:list")(harness.event, {});
|
||||
assert.equal(listed.length, 0);
|
||||
assert.equal(harness.listenersByPath.has(harness.localPath), false);
|
||||
});
|
||||
|
||||
test("external edit sync streams through the unified transfer runtime without buffering the whole file", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
const originalStartInternalTransfer = transferBridge.startInternalTransfer;
|
||||
const originalReadFile = fs.promises.readFile;
|
||||
let directPutCalls = 0;
|
||||
let wholeFileReadCalls = 0;
|
||||
const internalTransfers = [];
|
||||
const sent = [];
|
||||
|
||||
transferBridge.startInternalTransfer = async (_event, payload) => {
|
||||
internalTransfers.push(payload);
|
||||
return { transferId: payload.transferId };
|
||||
};
|
||||
fs.promises.readFile = async (...args) => {
|
||||
wholeFileReadCalls += 1;
|
||||
return originalReadFile(...args);
|
||||
};
|
||||
harness.sender.send = (channel, payload) => {
|
||||
sent.push({ channel, payload });
|
||||
};
|
||||
fileWatcherBridge.init({
|
||||
sftpClients: new Map([[
|
||||
harness.sftpId,
|
||||
{
|
||||
put: async () => { directPutCalls += 1; },
|
||||
},
|
||||
]]),
|
||||
electronModule: {},
|
||||
transferBridge,
|
||||
});
|
||||
t.after(() => {
|
||||
transferBridge.startInternalTransfer = originalStartInternalTransfer;
|
||||
fs.promises.readFile = originalReadFile;
|
||||
});
|
||||
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const listener = [...harness.listenersByPath.get(harness.localPath)][0];
|
||||
const previousStat = fs.statSync(harness.localPath);
|
||||
fs.writeFileSync(harness.localPath, "modified external edit");
|
||||
const currentStat = fs.statSync(harness.localPath);
|
||||
listener(
|
||||
currentStat,
|
||||
previousStat,
|
||||
);
|
||||
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (!sent.some((event) => event.channel === "netcatty:filewatch:synced")) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for external edit sync");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
assert.equal(internalTransfers.length, 1);
|
||||
assert.equal(internalTransfers[0].sourcePath, harness.localPath);
|
||||
assert.equal(internalTransfers[0].targetPath, "/remote/edit.txt");
|
||||
assert.equal(internalTransfers[0].sourceType, "local");
|
||||
assert.equal(internalTransfers[0].targetType, "sftp");
|
||||
assert.equal(internalTransfers[0].targetSftpId, harness.sftpId);
|
||||
assert.equal(wholeFileReadCalls, 0);
|
||||
assert.equal(directPutCalls, 0);
|
||||
});
|
||||
|
||||
test("closing the SFTP session cancels an active external edit stream", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
const originalStartInternalTransfer = transferBridge.startInternalTransfer;
|
||||
const originalCancelTransfer = transferBridge.cancelTransfer;
|
||||
let activeTransferId;
|
||||
let finishTransfer;
|
||||
const cancelledTransferIds = [];
|
||||
|
||||
transferBridge.startInternalTransfer = async (_event, payload) => {
|
||||
activeTransferId = payload.transferId;
|
||||
return new Promise((resolve) => {
|
||||
finishTransfer = resolve;
|
||||
});
|
||||
};
|
||||
transferBridge.cancelTransfer = async (_event, payload) => {
|
||||
cancelledTransferIds.push(payload.transferId);
|
||||
finishTransfer?.({ transferId: payload.transferId, cancelled: true, error: "Transfer cancelled" });
|
||||
return { success: true };
|
||||
};
|
||||
fileWatcherBridge.init({
|
||||
sftpClients: new Map([[harness.sftpId, {}]]),
|
||||
electronModule: {},
|
||||
transferBridge,
|
||||
});
|
||||
t.after(() => {
|
||||
transferBridge.startInternalTransfer = originalStartInternalTransfer;
|
||||
transferBridge.cancelTransfer = originalCancelTransfer;
|
||||
});
|
||||
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const listener = [...harness.listenersByPath.get(harness.localPath)][0];
|
||||
const previousStat = fs.statSync(harness.localPath);
|
||||
fs.writeFileSync(harness.localPath, "modified before close");
|
||||
listener(fs.statSync(harness.localPath), previousStat);
|
||||
|
||||
const startDeadline = Date.now() + 2_000;
|
||||
while (!activeTransferId) {
|
||||
if (Date.now() >= startDeadline) throw new Error("Timed out waiting for external edit stream");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
fileWatcherBridge.stopWatchersForSession(harness.sftpId, false);
|
||||
const cancelDeadline = Date.now() + 1_000;
|
||||
while (cancelledTransferIds.length === 0) {
|
||||
if (Date.now() >= cancelDeadline) throw new Error("Timed out waiting for stream cancellation");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(cancelledTransferIds, [activeTransferId]);
|
||||
});
|
||||
|
||||
test("session cleanup retries a temporarily locked external edit file", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
const originalUnlink = fs.promises.unlink;
|
||||
let unlinkAttempts = 0;
|
||||
fs.promises.unlink = async (filePath) => {
|
||||
if (filePath !== harness.localPath) return originalUnlink(filePath);
|
||||
unlinkAttempts += 1;
|
||||
if (unlinkAttempts === 1) {
|
||||
const error = new Error("file is still in use");
|
||||
error.code = "EBUSY";
|
||||
throw error;
|
||||
}
|
||||
return originalUnlink(filePath);
|
||||
};
|
||||
t.after(() => { fs.promises.unlink = originalUnlink; });
|
||||
|
||||
await harness.handlers.get("netcatty:filewatch:registerTempFile")(harness.event, {
|
||||
sftpId: harness.sftpId,
|
||||
localPath: harness.localPath,
|
||||
});
|
||||
await harness.handlers.get("netcatty:filewatch:start")(harness.event, {
|
||||
localPath: harness.localPath,
|
||||
remotePath: "/remote/edit.txt",
|
||||
sftpId: harness.sftpId,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
fileWatcherBridge.stopWatchersForSession(harness.sftpId, true);
|
||||
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.promises.stat(harness.localPath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") break;
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.ok(unlinkAttempts >= 2, "a transient Windows file lock must be retried");
|
||||
await assert.rejects(fs.promises.stat(harness.localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("failed external app launch can unregister and immediately delete its temp file", async (t) => {
|
||||
const harness = createHarness(t);
|
||||
await harness.handlers.get("netcatty:filewatch:registerTempFile")(harness.event, {
|
||||
sftpId: harness.sftpId,
|
||||
localPath: harness.localPath,
|
||||
});
|
||||
const result = await harness.handlers.get("netcatty:filewatch:unregisterTempFile")(harness.event, {
|
||||
sftpId: harness.sftpId,
|
||||
localPath: harness.localPath,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.retained, false);
|
||||
await assert.rejects(fs.promises.stat(harness.localPath), { code: "ENOENT" });
|
||||
});
|
||||
Reference in New Issue
Block a user