• sexyz: SIGFPE (divide by zero) when the ZMODEM transmit window is smal

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Fri Jul 24 11:14:53 2026
    open https://gitlab.synchro.net/main/sbbs/-/issues/1197

    `sexyz` dies with **SIGFPE** (integer divide-by-zero) as soon as it starts sending, whenever the ZMODEM transmit window is smaller than **4x the block size**.

    ### Reproduction

    ```
    $ dd if=/dev/urandom of=test.bin bs=1M count=8
    $ sexyz -8 -w8192 sz test.bin # window 8K, block 8K
    ```

    The sender handshakes (ZRQINIT/ZRINIT) and then dies before transferring any file data; the process exit status is signal 8 (SIGFPE). Anything that reports the raw wait status shows `-8`. Roughly 3 KB reaches the wire, all handshake.

    Also crashes: `-8 -w16384`, `-8 -w32767`, `-2 -w4096`, and the equivalents set via `MaxWindowSize` in `sexyz.ini`. Does **not** crash when the window is at least 4 blocks, e.g. `-8 -w32768`.

    ### Cause

    `src/sbbs3/zmodem.c`, in `zmodem_send_from()`:

    ```c
    tx_type = (subpkts_sent % (zm->max_window_size / zm->block_size / 4)) == 0
    ? ZCRCQ : ZCRCG;
    ```

    With `max_window_size == block_size == 8192` the divisor is
    `8192 / 8192 / 4` = `1 / 4` = **0**, so the `%` is a division by zero. The intent is "request an ACK every quarter-window"; a window narrower than four blocks has no valid quarter-window interval.

    ### Fix

    Clamp the interval to a minimum of 1, so a window smaller than four blocks requests an ACK on every subpacket.

    ### Scope

    `zmodem.c` is shared with SyncTERM, but **SyncTERM never sets `max_window_size`** (it leaves it 0, which short-circuits the branch), so this is not reachable there. The exposure is `sexyz` only, via `-w<size>` or the `MaxWindowSize` ini key.

    ### Related, not fixed here

    With the divide-by-zero guarded, a window exactly equal to the block size
    (`-8 -w8192`) no longer crashes but is pathologically slow -- roughly one subpacket per second, each iteration logging a one-second receive timeout followed by `Transmit-Window management: 8192 >= 8192`. It completes, but it cannot pipeline. That is a separate issue.

    ---
    *Reported and fixed by Claude (Anthropic), working with Rob Swindell.*
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Fri Jul 24 11:16:15 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1197#note_9689

    Fixed in `04b47329aa` — clamps the ack-request interval to a minimum of 1. `zmodem_ver` bumped 2.3 to 2.4.

    Verified over the bench harness against `lrz`: `-8 -w16384` and `-2 -w4096` both died on signal 8 before and now complete with a matching SHA-256 (3.08 and 0.71 MB/s). Windows that already worked are unchanged — `-w32768` 4.88 MB/s, `-w65536` 5.03 — as is streaming without `-w` at 11.6 MB/s.

    The "related, not fixed here" item above stands: `-8 -w8192` (window equal to block size) no longer crashes but still manages only about one subpacket per second.

    *Note by Claude (Anthropic).*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Fri Jul 24 12:04:34 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1197#note_9690

    ### Root cause of the `-w8192` slowness (the "related, not fixed here" item)

    Diagnosed. It is a real stall, not a benchmark artifact — it reproduces over a
    direct socketpair with no relay in the middle.

    **Symptom:** with the transmit window equal to the block size (`-8 -w8192`, and the same whenever the block-size ramp reaches the window), the transfer advances
    **exactly one window per second**. Data flows in bursts separated by 1-second silences.

    **Mechanism (confirmed by strace of both ends + single-clock wire capture):**

    1. With a one-block window, every data subpacket fills the window, so after each
    block the sender must wait for that block's ZACK before sending the next.
    2. `sexyz` sends through a producer/consumer ring: the protocol thread queues
    the subpacket, a separate `output_thread` drains it to the socket. When the
    protocol thread finishes a subpacket it calls `zmodem_flush()`, but `sexyz`'s
    flush callback is a no-op (it only `fflush(stdout)`s, and only in stdio mode).
    3. So the protocol thread starts *waiting for the ZACK* while the subpacket's
    **terminator** (`ZDLE ZCRCQ CRC`, ~6 bytes) is still sitting in the output
    thread's linear buffer, not yet written. The receiver has the 8192 data bytes
    but not the terminator, so it cannot see the subpacket boundary and does not
    ACK. Its `read()` blocks — measured at `<1.001374>`.
    4. Both sides now wait on each other. The sender's back-channel poll times out
    after **1 second**, it reads the next file block and queues it, and *that*
    write finally pushes the stuck terminator onto the wire. The receiver then
    completes the previous subpacket and ACKs it — one second late. Repeat.

    `-w32768` (and larger) hides this because the window holds 2–4 blocks, so the asynchronous ACKs for earlier blocks keep the window open and the sender never actually blocks on the not-yet-flushed terminator.

    **Confirmation:** replacing the no-op `flush()` with one that waits for the output thread to be genuinely idle (ring empty **and** its linear buffer written)
    takes `-8 -w8192` from a crawl to **1.72 MB/s**. That is proof of the cause, but
    not a shippable fix on its own — waiting on every subpacket serialises the producer and consumer and cuts streaming throughput roughly in half (11.6 → 4.5
    MB/s). A real fix wants an event the output thread signals when fully drained, waited on only where the protocol is about to block for a response — which is the same "flush actually reaches the wire" work the sender redesign in #1195 needs. Tracking the fix there.

    *Note by Claude (Anthropic).*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Fri Jul 24 21:34:09 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1197#note_9693

    ### Correction to the diagnosis above — the terminator was *not* the cause

    I built the event-signalled flush the previous note pointed at (output thread signals a "fully drained" event; the protocol waits on it before a *blocking* back-channel read, so streaming pays nothing). It is correct and regression-free
    — streaming stays 11.5 MB/s, `-w16384` 3.0, `-w32768` 4.9, all unchanged — but
    **it does not fix `-w8192`.**

    A single-clock wire capture shows why the terminator theory was wrong: with the flush in place, the output thread writes the whole subpacket *including the terminator* and goes idle **before** the sender's back-channel poll, and the receiver's `read()` still blocks a full second. So the data is genuinely on the wire and the receiver is caught up (it ACKs in ~20 µs and then blocks reading for
    the next block), yet the sender still paces at exactly one window per second. The 1.72 MB/s I reported from the "idle-aware flush" experiment was a scheduling
    side-effect of its busy-spin, not the flush doing its job.

    So the real `-w8192` mechanism is a **window == block-size lockstep**, not an unflushed byte: with a one-block window the sender can have only a single unacked block outstanding and must wait for that exact block's ACK before the next, and something in the window/ACK accounting misses the window-opening ACK so the 1-second receive timeout ends up pacing it. That is a separate,
    deeper problem in the shared `zmodem.c` window logic.

    **Practical note:** `-w8192` with `-8` (8 KB blocks) is a degenerate setting — a
    transmit window should be several times the block size to allow any pipelining. Every realistic window (`-w16384` and up) already works on HEAD after the SIGFPE
    fix. `-w8192` completes, just slowly. Leaving it open, unowned by the flush work;
    the event-flush is shelved since it buys nothing user-visible on its own.

    *Note by Claude (Anthropic).*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Fri Jul 24 21:39:05 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1197#note_9694

    ### Precise trigger condition

    Swept it. The stall is **not specific to `-w8192`** — it happens for **any `-w`
    value less than or equal to the effective block size**, and nothing else is relevant.

    Threshold tracks the block size, not an absolute 8192:

    | Block size flag | Stalls at `-w` | Works at `-w` |
    |---|---|---|
    | `-8` (8 KB) | ≤ 8192 | 8193 (→ 3.0 MB/s) |
    | `-4` (4 KB) | ≤ 4096 | 4097 |
    | `-2` (2 KB) | ≤ 2048 | 2049 |
    | default (1 KB) | ≤ 1024 | — |

    One byte over the block size clears it. Structural reason: with
    `window <= block_size` exactly one block fills the window, so only a single unacked block can be outstanding — pure lockstep, which is the case that hits the missed-window-opening-ACK 1-second timeout. With `window > block_size` a second block starts before the first is acked, the ack lands mid-flight, and it pipelines.

    Not relevant: CRC mode (`-o`/CRC-16 still stalls), file size (the low MB/s on a small file is just startup), telnet/escaping. Only `-w` and the block-size flag matter.

    Relationship to the SIGFPE fixed in `04b47329aa`: that was the wider
    `window < 4*block` range (the quarter-window divisor hitting zero). This stall is the tighter `window <= block` sub-range. Between them —
    `block < window < 4*block`, e.g. `-8 -w16384` — the ack-interval is clamped to 1
    but it still pipelines and works.

    *Note by Claude (Anthropic).*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Sat Jul 25 18:03:43 2026
    close https://gitlab.synchro.net/main/sbbs/-/issues/1197
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)