From c74a056b750401f37ae737f5b2f5fe5533e78f3b Mon Sep 17 00:00:00 2001 From: Ponder Date: Tue, 23 Jun 2026 14:19:24 +0800 Subject: [PATCH] fix: ZMODEM transfer (#11319) Co-authored-by: Eugene --- tabby-terminal/patches/zmodem.js+0.1.10.patch | 21 +++++ tabby-terminal/src/features/zmodem.ts | 77 +++++++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 tabby-terminal/patches/zmodem.js+0.1.10.patch diff --git a/tabby-terminal/patches/zmodem.js+0.1.10.patch b/tabby-terminal/patches/zmodem.js+0.1.10.patch new file mode 100644 index 00000000..d99b62ad --- /dev/null +++ b/tabby-terminal/patches/zmodem.js+0.1.10.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/zmodem.js/src/zsession.js b/node_modules/zmodem.js/src/zsession.js +index 5f0b8f9..3f88b68 100644 +--- a/node_modules/zmodem.js/src/zsession.js ++++ b/node_modules/zmodem.js/src/zsession.js +@@ -572,7 +572,15 @@ Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session { + else { + parsed = this._parse_and_consume_header(); + } +- } while (parsed && this._input_buffer.length); ++ } while (parsed && this._input_buffer.length && !this._got_ZFIN); ++ ++ //If consuming the ZFIN header left the trailing "OO" terminator in the ++ //buffer (it arrived in the same chunk), handle it now. Otherwise the ++ //loop above would feed "OO" to _trim_leading_garbage_until_header(), ++ //which discards it, and the session would never end. ++ if (this._got_ZFIN && this._input_buffer.length) { ++ this._consume_first(); ++ } + } + + _consume_data(subpacket) { diff --git a/tabby-terminal/src/features/zmodem.ts b/tabby-terminal/src/features/zmodem.ts index 8f177d79..08644cd0 100644 --- a/tabby-terminal/src/features/zmodem.ts +++ b/tabby-terminal/src/features/zmodem.ts @@ -16,6 +16,24 @@ class ZModemMiddleware extends SessionMiddleware { private activeSession: any = null private cancelEvent: Observable + // While non-null, terminal output is buffered here instead of being sent + // straight to the terminal. Used to hold back a receive session's trailing + // bytes (the shell prompt redrawn after sz exits) until after the final + // "Received"/"Complete" messages have been printed, so the prompt is not + // overwritten by showMessage()'s leading "\r". + private trailingBuffer: Buffer[] | null = null + + private flushTrailingBuffer () { + const buffered = this.trailingBuffer + this.trailingBuffer = null + if (!buffered?.length) { + return + } + for (const chunk of buffered) { + this.outputToTerminal.next(chunk) + } + } + private log = inject(LogService) private translate = inject(TranslateService) private platform = inject(PlatformService) @@ -26,8 +44,20 @@ class ZModemMiddleware extends SessionMiddleware { this.logger = this.log.create('zmodem') this.sentry = new ZModem.Sentry({ + // to_terminal is zmodem.js' single terminal-output channel. It + // receives normal passthrough data (while no session is active), + // protocol "garbage", and crucially the trailing bytes that follow + // a session's "OO" terminator (e.g. the shell prompt redrawn after + // sz/rz exits). These trailing bytes are emitted synchronously from + // within the same consume() call that fires session_end, so any + // guard based on isActive/activeSession would drop them on platforms + // where "OO" and the prompt arrive in the same chunk (Linux). + // While trailingBuffer is active they are queued so the final + // status messages can be printed first; otherwise forward directly. to_terminal: data => { - if (this.isActive && this.activeSession) { + if (this.trailingBuffer) { + this.trailingBuffer.push(Buffer.from(data)) + } else { this.outputToTerminal.next(Buffer.from(data)) } }, @@ -44,6 +74,15 @@ class ZModemMiddleware extends SessionMiddleware { defaultId: 0, cancelId: 1, })).response === 1) { + // Accept the detection to get a session, then immediately + // abort so that proper ZABORT frames are sent to the remote + // side, causing the remote rz/sz process to terminate. + try { + const zsession = detection.confirm() + zsession.abort() + } catch { } + // Clean up terminal output after rejection + this.showMessage(colors.bgRed.black(' Rejected ') + ' ZMODEM session') return } @@ -76,13 +115,16 @@ class ZModemMiddleware extends SessionMiddleware { return } } else { + // No active session: sentry.consume() routes everything straight + // back through to_terminal, so we must not output here as well or + // the data would be duplicated. Only on a consume() failure do we + // forward the raw data as a fallback so nothing is lost. try { this.sentry.consume(data) } catch (e) { this.logger.error('zmodem detection error', e) + this.outputToTerminal.next(data) } - - this.outputToTerminal.next(data) } } @@ -105,17 +147,33 @@ class ZModemMiddleware extends SessionMiddleware { sizeRemaining -= transfer.getSize() } await zsession.close() + + this.showMessage(colors.bgBlue.black(' ZMODEM ') + ' Complete') } else { + const pendingReceives: Promise[] = [] zsession.on('offer', xfer => { - this.receiveFile(xfer, zsession) + pendingReceives.push(this.receiveFile(xfer, zsession)) + }) + + // session_end fires synchronously inside sentry.consume(), + // immediately before the session's trailing bytes (the shell + // prompt redrawn after sz exits) are flushed via to_terminal. + // Start buffering here so those bytes are held back until after + // all "Received" messages and the "Complete" message have been + // printed; otherwise the prompt would be emitted first and then + // overwritten by showMessage()'s leading "\r". + zsession.on('session_end', () => { + this.trailingBuffer = [] }) zsession.start() await new Promise(resolve => zsession.on('session_end', resolve)) - } + await Promise.all(pendingReceives) - this.showMessage(colors.bgBlue.black(' ZMODEM ') + ' Complete') + this.showMessage(colors.bgBlue.black(' ZMODEM ') + ' Complete') + this.flushTrailingBuffer() + } } catch (error) { this.logger.error('ZMODEM session error', error) this.showMessage(colors.bgRed.black(' ZMODEM ') + ` Session failed: ${error.message}`) @@ -124,6 +182,13 @@ class ZModemMiddleware extends SessionMiddleware { } catch { } } finally { this.activeSession = null + + // Safety net: if an error left bytes buffered (e.g. session_end + // started buffering but the flush above was skipped), release them + // so terminal output is never permanently swallowed. + if (this.trailingBuffer) { + this.flushTrailingBuffer() + } } }