From e1526395233aad15dc9e2fdb49d5842de3e7e77b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 01:13:28 -0300 Subject: Drain the receiver while the transmitter is full: keystrokes were being lost Reported as "a key is stuck and is only sent when I send a new event". It was neither stuck nor late - it was gone, and a later frame repainting those cells is what made it look like it arrived eventually. The loop is read, apply, render, write, and `uart.write` blocks while the transmit FIFO is full. That wait is real backpressure and should stay: dropping half an escape sequence leaves the host terminal in the wrong colour for the rest of the session. But NOTHING drained the receive FIFO during it, and that FIFO is 128 bytes - 11 ms of wire at 115200. Measured on the die, typing a burst in one host write and counting what the firmware's loop actually took off the UART: burst before after after + chunked input 128 128 128 128 200 197 200 200 300 257 300 300 600 478 600 600 1200 - 1200 1200 2400 - 2316 2400 4096 - 3611 4096 Two windows had to close, and the second was only visible once the first was shut. `input_rescue.pump` drains the receiver on every iteration of the wait for transmitter room. That is the big one, and it is the whole reason this policy lives in its own file: `uart.zig` cannot be tested without the chip because every line of it is an MMIO access, while `pump` takes its port as `anytype` and runs against a fake with a two-byte transmit FIFO and an eight-byte receive FIFO in `zig build test`. The fake models the receive FIFO the way the hardware behaves - a byte arriving into a full FIFO is simply gone - so the test fails by 67 lost bytes with the rescue removed, which is the die's 88-of-200 in miniature. It also caught a flaw in its own first draft: a fake whose transmit FIFO drains as fast as it fills never blocks, so `pump` never waits and the test proves nothing. The second window was APPLYING the input. A keystroke costs 44 us on an empty line and 63 us at 640 characters, so handing the editor a full 128-byte batch is up to 8 ms in which nothing drains the receiver - against 11 ms of FIFO. The loop now feeds the editor eight bytes at a time and rescues between chunks. Splitting a burst at an arbitrary byte is already safe, because `pardes_p4_input` keeps whatever it could not parse; that is how it survives an escape sequence split across two UART reads. One render still happens per loop iteration, so this costs no extra wire. Eight rather than thirty-two by measurement: 32 left 2400 and 4096 lossy, 8 does not. Beyond 4096 bytes in one burst the editor genuinely cannot keep up, and the ring reports what it abandoned instead of losing it silently - `rxdrop` in the PROF line, alongside a running count of received bytes. That counter is the other lesson here: the first attempt at measuring this counted characters on the reconstructed screen, which cannot distinguish "never arrived" from "arrived but off the edge of the viewport", and it disagreed with the hardware in both directions. No cost to latency: round trip median 3687 us over 60 trials against 3687 before, maximum 3866, screen byte-identical to the vaxis reference, host tests green. --- src/pardes/uart.zig | 49 +++++++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 20 deletions(-) (limited to 'src/pardes/uart.zig') diff --git a/src/pardes/uart.zig b/src/pardes/uart.zig index d98ac62..7696742 100644 --- a/src/pardes/uart.zig +++ b/src/pardes/uart.zig @@ -26,10 +26,15 @@ //! register, and nothing else. const hal = @import("hal"); +const input_rescue = @import("input_rescue.zig"); /// UART0: the instance the CH340 is wired to, and the one the ROM and bootloader configured. const uart0 = hal.uart.Uart.init(0); +/// Keystrokes taken off the receiver while the transmitter was full. See `input_rescue`: without +/// this, anything typed into a frame longer than the 128-byte FIFO was silently gone. +var rescued: input_rescue.Ring = .{}; + /// Push `bytes` into the TX FIFO, blocking while it is full. /// /// The spin is normally bounded by the wire - a full 128-byte FIFO drains in 11 ms at 115200 - and @@ -47,23 +52,7 @@ const uart0 = hal.uart.Uart.init(0); /// The limit is per burst, not per call, and generous: 1,000,000 status reads is far longer than /// any legitimate drain and still a fraction of a second. pub fn write(bytes: []const u8) void { - var rest = bytes; - while (rest.len > 0) { - // One status read per burst, not per byte. - var room = uart0.txFree(); - var spins: u32 = 0; - while (room == 0) { - spins += 1; - if (spins > 1_000_000) { - dropped +%= @intCast(rest.len); - return; - } - room = uart0.txFree(); - } - const n = @min(room, rest.len); - for (rest[0..n]) |b| uart0.pushByte(b); - rest = rest[n..]; - } + dropped +%= input_rescue.pump(uart0, &rescued, bytes, 1_000_000); } /// Bytes abandoned because the transmitter stopped making progress. Nonzero means the console is @@ -119,9 +108,27 @@ pub fn dumpWord(v: u32) void { /// not stall on a keystroke that may never come. `rxCount` is read once per call and the FIFO /// drained to that mark, so a fast typist or a pasted buffer cannot hold the loop here. pub fn read(buf: []u8) usize { - const waiting = @min(uart0.rxCount(), buf.len); - for (buf[0..waiting]) |*slot| slot.* = uart0.popByte(); - return waiting; + // RESCUED BYTES FIRST. They arrived before anything still sitting in the FIFO, and an editor + // that reorders keystrokes is worse than one that drops them. + var n = rescued.pop(buf); + const waiting = @min(uart0.rxCount(), buf.len - n); + for (buf[n..][0..waiting]) |*slot| slot.* = uart0.popByte(); + n += waiting; + return n; +} + +/// Take whatever has arrived off the receiver right now, without waiting and without handing it to +/// anyone. For callers that are about to spend a while not reading: `write` does this while the +/// transmitter is full, and the loop does it between chunks of input, because applying a keystroke +/// gets more expensive as the line grows and 128 bytes of FIFO is only 11 ms at 115200. +pub fn rescueNow() void { + input_rescue.rescue(uart0, &rescued); +} + +/// Input abandoned because even the rescue buffer overflowed. Distinct from `dropped`, which is +/// OUTPUT abandoned by a stalled transmitter. +pub fn inputDropped() u32 { + return rescued.dropped; } /// Discard anything already received, returning how much. Used once at startup: the host-side @@ -133,6 +140,8 @@ pub fn read(buf: []u8) usize { pub fn drainInput() u32 { var discarded: u32 = 0; while (uart0.rxCount() > 0) : (discarded += 1) _ = uart0.popByte(); + discarded += @intCast(rescued.len); + rescued.clear(); return discarded; } -- cgit v1.3