Skip to content

Commit d776d32

Browse files
wesmclaude
andauthored
Fix race condition causing addressed items to briefly reappear (#67)
## Summary - Fix race condition where addressed items with hide-addressed filter active would disappear, briefly reappear (~100-200ms), then disappear again - The race occurred when success response cleared pending state before a stale jobs refresh arrived with old data - Now pending state is only cleared when jobs refresh confirms the server has the updated state 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0391dbf commit d776d32

File tree

2 files changed

+187
-17
lines changed

2 files changed

+187
-17
lines changed

cmd/roborev/tui.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,8 +1399,24 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
13991399
m.jobs = msg.jobs
14001400
}
14011401

1402-
// Apply any pending addressed changes to prevent flash during race condition
1403-
// (user pressed 'a' but server response arrived before addressed update completed)
1402+
// Clear pending addressed states that server has confirmed (data matches pending)
1403+
// This must happen before re-applying, so we can check the raw server state
1404+
for jobID, pending := range m.pendingAddressed {
1405+
for i := range m.jobs {
1406+
if m.jobs[i].ID == jobID {
1407+
// Check if server state matches pending state
1408+
// Treat nil as false (unaddressed) for comparison
1409+
serverState := m.jobs[i].Addressed != nil && *m.jobs[i].Addressed
1410+
if serverState == pending.newState {
1411+
delete(m.pendingAddressed, jobID)
1412+
}
1413+
break
1414+
}
1415+
}
1416+
}
1417+
1418+
// Apply any remaining pending addressed changes to prevent flash during race
1419+
// condition (server data is stale, from request sent before update completed)
14041420
for i := range m.jobs {
14051421
if pending, ok := m.pendingAddressed[m.jobs[i].ID]; ok {
14061422
newState := pending.newState
@@ -1557,13 +1573,15 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15571573
}
15581574
// Stale error responses are silently ignored
15591575
} else {
1560-
// Success: clear pending state if current
1561-
if isCurrentRequest {
1562-
if msg.jobID > 0 {
1563-
delete(m.pendingAddressed, msg.jobID)
1564-
} else if msg.reviewID > 0 {
1565-
delete(m.pendingReviewAddressed, msg.reviewID)
1566-
}
1576+
// Success handling differs by type:
1577+
// - For jobs (jobID > 0): don't clear here. Let the jobs refresh handler
1578+
// clear it when server data confirms the update. This prevents a race
1579+
// where we clear pending, then a stale jobs response arrives and briefly
1580+
// shows the old state.
1581+
// - For review-only (no jobID): clear immediately. The race condition doesn't
1582+
// apply because pendingReviewAddressed isn't affected by jobs refresh.
1583+
if isCurrentRequest && msg.jobID == 0 && msg.reviewID > 0 {
1584+
delete(m.pendingReviewAddressed, msg.reviewID)
15671585
}
15681586
}
15691587

cmd/roborev/tui_test.go

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4240,7 +4240,7 @@ func TestTUIPendingAddressedNotClearedByStaleResponse(t *testing.T) {
42404240
}
42414241
}
42424242

