aboutsummaryrefslogtreecommitdiffstats
path: root/serve/serve.c
Commit message (Collapse)AuthorAgeFilesLines
* Compute views with a body on view workers, off the connection threads, asked ↵eunhhu2026-10-041-81/+272
| | | | | | | | | | | | | | | | | | | | and pushed (020) - pure-serve --view-workers N (a CPU each; 0: on the connection threads): a GET of a view with a body (or a table with such a column) goes to the workers, which show it from the app's newest snapshot on readers of their own and give the answer back as an owner does (eventfd). The connection thread reads and writes its other connections meanwhile. - A snapshot of another module than the request was resolved with, or none (the app stopped), answers 503 stopped. Past an app's queue=N of them, 503 busy. - A push group of such a view hands one evaluation at a time; a newer snapshot meanwhile is evaluated after it; a group whose subscribers all left stays until its evaluation is back. - check.py server_view_workers: 8 askers of a long view while its app changes module (another view in its place) and then goes: every answer the old module's, the new one's, 503 or 404; streams closed at once leave nothing; --view-workers 0 as before.
* Wake each thread at most once a push, end streams only of stopped apps, and ↵eunhhu2026-10-041-4/+14
| | | | show a subscriber costs writes nothing (019)
* Watch a subscriber for writing only while it keeps output, and measure ↵eunhhu2026-10-041-2/+13
| | | | freshness by push and polling (019)
* Push a view's value to its subscribers over SSE, once per group and interval ↵eunhhu2026-10-041-5/+352
| | | | (019)
* 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-041-17/+230
| | | | | | | | | | | | | | | 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-011-1/+2
| | | | | | | | | | | | | 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-301-7/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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).
* Give bools their own type in the module format (v6, 016 stage 1)eunhhu2026-09-301-12/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-301-44/+52
| | | | | | | | | | | | | | | | | | | | | | | 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-301-6/+5
| | | | | | | | | | | | | | | | | | | | | | | 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-301-18/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-291-1/+1
| | | | | libpure-link.a holds them; pure-serve and the M5 harness link it instead of naming its source files.
* Remember each sender's secret beside the node's key: no X25519 at restarteunhhu2026-09-281-2/+6
| | | | | | | | | | | | | | 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.
* Let a link at rest cost the listener only its allowingeunhhu2026-09-281-7/+45
| | | | | | | | | | | | | | | | 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-281-21/+176
| | | | | | | | | | | | | | | | | | | | | 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
|
* Take a view's arguments from the request's queryeunhhu2026-09-281-10/+115
| | | | | | | | | | | | | | | | | | | | GET /views/name?argument=value gives a view that takes arguments its arguments: the query as it came is kept on the request's work slot (it outlives the connection, which may go before the view is shown), and when the view is shown, on the connection thread from a snapshot or by the owner, its pairs (values %XX decoded) are laid out as a payload of the arguments' shape and given before the view is evaluated. from and count still page a table. An argument missing, unknown, given twice, not of its type or badly escaped answers 400 invalid. A registered query takes its view's arguments the same way (GET /queries/name?k=4). An arguments input is not an input to post to. server_arguments opens 200 accounts, then asks 83 times against a model: balances by key (404 no_value for a key not there), counts above an argument, texts with &, =, % and non-ASCII shown back, a table filtered by two arguments whose column folds with one, paged, counts by an owner's name, a registered query with an argument, and six refusals. The format's document (008) describes arguments.
* Take a message's rows over HTTPeunhhu2026-09-281-19/+36
| | | | | | | | | | | | | | | | | | | | | | | A rows field's JSON value is an array of objects naming the record's fields (numbers, strings; a field left out takes its default). The connection thread encodes them as the list's stored form, which may be longer than the JSON, grows the request's input by that much (pure_runtime_grow: charged to the payload budget as admission charges an input, 503 payload_budget when it does not fit) and places them after the body, so the owner still applies a message it need not parse. A message whose only variable field is rows takes a body that is not JSON as those rows in their stored form: a bulk insert without JSON. An API's answer (--reply) gives rows the same way. A delivered batch holds no rows. server_rows loads 30,000 stock rows in one JSON request (some 250,000 rows a second, against 6,500 with a request a row) and 20,000 more as bytes, refuses a load with a key already there whole (409 exists), then checks 400 random orders against a model (294 applied, 106 refused with none of their rows applied), texts with escapes and Korean, a walk that removes, and a restart that keeps every state and no rows. The format's document (008) describes walks, rows fields and lists of rows, and OUTER_ROW.
* Register queries and show them from snapshotseunhhu2026-09-281-17/+145
| | | | | | | | | | | | | | | | PUT /queries/NAME takes a module compiled with aup query: the node loads and verifies it, checks that its states are the app's (names, types, records) and that it has a view NAME, and keeps it (64 at most; 409 for another app's module, 400 for bytes that are no module, 404 without the view). GET /queries/NAME?from=&count= shows that view from the last save's snapshot with a reader of the query's module, as a declared view is shown, so a query runs beside writes and sees one save whole. A query registered again answers anew. server_queries registers a per-branch total (a correlated fold), a join of history rows with their accounts and tellers, each rich account's rank and a filtered count, checks them against a model of the same transfers, asks while transfers go on, pages, and checks the refusals.
* Run a surface for people inside the node (design 010)eunhhu2026-09-271-11/+163
| | | | | | | | | | | | | | | | | | | | | | | pure-serve --surface LIBRARY [-- ARGUMENTS] maps a library and runs its pure_surface_run in a thread of its own (serve/surface.h). The surface reads the app as of the last save where it is, a published snapshot in a reader app, as a connection thread reads views; an eventfd tells it of each new snapshot; and its events (a key pressed, text typed) are requests without a host in the owner's queue, applied and saved with the round, their results handed back to the surface's waiter. So one node serves programs over HTTP and people on its display with one owner of its states, and nothing between the states and the screen. pure-serve exports its pure_* functions for the surface to call, one copy of the app model in the process; the table carries sizeof(PureApp) and sizeof(PureModule) so a surface built for another layout refuses. The check builds a probe surface that types, presses and reads: its events show in HTTP views, an HTTP input shows in it, wrong inputs are refused, it stops with the server, and a module without states cannot have one. Without a surface the request path measured the same (small GETs 140.9k vs 139.9k, app totals 126.3k vs 126.3k over eight rounds); the exported symbols cost 11 KB of the file and 24 KiB idle.
* Seal in one session a wire, authenticated by the sender's key (088)eunhhu2026-09-271-24/+121
| | | | | | | | | | | | | | | | | | | | | | 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.
* Map OpenSSL only in a server that uses iteunhhu2026-09-271-0/+5
| | | | | | | | | | | | | | | | | | 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.