aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* 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).
* Send small records in a RECORDS frame: 1 or 2 bytes of framing each, not 19eunhhu2026-09-282-9/+74
| | | | | | | | | | | | | | A RECORD frame carries a number, a size, an offset and a length before each record's bytes, 19 bytes: a packet held 13.7 records of 61 bytes. A RECORDS frame (0x06) carries the first record's number and a count, then whole records whose numbers follow on, each its size as a varint and its bytes: 17.2 a packet. A piece of a record larger than a piece still goes in a RECORD frame; a receiver takes both. The transport alone (research/experiments/019-idle-links/transport.c) drains 200,000 records of 61 bytes in 11,600 packets instead of 14,600; per record the link takes 0.217 us and the listener 0.23, 0.447 us in all, against quinn's 0.43 a message over one stream.
* Wake a link's thread only when it sleeps, onceeunhhu2026-09-282-3/+178
| | | | | | | | | | | | | | | Every queued record wrote the link's eventfd, a system call each, and woke the thread to send a packet of a few records. A record queued now wakes the thread only when it sleeps and nobody woke it yet; one queued while it sends marks the link, and the thread looks again before it sleeps. With records queued one at a time as fast as a thread can, 200,000 small ones went at 400k a second, and now at 730k; queuing one costs 0.43 us, not 1.0. research/experiments/019-idle-links/transport.c measures the transport alone: a link and a listener in one process, no app, no store. A backlog of 200,000 records of 61 bytes drains at 3 to 4 million a second, 0.24 us a record on each side.
* Drain a link's backlog without scanning it: 370k to 990k records a secondeunhhu2026-09-281-105/+218
| | | | | | | | | | | | | | | | | | | | | | A backlog made the link slow in its square: the sender looked at every queued record's pieces twice a turn to know whether any waited to be stored, and the receiver moved all its held records down each time it let the first one go. - The sender counts the pieces received and not stored, and keeps where the unsent ones begin; every change of a piece's state goes through one place that keeps both. - The receiver keeps its held records from a head index. One in order goes last, and one out of order finds its place by halving. The record's bytes and its pieces' marks are one allocation. - Packets go in bursts with UDP GSO, 54 at a time: 0.45 us a packet on this Pi, against 2.4 us for a send each. A full packet is padded to a whole datagram (PADDING frames, as QUIC does). Datagrams are taken 64 at a time (recvmmsg). Twenty thousand saved sends of 61 bytes drained at 370k a second; now at 990k. Per record, the sender's link thread took 1.1 us and now takes 0.5; the receiver's took 2.4 and now takes 0.5.
* Measure a restart under load and a first contact (095)eunhhu2026-09-289-30/+341
| | | | | | | | | | | | | | | | | | M2 and M4 of design 011. Ten thousand devices send an order every five seconds (2,000 a second); the receiver is killed (-9) at about 21 s and started again at once. With its secrets remembered the node shows no 100 ms step without an order stored, catches up in 0.2 s, spends no more CPU than before and makes no X25519; 34 orders were sent again, and every order made was applied once (three rounds; on the SD card the same, with 0.2 s silent). Without the memo it makes 10,000 X25519, 1.6 s of CPU. quinn under the same load stops for 1.0 s and catches up after 10.9 s: 10,115 new connections, about 7 s more of the service's CPU and 8 s of the clients'. A first contact takes Pure one round trip and QUIC two, at 10, 50 and 100 ms of RTT. run.py --restart and --first-contact make them; the quinn harness gains a fixed port with a reset key from its key, clients that connect again, and a drain for M5.
* Name the link threads, so their CPU is seen apart (pure-link, pure-listen)eunhhu2026-09-281-0/+2
|
* Remember each sender's secret beside the node's key: no X25519 at restarteunhhu2026-09-284-10/+89
| | | | | | | | | | | | | | A listener lets a quiet sender's secret go with its active state, so a node started again made one X25519 for every sender that came back (169 us on this Pi, most of a wake's CPU): asymmetric work in proportion to the links, which design 011 counts as a reason to design again. The secret is now remembered in a file beside the key (KEY.links, 0600, begun again for another node's key): a place of 64 bytes a sender in the order allowed, mapped, so a sender at rest takes no memory for it. A place is the first 28 bytes of the sender's key, a check (FNV-1a of the key and the secret) and the secret, written secret first and key last; a place whose check fails is made again. The UDP link checks see a receiver started again make no X25519.
* Measure a link at rest: 132 bytes against a QUIC connection's 38,106 (094)eunhhu2026-09-2810-1/+2123
| | | | | | | | | | | | | | | | | M1 of design 011. A Pure node allows 1,000 to 1,000,000 senders; each sends one order and, once told it is stored, rests. At rest a link costs the node 132 bytes (the sender allowed, 73; its number heard, about 60) and no transport state; with a million the node runs in 139 MB. A quinn 0.11 service holding the same exchange's clients connected at rest costs 38,106 bytes a connection: 289 times as much, past the design's 1/100. A link at rest takes its next record in 0.56 ms to stored, one X25519 on the node included; a new QUIC connection takes 1.06 ms and 2.8 times the receiver's CPU, without storing. research/experiments/019-idle-links: links.c, many senders written from serve/link.h's packet format alone (a second implementation), and the quinn service and clients (quic/, built offline); run.py makes both measurements.
* Keep a run's keys as bytes: one AES-GCM context serves the listenereunhhu2026-09-285-66/+100
| | | | | | | | | | | | Each run a listener knew held two OpenSSL contexts. With 16 KiB pages, what those allocations left behind when idle senders went kept 42 MB of free heap resident after 10,000 senders had come and gone (in use: 0.6 MB). A run now keeps its keys (16 bytes each way and the nonce bases), and the listener seals and opens with one context, giving it each packet's key: about 130 ns a packet on this Pi. Two runs a sender, not four; a sender's address as an IPv6 one (an IPv4 one fits). What idle senders held goes back to the system once 1,024 have gone, or 10 s after any went (malloc_trim). HKDF is fetched once, not at every derivation.
* Let a link at rest cost the listener only its allowingeunhhu2026-09-284-172/+433
| | | | | | | | | | | | | | | | A sender allowed is now a peer of 48 bytes (its key, its key's id, its sources), found through an index by key id; ids are 32 bits, so keys that share one are each tried. What a sending peer needs besides (the secret its key shares with the node's, its runs' keys and addresses, its streams) is made at its first packet and let go once it has been quiet for the idle period (--link-idle-ms, 30 s at first) with nothing of it waiting: the next packet makes it again, one X25519, no handshake. The listener hands over and answers only the streams and runs that changed, not every one at each turn. --senders FILE allows senders a line each. The UDP link checks take their sender from such a file among a thousand others, let it go after 400 ms of quiet, and see an order after a longer pause arrive all the same.
* Keep the numbers heard as a list: a save logs only the ones it changedeunhhu2026-09-285-123/+290
| | | | | | | | | | | | The last number heard from each source was written whole into the links value at every save, and counted in two bytes: a node that heard from more than 65,535 sources stored a wrong count, and each save cost as many bytes as there were sources. The numbers are now a runtime-owned list {source, seq} in order of source, stored as a list state is (its image "0heard" and its changes in the log), changed with the event that delivers, so a failed save takes them back with it. Links stored before (PLK1) move their numbers to the list at a start and are stored again without them (PLK2).
* Link two nodes without a connection over UDP (093, design 011's first stage)eunhhu2026-09-2813-26/+1890
| | | | | | | | | | | | | | | | | | | | | pure-serve --send OUTPUT=pure://host:port/INPUT sends an output's saved sends to another node's --link-port with no handshake: the first packet carries records. Keys come from the two nodes' keys (X25519, HKDF-SHA256, AES-128-GCM), with a random run in each packet's header so no nonce repeats across restarts; nothing is stored for them. What lasts is the apps' own: the sender's outbox and the receiver's heard numbers (pure_app_heard, new). The sender detects losses as RFC 9002 says, with NewReno, and trims a send once the receiver says it stored it; the receiver hands the owner whole records in order, which it applies from the one after the source's last heard number, stopping at a gap, and answers how far it stored with every acknowledgment. Stored bytes are the bytes on the wire: an output and an input of one shape need no JSON. server_links_udp kills the receiver, the sender, then both (-9) while four clients buy: every saved order is applied once, with no handshake (five runs, 10k-11k orders each). server_links_udp_lossy does it again in a namespace of its own with the link's packets delayed, lost (3 %) and reordered (10 %) by netem (three runs, 11k-12k orders).
* Redesign 011: no connections, the transport's state as the app's dataeunhhu2026-09-282-151/+172
| | | | | | | | | | | | | | | | | | | | | | | | | The first draft replaced a few of QUIC's layers with what Pure already has, every technique known and the gains constant factors, and it kept the connection. This one removes it. Nodes have keys, links are the app's wiring, paths are a cache of where the last authenticated packet came from, and a link's transport state (key epoch, stored-through number, heard numbers, packet-number reservation) is a row committed atomically with the app's state; memory holds only active links and per-path congestion state. So a restart of either node is invisible to the other (no handshake, nothing lost or doubled), an idle link costs a row on disk rather than process memory, and moving the store moves its links. Forward secrecy comes from key epochs rather than per-connection handshakes; replay protection survives restarts because heard numbers are stored with the events they applied (OSCORE, the closest prior art, needs an Echo round trip there). Hypotheses H1-H4 and a measurement plan against quinn and ngtcp2: memory per idle link at 10k to 1M links, kill -9 under load with 10k active links, moving the store, first contact, and CPU, loss and fairness as guardrails; stop if an idle link is not at least a hundred times cheaper than a QUIC connection, if restarts need handshakes, or if anything is lost or doubled. Beyond it: speculative execution of verified, deterministic modules, for interactions that do not wait.