aboutsummaryrefslogtreecommitdiffstats
path: root/research
Commit message (Collapse)AuthorAgeFilesLines
...
* Say where a node's memory goes, and the budget it now keeps (104, 013)eunhhu2026-09-305-0/+261
| | | | | | | | | | | | | | | | | | 104 measures a node's memory under a receiver that is down and under a snapshot held for long (021's run.py: resident memory against what was posted, 30 s a case). A held snapshot grew the node without end (6.4 to 678 MiB, fixed in d1e8f38); a slow receiver grows it by what was sent, 221 B a send, which is state until delivered, so the budget bounds state and refuses past it. 013 is that budget's design: what it counts (state bytes, deterministic), what an event over it does, why an answer takes its send's room, how links and HTTP wait, what it does not count and those bounds, and the checks. With --memory 4000000 the same load fills it in about 4 s and the node stays at 9.6-9.8 MiB (45.9 MiB and growing without). 012 records the hole the budget found in the link (the full flag), and 103 that its clock fix fell short (385754c). CHECKPOINT gains the row.
* Say what adopting the link took, and how it fares against ngtcp2 (102)eunhhu2026-09-291-0/+124
| | | | | | | | | | | | | | | | | | 102: the spec (012) and a receiver written from it alone; the four holes writing it found (REFUSED, the record's size, a peer's run places, the memo's places beside) and the credit M6 showed fixed at 1,024 records; M6 with ngtcp2 as a third, two rounds. Under reordering Pure carries the most (45 Mbit/s; ngtcp2 29-47, quinn 1-2), under random loss the three are the same, on a clean bottleneck Pure carries 3-5 % less (its framing, some 112 B a packet to QUIC's 41), and where loss and reordering meet ngtcp2 carries 1.3-3.6 times as much: undoing a loss it took for reordering, it restores the window a real loss had cut too (its log: 22 of 34 undos past the window before the last cut; none without reordering). Shares beside TCP stay within 25 %. 011 and the status table say so; 012 also says that a receiver answers within 25 ms (a sender's PTO counts on it), that a shape and a record's bytes are the app's, and why a packet of the past does no harm.
* Let ngtcp2's handshake through a path that reorderseunhhu2026-09-291-1/+8
| | | | | | | | OpenSSL 3.5's ClientHello offers ML-KEM with X25519 and takes two Initials; when the path brought the second first, ngtcp2's server was asked for a Retry and the harness gave up. The client now offers X25519 alone, as Pure's links agree, and the server lets go of a connection it may not keep, so the client's next Initial begins another.
* Put ngtcp2 beside quinn in M6, as it is in M5eunhhu2026-09-292-66/+210
| | | | | | | | | drain.c takes a rate and seconds as quinn's client does (paced messages stamped with the time they were written, and no more written past the seconds), and its service writes quinn's line for each stream: the messages read in each 100 ms and how long they took. 020's run.py runs it as a third, on port 40005 behind the same bottleneck, and --who picks whom to run.
* 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.
* Say LOST when a run's epoch is not known, and let its sender begin again at onceeunhhu2026-09-291-0/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* Say that a receiver moved to another machine is followed, and how (100)eunhhu2026-09-291-0/+4
| | | | | | | | | | | | | 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.
* Keep a sender's congestion state as one, so an episode undoes it wholeeunhhu2026-09-291-2/+2
| | | | | | | | 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.
* Say what epochs cost a link, and what the search for it found (099)eunhhu2026-09-294-0/+87
| | | | | | | | | | | | | | 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-291-3/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* Measure a link on paths of loss, reordering and a bottleneck, against quinn ↵eunhhu2026-09-291-0/+126
| | | | | | | | | | (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.
* 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-292-4/+4
| | | | | | | | | | | | | | | 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).
* 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-293-31/+374
| | | | | | | | | 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-291-0/+165
| | | | | | | | | | | | | | | 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.
* Make the load generator's devices real link senders, many on one threadeunhhu2026-09-292-380/+153
| | | | | | | | | | 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-291-3/+3
| | | | | | | | | | | | 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-291-6/+15
| | | | | | 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-292-7/+8
| | | | | 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-291-0/+18
| | | | | | | 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).
* Measure the CPU a record costs a link, against quinn (096)eunhhu2026-09-294-21/+267
| | | | | | | | | | | | | | | | | | | | | 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-291-6/+20
| | | | | | | | | | | | | | | | | 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.
* Wake a link's thread only when it sleeps, onceeunhhu2026-09-281-0/+165
| | | | | | | | | | | | | | | 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.
* Measure a restart under load and a first contact (095)eunhhu2026-09-286-25/+201
| | | | | | | | | | | | | | | | | | 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.
* Measure a link at rest: 132 bytes against a QUIC connection's 38,106 (094)eunhhu2026-09-287-0/+1974
| | | | | | | | | | | | | | | | | 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.
* Measure TPC-C again with compact log recordseunhhu2026-09-282-12/+12
|
* Check TPC-C's consistency from one snapshot a side, and measure TPC-C on RAM ↵eunhhu2026-09-282-0/+14
| | | | and an SD card
* Record queries on the bank beside SQLite (091)eunhhu2026-09-281-0/+4
| | | | | | | | | | | | 091 measures four queries on pgbench's tables of 100,000 accounts, the same rows and answers on Pure (registered queries, HTTP and JSON included) and SQLite 3.46.1, through each step: items field by field in leaves, folds and filters a leaf at a time, and one pass answering a fold in a column for every row. Per-branch totals went from 398 ms to 4.9 ms (SQLite 45, 14.3 with a DBA's indexes), a three-table join from 3.3 to 0.48 ms (1.47), and each rich account's rank from 9.2 s to 6.8 ms (2.84 s, 7.6 ms with an index); counting accounts in credit takes 1.05 ms where an index lets SQLite count 0.16 ms. Pure declares no index.
* Run pgbench's transaction on a bank of 100,000 accounts, beside SQLite (090)eunhhu2026-09-271-0/+15
| | | | | | | | | | | | | | | | | benchmarks/bank/bank.aup keeps accounts, tellers and branches as keyed lists and a history list; a transfer changes a row of each and appends a history row in one event, as pgbench's TPC-B-like transaction does in SQL. run.py makes a scale's rows and drives transfers through the load generator's new transfer kind; sqlite-bank.c runs the same tables and SQL on SQLite 3.46.1, a connection a thread, WAL. With lists as trees, transfers run at 30.5k a second on 1,000 accounts and 24.5k on 100,000 (RAM store, one connection thread, HTTP and JSON included), where they ran at 3.2k on 30,000 before. Durable on the SD card, Pure makes 1,339 a second and SQLite with synchronous=FULL 84 to 153. SQLite in process with no HTTP makes about 40k in RAM: recorded as it is, with the write amplification of 100,000-row tables on the card and ad hoc queries as what comes next.
* Record the messenger between two nodes and its latency (089)eunhhu2026-09-272-0/+128
| | | | | | | | | | | | | | The roadmap's E2EE messenger made of the runtime's general parts, with no messenger code in it: sealed, authenticated sessions between nodes (086, 088), Vega on each node's display as a surface (design 010), the app in Vega's examples. Vega's check runs two nodes through a recording relay: a line typed on one display is drawn on the other, frames match the vega command's, and the relay sees no text. A line takes 0.79 ms at p50 and 1.1 ms at p99 from one node's program to the other node's views with stores in RAM, both commits, the HTTP delivery and the seal included (sealing adds nothing measurable); on the Pi's SD card 3.5 ms at p50, the long tail being the card's syncs.
* Seal in one session a wire, authenticated by the sender's key (088)eunhhu2026-09-272-6/+17
| | | | | | | | | | | | | | | | | | | | | | A sealed request cost three X25519 operations (about 0.5 ms), so sealed sends drained at half the plain rate. A wire now keeps one HPKE context, encapsulated at its first seal, and seals each request as the context's next number (a failed seal or a restart starts a new session); the header carries the session's encapsulated key and the number, so a receiver opens a session once per connection thread (16 remembered, the least used replaced) and each seal by its number, also after it restarts, unasked. A sender with a key of its own seals in HPKE auth mode and names that key in the header; a receiver started with --sender NAME=KEY takes deliveries only from the senders it names, each sealed with its key (403 sender otherwise). The body is now the ciphertext and its tag. Sealed sends, sender authenticated, drain at 461-464k a second against 462-470k plain and 207-234k sealed a request at a time (Raspberry Pi 5). The check adds: a wire's requests share one session numbered from 0; a changed number or sender key does not open; a receiver restarted naming its sender opens the same session again; another node claiming that sender's name with its own key is refused and applies nothing.
* Record the small reads and idle memory work, next to axum and drogon (087)eunhhu2026-09-273-0/+147
| | | | | | | | | | | | What the app model, snapshot reads and delivery had cost small reads (126.7k -> 116.1k GETs a second) and idle memory (2.0 -> 4.5 MiB), how the sampler found it, and the four steps and the crypto split that took it back: 139.9k and 2.3 MiB. Measured again today beside axum 0.8.9 and drogon 1.9.0 on the same Pi: a one-thread pure-serve answers small GETs at 139k/s (2.2x axum and drogon a CPU second), 1 MiB GETs at 2,362/s (axum 1,304, drogon 754), durable app writes at 21.3k/s (both about 200), with the least idle memory of the three.
* Map OpenSSL only in a server that uses iteunhhu2026-09-271-2/+3
| | | | | | | | | | | | | | | | | | Linking OpenSSL cost every pure-serve 2.3 MiB of resident memory, 0.66 MiB of it the process's own (relocated data), though most servers never reach an https receiver or seal a send. The code that uses OpenSSL (TLS to https receivers, sealing) is now built as pure-crypto.so beside pure-serve and exports one table; pure-serve maps it the first time a wire to an https URL opens or a key is read or a seal wired (serve/crypto.c), and a missing library is reported at startup. What needs no OpenSSL, a key's hex and the suite this CPU seals with, stays in pure-serve. An idle server: RSS 4,496 -> 2,320 kB, PSS 1,639 -> 881 kB, private dirty 1,296 -> 864 kB. A static OpenSSL was measured too and is worse (RSS 3.8 MiB, but 2.3 MiB private: all of its tables relocated). The checks, https webhooks and sealed sends included, pass unchanged.
* Seal delivered sends to the receiver's key (086)eunhhu2026-09-272-0/+140
| | | | | | | | | | | | | | | | | | | | --seal OUTPUT=KEY seals each request's body whole to the receiving node's public key with HPKE (RFC 9180, base mode: X25519, HKDF-SHA256, AES-128-GCM where the CPU has AES, else ChaCha20-Poly1305), bound to the request's source, first number and count; X-Pure-Seal names the suite. A receiver's --key is a raw 32-byte file, made 0600 when missing, held in OpenSSL's secure heap; it opens the body where it lies (400 seal when it does not open) and zeroes the plain text when the request's ticket goes. --sealed-only refuses unsealed deliveries (403 unsealed). The check puts a recording relay between two apps: every order applies once, no body shows an item or a field name, a flipped bit is refused, a replayed request applies nothing twice, and a seal to another key does not open. Its hosts choose their own ports (--port 0). Sealed sends drain at 233-235k a second against 469-470k plain (Raspberry Pi 5): three X25519 operations a request, 164 us each. The plain request path measured the same as before.
* Give a compacted file the zeros of its whole cycle (085)eunhhu2026-09-275-3/+149
| | | | | | | | | | | | | | | | Commits grew the tree store's tail 256 KiB at a time, each growth a sync that also committed the ext4 journal for the new size and blocks. A file installed by compaction now carries written zeros up to where the next compaction will come (4 x live + slack, plus a commit's margin), capped at the size of the file it replaces, so a store never takes more space than before and shrinks when its live data does. The cycle's commits then only overwrite blocks the file has. Store commits: 317.5 -> 385.5 a second, ext4 journal commits 109 -> 45 for 4,000 commits; durable app posts +5.3%, their p99 the same within this SD card's noise. The new cycles test keeps sizes still between compactions once the live data is whole, and shows the file shrinking with it.
* Measure what moving saves off the owner would buy (084)eunhhu2026-09-272-0/+116
| | | | | | | | | | | | Nothing a thread can give: with writes saturated the device's one line of syncs is the bottleneck, so zeroing the tail or compacting on another thread only queues that device work behind the commits, and the owner's idle gap is shorter than one journal commit. Built without compaction the store is no faster; without compaction and without tail growth durable posts gain 1.4% and the p99 drops 24% (17.4 to 13.3 ms). New blocks are the tail latency, so the way to it is a store that rewrites its space in place, a ring cleaned forward instead of compacted into new files.
* Measure whether sharding owners by key would add writes (084)eunhhu2026-09-273-0/+380
| | | | | | | | | | | It would not, on this device. One file's syncs or four at once, the SD card completes the same 510-690 commits per second, the latency growing with the number of files. The scoreboard app with two owners, each with a store of its own, took 23% fewer durable posts than one owner (median 16,230 against 20,974 a second) with 2-3x the p99: syncs queue, and each carries half the events. One owner's group commit stays; cores serve reads, and writes grow by making the sync itself cheaper.
* Lay Pure out as a core with the parts selected around iteunhhu2026-09-273-12/+26
| | | | | | | | | | | | | | | | | | | | | The app model, the tree store and the HTTP host lived in prototypes/connections among the earlier document runtime, the store was known only by its format's name, and Vega compiled six Pure sources by path. Now, as the README's principle has it: core/ runtime, flow and the app model (module*.c), with what the core asks of storage (state.h, store.h) store/ the tree store (PURETRE3, PUREREC1) serve/ pure-serve: HTTP host, outbound delivery, JSON in place cli/ pure-run, pure-edit-module A top-level make builds libpure.a (app model and store, for hosts to link) and the hosts; make check runs the store's and the app model's tests. The earlier runtime still builds from prototypes/connections against core/ and store/ (check, check-tree and check-core pass). Design 009 records the rule: parts by role behind libraries; git repositories split by coupling, so the store stays here while its contract moves with the core, and Vega stays apart.
* Write each commit's head just before its bytes: one range per sync (081)eunhhu2026-09-263-0/+324
| | | | | | | | | | | | | | | | | | | | | | | A tree store commit wrote its bytes over the written zeros of the tail and its head into a slot at the start of the file: two ranges, and on the Pi's SD card a sync of two ranges costs about twice one (2.4 ms against 1.2 ms). A commit now writes a 128-byte record (its head) at the published end and its bytes right after it, then syncs once. Every 16 commits the head also goes into the older slot. The newest commit is found from the newer slot's head, or from the newest head this process has seen in the file while its record is still there, along the records whose bytes are whole; a torn record or bytes end the walk at the commit before, which the next writer overwrites. Slots of this format are PURETRE3; files of the earlier one are read and continued. Store commits went from 220-233/s to 371-396/s (p50 2.5 to 1.3 ms), and the scoreboard's durable posts from 13-16k/s to 21-26k/s with 64 connections and from 2.2-2.4k/s to 2.9-3.9k/s with 8, measured alternately with the previous build. The torn head check now tears a slot (the walk goes on from the other) and the newest record (a new process sees the commit before); a new check walks 42 commits from the slots, in a fresh process too, and stops at a damaged record.
* Compare the HTTP host with drogon and by CPU timeeunhhu2026-09-262-0/+127
| | | | | | | | | | | | | | | | | A drogon 1.9 service with the axum baseline's contract joins the benchmark (memory, fdatasync and crash-safe modes, three IO threads), built from Debian's packages unpacked under .deps without installing them. drogon listens once per event loop, so the service picks a free port itself: with port 0 every loop got a port of its own and the load reached one. The load generator has one core and saturates near 150k answers a second, so the harness now also reports answers per second of server CPU time (all threads). Over three rotated rounds: small GETs 151.6k/s on three shards (drogon 145.3k, axum 120.9k) and 128k per CPU-second on one thread (drogon 65k, axum 62k); 1 MiB GETs 2,794/s (axum 1,372, drogon 708); crash-safe PUTs 5.1-5.8 times axum and 13-30 times drogon; peak RSS 5.0 MiB against 14.4 and 76.5 MiB. --workloads is now a regular expression.
* Make a tree store commit durable with one synceunhhu2026-09-264-0/+328
| | | | | | | | | | | | | | | | | | | | | | A commit used to sync its nodes, then write the head and sync again: the order of writes was forced by syncs. Now the head carries where the commit's bytes start and their digest, and one fdatasync makes the bytes and the head durable together. A crash during it can leave a head whose bytes did not all reach the device; its digest then fails and the older slot stands, which is safe because a writer only builds on a durable head. Commits overwrite written zeros kept after the published end, so the sync changes neither the size nor the block map and needs no journal commit. On the SD card this is 2.4 ms per commit against 9.1 ms for two syncs; the store commits 289-304 times a second instead of 94-109, and pure-serve answers 2,012-2,611 durable PUTs a second against 583 for crash-safe axum. A process remembers the heads it published or checked, so it reads a commit's bytes at most once and never its own. Its readers pass over the head it is still making durable and so never answer with a commit a crash could take back. Head slots get a block each and the format becomes PURETRE2; a handle's refresh reads the slots through its mapping.
* Let the runtime own working versions as draftseunhhu2026-09-253-0/+234
| | | | | | | | | | | | | | | | | | state.h gives any app a runtime-owned working version of a stored value on the tree store. A draft shares unchanged extents with the stored version, edits in memory by path copying, lends ordered extents without copying, and commits only its new nodes when the key is still at its base revision. A failed commit changes neither the draft nor the store; a draft whose file was replaced by compaction copies its stored extents on commit. Unreachable arena nodes are reclaimed once they outweigh max(reachable, 32 KiB). Typing into the middle of a 1 MiB document and saving each character peaked at about 1,660 KiB RSS with a draft against about 4,480 KiB for an app copy compared with storage on save; saves were only 1-2 ms faster. This corrects 073, which had attributed the TUI's remaining 8 ms to that comparison. Drafts are not yet charged to the runtime budget or visible to .pure definitions.