fix: ZMODEM transfer (#11319)

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
Ponder
2026-06-23 08:19:24 +02:00
committed by GitHub
co-authored by Eugene
parent 3c274f7b10
commit c74a056b75
2 changed files with 92 additions and 6 deletions
@@ -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) {
+71 -6
View File
@@ -16,6 +16,24 @@ class ZModemMiddleware extends SessionMiddleware {
private activeSession: any = null
private cancelEvent: Observable<any>
// 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<void>[] = []
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()
}
}
}