[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

2075
electron/preload/api.cjs Normal file

File diff suppressed because it is too large Load Diff

View 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);
});

View 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" },
});
});

View 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,
};

View 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);
});

View 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 };

View 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" });
});

View 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,
};

View 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,
};

View 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,
};

View 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,
};