diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/pardes/app.zig | 35 | ||||
| -rw-r--r-- | src/pardes/input_rescue.zig | 248 | ||||
| -rw-r--r-- | src/pardes/uart.zig | 49 |
3 files changed, 310 insertions, 22 deletions
diff --git a/src/pardes/app.zig b/src/pardes/app.zig index 5b1431f..34f427d 100644 --- a/src/pardes/app.zig +++ b/src/pardes/app.zig @@ -41,6 +41,17 @@ const config = @import("config"); /// `-Dprof`: time the two phases of a keystroke on the board and print the cycle counts. A /// diagnostic, not a feature - see the loop. const prof = config.prof; + +/// Every byte this loop has taken off the UART, for `-Dprof`. Ground truth for "did the burst +/// arrive", which a screen reconstruction cannot answer: a character can be missing from the screen +/// because it never arrived, because the editor never applied it, or because the viewport does not +/// show that column. +var rx_total: u32 = 0; + +/// How many input bytes to hand the editor before draining the receiver again. Chosen against the +/// FIFO rather than against the editor: 128 bytes of FIFO is 11 ms of wire at 115200, and 32 +/// keystrokes cost about 2 ms even on a long line, which leaves five times the margin needed. +const input_chunk = 8; const hal = @import("hal"); const heapmod = @import("heap"); const uart = @import("uart.zig"); @@ -247,10 +258,27 @@ export fn zig_main() noreturn { // reads per phase and quantises at one cycle, which is four orders of magnitude below the // milliseconds being attributed. Gated on `prof` so the shipping build carries none of it. const n = uart.read(&in); + rx_total +%= @intCast(n); + var input_cy: u64 = 0; if (n > 0) { const t0 = if (prof) soc.cycles() else 0; - pardes_p4_input(&in, n); + // IN CHUNKS, rescuing the receiver between them. Applying a keystroke is not free and + // gets dearer as the line grows - measured at 44 us on an empty line and 63 us at 640 + // characters - so handing over a full 128-byte batch is up to 8 ms in which nothing + // drains the receiver, against a FIFO that holds only 11 ms of wire. A 600-byte paste + // lost 93 bytes to exactly that window even with the transmitter's own rescue in place. + // + // Splitting a burst at an arbitrary byte is safe: `pardes_p4_input` keeps whatever it + // could not parse, which is how it already survives an escape sequence split across two + // UART reads. One render still happens per loop iteration, so this costs no extra wire. + var off: usize = 0; + while (off < n) { + const chunk = @min(input_chunk, n - off); + pardes_p4_input(in[off..].ptr, chunk); + off += chunk; + if (off < n) uart.rescueNow(); + } if (prof) input_cy = soc.cycles() - t0; } @@ -279,13 +307,16 @@ export fn zig_main() noreturn { var vx_cy: u64 = 0; var flush_cy: u64 = 0; pardes_p4_frame_prof(©_cy, &vx_cy, &flush_cy); - soc.rom.print("PROF in=%u render=%u idle=%u copy=%u vaxis=%u flush=%u\r\n", .{ + soc.rom.print("PROF in=%u render=%u idle=%u copy=%u vaxis=%u flush=%u rx=%u rxdrop=%u txdrop=%u\r\n", .{ @as(u32, @intCast(input_cy)), @as(u32, @intCast(render_cy)), @as(u32, @intCast(idle_cy)), @as(u32, @intCast(copy_cy)), @as(u32, @intCast(vx_cy)), @as(u32, @intCast(flush_cy)), + rx_total, + uart.inputDropped(), + uart.dropped, }); } } diff --git a/src/pardes/input_rescue.zig b/src/pardes/input_rescue.zig new file mode 100644 index 0000000..ea242b2 --- /dev/null +++ b/src/pardes/input_rescue.zig @@ -0,0 +1,248 @@ +//! Keystrokes rescued from the receive FIFO while the transmitter is busy. +//! +//! THE BUG THIS EXISTS FOR. The firmware's loop is read, apply, render, write, and the write blocks +//! while the transmit FIFO is full - real backpressure, because dropping half an escape sequence +//! would leave the host terminal in the wrong colour for the rest of the session. But nothing +//! drained the RECEIVE FIFO during that wait, and the FIFO is 128 bytes (`hal/uart.zig:52`). A frame +//! of 240 bytes is 21 ms of wire at 115200, and 21 ms of a host sending at line rate is ~240 bytes, +//! so everything past the 128th was silently gone. +//! +//! Measured on the die before the fix, typing a burst in one host write and counting what the editor +//! actually held: 128 bytes arrived intact, 200 bytes lost 88, 300 bytes lost all 300. From a +//! keyboard that is a keystroke that never lands, and it looks like a stuck key - the screen is +//! behind what was typed, and typing more appears to fix it because a later frame repaints the cells +//! the lost keystrokes would have changed. +//! +//! WHY THE POLICY LIVES HERE and not in `uart.zig`: the interesting part is a decision - drain the +//! receiver while spinning on the transmitter, and what to do when even that overflows - and the +//! decision is worth testing. `uart.zig` cannot be tested at all without the chip, because every +//! line of it is an MMIO access. `pump` takes the port as `anytype`, so the same code runs against +//! the real UART on the board and against a fake with a two-byte FIFO in `zig build test`. + +const std = @import("std"); + +/// Capacity, sized for the worst frame this editor emits. +/// +/// A full repaint is ~1.4 KB, which is 121 ms of wire at 115200, and 121 ms of a host pasting at +/// line rate is ~1.4 KB of input. 4 KiB is that with headroom, a power of two so the wrap is a mask +/// rather than a division, and nothing at all against the board's RAM. +pub const capacity = 4096; + +/// A byte queue that drops the NEWEST byte when full. +/// +/// Dropping the newest rather than the oldest is deliberate: what survives is then a PREFIX of what +/// was typed. An editor that loses the end of a paste has done something a person can see and +/// correct; one that silently reorders keystrokes, or keeps the tail and discards the head, has +/// corrupted the document in a way that looks like the editor inventing input. +pub const Ring = struct { + buf: [capacity]u8 = undefined, + head: usize = 0, + len: usize = 0, + /// Bytes lost because even this overflowed. Nonzero means input was dropped; it is the honest + /// version of the bug rather than a cure for it. + dropped: u32 = 0, + + const mask = capacity - 1; + + comptime { + std.debug.assert(capacity & mask == 0); + } + + pub fn push(r: *Ring, b: u8) void { + if (r.len == capacity) { + r.dropped +%= 1; + return; + } + r.buf[(r.head + r.len) & mask] = b; + r.len += 1; + } + + /// Move as much as fits into `out`, oldest first. Returns the count. + pub fn pop(r: *Ring, out: []u8) usize { + const n = @min(out.len, r.len); + for (out[0..n]) |*slot| { + slot.* = r.buf[r.head]; + r.head = (r.head + 1) & mask; + } + r.len -= n; + return n; + } + + pub fn clear(r: *Ring) void { + r.head = 0; + r.len = 0; + } +}; + +/// Drain everything the port has received into `ring`, without waiting. +pub fn rescue(port: anytype, ring: *Ring) void { + var waiting = port.rxCount(); + while (waiting > 0) : (waiting -= 1) ring.push(port.popByte()); +} + +/// Push `bytes` through `port`, rescuing input whenever the transmitter has no room. Returns the +/// number of bytes abandoned because the transmitter stopped making progress altogether. +/// +/// The spin bound is why this returns a count rather than blocking forever: a UART whose core clock +/// has been gated never makes progress, and on a board with no debugger an infinite spin is +/// indistinguishable from a crash. A bounded wait turns that into visibly dropped output plus a +/// counter, which is a diagnosis instead of a mystery. +pub fn pump(port: anytype, ring: *Ring, bytes: []const u8, spin_limit: u32) u32 { + var rest = bytes; + while (rest.len > 0) { + // One status read per burst, not per byte: reading `txFree` once and pushing that many cuts + // the status reads by up to the FIFO depth. + var room = port.txFree(); + var spins: u32 = 0; + while (room == 0) { + // THE FIX. Every iteration of this wait is time the receiver is filling up, and this is + // the only place that can empty it. + rescue(port, ring); + spins += 1; + if (spins > spin_limit) return @intCast(rest.len); + room = port.txFree(); + } + const n = @min(room, rest.len); + for (rest[0..n]) |b| port.pushByte(b); + rest = rest[n..]; + } + return 0; +} + +// ------------------------------------------------------------------------------------ host tests + +test "the ring hands bytes back in order" { + var r: Ring = .{}; + for ("hello") |b| r.push(b); + var out: [8]u8 = undefined; + try std.testing.expectEqual(@as(usize, 5), r.pop(&out)); + try std.testing.expectEqualStrings("hello", out[0..5]); + try std.testing.expectEqual(@as(usize, 0), r.pop(&out)); +} + +test "the ring wraps without reordering" { + var r: Ring = .{}; + var out: [capacity]u8 = undefined; + // Push and pop most of the buffer so head sits near the end, then straddle the wrap. + for (0..capacity - 3) |i| r.push(@intCast(i & 0xff)); + _ = r.pop(out[0 .. capacity - 3]); + for ("straddle") |b| r.push(b); + const n = r.pop(&out); + try std.testing.expectEqualStrings("straddle", out[0..n]); +} + +test "a full ring drops the newest and says so" { + var r: Ring = .{}; + for (0..capacity) |i| r.push(@intCast(i & 0xff)); + try std.testing.expectEqual(@as(u32, 0), r.dropped); + r.push('!'); + r.push('!'); + try std.testing.expectEqual(@as(u32, 2), r.dropped); + // The head is intact: what survived is a prefix of what arrived. + var out: [4]u8 = undefined; + _ = r.pop(&out); + try std.testing.expectEqual(@as(u8, 0), out[0]); + try std.testing.expectEqual(@as(u8, 1), out[1]); +} + +/// A UART with a small transmit FIFO, a small RECEIVE FIFO, and a host that keeps typing into it. +/// +/// The receive FIFO is the part that matters and it is modelled the way the hardware behaves: it has +/// a fixed depth, and a byte that arrives when it is full is *gone*. That is the whole bug. +/// +/// Time advances on each transmitter status read, which is what `pump` does while it waits. The +/// transmitter frees a byte only every fourth tick while a typed byte lands on every one: the +/// transmitter therefore genuinely FILLS, which is the condition the bug needs. A fake whose FIFO +/// drains as fast as it fills never blocks, so `pump` never waits, so the rescue never runs and the +/// test proves nothing - the first version of this fake had exactly that flaw. +const FakePort = struct { + tx_cap: u32, + tx_used: u32 = 0, + sent: std.ArrayList(u8) = .empty, + gpa: std.mem.Allocator, + + incoming: []const u8, + delivered: usize = 0, + rx: [rx_depth]u8 = undefined, + rx_head: usize = 0, + rx_len: usize = 0, + /// Bytes the wire delivered into a full receive FIFO. The hardware has no counter for this, + /// which is exactly why the bug was invisible. + lost: u32 = 0, + + ticks: u32 = 0, + + const rx_depth = 8; + const tx_drain_every = 4; + + fn tick(p: *FakePort) void { + p.ticks += 1; + if (p.ticks % tx_drain_every == 0 and p.tx_used > 0) p.tx_used -= 1; + if (p.delivered < p.incoming.len) { + const b = p.incoming[p.delivered]; + p.delivered += 1; + if (p.rx_len == rx_depth) { + p.lost += 1; + } else { + p.rx[(p.rx_head + p.rx_len) % rx_depth] = b; + p.rx_len += 1; + } + } + } + + fn txFree(p: *FakePort) u32 { + p.tick(); + return p.tx_cap - p.tx_used; + } + + fn pushByte(p: *FakePort, b: u8) void { + p.sent.append(p.gpa, b) catch unreachable; + p.tx_used += 1; + } + + fn rxCount(p: *FakePort) u32 { + return @intCast(p.rx_len); + } + + fn popByte(p: *FakePort) u8 { + const b = p.rx[p.rx_head]; + p.rx_head = (p.rx_head + 1) % rx_depth; + p.rx_len -= 1; + return b; + } +}; + +test "a long transmit does not lose the input that arrives during it" { + // THE REGRESSION. Delete the `rescue` call inside `pump`'s wait and this fails: the receive FIFO + // is eight bytes deep, the typing below is far longer than that, and every byte that arrives + // into a full FIFO is gone with nothing to record it. That is the die's 88-of-200 in miniature. + const typed = "the quick brown fox jumps over the lazy dog, twice over, and then some more"; + var port: FakePort = .{ .tx_cap = 2, .incoming = typed, .gpa = std.testing.allocator }; + defer port.sent.deinit(std.testing.allocator); + var ring: Ring = .{}; + + const frame = "\x1b[1;1H" ++ "x" ** 400; + try std.testing.expectEqual(@as(u32, 0), pump(&port, &ring, frame, 1_000_000)); + + // Every output byte went out, in order. + try std.testing.expectEqualStrings(frame, port.sent.items); + // Nothing the wire delivered was dropped, by the FIFO or by the ring. + try std.testing.expectEqual(@as(u32, 0), port.lost); + try std.testing.expectEqual(@as(u32, 0), ring.dropped); + // And what was rescued, plus whatever is still sitting in the FIFO, is exactly what was typed - + // in order, which is the other half of the contract. + var got: [capacity]u8 = undefined; + var n = ring.pop(&got); + while (port.rxCount() > 0) : (n += 1) got[n] = port.popByte(); + try std.testing.expectEqualStrings(typed[0..port.delivered], got[0..n]); + try std.testing.expect(port.delivered == typed.len); +} + +test "a transmitter that never drains gives up and reports what it abandoned" { + var port: FakePort = .{ .tx_cap = 0, .incoming = "", .gpa = std.testing.allocator }; + defer port.sent.deinit(std.testing.allocator); + var ring: Ring = .{}; + // tx_cap 0 means txFree is always 0, so no byte can ever go out. + try std.testing.expectEqual(@as(u32, 5), pump(&port, &ring, "abcde", 32)); + try std.testing.expectEqual(@as(usize, 0), port.sent.items.len); +} 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; } |
