Skip to content

SwiftLink: don't block the main loop in client.write(); send Tx bytes in bursts - #80

Open
wesbiggs wants to merge 2 commits into
SensoriumEmbedded:mainfrom
wesbiggs:swiftlink-tx-nonblocking
Open

wesbiggs wants to merge 2 commits into
SensoriumEmbedded:mainfrom
wesbiggs:swiftlink-tx-nonblocking

Conversation

@wesbiggs

Copy link
Copy Markdown
Contributor

Summary

PollingHndlr_SwiftLink() sends each byte the C64 writes with its own client.write(SwiftTxBuf). In NativeEthernet,
EthernetClient::write() doesn't return early when the socket's send buffer is full. EthernetClass::socketSend()
busy-waits until there is room:

uint16_t EthernetClass::socketSend(uint8_t s, const uint8_t * buf, uint16_t len)
{
    while(socketSendAvailable(s) < len){}
    ...

So when a large upload fills the send buffer, loop() stops inside write() until the stack has freed space. That
means waiting for ACKs and, after a lost segment, for the retransmission. While it waits, no polling handler runs:
received data isn't queued, Rx NMIs aren't raised, and so on.

On stock firmware the stack runs from NativeEthernet's 1 ms PIT interrupt, so the wait does end, but the main loop can
stall for a long time. In a build that polls the stack from loop() instead (the change in
teensyrom-vdrive's patches that masks IRQ_PIT while Kernal
Replace/SwiftLink handlers run and calls fnet_poll() from loop()), nothing frees the buffer while loop() waits, so
it never returns. That build is how I found it, but the blocking write is upstream code.

A second, smaller problem: because every byte is its own write(), every byte goes out as its own TCP segment (40
bytes of headers per data byte). A few KB become thousands of packets, which fill the send buffer quickly and make it
likely that one of them is lost somewhere on the path.

How it was found

A C64 program sent an 8 KB HTTP POST through the SwiftLink at 38400 baud. It polls Tx-empty before every write, as a
6551 requires. The path was TeensyROM+ Ethernet → Raspberry Pi (NAT) → WiFi → server. Requests failed now and then,
and large ones almost always did. A logging proxy in front of the server received the headers and the first 675 body
bytes, then nothing more for 97 s, with the connection still open.

tcpdump on the Pi's Ethernet side (the TeensyROM side) of a failing request showed:

  • Handshake: the TeensyROM advertises win 2048, mss 1460.
  • After the first ~140 bytes, every data segment is 1 byte long: 2,054 data segments for about 2.5 KB.
  • Segment 1450:1451 left the TeensyROM but was lost on the WiFi leg: ordinary packet loss.
  • The TeensyROM kept sending 1451 ... 2540, then stopped, with ~1,090 bytes unacknowledged. The server's window was
    4096 throughout, so the limit was the TeensyROM's own send buffer.
  • The server's first ACK for 1450 arrived 2 ms before the TeensyROM's last segment. All of the duplicate ACKs that
    followed (165 in the capture) arrived after it had stopped sending, and none was acted on: no fast retransmit and
    no retransmission on timeout, although FNET does both (FNET_TCP_NUMBER_FOR_FAST_RET) and had retransmitted a
    segment earlier in the same connection.

So the stack wasn't running. The main loop was stuck in socketSend() waiting for space that only the stack could
free, and in this build the stack only ran from that same loop. The C64 side matches: Tx-empty stays clear (the byte
is still pending), so the program waits for it forever.

Fix

In IOH_Swiftlink.c:

  1. Never call write() with more than the stack can take. Bytes from the C64 go into a small buffer
    (SwiftTxOut, 256 bytes). SendSwiftTxOut() checks client.availableForWrite() and writes at most that much, so
    socketSend() never waits. Whatever doesn't fit stays in the buffer for a later poll, and loop() keeps running,
    with the stack and the rest of the handler.
  2. Flow control to the C64 through Tx-empty. While SwiftTxOut is full, the byte in SwiftTxBuf isn't taken and
    SwiftStatusTxEmpty stays clear, as on a real 6551 whose line is busy. Programs that check Tx-empty before writing
    wait and lose nothing. The Tx IRQ also isn't raised until there is room.
  3. Coalescing. The buffer is sent when the C64 has written nothing for 2 ms, when the oldest byte has waited
    20 ms, or when it is full. A burst then goes out in segments of up to 256 bytes instead of one segment per byte,
    while a single keystroke is delayed by about 2 ms.

The buffer is emptied when the connection drops and in InitHndlr_SwiftLink(). The +++ guard-time logic is
unchanged and still counts bytes as the C64 writes them. Browser mode and the offline/AT command path are unchanged.

Why this fixes the stall: once write() can't block, a full send buffer only means the C64 waits on Tx-empty. The main
loop keeps going, the stack keeps processing the duplicate ACKs, retransmits the lost segment, frees the buffer, and
the upload continues. With far fewer packets per KB, a loss is also much less likely to happen in the first place.

Testing

  • On the build with the stack polled from loop(), the same 8 KB uploads over the same WiFi path that stalled before
    now complete.
  • Builds on current main (npm run build:tr-plus) with only this change. Not run on hardware with stock firmware,
    where the stack runs from the PIT interrupt; the change doesn't depend on how the stack is driven.

Reproducing

Send a few KB in one burst through the SwiftLink to any TCP server, over a path with some packet loss (WiFi, or
tc qdisc add dev <if> root netem loss 1% on a gateway). Compare the bytes the server receives with the bytes sent,
and capture on the TeensyROM side to see the 1-byte segments and the unanswered duplicate ACKs.

🤖 Generated with Claude Code

NativeEthernet's socketSend() busy-waits until the send buffer has room,
so a large upload from the C64 stalled loop() inside client.write() until
the stack freed space (for good if the stack is polled from loop()).

Collect Tx bytes in a 256-byte buffer, send it after a 2 ms pause, after
20 ms or when full, and never write more than availableForWrite(). While
the buffer is full Tx-empty stays clear, so the C64 waits as it would on
a 6551. One segment per burst instead of one per byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wesbiggs added a commit to wesbiggs/teensyrom-vdrive that referenced this pull request Oct 31, 2026
NativeEthernet's client.write() busy-waits for send-buffer space. With
the stack polled from loop() (0.9.0.14), a full buffer after a lost
segment hung loop() for good: no retransmits, and the upload stalled.

firmware/swiftlink-tx-fix.patch (upstream PR SensoriumEmbedded/TeensyROM#80,
applied by make_tr_patch.sh after the Kernal Replace fixes) collects Tx
bytes in a 256-byte buffer, sends it after a 2 ms pause / 20 ms / when
full, and never writes more than availableForWrite(); while it is full
Tx-empty stays clear. Works on hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@SensoriumEmbedded SensoriumEmbedded left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for tracking this down, the tcpdump walk-through made the stall easy to follow, and getting large uploads through the SwiftLink reliably is a real improvement!

I reviewed this merged onto current main (136baa8) and built both targets. As always, feel free to push back on any that are inappropriate or would take significant memory/code to resolve.

Memory: TR+ main grows by 192 B of ITCM (RAM1 padding 27,112 → 26,920) and 256 B of RAM2 for the static SwiftTxOut; DTCM and free-for-locals are unchanged. Plain TR main grows by 256 B of ITCM.

There are 2 (small) things I'd like you to look at before merging, plus 1 optional tweak. Suggestions are in-line:

  1. Clear SwiftTxOut when the connection changes (IOH_Swiftlink.c:556)
  2. Allocate SwiftTxOut with the other SwiftLink buffers instead of as a static DMAMEM array (IOH_Swiftlink.c:188)
  3. Optional: send right away when nothing is in flight (IOH_Swiftlink.c:606)

Hardware testing: There's a lot of tight timing in the SwiftLink implementation, so we'll need to do a little more hardware testing before release:

Your 8 KB uploads over WiFi cover the lossy-path case.

  • CCGMS session on a BBS: some typing, plus an XMODEM upload and download, checksums compared
  • Your upload program again, on a build of this branch once the changes are in: it's the only test that fills the 256 B buffer, since CCGMS's XMODEM blocks are under 256 bytes

Thank you!

… a connection change, send at once when nothing is in flight

Review follow-ups:
- SwiftTxOut is malloc'd in InitHndlr_SwiftLink() and freed in
  FreeSwiftlinkBuffs(), so other modes keep the 256 B of RAM2
- emptied in the connection-change block, so Tx bytes for an old
  connection cannot reach a new one
- sent right away when nothing is unacknowledged
  (availableForWrite() >= FNET_SOCKET_DEFAULT_SIZE, Nagle's rule)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wesbiggs

wesbiggs commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! All three are in d42086f. Both targets build.

Hardware, on a stock main + this PR build:

  • CCGMS on a BBS with an XMODEM upload and download: works
  • The 8 KB HTTP upload program (the one that fills the 256 B buffer): works

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.

2 participants