[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:
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
385
electron/plugins/runtime/pluginStreamEndpoint.mjs
Normal file
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
createMessagePortStreamEnvelope,
|
||||
materializeStreamChunk,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
|
||||
function assertStreamId(streamId) {
|
||||
if (typeof streamId !== "string"
|
||||
|| streamId.length < 1
|
||||
|| streamId.length > PLUGIN_STREAM_MAX_ID_LENGTH
|
||||
|| streamId.includes("\0")) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream ID is invalid");
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
|
||||
function assertWindowBytes(windowBytes) {
|
||||
if (!Number.isSafeInteger(windowBytes)
|
||||
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|
||||
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
|
||||
throw new PluginError("invalid_argument", "Plugin stream window is invalid");
|
||||
}
|
||||
return windowBytes;
|
||||
}
|
||||
|
||||
function copyBytes(value) {
|
||||
let source;
|
||||
if (value instanceof Uint8Array) source = value;
|
||||
else if (value instanceof ArrayBuffer) source = new Uint8Array(value);
|
||||
else throw new PluginError("invalid_argument", "Plugin stream writes require Uint8Array or ArrayBuffer");
|
||||
const copy = new Uint8Array(source.byteLength);
|
||||
copy.set(source);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function closedError(streamId) {
|
||||
return new PluginError("unavailable", `Plugin stream is closed: ${streamId}`);
|
||||
}
|
||||
|
||||
export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
const maxStreams = options.maxStreams ?? 128;
|
||||
const incoming = new Map();
|
||||
const outgoing = new Map();
|
||||
const pendingIncoming = new Map();
|
||||
let closed = false;
|
||||
|
||||
const sendEnvelope = (frame, transfer) => {
|
||||
const envelope = createMessagePortStreamEnvelope(frame, transfer);
|
||||
transport.post(envelope, transfer ? [transfer] : []);
|
||||
};
|
||||
|
||||
const activeStreams = () => incoming.size + outgoing.size;
|
||||
const reservedStreams = () => activeStreams() + pendingIncoming.size;
|
||||
|
||||
function releaseCurrent(state) {
|
||||
if (!state.currentCreditBytes || closed) return;
|
||||
const creditBytes = state.currentCreditBytes;
|
||||
state.currentCreditBytes = 0;
|
||||
state.availableBytes += creditBytes;
|
||||
state.updateSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.updateSequence,
|
||||
kind: "windowUpdate",
|
||||
creditBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function settleIncomingWaiters(state) {
|
||||
while (state.readers.length > 0) {
|
||||
if (state.queue.length > 0) {
|
||||
const reader = state.readers.shift();
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
reader.resolve(chunk.data);
|
||||
continue;
|
||||
}
|
||||
if (!state.closed) break;
|
||||
const reader = state.readers.shift();
|
||||
if (state.error) reader.reject(state.error);
|
||||
else reader.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
function closeIncoming(state, error, notify = false) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.error = error ?? null;
|
||||
incoming.delete(state.streamId);
|
||||
if (notify) {
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "cancel",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
settleIncomingWaiters(state);
|
||||
}
|
||||
|
||||
function readableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
async read() {
|
||||
if (state.readers.length > 0) {
|
||||
throw new PluginError("failed_precondition", "Plugin stream does not allow concurrent reads");
|
||||
}
|
||||
if (state.currentCreditBytes) releaseCurrent(state);
|
||||
if (state.queue.length > 0) {
|
||||
const chunk = state.queue.shift();
|
||||
state.currentCreditBytes = chunk.creditBytes;
|
||||
return chunk.data;
|
||||
}
|
||||
if (state.closed) {
|
||||
if (state.error) throw state.error;
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve, reject) => state.readers.push({ resolve, reject }));
|
||||
},
|
||||
cancel() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was cancelled"), true); },
|
||||
dispose() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was disposed"), true); },
|
||||
});
|
||||
}
|
||||
|
||||
function flushOutgoing(state) {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const pending = state.queue[0];
|
||||
if (pending.bytes.byteLength > state.availableBytes) break;
|
||||
state.queue.shift();
|
||||
state.queuedBytes -= pending.bytes.byteLength;
|
||||
state.availableBytes -= pending.bytes.byteLength;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: pending.bytes.byteLength },
|
||||
}, pending.bytes.buffer);
|
||||
pending.resolve();
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
pending.reject(error);
|
||||
for (const queued of state.queue.splice(0)) queued.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
if (!state.closed && state.terminal === "end" && state.queue.length === 0 && !state.terminalSent) {
|
||||
state.terminalSent = true;
|
||||
state.nextSequence += 1;
|
||||
try {
|
||||
sendEnvelope({ streamId: state.streamId, sequence: state.nextSequence, kind: "end" });
|
||||
state.endResolve?.();
|
||||
if (state.availableBytes === state.windowBytes) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
state.endReject?.(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writableHandle(state) {
|
||||
return Object.freeze({
|
||||
id: state.streamId,
|
||||
write(value) {
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
const bytes = copyBytes(value);
|
||||
if (bytes.byteLength < 1 || bytes.byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
||||
return Promise.reject(new PluginError("out_of_range", "Plugin stream chunk size is invalid"));
|
||||
}
|
||||
if (state.queuedBytes + bytes.byteLength > state.windowBytes) {
|
||||
return Promise.reject(new PluginError("resource_exhausted", "Plugin stream pending writes exceed its window"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
state.queue.push({ bytes, resolve, reject });
|
||||
state.queuedBytes += bytes.byteLength;
|
||||
flushOutgoing(state);
|
||||
});
|
||||
},
|
||||
end() {
|
||||
if (state.terminal === "end") return state.endPromise;
|
||||
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
||||
state.terminal = "end";
|
||||
state.endPromise = new Promise((resolve, reject) => {
|
||||
state.endResolve = resolve;
|
||||
state.endReject = reject;
|
||||
});
|
||||
flushOutgoing(state);
|
||||
return state.endPromise;
|
||||
},
|
||||
fail(error) {
|
||||
if (state.closed || state.terminal) return;
|
||||
state.terminal = "error";
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const failure = new PluginError("data_loss", String(error?.message ?? "Plugin stream failed"));
|
||||
for (const pending of state.queue.splice(0)) pending.reject(failure);
|
||||
state.queuedBytes = 0;
|
||||
state.nextSequence += 1;
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
kind: "error",
|
||||
error: {
|
||||
code: -32013,
|
||||
message: String(error?.message ?? "Plugin stream failed").slice(0, 2048),
|
||||
},
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
const error = new PluginError("cancelled", "Plugin stream was cancelled");
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence + 1,
|
||||
kind: "cancel",
|
||||
});
|
||||
},
|
||||
dispose() { this.cancel(); },
|
||||
});
|
||||
}
|
||||
|
||||
function accept(message) {
|
||||
if (!message || typeof message !== "object" || !Object.hasOwn(message, "frame")) return false;
|
||||
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
||||
const frame = envelope.frame;
|
||||
if (frame.kind === "open") {
|
||||
if (!pendingIncoming.has(frame.streamId)) return false;
|
||||
if (closed || activeStreams() >= maxStreams || incoming.has(frame.streamId) || outgoing.has(frame.streamId)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${frame.streamId}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: frame.streamId,
|
||||
availableBytes: frame.windowBytes,
|
||||
nextSequence: 1,
|
||||
updateSequence: -1,
|
||||
currentCreditBytes: 0,
|
||||
queue: [],
|
||||
readers: [],
|
||||
closed: false,
|
||||
error: null,
|
||||
};
|
||||
incoming.set(frame.streamId, state);
|
||||
const waiter = pendingIncoming.get(frame.streamId);
|
||||
pendingIncoming.delete(frame.streamId);
|
||||
waiter.resolve(readableHandle(state));
|
||||
return true;
|
||||
}
|
||||
const output = outgoing.get(frame.streamId);
|
||||
if (output && frame.kind === "windowUpdate") {
|
||||
if (frame.sequence !== output.lastUpdateSequence + 1) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit is out of order: ${frame.streamId}`);
|
||||
}
|
||||
output.lastUpdateSequence = frame.sequence;
|
||||
output.availableBytes += frame.creditBytes;
|
||||
if (output.availableBytes > output.windowBytes) {
|
||||
throw new PluginError("data_loss", `Plugin stream credit exceeds its window: ${frame.streamId}`);
|
||||
}
|
||||
if (output.terminalSent && output.availableBytes === output.windowBytes) {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
} else flushOutgoing(output);
|
||||
return true;
|
||||
}
|
||||
if (output && frame.kind === "cancel") {
|
||||
output.closed = true;
|
||||
outgoing.delete(frame.streamId);
|
||||
const error = new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`);
|
||||
for (const pending of output.queue.splice(0)) pending.reject(error);
|
||||
output.queuedBytes = 0;
|
||||
output.endReject?.(error);
|
||||
return true;
|
||||
}
|
||||
const input = incoming.get(frame.streamId);
|
||||
if (!input || input.closed) throw new PluginError("data_loss", `Unknown Plugin stream: ${frame.streamId}`);
|
||||
if (frame.sequence !== input.nextSequence) {
|
||||
throw new PluginError("data_loss", `Plugin stream frame is out of order: ${frame.streamId}`);
|
||||
}
|
||||
input.nextSequence += 1;
|
||||
if (frame.kind === "chunk") {
|
||||
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
|
||||
if (materialized.encoding !== "binary" || !(materialized.bytes instanceof Uint8Array)) {
|
||||
throw new PluginError("data_loss", "Plugin byte stream received a non-binary chunk");
|
||||
}
|
||||
const data = materialized.bytes;
|
||||
if (data.byteLength > input.availableBytes) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream exceeded receive credit: ${frame.streamId}`);
|
||||
}
|
||||
input.availableBytes -= data.byteLength;
|
||||
input.queue.push({ data, creditBytes: data.byteLength });
|
||||
settleIncomingWaiters(input);
|
||||
return true;
|
||||
}
|
||||
closeIncoming(
|
||||
input,
|
||||
frame.kind === "error"
|
||||
? new PluginError("data_loss", frame.error.message)
|
||||
: frame.kind === "cancel"
|
||||
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
|
||||
: null,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
accept,
|
||||
async acceptReadable(streamId) {
|
||||
const id = assertStreamId(streamId);
|
||||
const existing = incoming.get(id);
|
||||
if (existing) return readableHandle(existing);
|
||||
if (closed || pendingIncoming.has(id) || outgoing.has(id)) {
|
||||
throw new PluginError("failed_precondition", `Plugin stream cannot be accepted: ${id}`);
|
||||
}
|
||||
if (reservedStreams() >= maxStreams) {
|
||||
throw new PluginError("resource_exhausted", "Plugin stream limit is exhausted");
|
||||
}
|
||||
return new Promise((resolve, reject) => pendingIncoming.set(id, { resolve, reject }));
|
||||
},
|
||||
rejectReadable(streamId, error = new PluginError("cancelled", "Plugin stream acceptance was cancelled")) {
|
||||
const id = assertStreamId(streamId);
|
||||
const waiter = pendingIncoming.get(id);
|
||||
if (!waiter) return false;
|
||||
pendingIncoming.delete(id);
|
||||
waiter.reject(error);
|
||||
return true;
|
||||
},
|
||||
async openWritable(streamId, windowBytes = 256 * 1024) {
|
||||
const id = assertStreamId(streamId);
|
||||
const window = assertWindowBytes(windowBytes);
|
||||
if (closed || reservedStreams() >= maxStreams || incoming.has(id) || outgoing.has(id) || pendingIncoming.has(id)) {
|
||||
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${id}`);
|
||||
}
|
||||
const state = {
|
||||
streamId: id,
|
||||
windowBytes: window,
|
||||
availableBytes: window,
|
||||
nextSequence: 0,
|
||||
lastUpdateSequence: -1,
|
||||
queue: [],
|
||||
queuedBytes: 0,
|
||||
terminal: null,
|
||||
terminalSent: false,
|
||||
endPromise: null,
|
||||
endResolve: null,
|
||||
endReject: null,
|
||||
closed: false,
|
||||
};
|
||||
outgoing.set(id, state);
|
||||
sendEnvelope({ streamId: id, sequence: 0, kind: "open", windowBytes: window });
|
||||
return writableHandle(state);
|
||||
},
|
||||
close(error = new PluginError("unavailable", "Plugin stream transport closed")) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const state of incoming.values()) closeIncoming(state, error);
|
||||
for (const state of outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
}
|
||||
outgoing.clear();
|
||||
for (const waiter of pendingIncoming.values()) waiter.reject(error);
|
||||
pendingIncoming.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { assertStreamId, assertWindowBytes };
|
||||
Reference in New Issue
Block a user