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