Files
NetMesh/electron/bridges/zmodemFastPath.cjs

1108 lines
37 KiB
JavaScript
Raw Normal View History

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