Add a control port that replaces a running program with a new version of its source - #121
Draft
dpmills wants to merge 18 commits into
Draft
Add a control port that replaces a running program with a new version of its source#121dpmills wants to merge 18 commits into
dpmills wants to merge 18 commits into
Conversation
dpmills
force-pushed
the
dmills/live-update
branch
4 times, most recently
from
August 21, 2026 22:58
38cec77 to
3c65ab9
Compare
dpmills
changed the base branch from
dmills/program-diffing-scoped
to
dmills/stream-buffer-trim
August 21, 2026 23:17
dpmills
force-pushed
the
dmills/live-update
branch
2 times, most recently
from
August 21, 2026 23:50
941e0e3 to
3a5d4e8
Compare
dpmills
force-pushed
the
dmills/live-update
branch
3 times, most recently
from
August 22, 2026 00:44
21866de to
8687ae2
Compare
dpmills
force-pushed
the
dmills/live-update
branch
12 times, most recently
from
August 22, 2026 21:56
93c5e0a to
37d3e4e
Compare
dpmills
force-pushed
the
dmills/live-update
branch
from
August 25, 2026 18:33
37d3e4e to
279c53c
Compare
dpmills
force-pushed
the
dmills/live-update
branch
from
August 25, 2026 22:00
279c53c to
bb2f645
Compare
dpmills
force-pushed
the
dmills/live-update
branch
from
August 26, 2026 21:42
bb2f645 to
c67841a
Compare
dpmills
force-pushed
the
dmills/live-update
branch
from
August 26, 2026 21:51
c67841a to
694a665
Compare
dpmills
force-pushed
the
dmills/live-update
branch
from
August 27, 2026 17:40
694a665 to
5842c10
Compare
dpmills
force-pushed
the
dmills/live-update
branch
4 times, most recently
from
August 28, 2026 05:40
42cd27b to
8c8d5db
Compare
…ition A mutation loop's accumulators reached the store as an unlabelled tuple, so nothing downstream could say which slot belonged to which variable. `mut_elim::fold_induction_loop` collects `(Name, Type)` per accumulator and then keeps only the types; `planning::plan_loops`, handed the result and nothing else, mints `acc0`/`acc1` from position to have something to key the mutable-variable record by. Everything after that reads the accumulator by a label the program never chose. The commit path already does this the other way — a transactional variable's key is its own spelling — so the two writer kinds only differed in whether the write set carried names. They no longer do. `writes` is a `Type::Record` keyed by each written variable's `field_key`, in `mut_elim` (induction) and `transact_phase` (transactions) alike, with `infer::emit` constraining the same shape. The reads follow: `__hist ≫ .writes ≫ .i` becomes `.writes ≫ .acc`, `plan_loops` takes each `TransactKey`'s name from the record label instead of inventing one, and `body_decision_at` decodes the set in `write_keys` order by name. `KeyReadInfo.index` — "the key's position in the writer's `writes` tuple" — is deleted. Both of its readers already ignored it: a commit read is keyed by `runtime_key` and a dense induction read folds by `runtime_key`, so position had no remaining consumer. Two `transactions.rs` assertions move off `acc0` and onto the names the programs use (`cnt`, `acc`), which is the visible half of the change.
… and swaps it in place Changing a running program means restarting it, dropping its open sockets and everything its operators accumulated. This adds a control port (`--control`, default 8081) serving `/diff` and `/update`: a new version inherits the running program's endpoints and may add to them, keeps the operator behind every binding whose computation is unchanged, and resumes each mutable variable from the value it was holding — so an edit swaps in place while the rest keeps serving. [live-update.md](src/ccl/design/live-update.md) states the model; the sections below are a reading order over it. Read the first commit first: keying a writer decision's write set by the variable written, rather than by position, is what lets an accumulator's carried state be named by the spelling its author chose. `SourceSinkRegistry` (`src/ccl/context.rs`) holds the open sources, each route's reply sink, and each bound port's listener on `GlobalContext` rather than per compilation, so a replacement binds a route the registry already holds — keeping its listener and everything buffered behind it — and opens one it does not. A version that stops serving a route retires it from its `SharedHttpServer`, so the address answers 404 rather than buffering requests for a reader that no longer exists. `LiveProgram::update` reads the variables the new version declares off its planned tree and refuses if the running program holds one it no longer declares, declares at a different type, or declares under a different store — the three shapes `StateConflict` names. Everything else is allowed. Only a value with nowhere to be seeded from is refused, because that is the one failure an author cannot observe: the program carries on answering and only the accumulated history is gone. Conversion keys each `Let` binding and each `Transact` store by the `resolved_hash` of the term it realizes and adopts the matching one. The key is stable across compilations, so an unchanged part of a program is recognized on that program's *first* update; heredity is tracked separately, by the set of bindings this compilation rebuilt. A carrier that holds nothing takes its cut from its source's agreed release; one that holds a value takes it from its own frontier. Crossing those two is what both defects here were, and the design doc tabulates every carrier. A rebuilt store therefore resumes rather than restarting. Its value already rides its own cyclic fan as a `Tile::Store`, so `live_state` reads each carried key off `FanOut::cached_tile` — no second channel out of the operator, which is what keeps this off the ledger `ci_shared_state` maintains. Each store hands its replacement one `CarriedState`: the values and the position they were read at, together, because seeding from one position and resuming at another decides a position twice or skips it. Both defects were a position taken from the wrong side of that — a resuming `DriverWindow` left based at `0`, and `TransactDriver` naming the item it was attempting by its index among the source's offered columns and never releasing a finished one, so a replacement re-attempted every committed transaction. Items now carry absolute source positions and are released on the commit-ack, which also bounds a transactional source's buffer. Sharing an operator across a swap requires knowing which subscriptions are still real, so a subscription now lasts exactly as long as its subscriber: `FanOutShared::subscribers` and `Scheduler::add_source_handle` hold `Weak` references, and `SinkConsumer::detach` clears the producer slot. This changes behavior for every program, not only an updated one — a dead fan-out slot no longer pins the release-guard intersection. Nothing on the interpreter side is `Send`, so the `src/control_port.rs` server thread only parses a request and blocks; the main loop services one per tick. `LiveProgram::update` compiles to `Phase::Planning` and runs the state guard before touching the running program, then tears down and recompiles. `main.rs` becomes a driver over `LiveProgram`. `tests/programs/live_update/` has 23 cases and a module doc tabulating its base programs against what each edit shape is expected to do. Eighteen drive six base programs over real HTTP, each variant named for the one edit that distinguishes it from its base; five drive the binary as a subprocess over `stdin`, covering a program whose output is its `main` value rather than a sink. The regressions for defects found while building this are worth reading first: `an_edit_to_the_accumulating_loop_takes_effect`, `reuse_does_not_depend_on_how_many_updates_came_before`, `a_store_resumes_however_far_its_source_has_advanced`, `a_transactional_variable_survives_an_edit_to_its_writer`, `a_transaction_writer_does_not_replay_what_it_committed`, and `reordering_two_accumulators_does_not_cross_their_state`. `UIntStreamBuffer`'s own unit tests pin what a producer registering after the swap reads.
`Phase` is the type, `phase` is the parameter every entry point takes, and `phase=` is what the control port reads, but `src/ccl/diff.rs` and `src/ccl/design/diffing.md` called the same thing a stage across 47 lines — including `diff_programs`'s own `stage: Phase` parameter and a `# Stage-agnostic` module heading. One name per concept, and this is the pair that already disagreed with itself: `diffing.md`'s test table said "at every phase" while the test it named was `every_stage_diffs_identical_source_as_identical`. The test is renamed to match, and `### The one that needs more than a stage` becomes `### ... than a phase`; neither had an inbound reference, which `./ci.sh doc_refs` confirms. One site was the other sense of the word: `let staged = compile_to(src, Phase::Planning)` meant "prepared", not "a pipeline position". It is `planned` now, after the phase it stops at, so the word is free. `stage` remains elsewhere in the tree in both senses — `context.rs`'s per-phase registries and error variants are the pipeline position, while `planning stages the carrier's writer sources` and `groupby`'s single- versus multi-stage key extraction are the verb and an unrelated internal sequence. Converting those needs a judgment per site rather than a rename, and none of them is what this branch touches.
`/diff` unregistered any route the version it compared against had stopped serving. A diff compiles the new version against the *running* registry, so the listener it sees is the running program's, and `run_frontend` retired routes on every compile — a question changed what the program served, and the address it answered on started replying 404. Retiring belongs to installing a version, so it moves to `compile_program`, past the point a scratch compile reaches. ## A port is held while a route is registered on it, and no longer `SharedHttpServer` moved its `tiny_http::Server` into the dispatcher thread, so nothing could reach it to stop the loop: the doc's "runs until the server is dropped" was untrue and dropping the handle released nothing. It holds the listener alongside the thread now and unblocks it on drop, which runs the shutdown path the loop already had — the one `register` documents by sending `None` to every route. `release_unrouted_ports` then drops the listener for a port no route remains on, and `absorb` takes the lowering pass's listeners rather than copying them so the registry is the only owner and the drop really closes the socket. A retired route whose port still serves a sibling answers 404 as before; only the port's last route takes the port with it. ## A fan-out reclaims its dead subscriber slots `FanOutShared`'s three parallel slot vectors were append-only, because a producer addresses its release guard by index and renumbering would point it at another subscriber's slot. Every update therefore left one dead slot per replaced subscriber, forever, and both the notify walk and the release intersection scan them. The index now lives in a `Cell` the producer and the registry share — the same object as the liveness token, since the slot number *is* the subscription's identity — so `reopen` drops the dead slots and writes each survivor its new number. It runs at a version handover with the graph already torn down, which is what makes renumbering safe. ## What the docs claimed and the code did not - `bind_store` described the correspondent as "a value unique to this compilation when rebuilt". It is the term's identity either way, exactly as `LetBinding::correspondent` is, and `rebuilt` is what keeps a term reading a rebuilt store from adoption. - Two doc blocks belonged to items that no longer exist: `inherited` carried the doc of the map `Inheritance` replaced, and `state_conflicts` carried `into_inheritance`'s. - "Order of an update" named `Endpoints::Inherited`, which does not exist, and listed two compiles where an accepted update runs four. The section now says which four and why the guard's tree cannot be the one that gets built. - `StateConflict::Moved` named an arbitrary one of several stores declaring the name, so the diagnostic varied with hash order. ## What `live_state` may skip It walks the same stores as the guard under three more conditions, and a store the guard promised to carry that this drops is the outcome the guard exists to prevent. An absent cached tile is now an assertion, since a store's fan is cyclic and a cyclic fan has one. The other two are ordinary and stay skips: a store that has decided no position has no frontier, and a variable no position has written has no value at one. Writing the assertion is what found that out — the frontier case fires on an empty store. ## Tests `a_request_that_arrived_before_the_swap_is_answered_after_it` covers the end-to-end half of the release carry, which only `UIntStreamBuffer`'s unit tests reached. `the_control_port_answers_a_diff_request` covers `/diff` over the wire — both ways of carrying the source, the leading `phase=`, and the 400 for a phase the port does not offer — which no case reached; `raw_http_response` exists because `raw_http` drops the status line, and the control port's contract is partly the status. `a_port_whose_last_route_goes_is_released` is the first case on two ports. `diffing_against_a_version_that_drops_a_route_does_not_retire_it` and `reopening_a_fan_out_drops_dead_slots_and_renumbers_the_rest` pin the two changes above. `launch_under_control` is split out of `stdin_across_update`, which was spawn, feed, update, and collect in one function; a test that only asks the control port a question needs the launch and none of the rest.
…ey share A program has one commit store per causal group, not one commit store. Every group's store answers to the same `__txn` id, so `live_state` bucketed them together and each wrote its own frontier into the bucket's single `resume_at` — two groups at different frontiers raced, and `minted.stores` being keyed by content hash meant hash order settled which position a replacement resumed at. `CarriedState` now carries one variable's value and the position it was read at, and the bucket holds one per runtime key. A store rebuilt in a retired store's place reads the position back off any variable it declares, asserting its variables agree, so nothing depends on the id naming one store. `two_transaction_groups_resume_at_their_own_positions` drives four commits into one group and one into the other, edits one group's writer, and expects each to stand where it stood. The id is still half the state key, and it is still not an identity: a loop's id is the collection it reads rather than the loop, and `__txn` is shared. What makes the pair sufficient today is that `Name::field_key` spellings are distinct within the record that declares them and no two records in reach declare the same one. The design doc and `TRANSACTION_STORE_ID` now say that rather than claiming the id identifies a store, and name what closing it takes: keying state by the variable's declaration, which needs neither half.
… holds it Two calls to one function, each carrying its own loop and its own accumulator, crossed their state on update. Inlining clones the function body per call site, so both accumulators are the same source declaration under the same spelling in the same store id — one entry, and both replacements read it. `two_instantiations_of_one_function_keep_their_accumulators_apart` is that program: it read `6042` where `6060` is right, the second accumulator seeded from the first's value, with no diagnostic and a successful update. Shadowing one declaration with another does the same thing without a function in sight. State is now keyed by a `VarPath` — the variable's spelling plus its index among the variables of that spelling, in tree order. The spelling carries the meaning and the index only disambiguates, counted among the variables sharing the spelling rather than among all of them, so a stateful loop added anywhere shifts nothing unless it declares that same name. Nothing the program computes can stand in for the index. Two instantiations of one function differ *only* in their writer bodies once arguments are substituted, so a content-derived identity either fails to tell them apart or changes under exactly the edit state has to survive. Nor can a lexical path: both declarations are the same point in the same source text. One walk assigns every identity (`OpConversionContext::set_var_paths`), and both the guard and conversion read its answers, so a store is built under the identity it was checked against. Before, `declared_state` derived the key from the planned tree and the store builders derived it again from what they were building — two computations that had to agree, where disagreeing reads as a still-declared variable having been dropped. `store_id` stays, in the role it can actually fill: `PositionSpace`, the space a `resume_at` was counted in. A data source, a transaction's private and restartable clock, or a fixed collection. A variable over a fixed collection carries nothing — its replacement recomputes the fold from the collection its own version declares, and seeding it would count the elements twice. `StateConflict::Moved` is now stated in terms of that space rather than a store id, which is the same refusal on the same programs. Its doc records that this is policy, not constraint: a value can be seeded into a recurrence that counts elsewhere, starting at `0`, and relaxing the refusal is deleting it — `build_induction_store_single` asserts the spaces agree by the time it seeds. `state_store_id` and `TRANSACTION_STORE_ID` are gone. So is `KeyReadInfo::carry_forward`, folded into `carried: Option<VarPath>`: a key that carries no value between commit ticks has no state to carry between versions either, and one field cannot disagree with itself. `tests/programs/live_update/` held twenty `.cambra` files where every other gallery directory holds one. It now holds `program.cambra` and the `updated.cambra` that replaces it — a reader looking for what an update *is* reads that pair. The eighteen variants are scaffolding and live inline in the test module's `fixtures`, each an `indoc!` beside the cases that drive it.
`PositionSpace` restated the `Transact`'s `domain` field: `Transaction` for `Type::Txn`, `Source(name)` for a data source, `Finite` for anything else. The node already carries that — its doc calls `domain` "the carrier's sequencing domain, the index of every key's history: a concrete iteration extent for a `mut` accumulator, `Type::Txn` for a transactional commit clock" — so the enum was a lossy copy of a field, given a second name. The domain itself is what a carried position is now compared against, and `carries_state` is the one thing the three cases were really distinguishing: a data source and a commit clock outlive the version reading them, while a concrete extent is part of the program, so a variable sequenced by one carries nothing and its replacement recomputes the fold. `store_id` goes with it. It derived the source from `writers.first()`, which silently rested on an induction store being single-writer — true, but asserted a phase later than the derivation. Reading the node's own field needs no writer, so the walk no longer looks at `writers` at all. A `debug_assert` confirmed the two agreed across the suite before the swap.
Changing where a loop reads from is an ordinary edit — moving a service to another port, or moving an accumulator between two loops — and the guard refused all of it, because the value and the position were carried as one thing. They are not one thing. The value is the variable's; the position belongs to the collection it was counted in. So a variable whose new version is sequenced by a different domain seeds with the value it held and decides from `0`. The positions it is about to decide belong to a collection its predecessor never read, so none is decided twice and none is skipped — which is the same argument that justified resuming at the frontier when the domain *is* the same, applied to the case where it is not. `moving_a_program_to_another_port_keeps_its_state` signs a guestbook twice on one port, moves the program to another, and signs again: all three entries. The port it left is released with its last route, so the old address stops answering. `a_variable_that_moves_to_another_loop_takes_its_value_and_restarts` moves an accumulator between two loops of one program and reads its `aa` back out of the loop it moved to. `StateConflict::Moved` is deleted. Two refusals are left, and both are a value with nowhere to go: a variable the new version does not declare, and one it declares at another type. ## A variable a version recomputes is not one it dropped A variable sequenced by a fixed collection carries nothing — its fold is recomputed from the collection its own version declares, and seeding it would count the elements twice. `declared_state` used to leave those out, so a variable moving from a source loop to a list loop read as *undeclared* and the update was refused for discarding a value that was going to be recomputed correctly. It reports every declared variable now, and the two callers ask what they each need: the guard skips the type check for one it will not seed, and the induction builder seeds from nothing when its own domain recomputes.
Swapping which source two loops read stalled the program. Both variables change domain at once, so neither resumes at its predecessor's frontier, and starting them at `0` based each drive below every position its new source will ever offer: the other loop had read that source all along and released what it consumed, so the elements the drive waited for were never coming. A silent hang rather than a wrong answer, and the same fault reached a case the design doc listed as working — a stateless loop that gains an accumulator over a source the program was already reading has nothing carried at all and equally cannot start at `0`. A store now starts at `first_position_for_a_new_producer`: `0` for a source nothing has read, which is every source of a program's first version, and the released frontier for one a retired version advanced. The exception stays the exception — a store replacing one over the *same* domain resumes at that store's frontier, deliberately behind its source, because a drive reads one position back through its input. `two_loops_may_swap_which_source_they_read` and `a_stateless_loop_may_gain_an_accumulator_over_an_advanced_source` cover the two shapes. Both hang without the change. ## A compile that answers a question opens nothing `moving_a_program_to_another_port_keeps_its_state` then failed to bind the port it was moving to, which turned out to be the update's own doing: `/diff` and the state guard compile the new version against the running registry, and a route that registry does not hold was *opened* — binding a socket and registering a route that outlive the context they were made in. Asking a question changed what the program served, and the compile that installs the version found the port taken by the compile that had only been asked about it. Such a route now lowers to an `UnopenedRoute`, which answers the type questions lowering and inference put to a source and makes every runtime method `unreachable`, since a compile that stops above operator conversion asks nothing else. `Endpoints::Inherited` selects it — the name the design doc had already given this, describing a distinction the code did not yet draw. `assert_port_released` polls rather than asserting outright. Releasing a port is not synchronous with the update that stopped serving it: dropping the last handle unblocks the dispatcher thread and the socket closes when that thread notices, so the contract is that the port is released, not that it is released before `update` returns.
Two doc comments still explained themselves through the store id: the `stdin` guard case named one, and the two-transaction case said its stores answer to `__txn`. Neither exists. The guard reads what a version declares off its planned tree, which is the same for `stdin` and for `http_serve`, and two commit stores are told apart by being sequenced by the same `Txn` domain rather than by sharing a bucket.
The design doc's table says such a variable is recomputed rather than seeded, and nothing checked it. Relaxing the refusal on a changed sequencing domain is what made the case reachable, and the row went in without the test. `a_variable_that_moves_to_a_fixed_collection_is_recomputed` bumps an accumulator twice over an HTTP source, moves it to a loop over a two-element list, and reads the list's fold alone.
A `FanOut` forwards the intersection of its subscribers' release guards to its input, so the input is free to drop what that covers — a `Memo` does, evicting its cache. A subscriber registering afterwards was given an empty guard, so it claimed to still want data the fan-out had already said would never be read again. Across a version handover that is the whole population: every subscriber the update rebuilds registers after the retired ones released. A new subscriber now starts at what the fan-out has released. Within one version every subscription is made before any data flows, so the seed is the empty guard and nothing changes; across a handover it is what the rebuilt subscriber inherits from the one it replaces. Where the agreement covers everything, the operator can only answer empty, and a version no longer offers such an operator to the one replacing it. Adopting one bound a name to nothing: a `Let`-bound `n := ""` read by a retired store and by the reader of its final value is released in full by both, and the store rebuilt in its place then panicked reading its own init. The value each variable holds is taken off its store before the withdrawal, so a store released in full still hands its variables on.
A variable's position and its value were carried under one rule keyed on the sequencing domain: `Txn` and a data source carried, a concrete iteration extent carried neither, so a variable that moved to a loop over a fixed collection lost what it held and that fold was recomputed from scratch on every update. The reason given — that seeding would count the elements twice — held only because the replacement also restarted at `0`. They are two decisions. The value belongs to the variable, so it carries wherever the variable goes, exactly as it already did between two loops over sources and into and out of a transaction. The position belongs to the sequence it was counted in, and a replacement counting in another one starts its own count. `Sequence` is what two versions compare. A source is named by itself, since it outlives every version reading it. A collection is named by the identity of the term that computes it: `[0, 2]` is the extent of `["y", "z"]` and of `["p", "q"]` alike, so resuming the second fold at the first's frontier would skip elements nothing ever read. A transaction hands on no position — its clock restarts with the store that counts it — which is why `CarriedState`'s resumption is optional rather than a third variant nothing compares. A resumed store now seeds tick `0` rather than the tick it resumes at, and starts its clock where it did before. A reader enumerating a fixed collection asks about every position of it, including the ones the predecessor decided; folding those to the value handed over answers them with the value the read is after, where before the fold resolved to nothing and tripped the changelog's seeding assertion. `DeclaredVariable` no longer carries a domain: with the value carrying unconditionally, the state guard is about the value having somewhere to go and nothing else.
`carry_release_to_new_producers` had a default that did nothing and was implemented only by the two sources backed by `UIntStreamBuffer`, so carrying a source's agreed release across a version handover was a property of how those two happened to store their releases. A source that stored them otherwise silently did not carry, which reads as the replacement answering every request the retired version had already answered. `ProducerReleases` is that bookkeeping named once: what each producer has released, accumulated by union; the agreement, which is what the source may drop and what a newcomer may skip; and the starting point a producer registering after the carry is recorded at. `UIntStreamBuffer` and `TestDataSource` each kept their own copy of it, and now hold one of these instead — `TestDataSource` gains the carry it was missing. The trait's two handover questions lose their defaults, so a source has to answer both. `UnopenedRoute` answers them the way it answers every other runtime question, by being unreachable.
`compile_program` fills a `SinkConsumer`'s producer slot after `subscribe` returns, so a notification raised from inside `subscribe` — an induction store raises one to start its loop — reaches a consumer with nothing to pull and is dropped. A first compile does not notice: a source holding data reports it as new on the next poll, and that drives everything. A replacement is not covered by that, because the version it replaces already took the report. So an update installed while work was outstanding sat until the next arrival. A fold over a fixed collection is where that is easiest to reach: it advances one position per pull, so a twenty-element list outlasts the notification round that starts it, and a request accepted before the swap went unanswered. Each sink is now notified once its slot is filled. Two tests, covering different halves. The first pulls the program's value itself rather than going through a sink, because that is what makes the position the update lands on nameable: one pull decides one position, so pulling eight times and swapping cuts the fold at seven, and the elements below the cut keep what the retired version decided. The second drives the same fold behind a route, which is the shape that stalls; it pins the invariant rather than the position, since where a socket lands in a notification round is not something the language promises.
`README.md` and `docs/design.md` both list a variable moving to another loop among the updates that are refused. It is accepted, and has been since the value started carrying into a recurrence that reads something else.
Six claims the code no longer supports. `bind_let` said a reused operator "hands the new version everything it has accumulated", because a `Memo`'s cache is cumulative. It is not: the cache drops what its consumers release, which is why a binding released in full is withheld from the next version at all. What a new version inherits is bounded by what the retired one had finished with, and the new subscriber is told so. `LiveProgram::update` said a variable must be declared "at the same type and under the same store", and called that three refusals. There are two, and a variable may move to another loop or into a transaction. `SinkConsumer::new` asked its caller to fill the producer slot "before the first notification fires". Nothing can: `subscribe` is what raises the first notifications and also what produces the producer, so the slot is empty when they arrive. Saying so is what makes the notify that follows the fill read as necessary rather than as belt-and-braces. `stream_buffer`'s module doc claimed the per-producer release bookkeeping as its own; it is `ProducerReleases`, which every source keeps. The subscription-lifetime section of `live-update.md` typed a fan-out slot's weak reference as `Weak<()>`, contradicting its own next sentence about the `Cell` the number lives in. And `transact_phase` still named a zip slot `acc0-view`, from before store keys took the program's own variable names.
The window under both drivers now carries a position on every row, so a resuming induction driver no longer needs one: `DriverWindow::new` loses the base it was given and the driver says where it starts by inheriting its predecessor's two cursors instead. `emitted_through` is the item cursor — the next position to iterate is the smallest delivered position above it, and the store's frontier is checked against it — so a driver resuming at `p` has emitted through `p - 1`, by its predecessor. `source_released_through` comes with it: the prefix below `p` is one this driver will not read, which is what that cursor records. Left empty, it would have this driver re-release a prefix its predecessor already released, and read a position the source re-offers there as an arrival out of order. That is also what makes a resume compose with a *filtered* source, whose decided positions are a subset of its extent and not contiguous. The resume position is one of the collection's positions rather than a count of decided elements, and both cursors take it as such.
dpmills
force-pushed
the
dmills/live-update
branch
from
August 28, 2026 21:50
ef7d734 to
5130e01
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Cambra program that accumulates state and serves endpoints has to be restarted to change its logic, which drops the state and the open sockets with it. This adds
--control(default 8081):/diffreports how a new version of the source differs from the running one and changes nothing, and/updateinstalls it — the listeners stay bound, every binding whose computation is unchanged keeps its operator and everything that operator has accumulated, and a variable whose logic the edit did touch resumes from the value it was holding. An update that could not take over the state is refused, with the running program still serving.src/ccl/design/live-update.mdis the design of record; "The model: every carrier has its own cut" and "Rebuilding a store resumes it" are the two sections to read first.What a version hands its successor
Three carriers, each cutting at its own point — there is no instant the whole graph stops at.
SourceSinkRegistry(src/ccl/context.rs) holds the open sources, the reply sink of each route, and the listener behind each port. Every compilation seeds a freshLoweringContextfrom it, so anhttp_servenaming a route already held binds it instead of opening a second listener on the same address. A route the new version stops naming is retired to a 404, and the port is released once its last route goes.resolved_hashof the term aLetorTransactbinds, and hereditarily: an operator is taken only if every binding it reads was taken too, so a carried-forward operator is never left reading a subgraph the update rebuilt.VarPath— the variable's own spelling plus its index among same-spelled variables in tree order. The value carries wherever the variable goes; the position carries only when both versions count it in the sameSequence.The refusal
state_conflictsruns against the new version's planned tree, before a single operator is built from it, so a rejection leaves the running program whole. Two shapes reach it: a variable the new version no longer declares, and one it declares at a different type. Everything else is accepted, including a variable moving to another loop or into a transaction — it seeds with the value it held and starts its new count from scratch.A write set keyed by the variable it writes
A writer decision's
writeswas a positional tuple, which was enough while a store's slots were only ever read by the compilation that made them. Carrying state across compilations needs a slot that names its variable, so the write set is a record keyed by the accumulator's own spelling andplan_loopsnames each store key after the program's variable rather than mintingacc0/acc1from position. That is the change spanningmut_elim.rs,transact_phase.rs,planning/loops.rs, andinfer/emit.rs.Where to look hardest
The releases. A subscriber registering after a swap must not claim to still want data the fan-out has already told its input will never be read again, and must not skip data no one has finished with. One rule, two implementations:
FanOutseeds a new subscriber's guard from what it has already released upstream, and withholds an operator released in full from the next version altogether.ProducerReleases(src/interpreter/producer_releases.rs) is that rule for a source's per-producer records —UIntStreamBufferandTestDataSourceeach kept their own copy of the bookkeeping and now hold one of these, and the trait's two handover methods lost their defaults so no source can silently fail to carry.Two smaller ones. Source registrations are
Weak(src/interpreter/scheduler.rs), so a registration lasts exactly as long as the producer it wakes. Andcompile_programnotifies each sink once its producer slot is filled — step 6 of "Order of an update", without which an update installed while work was outstanding sat unpulled until the next arrival.Tests and docs
37 cases in
tests/programs/live_update/, all against a program that is actually running. The gallery pair is a guestbook and the same guestbook with its accumulating loop edited; every other program is inline scaffolding differing from a base by the single edit its case is about. The module doc tabulates what an update may and may not do, change by change; the README's new Control port section is the same list from outside.The differ's
stageis renamedphase, matching thePhasethe control port'sphase=parameter selects.