A nightly architectural scan (Kraken, finding 7ffc07667004) flagged
ClientNodeManager, NodeObserver, and SharedNodeFunctions.snapshot() as
having uncoordinated state reads that could let UI screens observe stale
node state. Most of the specific mechanisms cited were already fixed by
earlier work (#428, #454, #656) or were mischaracterized (the snapshot()
casts are paired 1:1 with KrillApp type/meta invariants that are never
violated). Investigation found one real, narrower instance still present.
ClientNodeManager.execute(node: Node) — introduced in c58f90e16
(the Phase 4 /invoke migration), after #656’s mutateStored sweep had
already landed, so it was never brought into that fix’s scope — still did
the pre-#656 “capture-then-whole-node-write” pattern for its state-only
pulse:
DELETING/ERROR, never PAUSED. A caller holding a
pre-pause Node reference (e.g. a Compose pointerInput(node.id, …)
closure in NodeItem.kt, which is not reinstalled on every
recomposition and can hold a several-states-stale node) could still
have its local pulse write state = PROCESSING over a node the user had
live-paused.node straight to
NodeHttp.invokeNode, whose isInvokable() guard (added for
krill-oss#158 — itself a prior Kraken finding) evaluates target.state.
A stale node.state defeats that guard in either direction: a live-paused
node can slip through on a stale non-paused snapshot, or a live-invokable
node can be silently dropped on a stale paused/deleting snapshot.ClientNodeManager.kt: execute() now gates on the freshly-read stored
node’s state (current.state), adding PAUSED to the existing
DELETING/ERROR block, and removes the redundant second
readNodeStateOrNull read of the same value. It sends the freshly-pulsed
update (state = PROCESSING) to nodeHttp.invokeNode, not the caller’s
node, so the SDK guard always evaluates current state.ClientNodeManagerInvokeTest.kt: updated the existing invoke-shape test
to assert the captured node’s state instead of exact-matching the stale
btn object, and added two regression tests — a stale pre-pause
reference must not invoke or clobber a live-PAUSED node, and a stale
pre-snapshot reference must not forward stale meta to the server..copy() one field, whole-node update()”
pulse writer is the same bug class as #656 regardless of which method it
appears in — grep for nodes\[.*\]?\.value or readNodeState.*\.value
followed by a .copy(state = ...) + update(...) a few lines later
before adding a new one; use mutateStored/gate-then-pulse-the-fresh-value
instead.isInvokable()) to enforce a state
invariant, every call site that forwards a Node across an API boundary
must forward the freshly-read value, not whatever reference the caller
happened to hold — a stale argument silently defeats the guard.