Found one of the problems: I cleared the FPGA IRQ line latch after reading the interrupt status register. But I only read a single Ethernet frame per IRQ assertion.
So if two frames show up before I've read the first one, the second one won't get read until a third one shows up, etc.
Eventually enough frames will get forgotten that the buffer fills up and all traffic stops flowing.
Got a trivial fix (don't latch IRQ, it's asserted nonstop if there's data in the buffer) and am building a new bitstream with it.
But soooomeone wanted to go to the park so it's gonna be a while before I'll know if the fix worked...