Skip to content

Commit ca8bdf8

Browse files
author
gluck
committed
fix(cli): only a pushed frame may say a turn has started
CORRECTION FIRST. 8014af7's message asserts that catch-up history reaches the client through Merge and never Apply. That is false, and it was false when it was written. Four production callsites feed a read response straight into Apply: internal/cli/command.go:353 (desync Read), command.go:401 and stream.go:260 (the two deliberate pager doors), and stream.go:295 (readHistoryIntoPager). Only livelog_bridge.go and transcript.go use Merge. Aria 250ebd07 was asked to falsify the claim and did; the message is left standing rather than amended, so the record shows what was believed and what turned out to be true. THE RACE. Enter the pager while a turn is running and the catch-up ReadBefore snapshots that turn UNSEALED. The turn ends, turn.done lands and settles the status, and the read response arrives afterwards still describing the turn as live. Read as a lifecycle edge it says "a turn is now running" when none is, and nothing will ever say it stopped: the spinner runs until the session closes. claimsOpen cannot refuse it, because the snapshot was taken before the seal. THE CONTENT-BEARING HALF OF THIS PREDATES THE BRANCH. OnLive called beginTurn on any frame carrying assistant output, read or push, and has since it was written. A stale catch-up read carrying nodes could relight a finished turn on main today, with no part of this branch involved. 8014af7 widened the hole from content-bearing pages to inquiry-only ones; it did not open it. Both halves are fixed here, because a known silent bug deferred is a bug nobody fixes. PROVENANCE, NOT A STALENESS CHECK. Apply is the push door and ApplyRead is the read door: same fold, same order, withholding only the turn-open edge. A staleness check was considered and rejected. rpc.DoneEntry is {reason, idle} and carries no turn id, so such a check would have to infer which turn ended, which is guessing about precisely the ordering that is already broken, and a heuristic that is usually right is the worst available shape for a failure that is silent. The caller is holding either a response or a notification and always knows which; this makes it say so. Routing a read into the push door now takes typing the wrong word on purpose. ONE OWNER, NOT TWO GUARDS. Guarding OnTurnOpen alone fixed only the newer half, which the table-driven test caught. All three routes into the open slot (the question arriving first, a part carrying nodes, a live delta) now go through one claim helper that takes the slot AND reports the edge, so the two cannot drift apart and the push-only gate lives in one place. OnLive no longer touches the status. Deleting OnLive's arming needed its own test, because it did cover a case the inquiry path does not: a client attaching mid-turn receives a live delta as its first frame, carrying no question, because the question was broadcast before it was listening. claim() now reports that from the nodes and live branches. TestJoiningATurnInFlightMarksItRunning pins it, and replaces a companion test that asserted the duplicate mechanism this commit removes. The stale-read test is table-driven over both shapes and fails on the tip: inquiry-only fails against 8014af7, content-bearing fails against main. Also narrows a comment from the first commit. "so /nix/store detection is robust" became "works in every install layout", which traded a banned word for an absolute claim; it now says what EvalSymlinks actually does.
1 parent 0c3ad7f commit ca8bdf8

6 files changed

Lines changed: 159 additions & 39 deletions

File tree