4243-
func TestTUIPendingAddressedClearedByMatchingResponse(t *testing.T) {
4243+
func TestTUIPendingAddressedNotClearedOnSuccess(t *testing.T) {
42444244
m := newTuiModel("http://localhost")
42454245

42464246
addressedFalse := false
@@ -4251,21 +4251,83 @@ func TestTUIPendingAddressedClearedByMatchingResponse(t *testing.T) {
42514251
// User toggles addressed to true
42524252
m.pendingAddressed[1] = pendingState{newState: true, seq: 1}
42534253

4254-
// Response comes back for the correct toggle to true
4255-
matchingMsg := tuiAddressedResultMsg{
4254+
// Success response comes back
4255+
successMsg := tuiAddressedResultMsg{
42564256
jobID: 1,
42574257
oldState: false,
4258-
newState: true, // This response matches what we requested
4259-
seq: 1, // Matches pending seq
4258+
newState: true,
4259+
seq: 1,
42604260
err: nil,
42614261
}
42624262

4263-
updated, _ := m.Update(matchingMsg)
4263+
updated, _ := m.Update(successMsg)
42644264
m2 := updated.(tuiModel)
42654265

4266-
// pendingAddressed should be cleared because newState matches
4266+
// pendingAddressed should NOT be cleared on success - it waits for jobs refresh
4267+
// to confirm the update. This prevents race condition where stale jobs response
4268+
// arrives after success and briefly shows old state.
4269+
if _, ok := m2.pendingAddressed[1]; !ok {
4270+
t.Error("pendingAddressed should NOT be cleared on success response")
4271+
}
4272+
}
4273+
4274+
func TestTUIPendingAddressedClearedByJobsRefresh(t *testing.T) {
4275+
m := newTuiModel("http://localhost")
4276+
4277+
addressedFalse := false
4278+
m.jobs = []storage.ReviewJob{
4279+
{ID: 1, Status: storage.JobStatusDone, Addressed: &addressedFalse},
4280+
}
4281+
4282+
// User toggles addressed to true
4283+
m.pendingAddressed[1] = pendingState{newState: true, seq: 1}
4284+
4285+
// Jobs refresh arrives with server data confirming the update
4286+
addressedTrue := true
4287+
jobsMsg := tuiJobsMsg{
4288+
jobs: []storage.ReviewJob{
4289+
{ID: 1, Status: storage.JobStatusDone, Addressed: &addressedTrue},
4290+
},
4291+
}
4292+
4293+
updated, _ := m.Update(jobsMsg)
4294+
m2 := updated.(tuiModel)
4295+
4296+
// pendingAddressed should be cleared because server data matches pending state
42674297
if _, ok := m2.pendingAddressed[1]; ok {
4268-
t.Error("pendingAddressed should be cleared when response newState matches pending state")
4298+
t.Error("pendingAddressed should be cleared when jobs refresh confirms update")
4299+
}
4300+
}
4301+
4302+
func TestTUIPendingAddressedNotClearedByStaleJobsRefresh(t *testing.T) {
4303+
m := newTuiModel("http://localhost")
4304+
4305+
addressedFalse := false
4306+
m.jobs = []storage.ReviewJob{
4307+
{ID: 1, Status: storage.JobStatusDone, Addressed: &addressedFalse},
4308+
}
4309+
4310+
// User toggles addressed to true
4311+
m.pendingAddressed[1] = pendingState{newState: true, seq: 1}
4312+
4313+
// Stale jobs refresh arrives with old data (from request sent before update)
4314+
staleJobsMsg := tuiJobsMsg{
4315+
jobs: []storage.ReviewJob{
4316+
{ID: 1, Status: storage.JobStatusDone, Addressed: &addressedFalse}, // Still false!
4317+
},
4318+
}
4319+
4320+
updated, _ := m.Update(staleJobsMsg)
4321+
m2 := updated.(tuiModel)
4322+
4323+
// pendingAddressed should NOT be cleared because server data doesn't match
4324+
if _, ok := m2.pendingAddressed[1]; !ok {
4325+
t.Error("pendingAddressed should NOT be cleared when jobs refresh has stale data")
4326+
}
4327+
4328+
// Job should still show as addressed (pending state re-applied)
4329+
if m2.jobs[0].Addressed == nil || !*m2.jobs[0].Addressed {
4330+
t.Error("Job should still show as addressed due to pending state")
42694331
}
42704332
}
42714333

@@ -4632,3 +4694,93 @@ func TestTUIQueueNoScrollIndicatorPads(t *testing.T) {
46324694
t.Errorf("Queue with few jobs should maintain height, got %d lines for height %d", len(lines), m.height)
46334695
}
46344696
}
4697+
4698+
func TestTUIPendingReviewAddressedClearedOnSuccess(t *testing.T) {
4699+
// pendingReviewAddressed (for reviews without jobs) should be cleared on success
4700+
// because the race condition only affects jobs list refresh, not review-only items
4701+
m := newTuiModel("http://localhost")
4702+
4703+
// Track a review-only pending state (no job ID)
4704+
m.pendingReviewAddressed[42] = pendingState{newState: true, seq: 1}
4705+
4706+
// Success response for review-only (jobID=0, reviewID=42)
4707+
successMsg := tuiAddressedResultMsg{
4708+
jobID: 0, // No job - this is review-only
4709+
reviewID: 42,
4710+
reviewView: true,
4711+
oldState: false,
4712+
newState: true,
4713+
seq: 1,
4714+
err: nil,
4715+
}
4716+
4717+
updated, _ := m.Update(successMsg)
4718+
m2 := updated.(tuiModel)
4719+
4720+
// pendingReviewAddressed SHOULD be cleared on success (no race condition for review-only)
4721+
if _, ok := m2.pendingReviewAddressed[42]; ok {
4722+
t.Error("pendingReviewAddressed should be cleared on success for review-only items")
4723+
}
4724+
}
4725+
4726+
func TestTUIPendingAddressedClearsWhenServerNilMatchesFalse(t *testing.T) {
4727+
// When pending newState is false and server Addressed is nil,
4728+
// treat nil as false and clear the pending state
4729+
m := newTuiModel("http://localhost")
4730+
4731+
// Job with nil Addressed (e.g., partial payload or non-done status that became done)
4732+
m.jobs = []storage.ReviewJob{
4733+
{ID: 1, Status: storage.JobStatusDone, Addressed: nil},
4734+
}
4735+
4736+
// User had toggled to false (unaddressed)
4737+
m.pendingAddressed[1] = pendingState{newState: false, seq: 1}
4738+
4739+
// Jobs refresh arrives with nil Addressed (should match false)
4740+
jobsMsg := tuiJobsMsg{
4741+
jobs: []storage.ReviewJob{
4742+
{ID: 1, Status: storage.JobStatusDone, Addressed: nil},
4743+
},
4744+
}
4745+
4746+
updated, _ := m.Update(jobsMsg)
4747+
m2 := updated.(tuiModel)
4748+
4749+
// pendingAddressed should be cleared because nil == false matches newState=false
4750+
if _, ok := m2.pendingAddressed[1]; ok {
4751+
t.Error("pendingAddressed should be cleared when server nil matches pending false")
4752+
}
4753+
}
4754+
4755+
func TestTUIPendingAddressedNotClearsWhenServerNilMismatchesTrue(t *testing.T) {
4756+
// When pending newState is true and server Addressed is nil,
4757+
// do NOT clear (nil != true)
4758+
m := newTuiModel("http://localhost")
4759+
4760+
m.jobs = []storage.ReviewJob{
4761+
{ID: 1, Status: storage.JobStatusDone, Addressed: nil},
4762+
}
4763+
4764+
// User toggled to true (addressed)
4765+
m.pendingAddressed[1] = pendingState{newState: true, seq: 1}
4766+
4767+
// Jobs refresh arrives with nil Addressed (doesn't match true)
4768+
jobsMsg := tuiJobsMsg{
4769+
jobs: []storage.ReviewJob{
4770+
{ID: 1, Status: storage.JobStatusDone, Addressed: nil},
4771+
},
4772+
}
4773+
4774+
updated, _ := m.Update(jobsMsg)
4775+
m2 := updated.(tuiModel)
4776+
4777+
// pendingAddressed should NOT be cleared because nil != true
4778+
if _, ok := m2.pendingAddressed[1]; !ok {
4779+
t.Error("pendingAddressed should NOT be cleared when server nil mismatches pending true")
4780+
}
4781+
4782+
// Job should show as addressed due to pending state re-applied
4783+
if m2.jobs[0].Addressed == nil || !*m2.jobs[0].Addressed {
4784+
t.Error("Job should show as addressed due to pending state")
4785+
}
4786+
}

0 commit comments

Comments
 (0)