[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:
2075
electron/preload/api.cjs
Normal file
2075
electron/preload/api.cjs
Normal file
File diff suppressed because it is too large
Load Diff
17
electron/preload/api.cloudSyncPassword.test.cjs
Normal file
17
electron/preload/api.cloudSyncPassword.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
254
electron/preload/api.stageUploadFile.test.cjs
Normal file
254
electron/preload/api.stageUploadFile.test.cjs
Normal file
@@ -0,0 +1,254 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createPreloadApi } = require("./api.cjs");
|
||||
|
||||
function createFile(name, chunks) {
|
||||
let index = 0;
|
||||
return {
|
||||
name,
|
||||
stream: () => ({
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (index >= chunks.length) return { done: true };
|
||||
return { done: false, value: Uint8Array.from(chunks[index++]) };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createBlockingFile(name) {
|
||||
let markReadStarted;
|
||||
let finishRead;
|
||||
const readStarted = new Promise((resolve) => { markReadStarted = resolve; });
|
||||
return {
|
||||
file: {
|
||||
name,
|
||||
stream: () => ({
|
||||
getReader: () => ({
|
||||
read() {
|
||||
markReadStarted();
|
||||
return new Promise((resolve) => { finishRead = resolve; });
|
||||
},
|
||||
cancel() {
|
||||
finishRead?.({ done: true });
|
||||
return Promise.resolve();
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
readStarted,
|
||||
};
|
||||
}
|
||||
|
||||
test("superseding pathless upload staging uses an independent temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preload-stage-race-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const created = [];
|
||||
const deleted = [];
|
||||
const ipcRenderer = {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
async invoke(channel, payload) {
|
||||
if (channel === "netcatty:tempdir:createUploadPath") {
|
||||
const localPath = path.join(dir, `${payload.transferId}_${payload.fileName}.part`);
|
||||
created.push({ payload, localPath });
|
||||
return localPath;
|
||||
}
|
||||
if (channel === "netcatty:deleteTempFile") {
|
||||
deleted.push(payload.filePath);
|
||||
await fs.promises.unlink(payload.filePath).catch(() => {});
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(`Unexpected IPC call: ${channel}`);
|
||||
},
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const firstFile = createBlockingFile("same.bin");
|
||||
|
||||
const first = api.stageUploadFile(firstFile.file, "same-transfer");
|
||||
await firstFile.readStarted;
|
||||
const second = api.stageUploadFile(createFile("same.bin", [[4, 5, 6]]), "same-transfer");
|
||||
|
||||
await assert.rejects(first, /superseded/i);
|
||||
const secondPath = await second;
|
||||
|
||||
assert.equal(created.length, 2);
|
||||
assert.notEqual(created[0].payload.transferId, created[1].payload.transferId);
|
||||
assert.notEqual(created[0].localPath, created[1].localPath);
|
||||
assert.equal(secondPath, created[1].localPath);
|
||||
assert.deepEqual(await fs.promises.readFile(secondPath), Buffer.from([4, 5, 6]));
|
||||
await assert.rejects(fs.promises.stat(created[0].localPath), { code: "ENOENT" });
|
||||
assert.deepEqual(deleted, [created[0].localPath]);
|
||||
});
|
||||
|
||||
test("failed upload path allocation releases the staging controller", async () => {
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
async invoke(channel) {
|
||||
if (channel === "netcatty:tempdir:createUploadPath") throw new Error("path unavailable");
|
||||
throw new Error(`Unexpected IPC call: ${channel}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(api.stageUploadFile(createFile("file.bin", [[1]]), "failed-path"), /path unavailable/);
|
||||
assert.deepEqual(await api.cancelStagedUploadFile("failed-path"), { success: false });
|
||||
});
|
||||
|
||||
test("native tree scans use a bridge-safe cancellation id", async () => {
|
||||
const sent = [];
|
||||
const invoked = [];
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
send(...args) { sent.push(args); },
|
||||
async invoke(channel, payload) {
|
||||
invoked.push({ channel, payload });
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api.listLocalTree("/tmp/project", {
|
||||
scanId: "scan-123",
|
||||
onProgress: () => {},
|
||||
});
|
||||
await api.cancelLocalTreeScan("scan-123");
|
||||
|
||||
assert.deepEqual(invoked, [{
|
||||
channel: "netcatty:local:tree",
|
||||
payload: {
|
||||
path: "/tmp/project",
|
||||
progressChannel: "netcatty:local:tree-progress:scan-123",
|
||||
entriesChannel: undefined,
|
||||
cancelChannel: "netcatty:local:tree-cancel:scan-123",
|
||||
limits: undefined,
|
||||
},
|
||||
}]);
|
||||
assert.deepEqual(sent, [["netcatty:local:tree-cancel:scan-123"]]);
|
||||
});
|
||||
|
||||
test("listLocalTree keeps the entries listener until the tree-end marker arrives", async () => {
|
||||
const listeners = new Map();
|
||||
const batches = [];
|
||||
let removed = false;
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on(channel, handler) {
|
||||
listeners.set(channel, handler);
|
||||
},
|
||||
removeListener(channel) {
|
||||
if (channel.startsWith("netcatty:local:tree-entries:")) removed = true;
|
||||
listeners.delete(channel);
|
||||
},
|
||||
send() {},
|
||||
async invoke(channel, payload) {
|
||||
assert.equal(channel, "netcatty:local:tree");
|
||||
const entriesChannel = payload.entriesChannel;
|
||||
const handler = listeners.get(entriesChannel);
|
||||
assert.equal(typeof handler, "function");
|
||||
// Simulate the invoke reply racing ahead of a late nested batch.
|
||||
queueMicrotask(() => {
|
||||
handler({}, [
|
||||
{
|
||||
localPath: "/tmp/project/nested/deep.txt",
|
||||
relativePath: "project/nested/deep.txt",
|
||||
type: "file",
|
||||
size: 1,
|
||||
lastModified: 1,
|
||||
},
|
||||
]);
|
||||
handler({}, { type: "tree-end" });
|
||||
});
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api.listLocalTree("/tmp/project", {
|
||||
scanId: "scan-nested",
|
||||
onEntries: (batch) => {
|
||||
batches.push(batch);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(batches.length, 1);
|
||||
assert.equal(batches[0][0].relativePath, "project/nested/deep.txt");
|
||||
assert.equal(removed, true);
|
||||
});
|
||||
|
||||
test("openSftpForSession keeps the SSH source session id when options include an SFTP session id", async () => {
|
||||
const invoked = [];
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
async invoke(channel, payload) {
|
||||
invoked.push({ channel, payload });
|
||||
return { sftpId: "opened-sftp" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sftpId = await api.openSftpForSession("ssh-session-1", {
|
||||
sessionId: "sftp-left-browse-session",
|
||||
hostname: "192.168.9.138",
|
||||
port: 22,
|
||||
username: "zlhrs",
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "opened-sftp");
|
||||
assert.equal(invoked.length, 1);
|
||||
assert.equal(invoked[0].channel, "netcatty:sftp:openForSession");
|
||||
assert.equal(invoked[0].payload.sessionId, "ssh-session-1");
|
||||
assert.equal(invoked[0].payload.expectedEndpoint.sessionId, "sftp-left-browse-session");
|
||||
});
|
||||
|
||||
test("strict openSftpForSession closes and rejects a mismatched source result", async () => {
|
||||
const invoked = [];
|
||||
const api = createPreloadApi({
|
||||
webUtils: {},
|
||||
ipcRenderer: {
|
||||
on() {},
|
||||
removeListener() {},
|
||||
async invoke(channel, payload) {
|
||||
invoked.push({ channel, payload });
|
||||
if (channel === "netcatty:sftp:openForSession") {
|
||||
return { sftpId: "wrong-route-sftp", sourceSessionId: "other-session" };
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.openSftpForSession("requested-session", {
|
||||
hostname: "target.example",
|
||||
username: "alice",
|
||||
requireExactSourceSession: true,
|
||||
}),
|
||||
/requested terminal connection is no longer available/,
|
||||
);
|
||||
|
||||
assert.equal(invoked[0].payload.requireExactSourceSession, true);
|
||||
assert.deepEqual(invoked[1], {
|
||||
channel: "netcatty:sftp:close",
|
||||
payload: { sftpId: "wrong-route-sftp" },
|
||||
});
|
||||
});
|
||||
59
electron/preload/sessionTombstones.cjs
Normal file
59
electron/preload/sessionTombstones.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_SESSION_TOMBSTONE_TTL_MS = 5 * 60_000;
|
||||
const DEFAULT_MAX_SESSION_TOMBSTONES = 4096;
|
||||
|
||||
class SessionTombstones {
|
||||
constructor(options = {}) {
|
||||
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_SESSION_TOMBSTONES;
|
||||
this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_TOMBSTONE_TTL_MS;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
this.entries = new Map();
|
||||
}
|
||||
|
||||
prune() {
|
||||
const now = this.now();
|
||||
for (const [sessionId, closedAt] of this.entries) {
|
||||
if (now - closedAt < this.ttlMs) break;
|
||||
this.entries.delete(sessionId);
|
||||
}
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
add(sessionId) {
|
||||
if (!sessionId) return this;
|
||||
this.entries.delete(sessionId);
|
||||
this.entries.set(sessionId, this.now());
|
||||
this.prune();
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(sessionId) {
|
||||
return this.entries.delete(sessionId);
|
||||
}
|
||||
|
||||
has(sessionId) {
|
||||
this.prune();
|
||||
return this.entries.has(sessionId);
|
||||
}
|
||||
|
||||
get size() {
|
||||
this.prune();
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
this.prune();
|
||||
return this.entries.keys();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SESSION_TOMBSTONE_TTL_MS,
|
||||
DEFAULT_MAX_SESSION_TOMBSTONES,
|
||||
SessionTombstones,
|
||||
};
|
||||
32
electron/preload/sessionTombstones.test.cjs
Normal file
32
electron/preload/sessionTombstones.test.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { SessionTombstones } = require("./sessionTombstones.cjs");
|
||||
|
||||
test("closed session tombstones stay bounded across unique session ids", () => {
|
||||
const tombstones = new SessionTombstones({ maxEntries: 3, ttlMs: 10_000, now: () => 0 });
|
||||
for (let index = 0; index < 10; index += 1) tombstones.add(`session-${index}`);
|
||||
|
||||
assert.equal(tombstones.size, 3);
|
||||
assert.equal(tombstones.has("session-0"), false);
|
||||
assert.equal(tombstones.has("session-9"), true);
|
||||
});
|
||||
|
||||
test("closed session tombstones reject late events only during the safety window", () => {
|
||||
let now = 0;
|
||||
const tombstones = new SessionTombstones({ maxEntries: 10, ttlMs: 100, now: () => now });
|
||||
tombstones.add("session-1");
|
||||
assert.equal(tombstones.has("session-1"), true);
|
||||
|
||||
now = 101;
|
||||
assert.equal(tombstones.has("session-1"), false);
|
||||
assert.equal(tombstones.size, 0);
|
||||
});
|
||||
|
||||
test("reopening the same session removes its tombstone", () => {
|
||||
const tombstones = new SessionTombstones();
|
||||
tombstones.add("session-1");
|
||||
tombstones.delete("session-1");
|
||||
assert.equal(tombstones.has("session-1"), false);
|
||||
});
|
||||
64
electron/preload/stageUploadFile.cjs
Normal file
64
electron/preload/stageUploadFile.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
async function stageRendererFileToTemp(file, localPath, fsImpl, signal = null) {
|
||||
if (!file || typeof file.stream !== "function") {
|
||||
throw new Error("Upload file streaming is unavailable");
|
||||
}
|
||||
let handle = null;
|
||||
let reader = null;
|
||||
let stagedBytes = 0;
|
||||
const cancellationError = () => (
|
||||
signal?.reason instanceof Error ? signal.reason : new Error("Upload staging cancelled")
|
||||
);
|
||||
const onAbort = () => {
|
||||
void Promise.resolve(reader?.cancel?.(cancellationError())).catch(() => {});
|
||||
void handle?.close?.().catch(() => {});
|
||||
};
|
||||
try {
|
||||
handle = await fsImpl.promises.open(localPath, "wx", 0o600);
|
||||
// Stream/getReader can throw synchronously (revoked File, renderer teardown,
|
||||
// or a malformed provider). Initialize them inside the cleanup boundary so
|
||||
// the just-created file handle is never stranded.
|
||||
reader = file.stream().getReader();
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
while (true) {
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
const { done, value } = await reader.read();
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < chunk.length) {
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
const remaining = chunk.length - chunkOffset;
|
||||
let result;
|
||||
try {
|
||||
result = await handle.write(chunk, chunkOffset, remaining, stagedBytes);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
throw error;
|
||||
}
|
||||
if (signal?.aborted) throw cancellationError();
|
||||
const bytesWritten = result?.bytesWritten;
|
||||
if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0 || bytesWritten > remaining) {
|
||||
throw new Error("Unable to stage the complete upload file");
|
||||
}
|
||||
chunkOffset += bytesWritten;
|
||||
stagedBytes += bytesWritten;
|
||||
}
|
||||
}
|
||||
return localPath;
|
||||
} catch (error) {
|
||||
await handle?.close?.().catch(() => {});
|
||||
await fsImpl.promises.unlink(localPath).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
reader?.releaseLock?.();
|
||||
await handle?.close?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { stageRendererFileToTemp };
|
||||
246
electron/preload/stageUploadFile.test.cjs
Normal file
246
electron/preload/stageUploadFile.test.cjs
Normal file
@@ -0,0 +1,246 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
|
||||
|
||||
function createChunkedFile(chunks) {
|
||||
return {
|
||||
stream: () => new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(new Uint8Array(chunk));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createShortWriteFs(writeSizes, onWrite = null) {
|
||||
const writeCalls = [];
|
||||
let writeIndex = 0;
|
||||
return {
|
||||
writeCalls,
|
||||
fsImpl: {
|
||||
promises: {
|
||||
open: async (...args) => {
|
||||
const handle = await fs.promises.open(...args);
|
||||
return {
|
||||
write: async (buffer, offset = 0, length = buffer.length - offset, position = null) => {
|
||||
const requestedLength = Math.max(0, Number(length));
|
||||
const configuredLength = writeSizes[writeIndex] ?? requestedLength;
|
||||
const actualLength = Math.min(requestedLength, configuredLength);
|
||||
writeCalls.push({ offset, length: requestedLength, position, actualLength });
|
||||
const result = await handle.write(buffer, offset, actualLength, position);
|
||||
writeIndex += 1;
|
||||
await onWrite?.({ writeIndex, result });
|
||||
return result;
|
||||
},
|
||||
close: () => handle.close(),
|
||||
};
|
||||
},
|
||||
unlink: (...args) => fs.promises.unlink(...args),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("pathless renderer files stream into a controlled temp file without arrayBuffer", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
let arrayBufferCalls = 0;
|
||||
const chunks = [new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])];
|
||||
const file = {
|
||||
arrayBuffer: async () => { arrayBufferCalls += 1; return new ArrayBuffer(0); },
|
||||
stream: () => new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
};
|
||||
assert.equal(await stageRendererFileToTemp(file, localPath, fs), localPath);
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([1, 2, 3, 4, 5]));
|
||||
assert.equal(arrayBufferCalls, 0);
|
||||
});
|
||||
|
||||
test("a partial file-handle write retries the unwritten suffix", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-short-write-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
const { fsImpl, writeCalls } = createShortWriteFs([2]);
|
||||
|
||||
assert.equal(
|
||||
await stageRendererFileToTemp(createChunkedFile([[1, 2, 3, 4, 5]]), localPath, fsImpl),
|
||||
localPath,
|
||||
);
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([1, 2, 3, 4, 5]));
|
||||
assert.deepEqual(writeCalls.map(({ offset, length, position }) => ({ offset, length, position })), [
|
||||
{ offset: 0, length: 5, position: 0 },
|
||||
{ offset: 2, length: 3, position: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("multiple partial writes across chunks preserve every byte and advance offsets", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-many-short-writes-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
const { fsImpl, writeCalls } = createShortWriteFs([1, 2, 1, 1]);
|
||||
|
||||
await stageRendererFileToTemp(
|
||||
createChunkedFile([[10, 11, 12, 13], [20, 21]]),
|
||||
localPath,
|
||||
fsImpl,
|
||||
);
|
||||
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), Buffer.from([10, 11, 12, 13, 20, 21]));
|
||||
assert.deepEqual(writeCalls.map(({ offset, length, position }) => ({ offset, length, position })), [
|
||||
{ offset: 0, length: 4, position: 0 },
|
||||
{ offset: 1, length: 3, position: 1 },
|
||||
{ offset: 3, length: 1, position: 3 },
|
||||
{ offset: 0, length: 2, position: 4 },
|
||||
{ offset: 1, length: 1, position: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a zero-byte file-handle write fails instead of spinning and removes the temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-zero-write-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
const { fsImpl, writeCalls } = createShortWriteFs([0]);
|
||||
|
||||
await assert.rejects(
|
||||
stageRendererFileToTemp(createChunkedFile([[1, 2, 3]]), localPath, fsImpl),
|
||||
/Unable to stage the complete upload file/,
|
||||
);
|
||||
assert.equal(writeCalls.length, 1);
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("cancelling after a partial write stops the current chunk and removes the temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-cancel-short-write-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
const controller = new AbortController();
|
||||
const { fsImpl, writeCalls } = createShortWriteFs([2], ({ writeIndex }) => {
|
||||
if (writeIndex === 1) controller.abort(new Error("cancel mid-chunk"));
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
stageRendererFileToTemp(
|
||||
createChunkedFile([[1, 2, 3, 4, 5]]),
|
||||
localPath,
|
||||
fsImpl,
|
||||
controller.signal,
|
||||
),
|
||||
/cancel mid-chunk/,
|
||||
);
|
||||
assert.equal(writeCalls.length, 1);
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("failed pathless-file staging removes its partial temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-fail-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
let reads = 0;
|
||||
const file = {
|
||||
stream: () => ({
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
reads += 1;
|
||||
if (reads === 1) return { done: false, value: new Uint8Array([1]) };
|
||||
throw new Error("source failed");
|
||||
},
|
||||
releaseLock: () => {},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
await assert.rejects(stageRendererFileToTemp(file, localPath, fs), /source failed/);
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("a synchronous File.stream failure closes the new handle and removes the temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-stream-init-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
|
||||
await assert.rejects(stageRendererFileToTemp({
|
||||
stream() {
|
||||
throw new Error("stream init failed");
|
||||
},
|
||||
}, localPath, fs), /stream init failed/);
|
||||
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("a synchronous getReader failure closes the new handle and removes the temp file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-reader-init-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
|
||||
await assert.rejects(stageRendererFileToTemp({
|
||||
stream: () => ({
|
||||
getReader() {
|
||||
throw new Error("reader init failed");
|
||||
},
|
||||
}),
|
||||
}, localPath, fs), /reader init failed/);
|
||||
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("stream initialization failure still closes the handle when temp deletion fails", async () => {
|
||||
let closeCalls = 0;
|
||||
let unlinkCalls = 0;
|
||||
const fakeFs = {
|
||||
promises: {
|
||||
open: async () => ({
|
||||
close: async () => { closeCalls += 1; },
|
||||
}),
|
||||
unlink: async () => {
|
||||
unlinkCalls += 1;
|
||||
throw new Error("delete denied");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(stageRendererFileToTemp({
|
||||
stream() {
|
||||
throw new Error("stream init failed");
|
||||
},
|
||||
}, "/controlled/upload.part", fakeFs), /stream init failed/);
|
||||
|
||||
assert.equal(unlinkCalls, 1);
|
||||
assert.ok(closeCalls >= 1, "the file handle must close even if cleanup unlink fails");
|
||||
});
|
||||
|
||||
test("cancelling a blocked pathless-file read closes it and removes the partial file", async (t) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-upload-cancel-"));
|
||||
t.after(() => fs.promises.rm(dir, { recursive: true, force: true }));
|
||||
const localPath = path.join(dir, "upload.part");
|
||||
const controller = new AbortController();
|
||||
let cancelCalls = 0;
|
||||
let finishRead;
|
||||
const file = {
|
||||
stream: () => ({
|
||||
getReader: () => ({
|
||||
read: () => new Promise((resolve) => { finishRead = resolve; }),
|
||||
cancel: async () => {
|
||||
cancelCalls += 1;
|
||||
finishRead?.({ done: true });
|
||||
},
|
||||
releaseLock: () => {},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const staging = stageRendererFileToTemp(file, localPath, fs, controller.signal);
|
||||
while (!finishRead) await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort(new Error("cancel now"));
|
||||
await assert.rejects(staging, /cancel now/);
|
||||
assert.equal(cancelCalls, 1);
|
||||
await assert.rejects(fs.promises.stat(localPath), { code: "ENOENT" });
|
||||
});
|
||||
139
electron/preload/terminalDataBacklog.cjs
Normal file
139
electron/preload/terminalDataBacklog.cjs
Normal file
@@ -0,0 +1,139 @@
|
||||
"use strict";
|
||||
|
||||
const { mergeTerminalDataMeta } = require("./terminalDataMeta.cjs");
|
||||
|
||||
function hasPluginPipelineIngress(meta) {
|
||||
return Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
&& Number(meta.pluginPipelineIngressBytes) > 0;
|
||||
}
|
||||
|
||||
function hasPluginPipelineIngressMarker(meta) {
|
||||
return Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
&& Number(meta.pluginPipelineIngressBytes) >= 0;
|
||||
}
|
||||
|
||||
function createTerminalDataBacklog(options = {}) {
|
||||
const maxBytesPerSession = options.maxBytesPerSession ?? 64 * 1024;
|
||||
const pendingBySession = new Map();
|
||||
|
||||
function trimToLimit(value) {
|
||||
if (value.length <= maxBytesPerSession) return value;
|
||||
return value.slice(value.length - maxBytesPerSession);
|
||||
}
|
||||
|
||||
function append(sessionId, data, meta) {
|
||||
if (!sessionId || (!data && !hasPluginPipelineIngressMarker(meta))) return;
|
||||
const previous = pendingBySession.get(sessionId) || { data: "", meta: undefined };
|
||||
const nextData = trimToLimit(previous.data + data);
|
||||
const preserveTerminalPerf = previous.data.length === 0 && nextData === data;
|
||||
let previousMeta = previous.meta;
|
||||
let nextChunkMeta = meta;
|
||||
const previousHasIngress = Number.isFinite(previousMeta?.pluginPipelineIngressBytes);
|
||||
const nextChunkHasIngress = Number.isFinite(nextChunkMeta?.pluginPipelineIngressBytes);
|
||||
// Once one merged chunk carries explicit original-ingress accounting, the
|
||||
// metadata must cover every raw flow unit in the same replay entry. Flow
|
||||
// control is intentionally charged in JavaScript string length, not UTF-8
|
||||
// bytes, throughout the terminal renderer/worker path. Otherwise a
|
||||
// processed chunk followed or preceded by ordinary output would cause the
|
||||
// renderer to acknowledge only the annotated subset.
|
||||
if (previousHasIngress && !nextChunkHasIngress && data) {
|
||||
nextChunkMeta = {
|
||||
...(nextChunkMeta || {}),
|
||||
pluginPipelineIngressBytes: data.length,
|
||||
};
|
||||
} else if (!previousHasIngress && nextChunkHasIngress && previous.data) {
|
||||
previousMeta = {
|
||||
...(previousMeta || {}),
|
||||
pluginPipelineIngressBytes: previous.data.length,
|
||||
};
|
||||
}
|
||||
const nextMeta = mergeTerminalDataMeta(previousMeta, nextChunkMeta, { preserveTerminalPerf });
|
||||
pendingBySession.set(sessionId, {
|
||||
data: nextData,
|
||||
meta: nextMeta,
|
||||
});
|
||||
}
|
||||
|
||||
function takeEntry(sessionId) {
|
||||
const entry = pendingBySession.get(sessionId) || { data: "", meta: undefined };
|
||||
pendingBySession.delete(sessionId);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function take(sessionId) {
|
||||
return takeEntry(sessionId).data;
|
||||
}
|
||||
|
||||
function clear(sessionId) {
|
||||
pendingBySession.delete(sessionId);
|
||||
}
|
||||
|
||||
function size(sessionId) {
|
||||
return pendingBySession.get(sessionId)?.data.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
append,
|
||||
take,
|
||||
takeEntry,
|
||||
clear,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSessionListeners(listenersBySession, sessionId) {
|
||||
return (listenersBySession.get(sessionId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
function createTerminalDataDispatcher({
|
||||
dataListeners,
|
||||
displayDataListeners,
|
||||
terminalDataBacklog,
|
||||
onCallbackError = console.error,
|
||||
shouldDropSession = () => false,
|
||||
}) {
|
||||
return function deliverToListeners(sessionId, data, meta) {
|
||||
if (!data && !hasPluginPipelineIngressMarker(meta)) return;
|
||||
if (shouldDropSession(sessionId)) return;
|
||||
|
||||
if (!hasSessionListeners(displayDataListeners, sessionId)) {
|
||||
terminalDataBacklog?.append?.(sessionId, data, meta);
|
||||
}
|
||||
|
||||
const set = dataListeners.get(sessionId);
|
||||
if (!set || set.size === 0) return;
|
||||
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data, meta);
|
||||
} catch (err) {
|
||||
onCallbackError("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function clearTerminalDataSession({
|
||||
dataListeners,
|
||||
displayDataListeners,
|
||||
terminalDataBacklog,
|
||||
}, sessionId) {
|
||||
dataListeners?.delete?.(sessionId);
|
||||
displayDataListeners?.delete?.(sessionId);
|
||||
terminalDataBacklog?.clear?.(sessionId);
|
||||
}
|
||||
|
||||
function clearTerminalDataBacklog({
|
||||
terminalDataBacklog,
|
||||
}, sessionId) {
|
||||
terminalDataBacklog?.clear?.(sessionId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearTerminalDataBacklog,
|
||||
createTerminalDataBacklog,
|
||||
createTerminalDataDispatcher,
|
||||
clearTerminalDataSession,
|
||||
hasPluginPipelineIngress,
|
||||
hasPluginPipelineIngressMarker,
|
||||
};
|
||||
76
electron/preload/terminalDataMeta.cjs
Normal file
76
electron/preload/terminalDataMeta.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeTerminalDataMeta(meta) {
|
||||
if (!isRecord(meta)) return undefined;
|
||||
return { ...meta };
|
||||
}
|
||||
|
||||
function hasMetaFields(meta) {
|
||||
return Boolean(meta && Object.keys(meta).length > 0);
|
||||
}
|
||||
|
||||
function mergeTerminalDataMeta(first, second, options = {}) {
|
||||
const merged = {
|
||||
...(normalizeTerminalDataMeta(first) || {}),
|
||||
...(normalizeTerminalDataMeta(second) || {}),
|
||||
};
|
||||
|
||||
const droppedOutputMayAffectTerminalState = Boolean(
|
||||
first?.droppedOutputMayAffectTerminalState
|
||||
|| second?.droppedOutputMayAffectTerminalState
|
||||
);
|
||||
const droppedOutputAlternateScreenAction = second?.droppedOutputMayAffectTerminalState
|
||||
? second?.droppedOutputAlternateScreenAction
|
||||
: (second?.droppedOutputAlternateScreenAction ?? first?.droppedOutputAlternateScreenAction);
|
||||
const firstHasPluginPipelineIngress = Number.isFinite(first?.pluginPipelineIngressBytes);
|
||||
const secondHasPluginPipelineIngress = Number.isFinite(second?.pluginPipelineIngressBytes);
|
||||
const pluginPipelineIngressBytes = Math.max(
|
||||
0,
|
||||
Number(first?.pluginPipelineIngressBytes ?? 0)
|
||||
+ Number(second?.pluginPipelineIngressBytes ?? 0),
|
||||
);
|
||||
|
||||
if (typeof second?.pluginPipelineSensitiveInput === "boolean") {
|
||||
merged.pluginPipelineSensitiveInput = second.pluginPipelineSensitiveInput;
|
||||
} else {
|
||||
delete merged.pluginPipelineSensitiveInput;
|
||||
}
|
||||
|
||||
if (second?.pluginPipelineProcessed === true) {
|
||||
merged.pluginPipelineProcessed = true;
|
||||
} else {
|
||||
delete merged.pluginPipelineProcessed;
|
||||
}
|
||||
|
||||
if (droppedOutputMayAffectTerminalState) {
|
||||
merged.droppedOutputMayAffectTerminalState = true;
|
||||
} else {
|
||||
delete merged.droppedOutputMayAffectTerminalState;
|
||||
}
|
||||
|
||||
if (droppedOutputAlternateScreenAction) {
|
||||
merged.droppedOutputAlternateScreenAction = droppedOutputAlternateScreenAction;
|
||||
} else {
|
||||
delete merged.droppedOutputAlternateScreenAction;
|
||||
}
|
||||
|
||||
if (firstHasPluginPipelineIngress || secondHasPluginPipelineIngress) {
|
||||
merged.pluginPipelineIngressBytes = pluginPipelineIngressBytes;
|
||||
} else {
|
||||
delete merged.pluginPipelineIngressBytes;
|
||||
}
|
||||
|
||||
if (options.preserveTerminalPerf !== true) {
|
||||
delete merged.terminalPerf;
|
||||
}
|
||||
|
||||
return hasMetaFields(merged) ? merged : undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mergeTerminalDataMeta,
|
||||
};
|
||||
82
electron/preload/terminalOutputPorts.cjs
Normal file
82
electron/preload/terminalOutputPorts.cjs
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const { TERMINAL_OUTPUT_PORT_CHANNEL } = require("../bridges/terminalOutputChannel.cjs");
|
||||
const {
|
||||
hasPluginPipelineIngressMarker,
|
||||
} = require("./terminalDataBacklog.cjs");
|
||||
|
||||
function createTerminalOutputPortRegistry(options = {}) {
|
||||
const {
|
||||
ipcRenderer,
|
||||
deliverToListeners,
|
||||
filterData = null,
|
||||
closedTerminalDataSessions = new Set(),
|
||||
onPortError = console.error,
|
||||
onDrain = null,
|
||||
} = options;
|
||||
const ports = new Map();
|
||||
|
||||
function closeSession(sessionId) {
|
||||
const port = ports.get(sessionId);
|
||||
if (!port) return;
|
||||
try {
|
||||
port.close?.();
|
||||
} catch {
|
||||
// Ignore close races while replacing or closing output ports.
|
||||
}
|
||||
ports.delete(sessionId);
|
||||
}
|
||||
|
||||
function registerPort(sessionId, port) {
|
||||
if (!sessionId || !port) return;
|
||||
closeSession(sessionId);
|
||||
ports.set(sessionId, port);
|
||||
port.onmessage = (event) => {
|
||||
const message = event?.data || {};
|
||||
const targetSessionId = message.sessionId || sessionId;
|
||||
if (message.kind === "drain" && message.requestId) {
|
||||
onDrain?.(targetSessionId, message.requestId);
|
||||
return;
|
||||
}
|
||||
if (closedTerminalDataSessions.has(targetSessionId)) return;
|
||||
if (!message.data && !hasPluginPipelineIngressMarker(message.meta)) return;
|
||||
try {
|
||||
const filtered = typeof filterData === "function"
|
||||
? filterData(targetSessionId, message.data, message)
|
||||
: message.data;
|
||||
const data = filtered && typeof filtered === "object" && "data" in filtered
|
||||
? filtered.data
|
||||
: filtered;
|
||||
const meta = filtered && typeof filtered === "object" && "data" in filtered
|
||||
? filtered.meta
|
||||
: message.meta;
|
||||
if (data || hasPluginPipelineIngressMarker(meta)) {
|
||||
deliverToListeners?.(targetSessionId, data ?? "", meta);
|
||||
}
|
||||
} catch (err) {
|
||||
onPortError("Terminal output port callback failed", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function register() {
|
||||
ipcRenderer?.on?.(TERMINAL_OUTPUT_PORT_CHANNEL, (event, payload) => {
|
||||
registerPort(payload?.sessionId, event?.ports?.[0]);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
closeSession,
|
||||
closeAll() {
|
||||
for (const sessionId of Array.from(ports.keys())) {
|
||||
closeSession(sessionId);
|
||||
}
|
||||
},
|
||||
hasSessionForTest: (sessionId) => ports.has(sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTerminalOutputPortRegistry,
|
||||
};
|
||||
68
electron/preload/terminalUrgentInputPorts.cjs
Normal file
68
electron/preload/terminalUrgentInputPorts.cjs
Normal file
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
TERMINAL_URGENT_INPUT_PORT_CHANNEL,
|
||||
} = require("../bridges/terminalUrgentInputChannel.cjs");
|
||||
|
||||
function createTerminalUrgentInputPortRegistry(options = {}) {
|
||||
const {
|
||||
ipcRenderer,
|
||||
onPortError = console.error,
|
||||
} = options;
|
||||
let port = null;
|
||||
|
||||
function closePort() {
|
||||
if (!port) return;
|
||||
try {
|
||||
port.close?.();
|
||||
} catch {
|
||||
// Ignore stale urgent-port close races while replacing a worker.
|
||||
}
|
||||
port = null;
|
||||
}
|
||||
|
||||
function register() {
|
||||
ipcRenderer?.on?.(TERMINAL_URGENT_INPUT_PORT_CHANNEL, (event) => {
|
||||
closePort();
|
||||
const nextPort = event?.ports?.[0];
|
||||
if (!nextPort) return;
|
||||
port = nextPort;
|
||||
try {
|
||||
port.start?.();
|
||||
} catch {
|
||||
// Some Electron MessagePort implementations do not require start().
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function postInterrupt(sessionId, trace) {
|
||||
if (!sessionId || !port) return false;
|
||||
try {
|
||||
port.postMessage({
|
||||
kind: "interrupt",
|
||||
sessionId,
|
||||
trace,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
closePort();
|
||||
try {
|
||||
onPortError?.("Terminal urgent input port failed", err);
|
||||
} catch {
|
||||
// Diagnostics must not affect Ctrl+C fallback.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
postInterrupt,
|
||||
close: closePort,
|
||||
hasPortForTest: () => Boolean(port),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTerminalUrgentInputPortRegistry,
|
||||
};
|
||||
Reference in New Issue
Block a user