aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
...
* Take no send larger than a receiver holds: 16 MiB, not 64eunhhu2026-09-293-5/+69
| | | | | | | A sender took sends up to 64 MiB, but a receiver holds 16 MiB of a stream not yet stored, so a send between the two went again forever. The most a send may be is now PURE_LINK_RECORD_MOST, what a receiver holds; test-link sends one that large whole and is refused one byte more.
* Say how the four limits left went: three solved, one measured away (101)eunhhu2026-09-293-1/+153
| | | | | | | | | A path MTU black hole is seen and the link goes back to 1,200 bytes; a receiver that lost its memo says LOST and M2 without a memo catches up in 0.4-0.5 s, not 7.7; M5 stands beside ngtcp2 too (Pure 25 % lighter than both QUIC from 1 KiB, 1.04 of ngtcp2 and 0.90 of quinn at 61 B); and the stream's name in every packet, 096's guess for 61 B's gap, cost nothing measurable, so it stays. 098 notes the one line LOST changes.
* Put ngtcp2 beside quinn in M5, as the design plannedeunhhu2026-09-294-0/+505
| | | | | | | | | | | | | ngtcp2 1.25.0 with OpenSSL 3.5's QUIC TLS (its ossl backend), fetched and built into build/third by ngtcp2/build.sh; drain.c does in C what the quinn harness does: one connection, one stream, "pure" and then the messages each after its u32 length, counted by the service, packets in GSO bursts. Its stream bytes are a function of their offset, so what ngtcp2 keeps to send again points into a pattern that never changes. run.py's drain_ngtcp2 measures both sides' CPU as drain_quic does. Five rounds each, both sides' CPU a message: 61 B Pure 0.280, ngtcp2 0.270, quinn 0.310 us; 1 KiB 2.62, 3.54, 3.94; 16 KiB 40.2, 54.4, 60.2.
* Put records that come in order straight onto the line, a frame at onceeunhhu2026-09-291-1/+13
| | | | | | | | | | | | A RECORDS frame of records in order, with nothing held and no record's tail waiting, went through hold, on_line and line_append a record: the same checks each time (heard, credit, the line's end, held, the memory bound). Now they are made once for the frame and its records are copied onto the line one after another. The receiver's CPU a 61-byte record in M5 went from 0.15 to 0.133-0.143 us; both sides together 0.28 us, beside ngtcp2's 0.27 and quinn's 0.31 in the same rounds (the M5 host here also compares every record with what was sent). test-link's results are the same to the byte.
* Say LOST when a run's epoch is not known, and let its sender begin again at onceeunhhu2026-09-294-34/+154
| | | | | | | | | | | | | | | | | | | | | | | | | | A receiver that started again without its memo could open none of its senders' packets (their epochs were gone), said nothing, and each sender waited 2 s and four probes, then offered with no records. Worse, those packets failed to open, so each cost a token (256 a second, the flood guard): ten thousand senders came back 256 a second. M2 without a memo stopped 0.8 s and caught up in 7.7 s. Now the receiver answers a packet of a run it does not know, in an epoch past 0, with LOST: sealed in epoch 0 under the nodes' keys, naming the packet. As QUIC's stateless reset, it is smaller than the packet and comes out of a bucket of its own (4,096 a second). A sender that has that packet out begins again as a run begins: epoch 0 with its records at once, epoch 1 offered. Only the receiver can say LOST, and one of the past names a packet no longer out; silence alone (an attacker's doing, maybe) still gets offers only. Such a packet costs no key token: no keys were made for it, and the secret made is remembered, once a sender. M2 without a memo (ten thousand devices, twice): no 100 ms step stopped, caught up in 0.4-0.5 s, p99 5.8-5.9 ms, the most 48-57 ms (was 4.1 and 7.8 s). test-link: a changed packet may get a LOST smaller than itself and nothing else; half of a flood in epoch 1 got 267 LOSTs, 12 KB for its 6 MB; a record goes under the nodes' keys after an epoch began only after the receiver forgot, until the next epoch.
* See a path that stops carrying larger datagrams, and go back to the first sizeeunhhu2026-09-293-16/+54
| | | | | | | | | | | | | A link that found the path carries 1,452 bytes kept sending them when a route changed and larger packets were dropped with nothing said (a black hole): it lost them one after another and stood still (test-link: 8 of 3,000 records in ten minutes). Now, as RFC 8899 §4.3 has it, it goes back to 1,200 bytes when three larger packets in a row are lost while an acknowledgment covers only smaller ones, or when two probes in a row go unanswered, and tries the larger size again a second on (three tries, then ten minutes). The case completes in 1.2 s of virtual time; the M6 simulator's results are the same to the byte in eight paths of loss, reordering and a bottleneck.
* Say what a small record costs a link now, beside quinn in the same rounds ↵eunhhu2026-09-292-2/+10
| | | | | | | | | (096, 100) 61-byte records: 0.287 us a record against quinn's 0.31 in the same five rounds (0.93 of it); 096's 1.06-1.12 was against quinn at 0.27, which moves between 0.27 and 0.32 from day to day. H4 is no longer at the edge.
* Cut what a small record costs a link: no division a record, no walk a deliveryeunhhu2026-09-292-20/+36
| | | | | | | | | | | | | | | | Profiling both sides in one thread, without sockets (61-byte records): the rings of records and flights divided for each place, the record a number names was found three times over, a record in one part was searched for its part, and the receiver walked the line record by record to hand it over and again to let it go. The rings are powers of two long and now masked; a record is found once; a one-part record's part is the first; a line of a batch or less goes whole without a walk, and a delivery applied whole lets its bytes go at once. The protocol alone: 0.171 to 0.162 us a record. M5 (the link and listener threads, sockets too), five rounds against the commit before: 61 B 0.298 to 0.287 us, 1 KiB 2.63-2.90 to 2.48-2.79 us, 16 KiB the same or less; quinn measured 0.31, 3.85 and 60 us in the same rounds. test-link's results are the same to the byte.
* Say that a receiver moved to another machine is followed, and how (100)eunhhu2026-09-293-0/+134
| | | | | | | | | | | | | H3 of design 011 stands: with ten thousand devices sending 2,000 records a second, the node's store, key and memo carried to another address, nothing stopped for a 100 ms step, the moved node made no X25519, and every record was applied once. Each device's first record after the move waits one probe (30-45 ms here), as its first packet finds no way there. The search found the listener answering from an address other than the one a packet came to, and a link that looked for its receiver after its probe went, losing every answer. H1-H4 are all measured now; 61 B's CPU a delivery is at the edge of the criterion, to be looked at before step 5 (adoption and a specification).
* Move M2's node to another machine under load (M3's harness)eunhhu2026-09-292-11/+88
| | | | | | | | | | run.py --restart --move runs in namespaces of its own (unshare -rmn: a network and an /etc/hosts): the devices send to receiver.test, which names 127.0.0.2; at --kill-at the node is killed, its store, key and memo copied elsewhere, 127.0.0.2 made unreachable, the name pointed at 127.0.0.3, and the copy started there. The load generator's devices look for the node again as link/link.c does: after three probes in a row unanswered, or a send the system said had no way there.
* Look for a link's receiver at once when the system says it cannot get thereeunhhu2026-09-291-3/+16
| | | | | | | | | | A send that fails with no route, nobody at the port, or the network gone (ECONNREFUSED, EHOSTUNREACH, ENETUNREACH, ENETDOWN, EADDRNOTAVAIL) now has the link look its receiver's name up again before its next packet, not three unanswered probes later: a receiver moved away from an address that is gone costs a sender one probe's wait, not three. Such a failure no longer turns GSO off either (it said nothing of GSO). In the check that moves a receiver, the sender sent 24 records again, not 243.
* Look for a link's receiver again when its probes go unansweredeunhhu2026-09-294-26/+205
| | | | | | | | | | | | | | | | | | | A link resolved its receiver's name once, at its start: a receiver whose store moved to another machine (another address under the same name) was never found, and a sender whose own network changed kept a socket that no longer went anywhere. Now, when three probes in a row go unanswered, and at each probe after (a second apart at most), the link looks the name up again and sends from a new socket connected there. It looks before the probe goes, so the probe's answer comes to the new socket (looking after it lost every answer, one socket after another). A sender at rest does not look: only probes unanswered count. make check moves a receiver (H3 of docs/design/011-pure-transport.md): in namespaces of its own, the sender links to pure://receiver.test at 127.0.0.2; the receiver is killed, its store, key and memo copied, the old address made unreachable, the name pointed at 127.0.0.3 and the copy started there. Every order is applied once, and the moved receiver makes no X25519.
* Answer a link's packet from the address it came toeunhhu2026-09-292-17/+62
| | | | | | | | | | | | | | The listener's socket takes packets to any address of the node, and its answers went out from whichever address the system chose for the way back. A sender whose socket is connected to another of the node's addresses (a node with many, or a name that points at a second one) takes answers only from the address it sent to: it heard none, and its records went on arriving unacknowledged. Now the listener asks for the address each packet came to (IP_PKTINFO; IPv4 as ::ffff:a.b.c.d on a socket of both) and answers from it. The path a receiver keeps for a run is both addresses (48 bytes, not 28): a packet that comes to another of the node's addresses moves it, and is checked, as one from elsewhere is. Found by a receiver moved to 127.0.0.2 (the check that follows).
* Keep a sender's congestion state as one, so an episode undoes it wholeeunhhu2026-09-292-56/+53
| | | | | | | | The window, threshold, the start of recovery and CUBIC's four values were fields of their own, and an episode of congestion events copied each of the seven aside and back. Now they are one Congestion: an episode keeps a copy, and undoing it takes the copy back, the window grown since kept. test-link's results and the M6 simulator's are the same to the byte.
* Hold each epoch of a run as one thing: its number, secret and keyseunhhu2026-09-291-127/+112
| | | | | | | | | | | | | | | A receiver's run kept seventeen fields of epochs: numbers, flags, secrets and keys for now, the one before, the one accepted, and an offer waiting. Now it keeps three epochs (now, before, next), each its number, secret and the keys of the sender's packets in it, and every move is one of two: made from a secret, or become the epoch now (the one it was goes before, the answers are sealed in the new one). Using the one accepted, a chain begun again from epoch 0, and a run begun from the memo are these moves. The key of an offer waiting is the key offered: a new offer lets the one accepted before go at once. A sender's moves (an epoch begun, one taken for lost, the first) are one too: its packets sealed in epoch n. test-link's results are the same to the byte.
* Begin an epoch only from the answer to this offer, not an earlier oneeunhhu2026-09-293-20/+52
| | | | | | | | | | | | | | | A sender that took its epoch for lost offers epoch 1 again with a new key. The receiver accepts it in the turn, after the answers; until then its answers still carried the ACCEPT of the earlier offer, which named the same epoch. The sender began its epoch from that one, the two sides held different secrets, and 2 s later it took the epoch for lost again: a turn of that each time the order fell so. Now an ACCEPT carries the first four bytes of the key it answers, and a sender begins its epoch only from one that answers its own. test-link cuts the path for 3 s the moment the sender begins an epoch, three times; a sender's counts say how many epochs it took for lost, and it must be three. Before, it was four.
* Say what epochs cost a link, and what the search for it found (099)eunhhu2026-09-297-3/+313
| | | | | | | | | | | | | | Forward secrecy, 098's first task left: keys that leak later open only epoch 0. M2 after the epoch began and after a restart is as before it (p50 0.4 ms, p99 0.8 ms, the same CPU, no X25519 on restart). It costs a p50 of 0.7-1.0 ms more while ten thousand devices first meet and for about 40 s of accepts after, and a receiver that lost its memo now stalls 0.8 s and catches up in 7.7 s, not 0.2-0.7: it lost the epochs, and each sender opens one again. The search also found a packet more a device from each of M6's path probe and the security checks' challenge, and a load generator whose own pass over its devices was the floor of the latency it measured. 098's note that epochs narrow KCI to the first epoch was wrong; corrected.
* Move a link to a new epoch now and then, so keys that leak later open ↵eunhhu2026-09-295-71/+568
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | nothing past A sender offers the public half of a pair made for the next epoch (OFFER) until the receiver accepts with a half of its own (ACCEPT); their X25519 and the epoch's secret make the next one's, and the private halves go. The header's low four bits say a packet's epoch; epoch 0 is the nodes' static secret, as the keys were. The first epoch is offered in the run's first packet (it begins with the first answer when the receiver is not busy), then one an hour or GiB. One who later has both nodes' keys opens only epoch 0, and no record went there after an epoch began: test-link tries every packet sent under the static keys, with epochs a second long, loss, reordering, restarts and replays. The receiver writes the epoch it accepted in its memo (a place is 256 bytes now), so it goes on without an X25519 when it starts again; one that forgot is not heard from, and its senders, after 2 s and four probes unanswered, offer again in epoch 0 with no records until an epoch begins. Keys of four epochs are tried (accepted, now, before, zero), as two may share the four bits. An accept costs an X25519 on each side, 220 µs on this Pi: one of a sender with records costs a token (256 a second, 8 at once) and the next packet offers again when there is none; one of a sender with nothing else to send waits for none, one a second for it. The X25519 is made after the turn's answers, never between a packet and its answer. serve's report and M2's generator count keys, X25519s and epochs.
* Give M2's devices a timer each, and say when the slow records were madeeunhhu2026-09-291-7/+34
| | | | | | | | | | | The load generator asked all ten thousand senders for their timers every 5 ms: 83 % of its CPU (47 ns a sender, mostly cache misses), and records were timed from the start of that pass, so the pass (0.75-0.94 ms, longer as the sender grew) was the floor of every latency it measured. A device now keeps its sender's next timer, asked again only when the sender is touched (the only time it changes), and the p50 of M2 falls from 0.8 to 0.4 ms for every version alike. It also says the p50 and p99 of the records made in each second, and the keys and X25519s its senders made.
* Send no packet only to probe the path or say a challenge backeunhhu2026-09-292-14/+24
| | | | | | | | | | | | | M2 (ten thousand devices, a record each every 5 s) sent 118k packets for 80k records. A link whose packets were never full still probed for a 1,452-byte path: nothing larger would have been carried. And a sender said a receiver's challenge back in a packet of its own, one for each device each time the receiver started. Now a link probes once a packet of its was full, and a challenge rides in the next packet that goes (records, or a probe). Until then the receiver's answers there are at most three times what came, which the answers to one packet are not; when they are held back, the sender's probe or record sent again says it back. M2 sends 80,036 again (docs/research/099-link-epochs.md).
* Let links make an X25519 pair for once, and agree with iteunhhu2026-09-294-2/+48
| | | | | | | | An epoch's keys (docs/design/011-pure-transport.md): a pair made for the next epoch, held raw (a link may hold one for long and a node many, so not in OpenSSL's 16 KiB secure heap), and the secret its private half shares with the other side's public half, never all zeros. The crypto table's version is 5.
* Say what an attacker on the path could do to a link, and what now stops it (098)eunhhu2026-09-293-3/+119
|
* Close what an attacker on the path could do to a link, each checkedeunhhu2026-09-293-49/+412
| | | | | | | | | | | | | | | | | | | | | | | | | | | | test-link now also plays an attacker: packets changed on the way, packets of the past sent again from the sender's address and its own, keys and sources not allowed, and a flood of made-up packets. It found: - A nonce used twice: a receiver sealed every answer in its one run, and a sender at rest coming back with the same run got a new slot whose answers began at number 0 again under the same key. Each slot now draws the run its answers are sealed in. - A link stopped for good by one packet of the past: a stream that did not know its last heard (after a restart or a rest) handed over from its first record held, and a stale part of an old one never came whole. It hands over from its first whole record. - Answers taken away: any packet that opened, however old, moved where answers went. Only the newest of its run moves it now; a new address is sent a challenge the sender says back (CHALLENGE, RESPONSE), and until then gets at most three times the bytes that came from it. - An answer made by a packet that did not open: a number heard before was acknowledged before the packet was opened. It is opened first. - An old answer lowering the sender's credit, or its gap making it send again: the sender now sends up to the most heard and credit it was told, and takes a gap only from a word not older than it knows. - Ten thousand made-up packets made ten thousand keys: keys made for a run that did not open now cost a token, 256 a second. Counts now say runs keyed and secrets made.
* Never let a zero RTT make a link's pace infiniteeunhhu2026-09-291-2/+6
| | | | | | | A sample of 0 (a path within one clock tick, or a test's) made the pacing rate the window over 0, and its bytes infinity times 0: NaN, after which the sender never sent again. CUBIC's time-based estimate divided by it too. The RTT they take is at least a microsecond.
* Measure a link on paths of loss, reordering and a bottleneck, against quinn ↵eunhhu2026-09-294-1/+344
| | | | | | | | | | (097) M6 of design 011: with a 50 Mbit/s bottleneck, a link carries 21-41 times quinn's goodput when packets reorder, the same under random loss, and 1.3-3.5 times with both; its p99 at 50 records a second is the same or shorter; beside TCP CUBIC it takes 0.48-0.55 of the path, inside the guardrail. Only on a clean path is it 3-5% below quinn.
* Let M6's path waver by 1 ms when flows share it, and count from 20 s of 60eunhhu2026-09-291-6/+9
| | | | | | | | With a fixed delay the drop-tail queue dropped one flow's packets eight times as often as the other's by their phases alone (Floyd and Jacobson, 1992): Pure took 0.63-0.77 of the path from TCP and 0.69 from quinn, 0.47 from itself, and 0.49 under fq_codel. With 1 ms of jitter it takes 0.53 at an RTT of 20 ms.
* Keep Reno's pace at 0.53 datagrams an RTT, as deployed CUBIC doeseunhhu2026-09-291-6/+8
| | | | | Linux's CUBIC and quinn's keep it so; RFC 9438's whole datagram once past the window before the event is left out.
* Pace a link's packets, and keep Reno's pace by time as well as by ↵eunhhu2026-09-291-0/+30
| | | | | | | | | | | | | | | | acknowledgments Packets go at the window an RTT, a quarter more (twice in slow start), up to 2 ms of it at once (10 to 256 datagrams, RFC 9002 7.7); the sender says when the next may go. The Reno-friendly estimate grows by the acknowledgments (RFC 9438) or by time (RFC 8312), whichever is ahead, so a link its app holds back to a few records a second does not fall behind it. In the M6 simulator (RTT 100 ms, 5% loss, 50 records a second) the median time a record takes falls from 134 to 51-71 ms and the p99 from about 900 to 290-350 ms; a backlog with 1% loss at RTT 20 ms goes at 8.3 Mbit/s. The transport's CPU a record is unchanged.
* Record one round of M6 with the path's MTU found, before pacing (020)eunhhu2026-09-291-0/+42
|
* Let the M6 simulator queue records at a rate and time each oneeunhhu2026-09-291-5/+33
|
* Start M6's flows together, each a process, and count their shares from 10 seunhhu2026-09-291-26/+63
| | | | | | The TCP flow started first and in a thread of the runner; two TCP flows split 0.31 to 0.56. Now every flow is a process started at once, run 40 s, counted from 10 s on.
* Find how large a packet the path carries: 1,452 bytes once a probe is ↵eunhhu2026-09-296-44/+118
| | | | | | | | | | | | | | | acknowledged A link starts at 1,200 bytes; once an RTT is known it sends a PING padded to 1,452 (DPLPMTUD, RFC 8899), alone and with the don't-fragment bit, and uses that size once it is acknowledged. A probe lost is no congestion; it is tried again a second on, three times, then ten minutes on. GSO is asked for each burst (a control message), not the socket, so a packet sent alone is never cut. CUBIC counts in the datagram. On loopback packets fall by 19-27%; the CPU a record falls 13% at 1 KiB and 7% at 16 KiB; in the simulator of a 50 Mbit/s path with 1% loss the goodput rises from 6.1 to 7.0 Mbit/s (5%: 2.3 to 3.0).
* Record M6 with records in parts and no path MTU yet (020)eunhhu2026-09-291-0/+126
|
* Queue only the way to the receivers behind M6's bottleneckeunhhu2026-09-292-18/+30
| | | | | | | | tbf with netem under it lost tbf's queue (netem became it), and the answers queued behind the data. Now each way has its netem, the way to the receivers' ports then a tbf whose queue holds a round trip; the way back does not queue. transport.c takes its listener's port (TRANSPORT_PORT).
* Send a record in parts cut where a packet is full, not 1000-byte pieceseunhhu2026-09-292-129/+246
| | | | | | | | | A record that does not fit what is left of a packet goes in part when at least 64 bytes fit; its parts keep their cuts, so what a packet carried is whole parts. The receiver keeps the spans it had of a record, and a record coming in order from its start fills the line's tail with no allocation. Packets fall by 8% for 1 KiB records and 10% for 16 KiB; the CPU a record is the same at 61 B and 1 KiB and 5% less at 16 KiB.
* Apply a link's records in transport.c as a host doeseunhhu2026-09-291-11/+20
| | | | | | From the one after the last heard, letting be the ones heard before and stopping at a gap: the listener's first delivery may begin past it when packets reorder, which the harness counted as wrong.
* Measure a link on a path of loss, reordering and a bottleneck (M6 harness)eunhhu2026-09-295-33/+382
| | | | | | | | | 020-lossy-paths/run.py runs each case in an unprivileged namespace: a 50 Mbit/s tbf bottleneck under netem's delay, jitter and loss, both ways. A link may send without UDP GSO (pure_link_gso), as netem takes a burst as one packet; quinn the same (QUIC_GSO=0). transport.c and the quinn harness give the records handed over in each 100 ms, and at a steady rate the time each took; TCP CUBIC flows run beside them.
* Wait for late packets, undo what they cost, and grow as CUBICeunhhu2026-09-294-13/+311
| | | | | | | | | | | | | | | A packet called lost stays known a while; acknowledged after all, it came late: what it carried counts as received, the reordering awaited grows (later packets acknowledged, and time past the RTT, as RACK), and an episode of congestion events whose losses all came late is undone. The window grows only when it was used (RFC 9002 7.8), and congestion avoidance is CUBIC (RFC 9438), as quinn's. In a path of 50 Mbit/s whose packets reorder by up to 5 ms, with no loss, a link carried 1.5 Mbit/s at an RTT of 20 ms and 10 at 100 ms; now 43.6 and 40.1, as without reordering (research/experiments/020-lossy-paths, virtual time). With 1% random loss: 4.9 to 6.1 at 20 ms, 1.0 to 1.3 at 100 ms. The transport's CPU a record is unchanged.
* Say why the links became a part, and not a repository (009, 011)eunhhu2026-09-292-3/+38
|
* Make the load generator's devices real link senders, many on one threadeunhhu2026-09-294-384/+170
| | | | | | | | | | links.c was a second implementation of the packet format; each device is now a PureLinkSender on a socket of its own, keyed with a secret made outside (pure_link_sender_shared: a key kept elsewhere, or a test's many). M1 (1k, 10k) and M2 (10k, kill -9) pass with it: no silent step, caught up in 0.2 s, every order applied once; the slowest store after the restart is now the sender's 1 s wait for a word of the save (STORED_WAIT), where the old devices tried again after 200 ms.
* Keep a link's two sides as state alone, the sockets and threads aparteunhhu2026-09-298-1555/+2054
| | | | | | | | | | | | link/link-protocol.h is the protocol with no socket, thread or clock of its own: packets and time in, packets out. link/link.h carries it over UDP as before. A sender starts small (8 flights, 16 records, a 4 KiB ring) so one thread can hold many. The transport's CPU a record is the same within 2% (five rounds each way, 61 B to 16 KiB). test-link runs both sides through a network of its own: loss, packets held back and doubled, failed saves, an idle sweep, and both sides starting again, every record applied once as sent.
* Let links know nothing of the core: the host names inputs and shapeseunhhu2026-09-297-105/+137
| | | | | | A sender takes its messages' shape, and a listener asks its host which input a stream goes to. The core gives the shape (pure_module_shape); link/ now builds without the core's headers.
* Put the links and the crypto they use in link/, a part of their owneunhhu2026-09-2916-35/+47
| | | | | libpure-link.a holds them; pure-serve and the M5 harness link it instead of naming its source files.
* Measure the drains again with the outbox as a list (096)eunhhu2026-09-292-17/+48
| | | | | | | Between two pure-serve nodes, 16 KiB records drain at 33,600 a second (23,000 before), the links spending 58 us a record (76), under quinn's 60 while the receiver also stores each one. 1,013 bytes: 406,000 a second (347,000). Saving sends one by one: 5,000 of 16 KiB in 0.91 s (84.8).
* Keep the outbox as a list: a save logs only the sends and trims it madeeunhhu2026-09-297-153/+277
| | | | | | | | | | | | | | | | | | | | | The sends not yet trimmed were written whole into the links value at every save, as the numbers heard were: a sender whose receiver is away or slow saved all its waiting sends again with each new one. They are now a runtime list {output (its name), seq, payload}, in the order sent, stored as a list state is (image "0sent" and its changes in the log). A send is a change of the list with its event; a trim lets the sends go at once and a failed save brings them back; the host sees the saved ones (sent_saved). Outputs are named in the items, so a module replaced keeps them without moving anything; one that lacks or changed an output with sends left is refused as before. Links stored before (PLK1, PLK2) move their sends to the list at a start and are stored again without them (PLK3). A list field may now hold any bytes (kind 2), unchecked, besides a number and a UTF-8 text. Saving 5,000 sends of 16 KB one by one took 84.8 s and left a store of 400 MB; now 0.91 s and 144 MB. 20,000 of 61 bytes: 8.4 s, now 2.9 s.
* Measure the CPU a record costs a link, against quinn (096)eunhhu2026-09-296-22/+395
| | | | | | | | | | | | | | | | | | | | | M5 of design 011. At first a link spent eight times quinn's CPU on a small record: scans of the backlog, a system call and allocations a record, 19 bytes of framing. After the seven changes before this one, the transport alone (transport.c; a backlog drained, every record checked) spends per record 0.30 us for 61 bytes (quinn 0.27: 1.06 to 1.12 times over three campaigns, at the design's 10 % line), 2.91 us for 1,013 bytes (quinn 3.88) and 45.2 us for 16,013 bytes (quinn 60.9): 25 % less for the larger ones, and 30 % more records a second. What is left for small records is per packet: the stream's names and the RECORDS head. quinn's numbers carry no confirmation of storing and no exactly once. Between two pure-serve nodes the links spend more per record, woken at the receiving app's pace. Filling the sender showed the outbox is written whole at every save, as the numbers heard were (094): the next thing to change. run.py gains --transport and --drain; the quinn service reads a drain a chunk at a time and its client writes 64 KiB at a time.
* Send a record whole when it fits a packet, however many pieces it haseunhhu2026-09-293-46/+75
| | | | | | | | | | | | | | | | | A record over a piece (1,000 bytes) went in pieces even when it fit the packet whole: a record of 1,013 bytes went as two RECORD frames, and the receiver gathered it the slow way (an allocation, pieces, then onto the line). A record none of whose pieces went yet now goes whole in a RECORDS frame whenever it fits what is left of the packet; its pieces are marked sent together. A record too large for a packet, or one resent after some of its pieces were lost, still goes in pieces, and a receiver takes a record whole or in pieces alike. The transport alone takes 2.85 us a record of 1,013 bytes, not 3.18 (quinn: 3.90). transport.c now checks every record delivered: in order, once each, its bytes as sent. None was wrong in a network namespace with 1 ms of delay, 3 % loss and 10 % reordering, with records of 61, 1,013 and 16,013 bytes.
* Keep a link's queued records in one byte ring, taken back as they are storedeunhhu2026-09-281-23/+65
| | | | | | | | | | | | | A queued record had an allocation of its own for its bytes and its pieces' states, freed when the receiver stored it. Records come and go in order, so their bytes now go one after another into a ring, never cut by its end, and go back as the ring's head moves past the ones stored; a ring too small grows to twice its size, the records moved into it in order. A record of one piece keeps its piece's state in itself. The transport alone drains 200,000 records of 61 bytes at 5.3 to 6.5 million a second; per record the link takes 0.137 us, the listener 0.14: 0.28 in all, against quinn's 0.26 a message over one stream.
* Keep a stream's records in order on a line, handed over as they areeunhhu2026-09-281-13/+93
| | | | | | | | | | | | | | A receiver copied each record into an allocation of its own, copied them all again into a delivery, and freed each once stored: about 110 ns a record of 61 bytes. Once the last number heard is known, whole records that come in order now go onto the stream's line as a delivery carries them (u32 size, bytes). The line is handed over as it is, and let go by moving its head; its front is given back when there is no room at its end. Records that come early or in pieces, or before the last heard is known, still wait in held, and move onto the line once it reaches them. The transport alone: the listener took 0.20 us a record of 61 bytes, now 0.15; with the link, 0.31 us a record in all, against quinn's 0.26.
* Take a burst whole with UDP GRO; key the listener's AES only when the run ↵eunhhu2026-09-281-11/+63
| | | | | | | | | | | | | | | changes A listener's socket now asks for GRO: the kernel hands over the datagrams of one sender that came one after another as one buffer, each the segment's size but the last, and on the loopback a burst sent with GSO comes whole instead of cut into datagrams. The listener takes up to eight such buffers at once. It gives its AES-GCM context a key only when the packet's run has another than the one it holds, for opening and for sealing answers. The transport alone drained 200,000 records of 61 bytes at 4.2 million a second (0.454 us a record, both sides); now at 4.9 million (0.394 us).