Skip to content

Implement ZMODEM protocol support for in-band file transfers - #61

Merged
rthellend merged 17 commits into
mainfrom
zmodem
Sep 7, 2026
Merged

rthellend merged 17 commits into
mainfrom
zmodem

Conversation

@rthellend

Copy link
Copy Markdown
Contributor
  • Built core ZMODEM framing, encoding, and CRC verification directly in Go
  • Created sender and receiver state machines to orchestrate transfers
  • Added custom io stream interceptor in SSH client via --zmodem flag
  • Leveraged ServiceWorker StreamHelper for memory-efficient downloads
  • Updated README.md documentation

- Built core ZMODEM framing, encoding, and CRC verification directly in Go
- Created sender and receiver state machines to orchestrate transfers
- Added custom io stream interceptor in SSH client via `--zmodem` flag
- Leveraged ServiceWorker StreamHelper for memory-efficient downloads
- Updated README.md documentation
…rtial stream routing

- Introduced a 6-byte sliding window to correctly identify ZMODEM sequences split across arbitrary `Write()` boundaries
- Ensured bytes preceding the sequence continue to terminal output, preventing visual data loss
- Echo ZMODEM start sequences directly to the terminal for parity with other terminal emulators
- Eliminated deadlocks by properly unlocking the filter mutex before interacting with the ZMODEM pipe

Extract zmodem intercept filter to native code and add tests

- Split WASM-dependent API hooks into zmodem_wasm.go
- Preserved intercept logic in zmodem_filter.go for native testability
- Added TestZmodemFilterSplitSignature to verify 6-byte sliding window
- Added TestZmodemFilterFallback to ensure safe terminal passthrough on broken pipes

Discard and warn on user input during active ZMODEM transfers

- Replaced naive io.Copy with a custom read loop for terminal input
- Input is actively discarded while f.active is true to prevent protocol corruption
- Added a one-time terminal warning if the user attempts to type during a transfer

Allow users to interactively abort ZMODEM transfers via Ctrl-C

- Updated keyboard input interceptor to scan for ETX (`\x03`)
- Sends the standard 5 consecutive ZCAN bytes to the remote SSH server to abort the remote process
- Force-closes the local io.Pipe to immediately unblock and abort the local ZMODEM parser state machine
- Updated terminal warning text to indicate Ctrl-C can be used
…NIT headers

- Removed the superfluous ZRQINIT pulse at the start of Send() which forced receivers to reply with a second ZRINIT
- Upgraded the Send state machine to track currentFile pointer across reads
- Safely skip duplicate/queued ZRINIT headers received while actively negotiating a ZFILE transfer
- Properly reset currentFile state on ZEOF or ZSKIP to pop the next file sequentially
- Removed all leading `\r\n` prefixes from print statements to eliminate double-spaced blank lines in the terminal output
- Added dynamic pluralization logic to properly print '1 file'/'2 files' and '1 byte'/'2 bytes' during transfers

Update CHANGELOG.md with ZMODEM feature
- frame.go: ZCAN detection now requires 5 consecutive CAN bytes per spec
- receive.go: Wait for onFile callback via channel, propagate its error;
  use uint32 for offset to match protocol semantics
- send.go: Document that ZRPOS offset is not used (always sends from 0)
- zmodem_filter.go: Unexport TerminalPrinter; fix TOCTOU race by
  capturing pipe writer under lock; fix Ctrl-C race by setting
  f.active=false immediately and closing write side of pipe
- zmodem_filter_test.go: Add missing copyright header
- session_test.go: Remove polling loop (now unnecessary since Receive
  waits for onFile); remove unused time import
The cancel sequence was incorrectly sending the ZCAN frame type constant
(0x10) instead of the ASCII CAN character (0x18). The standard ZMODEM
cancel is 8 CAN bytes + 8 backspaces.

- Added CAN constant and CancelSeq variable to the zmodem package
- Updated both the Ctrl-C handler and file-picker cancel to use CancelSeq
- Reset the sliding window after cancel to prevent buffered ZRINIT
  bytes from immediately re-triggering the interceptor
@rthellend
rthellend marked this pull request as ready for review August 28, 2026 22:01
rthellend and others added 7 commits August 29, 2026 18:07
…filter

Correctness fixes:

- frame.go, data.go: cancel detection compared the byte after a ZDLE against
  zCAN (frame type 16) instead of the ASCII CAN byte (0x18), so the whole
  cancel path was dead code and a remote abort decoded as data byte 'X'. Both
  sites now share a checkCancel helper that counts consecutive CANs (starting
  at 2, since ZDLE and CAN are the same byte) and pushes back the byte that
  ends a short run.

- send.go: an unexpected ZRPOS (a retransmission request after ZEOF, or one
  following a ZSKIP) dereferenced a nil currentFile and panicked, which in
  WASM tears down the whole terminal app. It is now ignored.

- receive.go: the `continue` handling a ZEOF that arrives in place of ZDATA
  targeted the inner wait loop, so the following ZFILE or ZFIN aborted the
  session. The outer loop is now labeled.

- receive.go: ZCRCW means the frame ends and the sender waits for a ZACK, not
  that the file ends. Only ZCRCE ends the file now; on ZCRCW the reader ACKs
  and crosses into the next ZDATA frame (or consumes the ZEOF header itself).

- send.go: the last subpacket was detected with the declared file size, but
  jsutil.StreamReader never returns a non-zero count together with io.EOF, so
  any mismatch with File.size desynchronized the receiver. Chunks are now held
  back one read so ZCRCE lands on the actual end of stream.

- data.go: bound subpacket buffering so a peer that never sends a frame-end
  marker cannot exhaust memory.

Concurrency fixes:

- filter.go: pipeR/pipeW were read outside the mutex, and a session goroutine
  cleared f.active before closing its pipe, letting a signature arriving in
  that window start a second session on top of the first. The pipes are now
  captured under the lock, and finish() closes the pipe before clearing
  f.active.

- filter.go: the Ctrl-C handler wrote the cancel sequence to stdinW while
  holding f.mu. stdinW is only drained by Filter.Read, so that write can block
  forever and freeze all terminal output. It now happens after unlocking.

Tests: protocol_test.go drives the real send/receive against scripted peers to
cover the ZCRCW, empty-file, stray-ZRPOS and size-mismatch paths; unit tests
cover cancel detection and the subpacket size limit. mockTerminal needed a
mutex because the filter writes to the terminal from two goroutines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DNhvFpURUVmKbc8cLE87f
@rthellend
rthellend merged commit 8d1e451 into main Sep 7, 2026
5 checks passed
@rthellend
rthellend deleted the zmodem branch September 7, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant