A spreadsheet, VII: push
Part V ended with a page that found out about a write it did not make the
only way a reader of a name can: it asked again, every two seconds, whether anything had changed
or not. It also listed what the other direction needs, the kernel telling the page. There were
four pieces, and a caveat. Somebody to tell: the kernel has had that since core 0.1.82, Kernel::listen. What
to redraw: the host’s business. A channel: in this book the host is the page, so being told is a
function call. And a timer, because time does not cut. (The caveat, the same answer twice, is
early cutoff, a cost this part notes, not a piece it builds.)
This part builds the host’s half, in the page. It needs no server and no new core, and the sheet gains one template, the page that asks each cell to listen, and nothing else: not a line of its formulas, its evaluator or its views changes.
The model, on paper
| the set | its name | what it is |
|---|---|---|
| a cut listener | urn:iki:tutorial:push:listener:{name} | Sink a prefix to listen for cuts of every thread under it; Source takes what it has heard; Delete stops it. The state is the kernel’s queue |
| what a read carries | urn:iki:tutorial:push:carries | the golden threads an answer hangs from, and when it expires: a question about another read, answered by making it |
| the views drawn | not a name: the page’s own memory | for each view the page drew, what its answer carried |
| the listening page | urn:iki:tutorial:sheet:template:push | the sheet with each cell a view of its own, drawn again when the page says it is stale |
/// A cut listener, by name: `Sink` a prefix to listen, `Source` to take what it heard,
/// `Delete` to stop.
pub const LISTENER: &str = "urn:iki:tutorial:push:listener:{name}";
/// What a read of `target` carries: its golden threads and its deadline.
pub const CARRIES: &str = "urn:iki:tutorial:push:carries";
A listener is a resource so that it can be shown in a cell, and so that listening is checked the way everything else is: by the kernel, against what the resource declares. The page’s own push uses the same listener, and waits on it with a function call rather than reading it.
Listening is an authority
A listener learns the name of every thread cut under its prefix, and a thread is named after the
resource that was written. That is information, so listening needs a capability,
urn:cap:kernel:listen, and the listener declares it on every verb:
/// `push-listener`: a cut listener, as a resource.
///
/// * `Sink` registers a listener named `{name}` for every thread under the prefixes in
/// `content` (one per line), under the request's own capability, replacing any listener of
/// that name. The kernel decides what it may hear: under the root authority, every cut
/// there; under any other, only a cut of a name some read made under that same capability
/// rests on.
/// * `Source` takes what it has heard since it was last read ([`render`]). Reading takes: a
/// second read answers only what came after the first. Never cached.
/// * `Delete` stops it.
pub fn listener(listening: Arc<Listening>) -> FnEndpoint {
FnEndpoint::new("push-listener", move |inv: &Invocation<'_>| {
let name = inv
.bindings
.get("name")
.ok_or_else(|| Error::MissingArgument("name".to_string()))?
.to_string();
match inv.request.verb {
Verb::Sink => {
let prefixes: Vec<&str> = inv
.inline_str("content")?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
if prefixes.is_empty() {
return Err(Error::InvalidArgument {
name: "content".to_string(),
detail: "no prefix: say which threads to listen for, e.g. \
urn:iki:tutorial:sheet:"
.to_string(),
});
}
let spec = prefixes
.iter()
.fold(ListenSpec::new(), |spec, prefix| spec.prefix(*prefix));
let registered = listening.kernel()?.listen(spec, inv.capability)?;
listening.lock().insert(name, Arc::new(registered));
let hears = if inv.capability.is_root() {
"as root: every cut there"
} else {
"under this capability: a cut there only when a read made under it rests on it"
};
Ok(text(format!(
"listening for cuts under {}, {hears}\n",
prefixes.join(" and ")
)))
}
Verb::Delete => match listening.lock().remove(&name) {
Some(_) => Ok(text(format!("stopped listening as `{name}`\n"))),
None => Err(Error::NotFound(format!("nothing is listening as `{name}`"))),
},
_ => match listening.listener(&name) {
Some(heard) => Ok(text(render(&heard.drain()))),
None => Err(Error::NotFound(format!(
"nothing is listening as `{name}`: sink a prefix to it first"
))),
},
}
})
.with_description(
Description::new("push-listener")
.title("Cut listener")
.summary(
"A golden-thread listener, as a resource: sink a prefix to listen, source to \
take what it heard, delete to stop.",
)
.verb(Verb::Meta)
.action(
ActionSpec::new(Verb::Source)
.summary("take the cuts heard since the last read; never cached")
.input(name_input())
.requires(CAP_LISTEN)
.output(TEXT_PLAIN_UTF8),
)
.action(
ActionSpec::new(Verb::Sink)
.summary("listen for cuts of every thread under these prefixes")
.input(name_input())
.input(
ArgSpec::new("content")
.summary("the prefixes to listen under, one per line")
.class(XSD_STRING),
)
.requires(CAP_LISTEN)
.output(TEXT_PLAIN_UTF8),
)
.action(
ActionSpec::new(Verb::Delete)
.summary("stop listening")
.input(name_input())
.requires(CAP_LISTEN)
.output(TEXT_PLAIN_UTF8),
),
)
}
Narrow this cell’s capability to Bob’s, from Part VI, and ask to listen. Then ask again with the page’s own:
narrowed — capability: urn:cap:iki:tutorial:sheet:scenario:bob error: denied: capability does not grant `urn:cap:kernel:listen` (declared by `urn:iki:tutorial:push:listener:mine`) reset to identity — capability: root (full authority) listening for cuts under urn:iki:tutorial:sheet:, as root: every cut there [uncacheable] heard nothing [uncacheable]
The refusal is the kernel’s, made before the listener’s code ran, because the listener declares
what it needs. Kernel::listen refuses too, in case something registers a listener without going
through the resource. And the refusal is an answer: a page whose reader may not listen says so,
in words, and keeps working, as the sheet at the bottom of this page does if its registration is
refused.
The listener holds the registration and nothing else. The queue is the kernel’s:
/// The host's registrations: each listener it holds, by the name it was registered under, and
/// a handle on the kernel that registers them.
///
/// The handle is attached after the kernel is built ([`Listening::attach`]), because the kernel
/// is built over the space that binds the listener: the two would otherwise need each other
/// first. It is weak, so the space does not keep its own kernel alive.
#[derive(Default)]
pub struct Listening {
kernel: OnceLock<Weak<Kernel>>,
listeners: Mutex<BTreeMap<String, Arc<CutListener>>>,
}
impl Listening {
/// Give the listener endpoint the kernel it registers with. Once: a second call is ignored.
pub fn attach(&self, kernel: &Arc<Kernel>) {
let _ = self.kernel.set(Arc::downgrade(kernel));
}
/// The listener registered as `name`, for a host that awaits it ([`CutListener::wait`])
/// rather than reading the resource.
pub fn listener(&self, name: &str) -> Option<Arc<CutListener>> {
self.lock().get(name).cloned()
}
fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Arc<CutListener>>> {
self.listeners.lock().expect("the listeners' lock")
}
fn kernel(&self) -> Result<Arc<Kernel>> {
self.kernel
.get()
.and_then(Weak::upgrade)
.ok_or_else(|| Error::Endpoint("this host has not attached its kernel".to_string()))
}
}
A write, heard
Part V’s holding again: a price in a feed, a quantity, their value, and some arithmetic that has nothing to do with them. Then ask the listener what it heard:
100 [uncacheable] =FEED(acme) [uncacheable] 10 [uncacheable] =A1*A2 [uncacheable] =6*7 [uncacheable] 1000 [computed] heard 5 cuts cut urn:iki:tutorial:sheet:feed:acme cut urn:iki:tutorial:sheet:input:A1 invalidated urn:iki:tutorial:sheet:cell:A1 invalidated urn:iki:tutorial:sheet:view:cell:A1 cut urn:iki:tutorial:sheet:input:A2 invalidated urn:iki:tutorial:sheet:cell:A2 invalidated urn:iki:tutorial:sheet:view:cell:A2 cut urn:iki:tutorial:sheet:input:A3 invalidated urn:iki:tutorial:sheet:cell:A3 invalidated urn:iki:tutorial:sheet:view:cell:A3 cut urn:iki:tutorial:sheet:input:C1 invalidated urn:iki:tutorial:sheet:cell:C1 invalidated urn:iki:tutorial:sheet:view:cell:C1 [uncacheable]
Five writes, five cuts, each with the cached answers it invalidated. The views are the sheet’s at the bottom of this page, which drew all twenty-four of its cells, empty, when the page loaded, and four of those drawings just went out of date. The first cut invalidated nothing: nothing had read the feed yet.
Reading the listener took what it had heard. Now the price moves, and nothing else happens:
101.5 [uncacheable] heard 1 cut cut urn:iki:tutorial:sheet:feed:acme invalidated urn:iki:tutorial:sheet:cell:A1 invalidated urn:iki:tutorial:sheet:cell:A3 invalidated urn:iki:tutorial:sheet:feed:acme [uncacheable]
One write, one cut, and not one poll. The kernel’s list names the feed and the two values that
read it, the ones the previous cell computed. It does not name the views of A1 and A3 that the
sheet below is showing, and that is not an omission. The list is the kernel’s account of its
cache: what was stored and is now invalid. Those two views were already invalid, cut by the
previous cell’s writes, and nobody has drawn them since, because the sheet below is not pushing.
They are still on the screen. A page cannot learn what its screen shows from the kernel’s cache,
so it keeps its own account.
From a cut to the views
What the page needs to know about each view it drew is what that view’s answer rested on, and the answer already says. The kernel puts on every answer the golden threads of everything read while computing it, however far down, so a view carries the thread of every value, input, formula and feed under it. Ask what a read carries:
/// `push-carries`: what a read of `target` carries. Makes the read, and answers its deadline
/// and its threads, one per line. Never cached: it is a question about a read made now.
pub fn carries() -> AsyncFnEndpoint {
AsyncFnEndpoint::new("push-carries", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
Box::pin(async move {
let named = inv.inline_str("target")?.trim();
let target = Iri::parse(named).map_err(|e| Error::InvalidArgument {
name: "target".to_string(),
detail: format!("`{named}` is not a name: {e}"),
})?;
let carried = Carried::of(&inv.source(&target).await?);
let mut out = format!(
"{named}\nexpires {}\nhangs from {} threads\n",
expiry_text(carried.expiry),
carried.threads.len()
);
for thread in &carried.threads {
out.push_str(&format!(" {thread}\n"));
}
Ok(text(out))
})
})
.with_description(
Description::new("push-carries")
.title("What a read carries")
.summary("Read a resource, and answer the golden threads and the deadline it carries.")
.verb(Verb::Source)
.verb(Verb::Meta)
.input(
ArgSpec::new("target")
.summary("the name to read")
.class(XSD_ANY_URI),
)
.output(TEXT_PLAIN_UTF8),
)
}
urn:iki:tutorial:sheet:view:cell:A3 expires never: only a cut changes it hangs from 17 threads urn:iki:tutorial:sheet:cell:A1 urn:iki:tutorial:sheet:cell:A2 urn:iki:tutorial:sheet:cell:A3 urn:iki:tutorial:sheet:compile urn:iki:tutorial:sheet:feed:acme urn:iki:tutorial:sheet:formula:A1 urn:iki:tutorial:sheet:formula:A3 urn:iki:tutorial:sheet:input:A1 urn:iki:tutorial:sheet:input:A2 urn:iki:tutorial:sheet:input:A3 urn:iki:tutorial:sheet:precedents:A1 urn:iki:tutorial:sheet:precedents:A3 urn:iki:tutorial:sheet:refs:A1 urn:iki:tutorial:sheet:refs:A2 urn:iki:tutorial:sheet:refs:A3 urn:iki:tutorial:sheet:template:cell urn:iki:tutorial:sheet:view:cell:A3 [uncacheable] urn:iki:tutorial:sheet:view:cell:C1 expires never: only a cut changes it hangs from 8 threads urn:iki:tutorial:sheet:cell:C1 urn:iki:tutorial:sheet:compile urn:iki:tutorial:sheet:formula:C1 urn:iki:tutorial:sheet:input:C1 urn:iki:tutorial:sheet:precedents:C1 urn:iki:tutorial:sheet:refs:C1 urn:iki:tutorial:sheet:template:cell urn:iki:tutorial:sheet:view:cell:C1 [uncacheable]
A3’s view hangs from the feed, three names down; C1’s does not. That is the whole rule for
push: a cut of thread T makes stale exactly the views whose answers carried T. The page
remembers, for each view it draws, what the answer carried, and nothing else:
/// The host's memory of the views it has handed out: for each view's name, what its last
/// answer carried. One per page here; one per session on a server.
#[derive(Debug, Default)]
pub struct Drawn {
views: BTreeMap<String, Carried>,
}
impl Drawn {
/// The host drew `view` from `answer`: remember what the answer carried, replacing
/// whatever the view's last answer did.
pub fn drew(&mut self, view: impl Into<String>, answer: &Representation) {
self.views.insert(view.into(), Carried::of(answer));
}
/// The views a batch of cuts makes stale: each view whose threads include a thread that
/// was cut. No dependents index: the answer already named everything it rests on. A batch
/// that DROPPED cuts makes every view stale, since what was missed is unknown.
pub fn stale(&self, batch: &CutBatch) -> Vec<String> {
if batch.dropped > 0 {
return self.views.keys().cloned().collect();
}
self.views
.iter()
.filter(|(_, carried)| {
batch
.events
.iter()
.any(|event| carried.threads.contains(event.thread.as_str()))
})
.map(|(view, _)| view.clone())
.collect()
}
/// The earliest deadline among the views, in milliseconds since 1970: when to set the one
/// timer. A cut is announced; an expiry is not, so the host has to know when to look.
pub fn deadline(&self) -> Option<u64> {
self.views.values().filter_map(Carried::deadline).min()
}
/// The views whose deadline is at or before `now` (milliseconds since 1970).
pub fn expired(&self, now: u64) -> Vec<String> {
self.views
.iter()
.filter(|(_, carried)| carried.deadline().is_some_and(|at| at <= now))
.map(|(view, _)| view.clone())
.collect()
}
/// What the view's last answer carried, if the host has drawn it.
pub fn carried(&self, view: &str) -> Option<&Carried> {
self.views.get(view)
}
/// How many views the host remembers.
pub fn len(&self) -> usize {
self.views.len()
}
/// Whether the host remembers none.
pub fn is_empty(&self) -> bool {
self.views.is_empty()
}
}
There is no index of dependents anywhere, in the page or in the kernel. The dependency graph was recorded when the view was computed, and handed to the page on the answer. The same thing as a test, with every one of the sheet’s twenty-four views drawn:
#[test]
fn a_feed_write_is_one_cut_and_makes_exactly_its_readers_stale() {
let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
let kernel = host(clock);
the_market(&kernel);
let mut drawn = Drawn::default();
draw_all(&kernel, &mut drawn);
assert_eq!(drawn.len(), 24);
let listener = kernel
.listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
.expect("root may listen");
sink(&kernel, &format!("{PREFIX}feed:acme"), "101.5").expect("the price moves");
let batch = listener.drain();
assert_eq!(batch.dropped, 0);
assert_eq!(batch.events.len(), 1, "one write, one cut: {batch:?}");
assert_eq!(
batch.events[0].thread.as_str(),
"urn:iki:tutorial:sheet:feed:acme"
);
// A1 reads the feed and A3 reads A1: two of the twenty-four views, and no others.
assert_eq!(drawn.stale(&batch), [view("A1"), view("A3")]);
}
Two of twenty-four. A poll would have asked all twenty-four, and been told twenty-two times that nothing had changed.
Time does not cut
NOW() changes every minute, and nobody writes anything when it does. The kernel lets the
answer go at its deadline, and a listener hears nothing, because there was no cut. So the page
reads each view’s deadline off its answer too, and keeps one timer, at the earliest. The page’s
kernel has the browser’s clock (the sheet carries data-clock), so the outputs below are from a
test that stopped a clock at 14:05:20 UTC; your deadline is the next minute of your day.
=NOW() [uncacheable] urn:iki:tutorial:sheet:view:cell:D1 expires at 2026-09-29T14:06Z hangs from 11 threads urn:iki:tutorial:sheet:cell:D1 urn:iki:tutorial:sheet:compile urn:iki:tutorial:sheet:formula:D1 urn:iki:tutorial:sheet:input:D1 urn:iki:tutorial:sheet:precedents:D1 urn:iki:tutorial:sheet:refs:D1 urn:iki:tutorial:sheet:template:cell urn:iki:tutorial:sheet:view:cell:D1 urn:iki:tutorial:time:instant urn:iki:tutorial:time:now urn:iki:tutorial:time:today [uncacheable] heard 1 cut cut urn:iki:tutorial:sheet:input:D1 invalidated urn:iki:tutorial:sheet:cell:D1 invalidated urn:iki:tutorial:sheet:view:cell:D1 [uncacheable]
The view is good until 14:06, and the only cut was the write. Wait for the minute to turn, then:
heard nothing [uncacheable] not cached
The view is gone from the cache, and the listener heard nothing. A page that only listened would show 14:05 forever. The timer is what draws it again, and only it, since no other view has a deadline:
#[test]
fn time_does_not_cut_so_the_host_keeps_a_deadline() {
let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
let kernel = host(Arc::clone(&clock));
the_market(&kernel);
type_into(&kernel, "D1", "=NOW()");
let mut drawn = Drawn::default();
draw_all(&kernel, &mut drawn);
let listener = kernel
.listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
.expect("root may listen");
// One view has a deadline, the next minute; every other one lasts until a cut.
assert_eq!(drawn.deadline(), Some(millis_at("2026-09-29T14:06Z", 0)));
assert!(drawn.expired(millis_at("2026-09-29T14:05Z", 59)).is_empty());
clock.set(millis_at("2026-09-29T14:06Z", 0));
assert!(
listener.drain().is_empty(),
"the minute turned, and nobody cut anything"
);
assert_eq!(
drawn.expired(millis_at("2026-09-29T14:06Z", 0)),
[view("D1")]
);
// Drawn again, the view carries the next deadline.
draw(&kernel, &mut drawn, &view("D1"));
assert_eq!(drawn.deadline(), Some(millis_at("2026-09-29T14:07Z", 0)));
}
So push has two triggers, and the page needs both: a cut, announced by the kernel, and an expiry, which the page has to know about in advance, because nobody will announce it.
A scenario’s write is heard by everybody
Part VI admitted a limit: a golden thread is a name, not a name in a space, so Alice’s override in her scenario cuts the shared sheet’s values too (ledger item 581). They are computed again and come out the same, so nothing is ever wrong, only more work than necessary. With push, the work becomes visible. Alice sells twelve:
12 [uncacheable] 1218 [computed]
heard 1 cut cut urn:iki:tutorial:sheet:input:A2 invalidated urn:iki:tutorial:sheet:cell:A2 invalidated urn:iki:tutorial:sheet:cell:A3 invalidated urn:iki:tutorial:sheet:input:A2 invalidated urn:iki:tutorial:sheet:precedents:A3 invalidated urn:iki:tutorial:sheet:refs:A2 invalidated urn:iki:tutorial:sheet:view:cell:A3 [uncacheable]
Every entry on that list is the shared sheet’s: Alice’s scenario had read nothing before her write.
The shared view of A3 is among them, so a page showing the shared sheet with push on draws A2 and
A3 again, to show the values they already showed. The test says it plainly:
#[test]
fn a_scenario_edit_is_heard_by_the_shared_sheet_too() {
let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
let kernel = host(clock);
the_market(&kernel);
let mut drawn = Drawn::default();
draw_all(&kernel, &mut drawn);
let listener = kernel
.listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
.expect("root may listen");
// Alice overrides A2 in her scenario. The shared sheet does not change...
let alice = spreadsheet::scenario("alice", Arc::default()).expect("a scenario");
let write = request(Verb::Sink, &format!("{PREFIX}input:A2"))
.with_arg("content", ArgRef::Inline(b"12".to_vec()));
block_on(kernel.issue_in(write, &Capability::root(), alice)).expect("her override");
// ...but a thread is a name, not a name in a space (ledger item 581): the shared views
// that read A2 are stale too, and are drawn again to show the same values.
let batch = listener.drain();
let cut: Vec<&str> = batch.events.iter().map(|e| e.thread.as_str()).collect();
assert_eq!(cut, ["urn:iki:tutorial:sheet:input:A2"]);
assert_eq!(drawn.stale(&batch), [view("A2"), view("A3")]);
for cell in ["A2", "A3"] {
let before = source_as(&kernel, &Capability::root(), &view(cell)).unwrap();
draw(&kernel, &mut drawn, &view(cell));
assert_eq!(
source_as(&kernel, &Capability::root(), &view(cell)).unwrap(),
before
);
}
}
Polling hides this, because a poll of an unchanged value is a cache lookup either way. Push turns it into traffic: a redraw sent to every reader of the shared sheet, for a change none of them can see. A hundred people with scenarios would each send the shared sheet’s readers a redraw per edit. The fix is still the kernel’s, a thread that knows which corridor answered the read it hangs from, and the page cannot make it. It can only show it, and on the sheet below you can watch it happen.
A listener hears only what its reads rest on
The page’s cells run as root, and a root listener hears every cut under its prefix. Any other authority hears less: a cut of a thread only when some answer computed under that same capability rests on it, and a list of invalidated answers from its own reads and nobody else’s. It learns only names it was already given.
narrowed — capability: urn:cap:kernel:listen listening for cuts under urn:iki:tutorial:sheet:, under this capability: a cut there only when a read made under it rests on it [uncacheable] 1015 [computed] reset to identity — capability: root (full authority) 102 [uncacheable] hello [uncacheable] narrowed — capability: urn:cap:kernel:listen heard 1 cut cut urn:iki:tutorial:sheet:feed:acme invalidated urn:iki:tutorial:sheet:cell:A1 invalidated urn:iki:tutorial:sheet:cell:A3 invalidated urn:iki:tutorial:sheet:feed:acme [uncacheable] reset to identity — capability: root (full authority)
The session read A3; somebody else moved the price and typed into B6. The session heard the
price, which its A3 rests on, and not B6, which it never read. This is the shape a server wants:
one listener per session, registered under the session’s own capability, so that it hears about
exactly what that session was sent.
The page, pushing
The sheet below is Part V’s, drawn a different way. Each cell is a view of its own, and each says, in its markup, when it wants to be drawn: when the page loads, when the page says it is stale, and every two seconds while polling is on. Here is the first row of the template:
<tr><th scope="row">1</th><td hx-get="iki/tutorial/sheet/view/cell/A1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/B1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/C1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/D1" hx-trigger="load, stale, every 2s" data-poll></td></tr>
The markup changed from “ask every two seconds” to “listen for stale”, as Part V said it would
(the event is stale, per cell, rather than Part V’s grid, because each cell is now its own view),
and nothing in the sheet’s code changed with it. What the page adds is small. Its wasm module (the
page’s host, crates/book-wasm) exports four things for it: a draw that remembers what the answer carried, a wait on a listener, and the
deadline and the expired views for the timer:
/// A read of `view` in the root's chain, as [`issue_async`] makes it, for a sheet that LISTENS
/// (`data-push`, part VII): the page also remembers what the answer carried ([`Page::drew`]),
/// so that a cut can be mapped to the views it makes stale, and an expiry can be timed.
#[wasm_bindgen(js_name = drawAsync)]
pub fn draw_async(view: String) -> js_sys::Promise {
let page = PAGE.with(Rc::clone);
let was_cached = page.is_cached(None, Verb::Source, &view);
let issued = page.issue(None, Verb::Source, &view);
wasm_bindgen_futures::future_to_promise(async move {
let answer = issued.await;
if let Ok(representation) = &answer {
page.drew(&view, representation);
}
Ok(JsValue::from_str(&reply_to_json(answer, was_cached)))
})
}
/// Wait for the next cuts the listener `name` hears (registered by a `Sink` to
/// `urn:iki:tutorial:push:listener:{name}`), and resolve to what they mean for the page:
/// `{ "cuts": [thread…], "dropped": n, "stale": [view…] }`. `stale` is every view the page drew
/// whose answer carried a thread that was cut, or every view it drew when cuts were dropped.
/// Resolves to `{ "error": … }` when nothing is listening under that name.
///
/// The kernel never calls the page: a cut appends to the listener's queue and wakes this
/// future, and the page decides what to do, on its own turn of the event loop.
#[wasm_bindgen(js_name = pushWaitAsync)]
pub fn push_wait_async(name: String) -> js_sys::Promise {
let page = PAGE.with(Rc::clone);
let listener = page.listening.listener(&name);
wasm_bindgen_futures::future_to_promise(async move {
let Some(listener) = listener else {
let error = format!("nothing is listening as `{name}`");
return Ok(JsValue::from_str(
&serde_json::json!({ "error": error }).to_string(),
));
};
let batch = listener.wait().await;
Ok(JsValue::from_str(&batch_to_json(
&batch,
&page.stale(&batch),
)))
})
}
/// The earliest deadline among the views the page drew, in milliseconds since 1970, or `-1`
/// when none has one: when to set the page's one timer.
#[wasm_bindgen(js_name = pushDeadline)]
pub fn push_deadline() -> f64 {
PAGE.with(|page| page.deadline().map_or(-1.0, |at| at as f64))
}
/// The views the page drew whose deadline has passed, as a JSON array: what the timer draws
/// again when it fires.
#[wasm_bindgen(js_name = pushExpired)]
pub fn push_expired() -> String {
PAGE.with(|page| serde_json::json!(page.expired()).to_string())
}
And the page’s side, in js/sheet.js: register, wait, find the cells that show the stale views,
trigger stale on each, and wait again; and keep one timer at the earliest deadline:
// A sheet that listens: its listener's name on this page, and the prefix it listens
// under (every name the sheet has).
var PREFIX = "urn:iki:tutorial:sheet:";
var LISTENER = "urn:iki:tutorial:push:listener:";
// The cells of `host` that show `views` (names like urn:iki:tutorial:sheet:view:cell:A1),
// found by the path each cell asks for.
function cellsShowing(host, views) {
var found = [];
views.forEach(function (view) {
var path = view.replace(/^urn:/, "").split(":").join("/");
host.querySelectorAll('td[hx-get="' + path + '"]').forEach(function (td) {
found.push(td);
});
});
return found;
}
function cellName(td) {
return td.getAttribute("hx-get").split("/").pop();
}
// Draw `cells` again, and say why: htmx asks for each one (its `stale` trigger), and the
// shim below lets the request through because the page asked for it.
function drawAgain(host, state, cells, why) {
state.marked.forEach(function (td) {
td.classList.remove("sheet-pushed");
});
state.marked = cells;
cells.forEach(function (td) {
state.asked.set(td, why);
td.classList.add("sheet-pushed");
htmx.trigger(td, "stale");
});
state.redrew += cells.length;
}
// Wait for the next cuts, draw the cells they make stale, and wait again, for as long
// as push is on and this is still the listener it started.
function listen(host, state, generation) {
kernel.pushWaitAsync(state.listener).then(function (json) {
if (!state.push || generation !== state.generation) {
return;
}
var batch = JSON.parse(json);
if (batch.error) {
stopPush(host, state, "Push stopped: " + batch.error + ".");
return;
}
state.heard += batch.cuts.length;
state.dropped += batch.dropped;
var cells = cellsShowing(host, batch.stale);
drawAgain(host, state, cells, "push");
var cut = batch.cuts.map(function (thread) {
return thread.replace(PREFIX, "");
}).join(", ");
state.last = batch.dropped > 0
? "The page fell behind: " + batch.dropped + " cuts were dropped, so it drew " +
"every cell again."
: "Last: " + (batch.cuts.length === 1 ? "a cut of " : "cuts of ") + cut +
(cells.length ? ", which drew " + cells.map(cellName).join(", ") + " again."
: ", which no cell on the grid rests on.");
showPush(state);
listen(host, state, generation);
});
}
// One timer, at the earliest deadline among the cells drawn: an expiry is not a cut,
// so nobody announces it, and the page has to know when to look.
function schedule(host, state) {
clearTimeout(state.timer);
state.timer = null;
state.deadline = state.push ? kernel.pushDeadline() : -1;
if (state.deadline >= 0) {
state.timer = setTimeout(function () {
state.timer = null;
var cells = cellsShowing(host, JSON.parse(kernel.pushExpired()));
if (cells.length === 0) {
schedule(host, state);
return;
}
state.expired += cells.length;
drawAgain(host, state, cells, "expired");
state.last = "Last: the deadline " + clockText(state.deadline) +
" passed, which drew " + cells.map(cellName).join(", ") + " again.";
showPush(state);
}, Math.max(0, state.deadline - Date.now()) + 5);
}
showPush(state);
}
function startPush(host, state) {
kernel.issueWithArgsAsync("", "sink", LISTENER + state.listener,
JSON.stringify([["content", PREFIX]])).then(function (json) {
var reply = JSON.parse(json);
if (reply.kind !== "output") {
stopPush(host, state, "Push was refused: " + reply.text);
return;
}
state.push = true;
state.generation += 1;
state.pushToggle.setAttribute("aria-pressed", "true");
listen(host, state, state.generation);
schedule(host, state);
});
}
function stopPush(host, state, why) {
var was = state.push;
state.push = false;
state.generation += 1;
state.pushToggle.setAttribute("aria-pressed", "false");
state.last = why || "";
schedule(host, state);
if (was) {
kernel.issueAsync("", "delete", LISTENER + state.listener);
}
}
The kernel never calls the page. A cut runs inside the write that caused it, so it only appends to the listener’s queue and wakes the page’s wait, and the page draws on its own turn of the event loop. The queue is bounded, and a cut that finds it full is counted as dropped. A page that fell behind does not know what it missed, so it draws every cell again, and says so:
#[test]
fn a_full_queue_counts_a_drop_and_the_host_draws_everything_again() {
let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
let kernel = host(clock);
the_market(&kernel);
let mut drawn = Drawn::default();
draw_all(&kernel, &mut drawn);
let listener = kernel
.listen(
ListenSpec::new().prefix(PREFIX).capacity(1),
&Capability::root(),
)
.expect("root may listen");
type_into(&kernel, "D5", "1");
type_into(&kernel, "D6", "2");
let batch = listener.drain();
assert_eq!((batch.events.len(), batch.dropped), (1, 1));
assert_eq!(drawn.stale(&batch).len(), 24);
assert!(push::render(&batch).ends_with(
"dropped 1: the queue was full, so nothing after the last cut above is known\n"
));
}
Three toggles, all off to start. Run the market writes the feed every three seconds, as in Part
V. Push registers the page’s own listener and draws what it is told to. Poll asks every cell
again every two seconds, which is Part V’s way, kept here to compare. Put =FEED(acme) in a cell
and something that reads it in another (the cells above did, if you ran them), =NOW() in a third,
then run the market with push on. A cell the page drew again because it was told is outlined until
the next one is. With push and polling both off, the grid is the copy the page drew when it loaded,
and an edit from the formula bar changes nothing on it: a listening sheet never draws itself again
because the page made the write. Whoever writes, the listener hears it, or nobody does.
The sheet appears here when the in-page kernel has loaded.
With push on, the cells above may answer differently from their expected output. The page draws a
stale view as soon as it hears the cut, so a value a cell expects to find not cached has often been
computed again already. That is the other half of a golden-thread listener, which NetKernel also
had: recompute before the first reader, so the next reader is served from the cache.
Polling and push, compared
Neither is free, and the costs fall in different places.
Polling costs a request per view per interval, whether or not anything changed. The sheet above
polls twenty-four views every two seconds: 720 requests a minute, nearly all of them a cache lookup
(over HTTP, a 304 Not Modified and no body). It sees a change up to one interval late. It needs no
state anywhere but the cache, and it degrades gently: a missed poll is made up by the next.
Push costs a listener, a memory of what was drawn, and a timer, and nothing at all while nothing changes. It sees a change on the next turn of the event loop. But the costs it does have are the kind that grow:
- State per reader. A listener (a bounded queue) and a record of every view sent, with its threads and its deadline. The page keeps one. A server keeps one per session.
- Fan-out. A cut of a name a thousand readers drew is a thousand redraws, at once. A poll spreads the same load over the interval.
- Redraws that change nothing. A cut is by name, not by content, so a write of the value a feed already holds makes its readers stale anyway, and each is drawn again, the same. Stopping that needs the recomputed answer compared with the old one before anyone is told: early cutoff, which the kernel does not do (Part III). Ledger item 581’s over-reach, above, is another source of the same.
- Two triggers. A cut is announced and an expiry is not, so push is a listener and a timer. A poll covers both without knowing either exists.
- Recovery. A dropped cut means “draw everything”, and a server’s page that loses its connection must do the same when it reconnects.
So polling is the right first answer for a few readers of values that change slowly and cache well, and push is the answer for many readers of values that change often, or when a second of latency matters. They also combine: push as the fast path, and a slow poll as the net under it.
What a server needs
This page is its own host, so push is a function call. A sheet served over HTTP needs the same four pieces, each a little further apart:
- One listener per session, registered under that session’s capability, so that it hears only about names that session read.
- The session’s memory of what it sent: for each view, the threads and the deadline its answer carried. Server state, held as long as the session is.
- A channel the server can write to. Server-sent events suit it, and htmx can listen to one: the cell’s trigger becomes an event on that channel instead of a DOM event, and nothing else in the markup changes.
- A timer per session, at the earliest deadline among the views it sent, and a full redraw when cuts were dropped or the channel reconnects.
What was built
| the set | the name | what it is |
|---|---|---|
| a cut listener | urn:iki:tutorial:push:listener:{name} | code: a registration held by name over the kernel’s own queue, behind urn:cap:kernel:listen |
| what a read carries | urn:iki:tutorial:push:carries | code: one read, and what its answer carries, in words |
| the views drawn | the page’s memory (push::Drawn) | code: per view, the threads and the deadline its answer carried; the stale views for a cut, and the earliest deadline |
| the listening page | urn:iki:tutorial:sheet:template:push | a template: each cell a view that listens for stale |
The kernel already had the hard parts: the dependency graph, recorded as every answer was computed and handed out on the answer, and a queue that tells a host when a thread is cut. Push needed a place to keep what the page drew, a rule to map a cut onto it, and a timer for the one kind of change nobody announces. The sheet’s code did not change, beyond one template, and its values do not know whether anybody is looking.