aboutsummaryrefslogtreecommitdiffstats
path: root/serve
Commit message (Collapse)AuthorAgeFilesLines
* Let a view compute in its body, on a reader's own lists (018 stage 3)eunhhu2026-10-041-1/+6
|
* Take an app's requests only with a token its tokens file grants (017, 3b)eunhhu2026-10-043-17/+288
| | | | | | | | | | | | | | | An app given a tokens file (tokens=FILE in --apps, --tokens FILE for the one app of a host) answers a request only when it bears a token (Authorization: Bearer) whose SHA-256 the file lists with a grant: read for its views, queries and documents, write for all, input=NAME for those inputs alone; 401 unauthorized (WWW-Authenticate: Bearer) without one, 403 forbidden past its grant. A delivery sealed by a key --sender names needs none. The digests are compared in constant time; a connection keeps its last token's digest, so the same token is hashed once. SHA-256 is the host's own (serve/sha256.c), no OpenSSL loaded. A reading puts a changed tokens file in place with the app running on; a file that is not one stops the app. pure-serve --make-token GRANT makes a token and its line. load takes PURE_LOAD_TOKEN.
* Say that reading the code found stage 3's two races, and how a host changes appseunhhu2026-10-041-1/+2
| | | | | | | | | The use after free in local_done and the owner's lock made after another app's outlet used it were found reading the code, not by the check, which passed with both before it was made to change senders (115, 017). The usage now says changed lines change in place unless their store moved; and 115 names the path no check takes: a module refused while changing in place.
* Change a running app in place, its states kept in memory (017, stage 3)eunhhu2026-10-041-33/+99
| | | | | | | | | | | | | | | | | | Stopping an app and starting it again reads its whole store again: a million items took 142 ms, as long as restarting the host (030). A running app whose line changed but not its store now goes quiet (what it took applied, saved and answered, its batches back, its outlets closed, its checkpoint finished), has its module replaced in memory (pure_app_replace carries its states, and the sends in its outbox, by name), its budgets set and its outlets made anew, and resumes; a module its states do not go into stops it. An app whose store changed, one a save of which is uncertain, and one that does not run still start from their stores. A reading says changed for the first. A snapshot is published again when the module changed though the states did not. server_apps_lifecycle reads the changing apps' views (lists among them) as they change, and moves an app's store; it passed 10 times alone, 5 under load and 6 with an ASan pure-serve.
* Start, stop and change apps on a running host on SIGHUP (017, stage 3)eunhhu2026-10-041-142/+509
| | | | | | | | | | | | | | | | | | | | | | | | A host of apps reads its --apps file again on SIGHUP, on a thread of its own (pure-apps, the one that takes the signal): a file with a wrong line changes nothing; the apps of lines gone stop and their routes go (404 no_app); those of changed lines, and those not running (they could not start, or a save of theirs is uncertain), start again from their stores, states carried by name; new ones take places of their own; a line out says what was done. An app stops by taking nothing more (503 stopped), applying, saving and answering what it took, waiting for its batches at other apps, then closing its outlets (what they did not deliver stays in the outbox) and its store. Its link streams find their inputs again as it starts. Owners' locks are made with their places; counts are kept over an app's runs; workers grow with the apps that run, a CPU each at most. server_apps_lifecycle adds, removes and changes apps, and changes the receivers (a local outlet's and a link's, their inputs at other numbers, taken by name) and the senders while 600 sends each and writes go: every send arrives once and in order, a write answered 200 is applied once and one answered 503 stopped not at all, the app left alone fails no request; an app whose store cannot be made starts at the next reading once it can; kill -9 and the host starts as the file says. It passed 10 times alone, 5 under load, 6 with an ASan pure-serve.
* Carry the module a request was resolved with, and give apps places (017, ↵eunhhu2026-10-041-121/+269
| | | | | | | | | | | | | | | | | stage 3) Groundwork for apps that start, stop and change on a running host, with no change in what a host does: - an app's module is on the heap, and the module its requests are resolved with is published (live, NULL while it does not run); a module it ran is kept while the host runs; - a request, a view, a link's binding and a local outlet carry the module they were resolved with, and an owner takes none resolved with another (503 stopped); a local outlet finds its input again when its receiver runs another module, and holds its sends while it runs none; - the published snapshot is the owner's, outliving an executor; - apps take places, made when the host starts (--app-slots, 256), never given to another name, so threads find apps by name as others start.
* Build with clang, and the core and store on macOS, againeunhhu2026-10-011-1/+1
| | | | | | | | | | | | | | Apple clang 21 (an M3) and clang 19 refused what gcc let pass (research 029): - PURE_FIELD_REST was an enum of UINT32_MAX, past an int (C17): a macro. - a store's publish copied an entry rebuild left unset when the record was not found (the copy was never used): copied when it was made. - the log's image writer kept a count only Linux's sync_file_range uses. - ",{" + (row == from) read as adding an int to a string: said plainly. - the tests: mkdtemp, hidden by _POSIX_C_SOURCE on macOS, needs _DARWIN_C_SOURCE; a string split so that \x80 and a stay apart is bracketed; a count left over and a malloc measure only glibc has. The core's and the store's tests pass on the Mac.
* Deliver wires on the sending loop, a step at a time (017, 2b-2)eunhhu2026-10-013-278/+457
| | | | | | | | | | | | | A wire is now a sender of link/sending.h: its request goes on a non-blocking socket as far as the socket takes it (connect, TLS handshake, write, answer read), and its round waits for the socket or its deadline (10 s with nothing moved). The answer is read as it comes: the head whole, then a sized body counted down, a chunked one (few and small) parsed again as it grows, or a body to the close. The crypto table (version 6) takes the TLS handshake a step at a time, and reads and writes say what they wait for. The owner pushes a wire once a turn, as it does a link. A host whose app sends to ten links and ten wires has 5 threads where it had 24 (none: 3).
* Push a turn's sends to a link at once, and round only the links that want ↵eunhhu2026-10-011-0/+2
| | | | | | | | | | | | one (017, 2b-1) The sending loop was woken by every record queued while it slept: the owner queues one by one, the loop caught up and slept between them, and every link took a round on every wake. Four links sent 64k packets of 12 records where threads sent 40k of 20, and ran at 257k sends/s against 581k. The owner now queues a turn's sends and pushes each link once; the loop rounds a link only when its socket has answers, it was pushed, or its time came.
* Hand sends to another app of the host with no socket (017)eunhhu2026-10-011-21/+286
| | | | | | | | | | | | | | | | | | | | | | send=OUTPUT=APP/INPUT in --apps wires an output to another app of the same host through a local outlet: no socket, seal or packet. The sender's turn queues its saved sends there, under the window links and wires have, and hands them, one batch at a time, to the receiving app's queue laid out as a link delivers records (first number, count, source APP.OUTPUT, the records as stored, and the sender's fields when the input takes them by name, which pure_module_takes checks at start). The receiver applies them after its last number heard; its save over, it tells the outlet what it heard and the sender's next turn trims its outbox so far. What it did not store is queued again from the outbox, after 100 ms when it had no room. An output now goes one way only: two outlets would trim each other's sends. server_apps_links: an app sends each of 400 messages to an app of another host over a link and to three apps of its own host (one of the same shape, one taking the fields by name, one with a memory budget that fills), with a kill -9 of the sending host between two requests; all arrive once and in order, and the full app's sends wait in the sender's outbox until it lets go of what it holds.
* Wire each app's sends, and take every app's links on one port (017)eunhhu2026-10-011-27/+78
| | | | | | | | | | | | | | | An app of --apps takes send=OUTPUT=URL, seal=OUTPUT=KEY and reply=OUTPUT=INPUT, as --send, --seal and --reply give the one app of a host; its sends' source is APP.OUTPUT. An app wired wrong stops alone, its wires closed. The host's --key, --sender, --senders, --sealed-only and --link-port now go with --apps: the one listener resolves a stream's input name APP/INPUT to that app's input (a stopped app or an unknown name is no input, REFUSED 1), its deliveries queued at that app's owner. 012 is unchanged. server_apps_links: an app of one host sends 300 messages, sealed, over a link to an app of another host, which takes them all once and in order.
* Count work only under a budget, and say why a view failed only then (017)eunhhu2026-10-011-9/+8
| | | | | | | | | | | | | | The work budget added about 26 instructions to every row of a table a host shows (bank, a table of 10,000 rows: +3.0% instructions an ask), which 017's one-app criterion allows at 1%. A sample put most of it in show_given asking every cell why it failed, and the rest in evaluate and the row lookup writing that reason and the count for every cell. Now nothing is counted without a budget (PureApp.work stays), the reason is written only when a view, a cell or a row has no value, and pure-serve asks it only of a cell without one. The table: +0.94%; the other bank queries within 0.4%. The tests that read the count set a budget that is never reached.
* Move a connection to its app's home thread at its first request (017)eunhhu2026-10-011-32/+122
| | | | | | | | | | | | | | | | | | | | | | With several connection threads, a host of apps had each app's connections spread over all of them: every thread's round handed each owner a share, and every turn handed answers back to every thread. The apps took a third more CPU a request than as processes, with five times the voluntary switches, and when two apps were busy each waited a kernel tick at its 99th percentile (4.1 ms, against 1.5 ms as two processes). Each app now has a home thread (its place in --apps, modulo the threads). A connection whose first request names an app at home elsewhere moves there once, its head read and no ticket taken yet: the home thread adopts its descriptor and the bytes read, and reads the request again. Each app's requests then meet its owner from one thread, as from a process of its own. Two busy apps: 99th percentile 1.4 ms, the same 145k writes a second. server_apps restarts its apps on three connection threads, asks each app from fresh connections, and checks that some moved and all were answered right.
* Keep a busy app on its worker while no other app waits (017)eunhhu2026-10-011-21/+43
| | | | | | | | | | | | | | | | A worker put its app back at the queue's tail after every turn, and the queue woke another worker for it: an app with steady work hopped between workers (and cores) each turn, and every turn woke a thread. Two busy scoreboard apps on two workers wrote 84k a second where two processes wrote 117k. Now an app takes its turns one after another on the same worker while it has work and no other app waits; it goes to the tail only when one does, and then without waking another worker, since this one takes the head. Due timers are looked at between its turns, so a timer of another app still gets a turn. A worker wakes another only when it leaves apps waiting behind the one it took. The two apps now write about as much as the two processes (measurements in research/experiments/026).
* Run many apps on one host, by turns on workers (017)eunhhu2026-10-011-241/+633
| | | | | | | | | | | | | | | | | | | | | | | | | | | | pure-serve --apps FILE hosts an app a line: NAME MODULE STORE and its budgets (memory=, work=, queue=). Each app's routes start with its name and reach its own module, states, snapshot and queries; a view is shown on the connection thread from its own app's snapshot, read once a round. No app has a thread of its own. Its owner is what used to be a thread's loop, now a turn: its due timers, what its wires delivered, and the requests waiting for it applied and saved once, its answers handed back. Workers (--workers N; one for a host of one app, else one a CPU at most) take apps from a queue in turn; a worker sleeps until the soonest timer. A turn takes every waiting request when no other app waits, else 256; it gathers the clients its last save answered only when alone. An app's queue budget refuses what would wait past it (503 busy) on the connection thread, before its owner sees it. An app whose module does not load, whose store does not open, or whose store is another app's stops alone: the start line says why, and its routes answer 503 stopped. The stop line counts each app's turns, events and refusals. A host of one app (--module) runs as before, its owner on one worker named pure-owner: every check passes unchanged. server_apps runs six apps of which two stop, floods one past its queue while another answers, refuses a walk past one app's work budget that another runs, holds an app's store from another process (that app answers 503 store_busy and applies nothing, the others save), and restarts.
* Bound what an event or a view may run: a work budget (017)eunhhu2026-10-011-12/+27
| | | | | | | | | | | | | | | | | | | | | | | An event's run was bounded only by its lists: a walk or a fold is its block's length for each item, and a list holds up to 16 million. One event could hold the owner for seconds, one view a connection thread. pure_app_work(app, n) budgets one unit of work: an event, or one evaluation of a view, a cell or a table's filter. It is counted as written, as each part begins: a block's length, and a fold's or walk's block length for every item of its list (or slice), whatever the plan runs (lanes, a group or rank pass, a range). Nothing is counted inside the interpreter's loop. Past the budget an event fails with the new fault work and changes nothing (PURE_LIMIT, so 422 work over HTTP); a view, a cell or a table's rows have no value and pure_app_view_fault says work, which pure-serve answers 422 work, for a whole table when a cell of it ran past. pure-serve --work N sets it (0: none). test-module counts exact amounts on the shop's walk, the tasks' fold view and a filtered table, and lane_folds now checks that a planned fold counts what the same fold run item by item counts (3,600 folds, every view, cell, filter and handler). server_work grows a list over HTTP until the walk, the table and the fold each pass a budget of 400.
* Give i64 its own type in modules, lists, stores and the host (016 stage 2)eunhhu2026-09-304-21/+80
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An i64 is an int64_t in a value's 8 bytes, its own stack type: CONST and NUM_TEXT take argument 2 for one, eleven opcodes are appended (ADD, SUB and MUL fail as overflow past its range, DIV and MOD floor and fail as divide by zero, NEG, COMPARE with a relation, MIN, MAX, and I64_FROM and NUM_FROM, which convert only where the value fits and else fail as limit), and moves (LOAD, STORE, locals, items, FIELD, SEND) copy it as they copy a number. Type codes: state 6, record field 3, message field 7 (8 bytes), view 4, local 3. v6 stays: all of it is added. - Folds with PURE_FOLD_INTEGER sum (overflow fails), keep the most or least of, or count the different i64s; distinct's set keeps an i64's -1 apart from its free mark. Blocks with an i64 run item by item. - A list's kinds are its stored header's (number, text, bytes, bool, integer): integer columns are int64_t, ordered as integers as keys (found without guide keys for now), stored in 8 bytes and logged as zigzag varints of any size. Records name their fields' type in one array (record_field_type) in place of text and bool flags. - A number, a bool and an i64 carry over each other by one rule, pure_cell_as, wherever one becomes another: a module replaced, a stored value or item read by another module, a waiting send recast. A u32 field delivers to an i64 one. - The host reads i64 fields from JSON, rows and queries as integer digits in range and nothing else, and shows every digit in views, tables, deliveries and pure-run. Faults are named by one function, pure_fault_name, in place of three tables; overflow is new. test-module: 28 arithmetic cases at the range's ends, relations, conversions both ways, 14 refusals, a keyed i64 list app (order, a sum that overflows, a failed add undone, messages and sends of 8 bytes, every digit as text), recasts, replacement between numbers, bools and i64s, and three stored runs against memory with restarts (46 images written).
* Let a value's 8 bytes hold a number or an integereunhhu2026-09-301-2/+2
| | | | | | | | | Before i64 (016 stage 2): a state's value, a record field's default, a list cell's number, the interpreter's stack slot and an instruction's constant become unions of a double and an int64_t (PureValue, anonymous unions in PureCell, Slot and PureOp). LOAD and STORE, and a list cell made from the stack, copy the bits; everything else reads .num as before. No behaviour changes: every check passes as it did.
* Give bools their own type in the module format (v6, 016 stage 1)eunhhu2026-09-305-21/+62
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A condition was a number, non-zero being true: a flag was a number field, a view showed 1 for true, and nothing kept a number from standing for a bool or a bool from being added to. Now the verifier has a bool among its stack types, and a verified module never mixes the two. No opcode is new: - CONST with argument 1 is a bool (+0 or 1), NUM_TEXT with argument 1 writes one as true or false; LOAD and STORE take a number or a bool state, locals may hold a bool (2). - Comparisons (LT to NE, TEXT_COMPARE) and LIST_HAS give a bool; AND, OR and NOT take and give them; JZ, REQUIRE, filters and folds' tests take one (count and first leave a bool, sum, max, min and distinct a number and a bool); EQ and NE compare two bools too. - Type codes: state 5, record field 2, message field 6 (one byte in the prefix, 0 or 1, or the message fails as limit), view 3. A bool's constant, initial value and default are +0 or 1; a list is not keyed by one. PUREMOD6: v5 modules are built again. At run time a bool is a number's 0 or 1, so leaf-at-a-time folds run as before. A number that becomes a bool (a module replaced, a stored state or item read again, a waiting send recast) keeps 0 (either sign) and 1 and takes the default otherwise; a bool that becomes a number is 0 or 1. The host carries bools on every boundary: JSON true and false only (a row's field too), a query's true and false, true and false in views, table JSON and HTTP deliveries; a link takes a bool only into a bool. A stored outbox's field types were read back with a check that refused bool (at or past rows): a node whose output had a bool field and a send waiting could not start again. test-module now stores such a send and opens it (it fails on the old check), beside 28 bool refusals of the verifier and bool states, fields, views, sends, links and migration. The lane generator makes numbers and bools apart.
* Decode a query's %XX for an input's fields, as for a view's argumentseunhhu2026-09-301-23/+26
| | | | | | | A view's arguments were decoded and an input's fixed fields were not, so a client that sends an exponent's + as %2B, as Python's urlencode and browsers do, had ?n=1e%2B21 refused. show_given's comment is back above show_given.
* Name the owner's thread and the checkpoint's, so their CPU is seen aparteunhhu2026-09-301-0/+2
|
* Say a node's name when pure-serve startseunhhu2026-09-301-2/+7
| | | | | | | | | A node's sends are named NAME.OUTPUT, and a receiver's --sender must say that name. Without --name it is the module file's name without .pmod, which nothing said: two board nodes built from board2.aup sent as board2.changed and were refused by receivers expecting a.changed. The first line out now carries the name beside the port and the key, and the usage says where it comes from.
* Take a delivery's fields by name, dropping noneeunhhu2026-09-301-7/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A message from another node was taken only in its input's exact shape (the link's STREAM shape, fields and order alike; over HTTP, every field). So a board whose nodes are upgraded one after the other stopped both ways until both were, and an output and its input whose fields differ only in order were refused for good (docs/design/014). An input now takes a message when it has every field the sender sends, by name, of the same type or one that holds its values (a u32 in a number, text in bytes); a field only the input has is 0 or empty. One that sends a field the input lacks is refused: taking it would drop the value, and with its number heard the value would never come again (two boards apart for good). So add fields to receivers first and take them from senders first, and nothing stops or is lost. The link carries a sender's fields in a new frame, FIELDS (0x0d: names, shape, u8 count, each {type, name length, name}): after a REFUSED for another shape the sender puts it in its packets (after OFFER, before the records, so a receiver that knows no FIELDS reads what it read before) until the stream is taken, the first time at once. A receiver seeing another shape waits for the packet's FIELDS before refusing, and asks its host with them (PureLinkHost.input takes them); REFUSED says 4 when the input lacks a field. Deliveries carry the fields and the host lays each record out anew (pure_module_takes, pure_module_recast, pure_module_fields, over pure_send_recast's rule). A stream whose sender starts again in another shape lets go what it held; the sender sends it again laid out anew. A refusal already queued for a stream now says the last reason judged, not the first. Over HTTP a delivery (X-Pure-Source) may lack fields; an unknown member is still refused. test-link named: a stream of another shape naming its fields is taken, 200 stored in 47 ms with every delivery naming them; an input that does not take them refuses (4) and the sender backs off. check.py server_fields: five outputs, three linked and two over HTTP, fields in another order taken, an input with a field more taking messages without it, one lacking a field refusing until it starts again with it, then taking all. server_upgrade's new receiver declares its fields in another order. 012 names the frame and the rules.
* Recast waiting sends by field name when an output's fields changeeunhhu2026-09-301-1/+1
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review 004's F3 made a module refuse to start while sends waited on an output whose fields it had changed: their bytes would have meant something else. Safe, but it deadlocks a rolling upgrade. Growing a board shared by two nodes: one upgraded to a card with a new field refuses the other's old sends (another shape), and the other, its old sends waiting, cannot start the new module at all ("Cannot start the module on the store", saying nothing more). Waiting sends now move to the new fields by name, as a list's items do: a value goes over when the new type can hold it (u32 and number are numbers, bytes and text are bytes; a number that is not a u32, or bytes that are not UTF-8 for a text, cannot), the rest start at 0 or empty (pure_send_recast, with the payload layout load uses, pure_fields_lay_out). A start recasts them and a replace does into a new outbox. Their meaning must survive a crash between the recast and the save that names the new fields in the links. So the outbox's image now names the fields its sends were laid out with ("PLO1": the module's outputs after the save it holds), and is written again before any send is logged with other fields; a start reads those names over the links'. Checkpoints of the outbox name them too. A module without an output that sends wait on is still refused, now saying so in PureApp.why, which pure-serve prints ("sends wait on the output 'ordered', which this module does not have"); a failed start keeps it. test-module send_recast (widened, narrowed, UTF-8, a payload not laid out, there and back) and output_meaning (the swapped fields recast on a replace, read right after a restart that never saved, back again, sends logged after the image, the refusal and its reason). check.py server_upgrade: five sends wait on the old output, the sender starts with the new one, the old receiver refuses the new shape, the new one takes all eight.
* Hold a window of each output's sends in its transport, not the outboxeunhhu2026-09-303-46/+68
| | | | | | | | | | | | | | | | | | | | | | | A link's ring and a wire's queue took every saved send of their output and kept it until delivered: with the receiver down they held the outbox a second time, 178 B and 313 B more a send (104). Each outlet now holds a window, --window bytes (4 MiB) and 65,536 sends past what its receiver has, at least one; the outbox keeps the rest. The owner remembers each outlet's place in the outbox and looks on from there after a save and after its transports deliver. Places move back by the sends that left the outbox since (PureApp.sends_gone), so a trim before a place skips nothing, and sends queued before are not looked at again however many wait. pure_app_sent reads a send by its place (the newest-first visit it replaces is gone); pure_link_held and pure_wire_held say what a transport holds. With the receiver down a node now grows by the outbox alone once the window is full: 219 and 218 B a send over the link and HTTP (399 and 534 B before). check.py server_windows keeps the receivers of a sender's two outputs, one linked and one over HTTP, down through 300 buys with a 2,000-byte window, then starts them: each applies all 300 once. Without the refill after deliveries the link stops at 117.
* Wait on the monotonic clock everywhere a deadline is waited foreunhhu2026-09-302-12/+8
| | | | | | | | | | | | | | | | | | | | | | | Review 004's timer fix (103) counted the deadlines on the monotonic clock, but the owner still slept until a wall-clock time: with the clock set back an hour a node's timers would stop for that hour. So would a wire's pause between tries, and a lone commit after a group would wait an hour for the others to join it. The test's LD_PRELOAD moves only what the process reads, so there each wait ended at once instead, and the timers went on ticking while the owner spun a core. core/wait.h makes conditions that wait on the monotonic clock (pthread_condattr_setclock; macOS keeps the wall clock) and deadlines on it. The owner's waits, the wire's pauses and the group commit's gathering use them. clock-back.so can now move the clock forward too (CLOCK_SHIFT_S), and check.py server_clocks moves it an hour each way under a node with a timer and a wire to a port no one listens on, and under a document server whose three threads commit together, then one alone. Before: back, 159 % CPU and 50,886 tries in 1.2 s; forward, 1 tick, 1 try, and a lone commit that never ended. After: 59 ticks, 0 % CPU, 4 tries and a lone commit of 2.6 ms, both ways.
* Bound what an app holds (--memory): refuse what would grow past iteunhhu2026-09-303-35/+74
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | pure-serve --memory BYTES gives the app a budget of state bytes: its lists' items as they are stored, its texts, the outbox and the numbers heard (docs/design/013-memory-budget.md). The count is deterministic, the same on any device. An event after which the states hold more than the budget, and more than before it, fails with the fault memory and changes nothing; HTTP answers 503 memory. One that frees or keeps them goes on, so an app over its budget can always shrink. Sends waiting for their receivers are state: a full outbox refuses the next send until some are stored, which is the backpressure a slow receiver puts on clients. A delivery refused for memory is not heard: the link keeps it (c9c0edf) and HTTP senders try again after 503. An answer (--reply) now takes its send out of the outbox in its own event (pure_app_answer), so it has the room its send held. Apart, an app whose outbox filled the budget could never apply an answer that keeps anything, and the send leaves only after its answer: both would stay for good. An answer that still does not fit waits, with the wire's after it, and is tried again every 100 ms: pure_wire_answer leaves it in place until pure_wire_answered. Checks: test-module memory_budget (refused and undone, freeing over the budget, a full outbox, a delivery not heard, answers that fit or wait, across a restart); check.py server_memory (a sender refused 503 memory with its receiver down and taking buys once it is up; a receiver restarted below what it keeps, refusing 7 times in 2 s, then taking all) and server_answers_memory (answers wait while receipts fill the budget, then apply once each; the API called once per order). The stats report state_bytes and memory_refused.
* Let a receiver whose app is full keep what came, and say soeunhhu2026-09-301-1/+2
| | | | | | | | | | | | | | | | | | | | A link's delivery the app had no room for was handed over again at the next turn: a receiver over its memory budget would refuse the same records as fast as the owner could apply them. And its sender, seeing records received but not stored for a second, sent the whole window again every second. Now a host says a delivery found it full (PureLinkDone.full). The receiver keeps the records and hands them over again after a pause, 100 ms doubling to 500 ms, and meanwhile its STORED frames carry flag 2: the app is full and what came is kept. A sender hearing it starts its one-second wait for the save again, and sends none of it again. The spec (012) says both sides; the pause is a local choice that must stay under the sender's wait. pure_link_done takes the PureLinkDone itself. test-link's new case keeps the app full for 5 s: 12 hand-overs meanwhile, taken within 0.5 s of having room, and no record sent again (21,640 without the flag).
* Lead pure-serve's usage with pure:// between nodes, and count refusalseunhhu2026-09-291-8/+11
| | | | | | | | | The usage showed --send OUTPUT=http[s]:// first; links are now the way between nodes (012), so it shows pure:// for a Pure node and HTTP for anything else. The statistics at exit count the streams a node's links were refused (link_refused) and the ones its listener refused (listener_refused), so a wiring mistake is in the numbers too, not only the line on stderr; server_links_refused checks both.
* Serve documents when a module keeps a collection, not by its inputs' nameseunhhu2026-09-291-2/+4
| | | | | | | | | | pure-serve took a module with an input named show or edit for a document app and refused it unless both were keyed messages: an app with a plain message named edit compiled and then would not start (review 004, an earlier P2). Documents are what a module keeps in a keyed collection; now the host serves them when the module declares one, and inputs named show and edit are otherwise messages as any other. check.py's server_plain_edit starts such an app and posts to edit.
* Tick a stateless app's timers, and wait for them on the monotonic clockeunhhu2026-09-291-9/+14
| | | | | | | | | | | | | | | | | | | | run_timers ticked nothing in an app without states, taking a timer to be able to change nothing else; but a tick may send, and the outbox is the app's state (the host gives such an app an owner for it). A timer that only sends never ticked (review 004, F4). Now an app ticks when it has states or outputs; one with neither runs on every connection thread and has nothing a tick could do. When a timer is due was reckoned on the wall clock: set back, a timer waited for the time it had reached again (review 004, an earlier P2). The next tick is now reckoned on the monotonic clock; events still see the wall clock (now). check.py's server_timers runs the review's heartbeat (a timer, an output, no state) and sees it tick, then again with the wall clock set back an hour 0.2 s in (build/clock-back.so, an LD_PRELOAD of clock_gettime): 29 ticks in 0.6 s, where the server before this stops at 9.
* Say why a stream is refused (REFUSED), and try it again seldomeunhhu2026-09-291-4/+2
| | | | | | | | | | | | | | | | | | | A receiver that took no records of a stream (a source the key may not send as, no input of that name, an input of another shape) said nothing: its sender heard them acknowledged, never stored, and sent them all again every second, for ever, and its operator saw nothing. Now the receiver answers REFUSED (0x0c: the names, and why), and the sender sends no records for a second, then a packet of them to learn whether they are taken now, the wait twice as long each time (64 s at most). A refusal ends when that packet is acknowledged with no REFUSED beside, or a record is stored. link.c says it once on stderr, as a wire says a receiver does not take its sends; serve's input says which of the two the app lacks. test-link: each of the three is refused seven times in 90 s with nine packets, and a stream refused until the app has its input goes on then, all 200 records stored. check.py links a node to an input its receiver lacks: said once, two packets in 2.5 s.
* Move a link to a new epoch now and then, so keys that leak later open ↵eunhhu2026-09-291-2/+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.
* Keep a link's two sides as state alone, the sockets and threads aparteunhhu2026-09-291-8/+9
| | | | | | | | | | | | 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-8/+27
| | | | | | 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-2910-2581/+3
| | | | | libpure-link.a holds them; pure-serve and the M5 harness link it instead of naming its source files.
* Send a record whole when it fits a packet, however many pieces it haseunhhu2026-09-292-40/+55
| | | | | | | | | | | | | | | | | 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-281-3/+13
| | | | | | | | | | | | | | | 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.
* 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-283-8/+82
| | | | | | | | | | | | | | 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.
* 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-283-169/+415
| | | | | | | | | | | | | | | | 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.
* Link two nodes without a connection over UDP (093, design 011's first stage)eunhhu2026-09-287-23/+1629
| | | | | | | | | | | | | | | | | | | | | 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).
* Keep lists as images and a log of their changes, checkpointed off the ownereunhhu2026-09-281-3/+92
|
* Gather returning requests only as long as the last save waited for its deviceeunhhu2026-09-281-3/+18
|