‎internal/cli/command.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ func (in *interactiveInput) desyncHandler(gen uint64) func(int) {
350350
return
351351
}
352352
in.mu.Lock()
353-
in.lt.apply(r)
353+
in.lt.applyRead(r)
354354
in.mu.Unlock()
355355
}()
356356
}
@@ -398,7 +398,7 @@ func (in *interactiveInput) seedSubject() {
398398
defer in.mu.Unlock()
399399
in.caughtUp = rerr == nil
400400
if rerr == nil {
401-
in.lt.apply(r)
401+
in.lt.applyRead(r)
402402
in.lt.setMoreBefore(r.More.Before)
403403
}
404404
in.wireHooks()

‎internal/cli/livelog_bridge.go‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type livelogTurn struct {
6868
// Holding is a few milliseconds at the very start of a session and nothing
6969
// is dropped: openInline applies every held page in arrival order.
7070
hold bool
71-
held []aria.Page
71+
held []heldPage
7272

7373
// seeded is the catch-up page fetched when we joined a turn we did NOT open.
7474
// The inline view prints a bounded slice of it (seedContext); the PAGER gets
@@ -199,7 +199,12 @@ func (t *livelogTurn) wireClient() {
199199
t.finished = false
200200
}
201201
t.thinkingOpen = false // adopted by real content
202-
t.status.beginTurn()
202+
// The status is NOT armed here. OnTurnOpen owns that edge now,
203+
// and it fires for pushes only. This line used to arm on any
204+
// content frame, which meant a catch-up read that snapshotted a
205+
// live turn could relight a turn that had already ended, with no
206+
// later event to put it out. That predates OnTurnOpen; it is the
207+
// same race, on the older half.
203208
}
204209
// A turn taller than the viewport can't render inline without scrolling
205210
// its own live region off-screen; move it to the scrollable pager
@@ -346,12 +351,31 @@ func (t *livelogTurn) armThinking() {
346351
// conversation.
347352
func (t *livelogTurn) setMoreBefore(more bool) { t.client.SetMoreBefore(more) }
348353

349-
func (t *livelogTurn) apply(r aria.Page) {
354+
// apply folds a PUSHED frame. applyRead folds a READ RESPONSE, which is the
355+
// same fold minus the turn-open edge: see aria.Client.ApplyRead for the race
356+
// that separates them. The two doors exist so a callsite has to say which it
357+
// is holding, because from the page alone nothing can tell.
358+
func (t *livelogTurn) apply(r aria.Page) { t.applyPage(r, true) }
359+
360+
func (t *livelogTurn) applyRead(r aria.Page) { t.applyPage(r, false) }
361+
362+
func (t *livelogTurn) applyPage(r aria.Page, live bool) {
350363
if t.hold {
351-
t.held = append(t.held, r)
364+
t.held = append(t.held, heldPage{page: r, live: live})
365+
return
366+
}
367+
if live {
368+
t.client.Apply(r)
352369
return
353370
}
354-
t.client.Apply(r)
371+
t.client.ApplyRead(r)
372+
}
373+
374+
// heldPage is a buffered page and where it came from. Provenance survives the
375+
// hold because it is not recoverable from the page: see applyPage.
376+
type heldPage struct {
377+
page aria.Page
378+
live bool
355379
}
356380

357381
// holdFrames buffers applied pages until openInline. Armed before the
@@ -366,12 +390,12 @@ func (t *livelogTurn) openInline(fetched historyPage) {
366390
// store when the pager opens: see enterPager).
367391
t.seeded, t.seedExtents, t.seedMore = fetched.msgs, fetched.extents, fetched.more
368392
t.seedContext(fetched.msgs) // no-op when there is nothing to orient with
369-
t.armThinking() // no-op in the pager, and no-op if already pinned
393+
t.armThinking() // the incipit half is a no-op in the pager
370394
t.hold = false
371395
held := t.held
372396
t.held = nil
373-
for _, r := range held {
374-
t.client.Apply(r)
397+
for _, h := range held {
398+
t.applyPage(h.page, h.live)
375399
}
376400
// The pager draws from the shared model rather than from the event, so a
377401
// page that finalized nothing would leave it showing the pre-release state.

‎internal/cli/stream.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ func (in *interactiveInput) enterPager(history bool) {
257257
in.lt.setQueuedFetch(in.refreshQueued)
258258
in.lt.setHistoryFetcher(in.historyFetcher())
259259
if rerr == nil {
260-
in.lt.apply(r)
260+
in.lt.applyRead(r)
261261
// The wire's own answer to "is there anything before this page", which
262262
// is the only honest source for it: the pager reads it back as "can I
263263
// still page older history" instead of latching a bit of its own.
@@ -292,7 +292,7 @@ func (in *interactiveInput) readHistoryIntoPager() {
292292
// inline branch of OnClosed (which freezes to native scrollback) is not
293293
// reachable while tr.active, which is the trap this whole design is built
294294
// around.
295-
in.lt.apply(r)
295+
in.lt.applyRead(r)
296296
in.lt.setMoreBefore(r.More.Before)
297297
// The window was derived from the tail before this page existed; re-derive it
298298
// so the rows that just arrived are reachable by a scroll.

‎internal/cli/thinking_armed_test.go‎

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,28 +65,26 @@ func TestInquiryFrameMarksTheTurnRunning(t *testing.T) {
6565
}
6666
}
6767

68-
// The companion that proves the two above can fail: content-bearing frames
69-
// arm the status by a different route (OnLive), so a test that fed one would
70-
// pass whether or not the inquiry path works at all.
71-
func TestInquiryArmingIsNotJustOnLive(t *testing.T) {
68+
// JOINING A TURN ALREADY IN FLIGHT. A client that attaches mid-turn, or
69+
// reconnects, gets a live delta as its FIRST frame: the question was
70+
// broadcast before it was listening, so no inquiry rides on it. That case
71+
// used to be covered by OnLive arming on any content frame, which is the
72+
// half that also caused the stale-read bug. It must survive the removal.
73+
func TestJoiningATurnInFlightMarksItRunning(t *testing.T) {
7274
var out bytes.Buffer
7375
status := newSessionStatus("aria1234", time.Now())
7476
lt := newLivelogTurn(&out, 80, 20, &renderSettings{}, "aria1234", time.Now(), status, nil, nil)
7577
lt.enterTranscript()
7678

77-
lt.apply(inquiryPage(7, "WHAT IS THE GAP?"))
78-
armedByInquiry := status.turnRunning()
79-
80-
status.setTurn(turnStatusIdle)
79+
// No inquiry: we were not listening when it was asked.
8180
lt.apply(aria.Page{Parts: []aria.TurnPart{{Turn: aria.Turn{
82-
ID: 7, Live: &aria.Live{From: 0, Nodes: []aria.NodeDelta{{
83-
ID: 0, Set: map[string]any{"type": "prose", "markdown": "the answer"},
81+
ID: 57, Live: &aria.Live{From: 0, Nodes: []aria.NodeDelta{{
82+
ID: 0, Set: map[string]any{"type": "prose", "markdown": "mid sentence"},
8483
}}},
8584
}}}})
86-
armedByContent := status.turnRunning()
8785

88-
if !armedByInquiry || !armedByContent {
89-
t.Fatalf("inquiry=%v content=%v, want both true", armedByInquiry, armedByContent)
86+
if !status.turnRunning() {
87+
t.Fatal("joined a running turn and showed idle")
9088
}
9189
}
9290

@@ -109,3 +107,75 @@ func TestSealedFrameDoesNotMarkTheTurnRunning(t *testing.T) {
109107
t.Fatalf("a sealed turn lit the spinner:\n%s", strings.TrimSpace(out.String()))
110108
}
111109
}
110+
111+
// THE ORDERING RACE A SEALED-PART GUARD DOES NOT COVER.
112+
//
113+
// Enter the pager while a turn is running and the catch-up ReadBefore
114+
// snapshots that turn UNSEALED. The turn then ends, turn.done lands and
115+
// settles the status, and the read response arrives afterwards still
116+
// describing the turn as live. claimsOpen cannot refuse it: the snapshot was
117+
// taken before the seal, so it is not a sealed part.
118+
//
119+
// Treated as a lifecycle edge it says "a turn is now running" when none is,
120+
// and nothing will ever say it stopped. The spinner runs until the session
121+
// closes. So a read does not carry that edge at all, which is the whole
122+
// difference between apply and applyRead.
123+
func TestStaleCatchUpReadDoesNotRelightTheSpinner(t *testing.T) {
124+
for _, tc := range []struct {
125+
name string
126+
page aria.Page
127+
}{
128+
{"inquiry only", inquiryPage(9, "A QUESTION")},
129+
{"with content", aria.Page{Parts: []aria.TurnPart{{Turn: aria.Turn{
130+
ID: 9, Inquiry: "A QUESTION", Live: &aria.Live{From: 0, Nodes: []aria.NodeDelta{{
131+
ID: 0, Set: map[string]any{"type": "prose", "markdown": "the answer"},
132+
}}},
133+
}}}}},
134+
} {
135+
t.Run(tc.name, func(t *testing.T) {
136+
var out bytes.Buffer
137+
status := newSessionStatus("aria1234", time.Now())
138+
lt := newLivelogTurn(&out, 80, 20, &renderSettings{}, "aria1234", time.Now(), status, nil, nil)
139+
lt.enterTranscript()
140+
141+
lt.apply(inquiryPage(9, "A QUESTION")) // the turn ran
142+
lt.finishTurn("stop") // and ended
143+
if status.turnRunning() {
144+
t.Fatal("still running after turn.done")
145+
}
146+
147+
lt.applyRead(tc.page) // the read that was already in flight
148+
149+
if status.turnRunning() {
150+
t.Fatal("STUCK SPINNER: a stale catch-up read relit a finished turn, " +
151+
"and no later event will put it out")
152+
}
153+
})
154+
}
155+
}
156+
157+
// The companion: the SAME page through the push door must still arm, or the
158+
// test above would pass just as well with the whole feature deleted.
159+
func TestTheReadDoorIsWhatWithholdsTheEdgeNotThePage(t *testing.T) {
160+
page := inquiryPage(11, "A QUESTION")
161+
162+
armed := func(push bool) bool {
163+
var out bytes.Buffer
164+
status := newSessionStatus("aria1234", time.Now())
165+
lt := newLivelogTurn(&out, 80, 20, &renderSettings{}, "aria1234", time.Now(), status, nil, nil)
166+
lt.enterTranscript()
167+
if push {
168+
lt.apply(page)
169+
} else {
170+
lt.applyRead(page)
171+
}
172+
return status.turnRunning()
173+
}
174+
175+
if !armed(true) {
176+
t.Fatal("the push door stopped arming; the feature is gone, not fixed")
177+
}
178+
if armed(false) {
179+
t.Fatal("the read door armed")
180+
}
181+
}

‎internal/livelog/aria/client.go‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,45 @@ func (c *Client) OpenAnimating() bool {
277277
return c.store.OpenAnimating()
278278
}
279279

280-
// Apply folds one page.
281-
// Apply folds a Page into the local view.
282-
func (c *Client) Apply(p Page) {
280+
// Apply folds one PUSHED frame: a figaro.aria notification, which describes
281+
// the aria as it is right now. Only a push may report lifecycle edges, which
282+
// is the whole difference between this and ApplyRead.
283+
func (c *Client) Apply(p Page) { c.apply(p, true) }
284+
285+
// ApplyRead folds a READ RESPONSE: the answer to figaro.read or a backward
286+
// page. Same fold, same order, one thing withheld: a read describes the aria
287+
// AS IT WAS WHEN THE DAEMON SERVED IT, which may be several events ago, so it
288+
// may not report a turn as starting.
289+
//
290+
// The race that forces the distinction: enter the pager while a turn is
291+
// running and the catch-up ReadBefore snapshots the turn unsealed. The turn
292+
// then ends, turn.done lands and settles the status, and the read response
293+
// arrives after it, still describing the turn as live. Fired as a lifecycle
294+
// edge that says "a turn is now running", with no later event to say it
295+
// stopped, and the spinner runs until the session is closed. The sealed-part
296+
// guard in claimsOpen does not cover it, because the snapshot was taken
297+
// before the seal.
298+
func (c *Client) ApplyRead(p Page) { c.apply(p, false) }
299+
300+
// pushed says the page came from a notification. See Apply and ApplyRead.
301+
func (c *Client) apply(p Page, pushed bool) {
283302
c.mu.Lock()
284303
var finalized []Message
285304
desync := -1
286305
// opened is the turn that took the open slot on this page, 0 for none.
287306
opened := 0
307+
// claim takes the open slot AND reports the lifecycle edge, so the two
308+
// cannot drift apart. Every route into the slot goes through here: the
309+
// question arriving first (the common case), and a client that joined a
310+
// turn already in flight, whose first frame is a live delta carrying no
311+
// question at all. Reads claim the slot but report nothing (ApplyRead).
312+
claim := func(id int) {
313+
c.store.ClaimOpen(id)
314+
if pushed && id > c.openedTurn {
315+
c.openedTurn = id
316+
opened = id
317+
}
318+
}
288319
metrics := p.Metrics
289320

290321
for _, part := range p.Parts {
@@ -311,21 +342,16 @@ func (c *Client) Apply(p Page) {
311342
// describes a turn we hold only the tail of, and claiming the open
312343
// slot for it would destroy the live turn on its way past.
313344
if staged && !part.ClippedHead {
314-
c.store.ClaimOpen(id)
315-
// This is the earliest a client can know a turn is running:
316-
// the question is committed and the provider has not been
317-
// called yet. Reported outside the lock with everything else.
318-
if id > c.openedTurn {
319-
c.openedTurn = id
320-
opened = id
321-
}
345+
// The earliest a client can know a turn is running: the
346+
// question is committed and no provider has been called yet.
347+
claim(id)
322348
}
323349
}
324350

325351
// Adopt any nodes the part carries. From is the positional id of
326352
// Nodes[0], so a clipped part slots into place rather than replacing.
327353
if len(part.Nodes) > 0 && staged {
328-
c.store.ClaimOpen(id)
354+
claim(id)
329355
// A part CLIPPED off the head of a turn is ALL we hold of it: the
330356
// nodes below From were never delivered, so they are not ours to
331357
// release. Floor the emit cursor at From, or absorb's padding slots
@@ -340,7 +366,7 @@ func (c *Client) Apply(p Page) {
340366
}
341367

342368
if part.Live != nil && staged {
343-
c.store.ClaimOpen(id)
369+
claim(id)
344370
c.store.SetLiveFrom(part.Live.From)
345371
for _, nd := range part.Live.Nodes {
346372
c.store.FoldAt(nd)

‎internal/update/update.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func DetectChannel() Channel {
6262
// without a binary in every install layout.
6363
func channelFor(exe string) Channel {
6464
// Resolve one level of symlink (Nix wrappers, ~/.nix-profile, …)
65-
// so /nix/store detection works in every install layout.
65+
// so /nix/store detection sees the real path.
6666
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
6767
exe = resolved
6868
}

0 commit comments

Comments
 (0)