Files
NetMesh/electron/bridges/zmodemFastPath.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1108 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* ZMODEM receive fast path for zmodem.js (0.1.10).
*
* The library processes every incoming byte as an element of a
* JavaScript number[] array: Sentry.consume() converts Buffers with
* `Array.prototype.slice.call(new Uint8Array(input))`, subpackets are
* ZDLE-decoded and CRC-checked with per-byte JS loops, and the decoded
* payloads are handed to on_input as number[] again. That costs tens of
* JS ops per byte and caps `sz` download throughput in the low tens of
* MB/s even though ssh2 can deliver ~100MB/s. The fast path deliberately
* keeps receive payloads as Uint8Array instances so the download bridge can
* pass them to the file stream without another full-payload copy.
*
* This module patches the library's prototypes so that while a receive
* session is streaming ZDATA subpackets, bytes stay as Buffers the whole
* way: the frame-end scan uses Buffer.indexOf (native memchr), the
* payload is ZDLE-decoded into a fresh Uint8Array in one pass, and
* CRC16/CRC32 run over typed arrays with precomputed tables. Headers are
* tiny and rare, so they keep the original array-based pipeline
* untouched — protocol state machines, error recovery, wire bytes, and
* event ordering behave exactly as before. The receive payload container is
* intentionally Uint8Array on this path for throughput.
*
* The same module also carries `applyZmodemSendSessionFixes()`, which
* patches zmodem.js Send-session correctness hazards that make `rz`
* uploads fail intermittently. Those are protocol fixes, not a
* throughput fast path, and are not gated by the kill-switch below.
*
* Kill-switch: set NETCATTY_ZMODEM_FAST_PATH=0 to disable both performance
* fast paths. The send-session correctness fixes remain enabled.
*/
const ZDLE = 0x18;
const XON = 0x11;
const XOFF = 0x13;
const XON_HIGH = XON | 0x80; // 0x91
const XOFF_HIGH = XOFF | 0x80; // 0x93
const OVER_AND_OUT = [79, 79]; // "OO"
// frame-end byte (104-107) → Subpacket.build() frameend key
const FRAME_END_KEYS = {
104: "end_no_ack", // ZCRCE - frame ends, no ack
105: "no_end_no_ack", // ZCRCG - frame continues
106: "no_end_ack", // ZCRCQ - frame continues, ack expected
107: "end_ack", // ZCRCW - frame ends, ack expected
};
const EMPTY_BUFFER = Buffer.alloc(0);
function isFastPathDisabled() {
return process.env.NETCATTY_ZMODEM_FAST_PATH === "0";
}
//----------------------------------------------------------------------
// CRC16 (CRC-CCITT/XModem) — replicates zcrc.js `_compute_crctab()`.
//----------------------------------------------------------------------
const CRC16_TABLE = new Uint32Array(256);
for (let divident = 0; divident < 256; divident++) {
let currByte = (divident << 8) & 0xffff;
for (let bit = 0; bit < 8; bit++) {
currByte =
currByte & 0x8000
? ((currByte << 1) ^ 0x1021) & 0xffff
: (currByte << 1) & 0xffff;
}
CRC16_TABLE[divident] = currByte;
}
//----------------------------------------------------------------------
// CRC32 (standard reflected, poly 0xEDB88320) — matches the crc-32
// package that zmodem.js uses via `CRC32_MOD.buf()`.
//----------------------------------------------------------------------
const CRC32_TABLE = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
CRC32_TABLE[n] = c >>> 0;
}
/** One CRC16 step; identical to zcrc.js `_updcrc()`. */
function crc16Step(crc, byte) {
return CRC16_TABLE[(crc >> 8) & 255] ^ ((255 & crc) << 8) ^ byte;
}
/**
* CRC16 over decoded payload + frame-end byte, including the two
* zero-byte passes that zmodem.js `CRC.crc16()` appends.
*
* TABLE[0] is 0, so seeding with 0 and updating with the first byte is
* equivalent to the library seeding with the first byte.
*/
function crc16Bytes(payload, frameEndNum) {
let crc = 0;
for (let i = 0; i < payload.length; i++) {
crc = crc16Step(crc, payload[i]);
}
crc = crc16Step(crc, frameEndNum);
crc = crc16Step(crc, 0);
crc = crc16Step(crc, 0);
return crc & 0xffff;
}
/** CRC32 over decoded payload + frame-end byte (standard init/final xor). */
function crc32Bytes(payload, frameEndNum) {
let crc = 0xffffffff;
for (let i = 0; i < payload.length; i++) {
crc = CRC32_TABLE[(crc ^ payload[i]) & 255] ^ (crc >>> 8);
}
crc = CRC32_TABLE[(crc ^ frameEndNum) & 255] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
/**
* ZDLE-decode `u8` into a fresh Uint8Array.
*
* Decoding is `second_byte - 64`, exactly like zmodem.js's
* `octets[o + 1] - 64`. (XOR 0x40 would only be equivalent for escaped
* values < 0x80; conforming encoders never emit escape pairs whose
* second byte is in 0x80-0xBF, but we replicate the library verbatim.)
* A trailing bare ZDLE is malformed input; it decodes to ZDLE-64 here
* and the CRC check downstream rejects it, same as the library.
*/
function zdleDecode(u8) {
const out = new Uint8Array(u8.length); // never longer than encoded
let w = 0;
for (let i = 0; i < u8.length; i++) {
let b = u8[i];
if (b === ZDLE) {
i += 1;
b = u8[i] - 64;
}
out[w++] = b;
}
return out.subarray(0, w);
}
/**
* Read `count` ZDLE-decoded bytes starting at `start` in `u8`.
*
* Returns null if the encoded bytes aren't fully available (including a
* trailing bare ZDLE), mirroring `Zmodem.ZDLE.splice()`.
*/
function readDecodedBytes(u8, start, count) {
const bytes = new Uint8Array(count);
let i = start;
let got = 0;
while (got < count) {
if (i >= u8.length) return null;
let b = u8[i];
if (b === ZDLE) {
i += 1;
if (i >= u8.length) return null; // bare trailing ZDLE
b = u8[i] - 64;
}
bytes[got++] = b;
i += 1;
}
return { bytes, consumed: i - start };
}
/**
* Replicates Zmodem.ZMLIB.strip_ignored_bytes() (XON/XOFF and their
* high-bit variants) without the per-byte Array.splice().
* Zero-copy when there's nothing to strip.
*/
function stripIgnoredBytesFast(u8) {
let dirty = false;
for (let i = 0; i < u8.length; i++) {
const b = u8[i];
if (b === XON || b === XON_HIGH || b === XOFF || b === XOFF_HIGH) {
dirty = true;
break;
}
}
if (!dirty) return u8;
const out = new Uint8Array(u8.length);
let w = 0;
for (let i = 0; i < u8.length; i++) {
const b = u8[i];
if (b === XON || b === XON_HIGH || b === XOFF || b === XOFF_HIGH) {
continue;
}
out[w++] = b;
}
return out.subarray(0, w);
}
/**
* Fast equivalent of zsubpacket.js `Subpacket._parse()` for Buffer input.
*
* - Returns null when the buffer doesn't (yet) hold a complete subpacket
* (i.e., the caller should keep accumulating bytes).
* - Throws `new Zmodem.Error("crc", got, expected)` on a CRC mismatch,
* exactly like the library's CRC.verify16()/verify32().
*
* @param {Object} Zmodem - The zmodem.js module (used for
* Subpacket.build() and the Zmodem.Error class).
* @param {Buffer} buf - Encoded bytes, starting at a subpacket boundary.
* @param {number} crcLen - 2 for CRC16, 4 for CRC32.
* @returns {{ subpacket: Object, consumed: number } | null}
*/
function parseSubpacketFast(Zmodem, buf, crcLen) {
// Find the first ZDLE followed by a frame-end byte (104-107). ZDLE
// escaping guarantees no payload byte can decode to a frame-end value,
// so the first such pair is the marker — the same scan the library
// does in Subpacket._parse().
let zdleAt = -1;
let frameEndKey = null;
let frameEndNum = 0;
while (true) {
zdleAt = buf.indexOf(ZDLE, zdleAt + 1);
if (zdleAt === -1) return null; // no marker yet → need more data
if (zdleAt + 1 >= buf.length) return null; // trailing ZDLE → need more data
frameEndNum = buf[zdleAt + 1];
frameEndKey = FRAME_END_KEYS[frameEndNum];
if (frameEndKey) break;
}
// Payload is everything before the marker's ZDLE, ZDLE-decoded.
const payload = zdleDecode(buf.subarray(0, zdleAt));
// The CRC bytes follow the marker and are individually escaped.
const got = readDecodedBytes(buf, zdleAt + 2, crcLen);
if (!got) return null; // CRC straddles chunks → need more data
// Verify the CRC over decoded payload + frame-end byte.
let expected;
if (crcLen === 2) {
const v = crc16Bytes(payload, frameEndNum);
expected = [(v >> 8) & 0xff, v & 0xff];
} else {
const v = crc32Bytes(payload, frameEndNum);
expected = [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff];
}
for (let i = 0; i < crcLen; i++) {
if (got.bytes[i] !== expected[i]) {
throw new Zmodem.Error(
"crc",
Array.prototype.slice.call(got.bytes),
expected,
);
}
}
// Keep the decoded payload typed through the fast receive path. The
// application download handler turns this into a zero-copy Buffer view;
// converting to a number[] here would erase the throughput gain with a
// full payload allocation on every subpacket.
const subpacket = Zmodem.Subpacket.build(payload, frameEndKey);
return { subpacket, consumed: zdleAt + 2 + got.consumed };
}
/** Replicates `_trim_OO()` from zsession.js. */
function trimOverAndOut(Zmodem, array) {
if (0 === Zmodem.ZMLIB.find_subarray(array, OVER_AND_OUT)) {
array.splice(0, OVER_AND_OUT.length);
} else if (array[0] === OVER_AND_OUT[OVER_AND_OUT.length - 1]) {
array.splice(0, 1);
}
return array;
}
/**
* zsentry.js declares its Detection class privately; the patched
* Sentry.consume() needs to hand on_detect a Detection object of the
* same shape. Recreate it faithfully (confirm/deny/is_valid/
* get_session_role — callers only ever use these methods).
*/
class Detection {
constructor(session_type, accepter, denier, checker) {
this._confirmer = accepter;
this._denier = denier;
this._is_valid = checker;
this._session_type = session_type;
}
confirm() {
return this._confirmer.apply(this, arguments);
}
deny() {
return this._denier.apply(this, arguments);
}
is_valid() {
return this._is_valid.apply(this, arguments);
}
get_session_role() {
return this._session_type;
}
}
//----------------------------------------------------------------------
// The patch itself.
//----------------------------------------------------------------------
/**
* Patch zmodem.js so receive sessions consume Buffers on a fast path.
*
* Safe to call more than once (idempotent), and a no-op when the
* NETCATTY_ZMODEM_FAST_PATH kill-switch is set.
*
* @param {Object} Zmodem - The zmodem.js module, as returned by require().
* @returns {Object} The same module object, patched (or not).
*/
function applyZmodemFastPath(Zmodem) {
// Kill-switch for diagnosing protocol trouble in the field.
if (isFastPathDisabled()) return Zmodem;
if (Zmodem.__netcattyZmodemFastPathApplied) return Zmodem;
const sentryProto = Zmodem?.Sentry?.prototype;
const sessionProto = Zmodem?.Session?.prototype;
if (
!sentryProto ||
!sessionProto ||
typeof sentryProto.consume !== "function" ||
typeof sessionProto.consume !== "function" ||
!Zmodem.ZMLIB?.ABORT_SEQUENCE
) {
return Zmodem;
}
const ORIGINAL_SENTRY_CONSUME = sentryProto.consume;
const ORIGINAL_SESSION_CONSUME = sessionProto.consume;
const ABORT_SEQUENCE_BUF = Buffer.from(Zmodem.ZMLIB.ABORT_SEQUENCE);
//--------------------------------------------------------------------
// Session-level helpers. Bytes are kept in `_zmodem_fast_chunks`, a
// list of zero-copy Buffer views of the incoming chunks; consumed
// prefixes are dropped with subarray() instead of array.splice().
//--------------------------------------------------------------------
function fastPush(u8) {
if (!u8.length) return;
if (!this._zmodem_fast_chunks) this._zmodem_fast_chunks = [];
this._zmodem_fast_chunks.push(
Buffer.isBuffer(u8)
? u8
: Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength),
);
this._zmodem_fast_length = (this._zmodem_fast_length || 0) + u8.length;
}
/** A single Buffer with all pending fast bytes (concats on demand). */
function fastContiguous() {
const chunks = this._zmodem_fast_chunks;
if (!chunks || !chunks.length) return EMPTY_BUFFER;
if (chunks.length > 1) {
this._zmodem_fast_chunks = [Buffer.concat(chunks)];
// The chunk-index cursors refer to the old list. The frame marker, if
// any, is intentionally retained while its CRC bytes are still pending;
// the abort scan must restart on the merged buffer.
this._zmodem_fast_sequence_scan = null;
}
return this._zmodem_fast_chunks[0];
}
function fastLen() {
return this._zmodem_fast_length || 0;
}
/** Find a small byte sequence incrementally without flattening chunks. */
function fastFindSequence(needle) {
const chunks = this._zmodem_fast_chunks;
if (!chunks || !chunks.length || !needle.length) return -1;
let scan = this._zmodem_fast_sequence_scan;
if (!scan || scan.needle !== needle) {
scan = { needle, chunkIndex: 0, byteIndex: 0, offset: 0, matched: 0 };
this._zmodem_fast_sequence_scan = scan;
}
for (let chunkIndex = scan.chunkIndex; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
const start = chunkIndex === scan.chunkIndex ? scan.byteIndex : 0;
for (let i = start; i < chunk.length; i++) {
const byte = chunk[i];
const offset = scan.offset++;
if (byte === needle[scan.matched]) {
scan.matched += 1;
if (scan.matched === needle.length) return offset - needle.length + 1;
} else {
scan.matched = byte === needle[0] ? 1 : 0;
}
}
scan.chunkIndex = chunkIndex + 1;
scan.byteIndex = 0;
}
scan.chunkIndex = chunks.length;
scan.byteIndex = 0;
return -1;
}
/** Find a ZDLE frame-end marker incrementally without flattening a packet. */
function fastFindFrameEnd() {
const chunks = this._zmodem_fast_chunks;
if (!chunks || !chunks.length) return -1;
const existing = this._zmodem_fast_frame_end_at;
if (existing !== undefined) return existing;
let scan = this._zmodem_fast_frame_scan;
if (!scan) {
scan = { chunkIndex: 0, byteIndex: 0, offset: 0, previousWasZdle: false };
this._zmodem_fast_frame_scan = scan;
}
for (let chunkIndex = scan.chunkIndex; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
const start = chunkIndex === scan.chunkIndex ? scan.byteIndex : 0;
for (let i = start; i < chunk.length; i++) {
const byte = chunk[i];
const offset = scan.offset++;
if (scan.previousWasZdle && FRAME_END_KEYS[byte]) {
this._zmodem_fast_frame_end_at = offset - 1;
return this._zmodem_fast_frame_end_at;
}
scan.previousWasZdle = byte === ZDLE;
}
scan.chunkIndex = chunkIndex + 1;
scan.byteIndex = 0;
}
scan.chunkIndex = chunks.length;
scan.byteIndex = 0;
return -1;
}
/** Drop the first `n` fast bytes (like Array.splice(0, n)). */
function fastConsumeAt(n) {
const chunks = this._zmodem_fast_chunks;
const available = this._zmodem_fast_length || 0;
const toConsume = Math.min(Math.max(0, n), available);
while (n > 0 && chunks.length) {
const head = chunks[0];
if (head.length > n) {
chunks[0] = head.subarray(n);
n = 0;
} else {
chunks.shift();
n -= head.length;
}
}
this._zmodem_fast_length = available - toConsume;
this._zmodem_fast_frame_end_at = undefined;
this._zmodem_fast_frame_scan = null;
this._zmodem_fast_sequence_scan = null;
}
/** Move all pending fast bytes into `_input_buffer` (header parsing). */
function fastMoveAllToArray() {
const buf = this._zmodem_fast_contiguous();
this._zmodem_fast_chunks = [];
this._zmodem_fast_length = 0;
this._zmodem_fast_frame_end_at = undefined;
this._zmodem_fast_frame_scan = null;
this._zmodem_fast_sequence_scan = null;
// push.apply() takes up to ~32k args per call; chunk defensively.
for (let i = 0; i < buf.length; i += 0x8000) {
Array.prototype.push.apply(this._input_buffer, buf.subarray(i, i + 0x8000));
}
}
/**
* Move array-side bytes back into the fast chunks, before them.
* Used when a ZDATA header and its first subpacket(s) arrive in one
* chunk: the header parser leaves the data bytes in `_input_buffer`,
* and they must be consumed in stream order.
*/
function fastMoveArrayToFast() {
if (!this._input_buffer.length) return;
const arr = this._input_buffer.splice(0);
if (!this._zmodem_fast_chunks) this._zmodem_fast_chunks = [];
this._zmodem_fast_chunks.unshift(Buffer.from(arr));
this._zmodem_fast_length = arr.length + (this._zmodem_fast_length || 0);
this._zmodem_fast_frame_end_at = undefined;
this._zmodem_fast_frame_scan = null;
this._zmodem_fast_sequence_scan = null;
}
/** Mirrors `_check_for_abort_sequence()`. */
function fastCheckAbort() {
// Header parsing leaves incomplete frames in _input_buffer. Before a
// fast chunk is parsed, move it beside that buffered prefix so CAN x5
// is detected even when the cancel sequence crosses the boundary.
// This also lets the upstream helper own the removal semantics.
if (this._input_buffer.length) {
this._zmodem_fast_move_all_to_array();
return this._check_for_abort_sequence();
}
const at = this._zmodem_fast_find_sequence(ABORT_SEQUENCE_BUF);
if (at === -1) return false;
this._zmodem_fast_consume_at(at + ABORT_SEQUENCE_BUF.length);
this._aborted = true;
this._on_session_end();
throw new Zmodem.Error("peer_aborted");
}
/** Mirrors `_parse_and_consume_subpacket()`, parsing from fast bytes. */
function fastParseSubpacket() {
if (this._zmodem_fast_find_frame_end() === -1) return null;
const buf = this._zmodem_fast_contiguous();
const crcLen = this._last_header_crc === 16 ? 2 : 4;
const parsed = parseSubpacketFast(Zmodem, buf, crcLen);
if (!parsed) return null;
this._zmodem_fast_consume_at(parsed.consumed);
if (Zmodem.DEBUG) {
console.debug(this.type, "RECEIVED SUBPACKET", parsed.subpacket);
}
this._consume_data(parsed.subpacket);
if (parsed.subpacket.frame_end()) {
this._next_subpacket_handler = null;
}
return parsed.subpacket;
}
/** Mirrors the `_consume_first()` do-while for the receive path. */
function fastConsumeLoop() {
while (true) {
if (this._next_subpacket_handler) {
// A ZDATA header and its first subpacket may have arrived in one
// chunk and been routed through the array pipeline; pull those
// bytes back in order before fast-parsing.
this._zmodem_fast_move_array_to_fast();
if (!this._zmodem_fast_len()) break;
if (!this._zmodem_fast_parse_subpacket()) break;
} else {
// Headers are tiny and rare; hand bytes to the original
// array-based header pipeline.
this._zmodem_fast_move_all_to_array();
if (!this._input_buffer.length) break;
if (!this._parse_and_consume_header()) break;
}
}
}
/**
* Fast equivalent of Session.consume() for a receive session fed
* with Uint8Array/Buffer input.
*/
function fastSessionConsume(octets) {
this._before_consume(octets);
if (this._aborted) throw new Zmodem.Error("already_aborted");
if (!octets.length) return;
// Replicates _strip_and_enqueue_input(). The original path strips
// `octets` in place and keeps that same reference as
// _bytes_being_consumed; keep the stripped bytes so the post-"OO"
// trailing-bytes logic sees the same thing (per chunk, as before).
const stripped = stripIgnoredBytesFast(octets);
this._bytes_being_consumed = stripped;
this._zmodem_fast_push(stripped);
// The original Session.consume() checks for CAN x5 before its special
// post-ZFIN OO handling. Preserve that ordering on the fast path.
this._zmodem_fast_check_abort();
if (this._got_ZFIN) {
// Session is ending: only "OO" + trailing prompt bytes may follow.
// _trim_OO() and the header parser use Array.splice(), so route
// through the array pipeline (once, at session end).
this._zmodem_fast_move_all_to_array();
if (this._input_buffer.length < 2) return;
if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) {
// This doubles as an indication that the session has ended.
this._bytes_after_OO = trimOverAndOut(
Zmodem,
Array.prototype.slice.call(this._bytes_being_consumed),
);
this._on_session_end();
return;
}
throw (
"PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " +
this._input_buffer.join()
);
}
this._zmodem_fast_consume_loop();
}
try {
sentryProto.consume = function consume(input) {
let consumedViaFastPath = false;
if (!(input instanceof Array)) {
if (this._zsession && input instanceof Uint8Array) {
// Fast path: hand raw bytes straight to the session instead of
// converting every byte into a JS-array element (that
// conversion is what caps receive throughput). The session
// falls back to the original pipeline internally for send
// sessions and array input.
consumedViaFastPath = true;
const session = this._zsession;
session.consume(input);
if (!session.has_ended()) return;
if (session.type === "receive") {
input = session.get_trailing_bytes();
} else {
input = [];
}
} else {
input = Array.prototype.slice.call(new Uint8Array(input));
}
}
// Everything below is verbatim from the library's Sentry.consume()
// (zsentry.js), minus the fast-path branch above.
if (this._zsession && !consumedViaFastPath) {
var session_before_consume = this._zsession;
session_before_consume.consume(input);
if (session_before_consume.has_ended()) {
if (session_before_consume.type === "receive") {
input = session_before_consume.get_trailing_bytes();
} else {
input = [];
}
} else return;
}
var new_session = this._parse(input);
var to_terminal = input;
if (new_session) {
let replacement_detect = !!this._parsed_session;
if (replacement_detect) {
//no terminal output if the new session is of the
//same type as the old
if (this._parsed_session.type === new_session.type) {
to_terminal = [];
}
this._on_retract();
}
this._parsed_session = new_session;
var sentry = this;
function checker() {
return sentry._parsed_session === new_session;
}
//This runs with the Sentry object as the context.
function accepter() {
if (!this.is_valid()) {
throw "Stale ZMODEM session!";
}
new_session.on("garbage", sentry._to_terminal);
new_session.on(
"session_end",
sentry._after_session_end.bind(sentry),
);
new_session.set_sender(sentry._sender);
delete sentry._parsed_session;
return (sentry._zsession = new_session);
}
function denier() {
if (!this.is_valid()) return;
}
this._on_detect(
new Detection(
new_session.type,
accepter,
this._send_abort.bind(this),
checker,
),
);
} else {
var expired_session = this._parsed_session;
this._parsed_session = null;
if (expired_session) {
//If we got a single “C” after parsing a session,
//that means our peer is trying to downgrade to YMODEM.
//That wont work, so we just send the ABORT_SEQUENCE
//right away.
if (to_terminal.length === 1 && to_terminal[0] === 67) {
this._send_abort();
}
this._on_retract();
}
}
this._to_terminal(to_terminal);
};
sessionProto.consume = function consume(octets) {
if (this.type === "receive" && octets instanceof Uint8Array) {
return this._zmodem_fast_consume(octets);
}
// Everything else (send sessions, array input) keeps the original
// pipeline.
return ORIGINAL_SESSION_CONSUME.call(
this,
octets instanceof Uint8Array
? Array.prototype.slice.call(octets)
: octets,
);
};
Object.assign(sessionProto, {
_zmodem_fast_consume: fastSessionConsume,
_zmodem_fast_push: fastPush,
_zmodem_fast_contiguous: fastContiguous,
_zmodem_fast_len: fastLen,
_zmodem_fast_find_sequence: fastFindSequence,
_zmodem_fast_find_frame_end: fastFindFrameEnd,
_zmodem_fast_consume_at: fastConsumeAt,
_zmodem_fast_move_all_to_array: fastMoveAllToArray,
_zmodem_fast_move_array_to_fast: fastMoveArrayToFast,
_zmodem_fast_check_abort: fastCheckAbort,
_zmodem_fast_parse_subpacket: fastParseSubpacket,
_zmodem_fast_consume_loop: fastConsumeLoop,
});
Zmodem.__netcattyZmodemFastPathApplied = true;
} catch (err) {
// Never break ZMODEM transfers over a patch problem.
sentryProto.consume = ORIGINAL_SENTRY_CONSUME;
sessionProto.consume = ORIGINAL_SESSION_CONSUME;
console.error(
"[ZMODEM] fast path patch failed; using original pipeline:",
err && err.message ? err.message : err,
);
}
return Zmodem;
}
//----------------------------------------------------------------------
// Send-session robustness fixes (rz upload path)
//----------------------------------------------------------------------
//
// zmodem.js's Send session has a few hazards that make `rz` uploads fail
// intermittently:
//
// 1. Duplicate-ZSINIT race: send_offer() -> _ensure_receiver_escapes_ctrl_chars()
// sends a ZSINIT whenever the receiver's ZRINIT lacks ESCCTL and no ZACK
// has been seen yet, without checking whether the 5s keepalive just sent
// one. rz ACKs both ZSINITs; the second ZACK lands in a handler state that
// no longer expects it and consume() throws "Unhandled header: ZACK",
// killing the upload mid-transfer. Fix: track an in-flight ZSINIT
// (_zsinit_pending) and share its ZACK instead of sending a duplicate.
//
// 2. Shared-ACK plumbing: the keepalive's ZACK handler only sets
// _got_ZSINIT_ZACK, so a waiter parked on a pending ZSINIT (from #1)
// would never wake up. All send-session ZACKs now route through
// _on_zsinit_ack(), which sets the flag and flushes every waiter.
//
// 3. _stop_keepalive() nulls _keep_alive_promise (typo) instead of
// _keepalive_promise, so the keepalive can never restart after the first
// offer. Fix the field name.
//
// These are protocol-correctness fixes, not a throughput fast path, so they
// are not gated by NETCATTY_ZMODEM_FAST_PATH.
function applyZmodemSendSessionFixes(Zmodem) {
if (Zmodem.__netcattyZmodemSendSessionFixesApplied) return Zmodem;
const Send = Zmodem.Session && Zmodem.Session.Send;
if (!Send) return Zmodem;
const proto = Send.prototype;
if (
[
proto._send_ZSINIT,
proto._start_keepalive,
proto._stop_keepalive,
proto._ensure_receiver_escapes_ctrl_chars,
].some((method) => typeof method !== "function")
) {
return Zmodem;
}
const ORIGINAL_SEND_ZSINIT = proto._send_ZSINIT;
const ORIGINAL_START_KEEPALIVE = proto._start_keepalive;
const ORIGINAL_STOP_KEEPALIVE = proto._stop_keepalive;
const ORIGINAL_ENSURE_ESCAPES = proto._ensure_receiver_escapes_ctrl_chars;
// Deliberately leave _consume_ZRINIT untouched. A non-zero receiver
// buffer requires ZCRCW/ZACK pacing; TCP backpressure is not equivalent.
// The upstream fail-closed check prevents silent receiver-buffer overrun
// until a protocol-level window implementation is added.
try {
// One shared ACK path for every ZSINIT (keepalive or offer-time).
proto._on_zsinit_ack = function _on_zsinit_ack() {
this._got_ZSINIT_ZACK = true;
this._zsinit_pending = false;
const waiters = this._zsinit_ack_waiters;
this._zsinit_ack_waiters = null;
if (waiters) {
for (const res of waiters) {
try {
res();
} catch {
/* ignore */
}
}
}
};
// _send_ZSINIT is the only ZSINIT emitter (keepalive + ensure).
proto._send_ZSINIT = function _send_ZSINIT() {
this._zsinit_pending = true;
return ORIGINAL_SEND_ZSINIT.apply(this, arguments);
};
proto._start_keepalive = function _start_keepalive() {
if (!this._keepalive_promise) {
const sess = this;
this._keepalive_promise = new Promise(function (resolve) {
sess._keepalive_timeout = setTimeout(resolve, 5000);
}).then(function () {
sess._keepalive_promise = null;
// Never overlap ZSINIT frames. The receiver sends one ZACK per
// frame; replacing the handler while an earlier ACK is pending
// turns the later ACK into an unhandled-header failure.
if (!sess._zsinit_pending) {
sess._next_header_handler = {
ZACK: function () {
sess._on_zsinit_ack();
},
};
sess._send_ZSINIT();
}
sess._start_keepalive();
});
}
};
proto._stop_keepalive = function _stop_keepalive() {
if (this._keepalive_promise) {
clearTimeout(this._keepalive_timeout);
this._keepalive_promise = null;
}
};
proto._ensure_receiver_escapes_ctrl_chars = function _ensure_receiver_escapes_ctrl_chars() {
let promise;
const needs_ZSINIT =
!this._last_ZRINIT.escape_ctrl_chars() && !this._got_ZSINIT_ZACK;
if (needs_ZSINIT) {
const sess = this;
if (sess._zsinit_pending) {
// A keepalive ZSINIT is awaiting its ZACK: sending another would
// produce a stray ZACK that aborts the session as
// "Unhandled header: ZACK". Share the pending ACK instead.
sess._next_header_handler = {
ZACK: function () {
sess._on_zsinit_ack();
},
};
promise = new Promise(function (res) {
if (!sess._zsinit_ack_waiters) sess._zsinit_ack_waiters = [];
sess._zsinit_ack_waiters.push(res);
});
} else {
promise = new Promise(function (res) {
if (!sess._zsinit_ack_waiters) sess._zsinit_ack_waiters = [];
sess._zsinit_ack_waiters.push(res);
sess._next_header_handler = {
ZACK: function () {
sess._on_zsinit_ack();
},
};
sess._send_ZSINIT();
});
}
} else {
promise = Promise.resolve();
}
return promise;
};
Zmodem.__netcattyZmodemSendSessionFixesApplied = true;
} catch (err) {
// Never break ZMODEM uploads over a patch problem.
proto._send_ZSINIT = ORIGINAL_SEND_ZSINIT;
proto._start_keepalive = ORIGINAL_START_KEEPALIVE;
proto._stop_keepalive = ORIGINAL_STOP_KEEPALIVE;
proto._ensure_receiver_escapes_ctrl_chars = ORIGINAL_ENSURE_ESCAPES;
delete proto._on_zsinit_ack;
console.error(
"[ZMODEM] send-session fixes patch failed; using original Send session:",
err && err.message ? err.message : err,
);
}
return Zmodem;
}
//----------------------------------------------------------------------
// Send-side fast path (rz uploads).
//----------------------------------------------------------------------
//
// `_send_file_part()` splits every chunk into MAX_CHUNK_LENGTH (8192)
// byte subpackets and routes each one through the array-based encode
// pipeline: the Uint8Array chunk is converted to a boxed number Array,
// then `Subpacket._encode()` makes several more full-payload copies
// (`slice(0)`, `zencoder.encode()`, `concat(frameend)` for the CRC,
// the final `.concat()`), and `CRC.crc16()` calls `_updcrc()` per byte.
// At ~60 MB/s that is ~7700 subpackets per second and GB/s of
// short-lived arrays: CPU-bound in the fast phase and GC-stormed into
// a few MB/s when the collector catches up. This is the same array
// pipeline disease the receive fast path already cured for downloads.
//
// The replacement builds each subpacket frame straight into one Buffer
// (one pass for the CRC, one pass to ZDLE-escape) and is byte-for-byte
// identical to the original under the encoder configuration the send
// sessions always use (FORCE_ESCAPE_CTRL_CHARS => escape_ctrl_chars on).
const SEND_FRAME_END_NUM = { no_end_no_ack: 0x69, end_no_ack: 0x68 };
/**
* ZDLE-escape rule for the send sessions' fixed encoder configuration
* (escape_ctrl_chars on, turbo_escape off): bytes 0x00-0x1f and
* 0x80-0x9f escape as ZDLE + (byte ^ 0x40); everything else passes
* through. Mirrors the zdle.js zsendline_tab for that configuration.
*/
function sendSessionEscapes(byte) {
return (byte & 0x60) === 0;
}
/**
* Build one ZMODEM data subpacket frame into a fresh Buffer: ZDLE-
* escaped payload (the `length` bytes of `source` starting at `offset`),
* the [ZDLE, frameEndNum] trailer, then the ZDLE-escaped CRC16.
*
* Wire-identical to `Subpacket.build(chunk, frameend).encode16(encoder)`
* with escape_ctrl_chars on, including the two zero-byte CRC passes and
* the empty-payload case.
*/
function buildSendSubpacketFast(source, offset, length, frameEndNum) {
let crc = 0;
let escapedLen = 0;
for (let i = offset; i < offset + length; i++) {
const b = source[i];
crc = crc16Step(crc, b);
escapedLen += sendSessionEscapes(b) ? 2 : 1;
}
crc = crc16Step(crc, frameEndNum);
crc = crc16Step(crc, 0);
crc = crc16Step(crc, 0);
crc &= 0xffff;
const crcHi = crc >> 8;
const crcLo = crc & 0xff;
const out = Buffer.allocUnsafe(
escapedLen +
2 +
(sendSessionEscapes(crcHi) ? 2 : 1) +
(sendSessionEscapes(crcLo) ? 2 : 1),
);
let w = 0;
for (let i = offset; i < offset + length; i++) {
const b = source[i];
if (sendSessionEscapes(b)) {
out[w++] = 0x18; // ZDLE
out[w++] = b ^ 0x40;
} else {
out[w++] = b;
}
}
out[w++] = 0x18; // ZDLE
out[w++] = frameEndNum;
for (const b of [crcHi, crcLo]) {
if (sendSessionEscapes(b)) {
out[w++] = 0x18;
out[w++] = b ^ 0x40;
} else {
out[w++] = b;
}
}
return out;
}
/**
* Patch zmodem.js so the send session builds data subpackets directly
* as Buffers instead of churning through the array pipeline (see the
* block comment above). Falls back to the original for anything that
* is not the fixed escape_ctrl_chars configuration or an unknown
* frame-end, so wire behavior is unchanged elsewhere.
*
* @param {Object} Zmodem - The zmodem.js module, as returned by require().
* @returns {Object} The same module object, patched (or not).
*/
function applyZmodemSendFastPath(Zmodem) {
// The field kill-switch must cover both throughput patches. Keep the
// independent send-session correctness fixes active for diagnostics.
if (isFastPathDisabled()) return Zmodem;
if (Zmodem.__netcattyZmodemSendFastPathApplied) return Zmodem;
try {
const proto = Zmodem.Session.Send.prototype;
const ORIGINAL_SEND_FILE_PART = proto._send_file_part;
if (typeof ORIGINAL_SEND_FILE_PART !== "function") return Zmodem;
proto._send_file_part = function _send_file_part_fast(bytes_obj, final_packetend) {
const frameEndNum = SEND_FRAME_END_NUM[final_packetend];
if (
!frameEndNum ||
!this._zencoder ||
!this._zencoder.escapes_ctrl_chars()
) {
return ORIGINAL_SEND_FILE_PART.apply(this, arguments);
}
if (!this._sent_ZDATA) {
this._send_header("ZDATA", this._file_offset);
this._sent_ZDATA = true;
}
const bytes_count = bytes_obj.length;
let obj_offset = 0;
// Same 8192-byte split as the library (MAX_CHUNK_LENGTH in
// zsession.js); intermediate subpackets use no_end_no_ack and the
// final one uses the caller's frame end.
while (true) {
const chunk_size =
Math.min(obj_offset + 8192, bytes_count) - obj_offset;
const at_end = (chunk_size + obj_offset) >= bytes_count;
const frame = buildSendSubpacketFast(
bytes_obj,
obj_offset,
chunk_size,
at_end ? frameEndNum : SEND_FRAME_END_NUM.no_end_no_ack,
);
this._sender(frame);
// zdle.js keeps _lastcode on the encoder; replicate it for
// fidelity (it does not affect escape_ctrl_chars output).
this._zencoder._lastcode = frame[frame.length - 1];
this._file_offset += chunk_size;
obj_offset += chunk_size;
if (obj_offset >= bytes_count) break;
}
};
Zmodem.__netcattyZmodemSendFastPathApplied = true;
} catch (err) {
console.error(
"[ZMODEM] send fast-path patch failed; using original sender:",
err && err.message ? err.message : err,
);
}
return Zmodem;
}
module.exports = {
applyZmodemFastPath,
applyZmodemSendSessionFixes,
applyZmodemSendFastPath,
// Exposed for unit tests.
_internals: {
parseSubpacketFast,
zdleDecode,
readDecodedBytes,
stripIgnoredBytesFast,
crc16Bytes,
crc32Bytes,
buildSendSubpacketFast,
sendSessionEscapes,
},
};