[minor-10] Watch DELETE order can strand watcher in watcher_list #94

Closed
opened 2026-09-05 02:40:27 +00:00 by crueber · 3 comments
Owner

[minor-10] Watch DELETE order can strand watcher in watcher_list

internal/notify/watch.go:61-68: SetWatch(off) deletes the record BEFORE the socialWatch CAS. If the CAS fails (or the process dies between), the record is gone while watcher_list still contains the principal — they keep receiving subscribed-class fan-out with no record to delete (repair only via deliberate re-PUT+DELETE).

Fix

Reverse the order: list CAS first, then record delete (a crash between then leaves a record without list membership — fail-closed, self-heals on next toggle). Regression test (fault-injected CAS failure → no phantom watcher). Coverage gate holds; doc Decisions entry (law 12).

Acceptance criteria

  • No failure ordering strands a watcher; test green -race.
# [minor-10] Watch DELETE order can strand watcher in `watcher_list` `internal/notify/watch.go:61-68`: `SetWatch(off)` deletes the record BEFORE the `socialWatch` CAS. If the CAS fails (or the process dies between), the record is gone while `watcher_list` still contains the principal — they keep receiving subscribed-class fan-out with no record to delete (repair only via deliberate re-PUT+DELETE). ## Fix Reverse the order: list CAS first, then record delete (a crash between then leaves a record without list membership — fail-closed, self-heals on next toggle). Regression test (fault-injected CAS failure → no phantom watcher). Coverage gate holds; doc Decisions entry (law 12). ## Acceptance criteria - [ ] No failure ordering strands a watcher; test green `-race`.
Author
Owner

Fixed by #103 (#103): SetWatch(off) now CASes watcher_list before deleting the record — fail-closed + self-healing. Regression test + Decisions entry included; -race green, coverage 97.3%.

Fixed by #103 (https://git.packden.us/crueber/walhub/pulls/103): SetWatch(off) now CASes watcher_list before deleting the record — fail-closed + self-healing. Regression test + Decisions entry included; -race green, coverage 97.3%.
Author
Owner

PR #103 review (fix/issue-94, commit 6967d10) — APPROVED, ready to merge.

Order reversal correct (watch.go:69-78): off-path is now repoAlive probe → socialWatch CAS(false) → record Delete. Ghost-repo path (70-73) deletes-then-skips-CAS with zero writes to social.json — resurrection impossible (repoAlive at notify.go:614 is a read-only Exists probe; Delete cannot create).

Crash-between fail-closed confirmed: fan-out resolve() (emit.go:341) consumes s.watchers() (emit.go:405-414), which reads social.json watcher_list only — never the userspace record. Record-without-membership notifies no one.

Self-heal walked: retry off → socialWatch removes member (or !on&&!have reconcile no-op at watch.go:105-115) → Delete → converges to record-gone+list-gone. Retry on → putCreate 412-tolerated + CAS re-add. Both converge.

on=true path unchanged (diff touches only the else-branch) and already safe: CAS failure there leaves record+no-member — the same fail-closed shape, retry converges via the 412-tolerated putCreate.

CAS-failure return correct: socialWatch err → SetWatch returns WatchState{},err; http.go:300-306 propagates via statusFor (no swallow) — caller sees the error and can retry.

Regression test genuine: TestSetWatchUnwatchCASFailureNoPhantom (cover2_test.go:571) FAILS on the pre-fix order at line 624 ('phantom watcher stranded in watcher_list') and PASSES with the fix. Retry-convergence asserted (629-637).

Hygiene: no new imports/locks (diff reorders two statements + comment); gofmt clean; go vet clean; go test -race ./internal/notify/... green; coverage 97.3% (≥95% gate holds). Doc entry (06_notifications.md:466) accurate — matches code, law-4 rationale correct.

No changes pushed (nothing to fix). MERGE RECOMMENDATION: ready to merge.

PR #103 review (fix/issue-94, commit 6967d10) — APPROVED, ready to merge. Order reversal correct (watch.go:69-78): off-path is now repoAlive probe → socialWatch CAS(false) → record Delete. Ghost-repo path (70-73) deletes-then-skips-CAS with zero writes to social.json — resurrection impossible (repoAlive at notify.go:614 is a read-only Exists probe; Delete cannot create). Crash-between fail-closed confirmed: fan-out resolve() (emit.go:341) consumes s.watchers() (emit.go:405-414), which reads social.json watcher_list only — never the userspace record. Record-without-membership notifies no one. Self-heal walked: retry off → socialWatch removes member (or !on&&!have reconcile no-op at watch.go:105-115) → Delete → converges to record-gone+list-gone. Retry on → putCreate 412-tolerated + CAS re-add. Both converge. on=true path unchanged (diff touches only the else-branch) and already safe: CAS failure there leaves record+no-member — the same fail-closed shape, retry converges via the 412-tolerated putCreate. CAS-failure return correct: socialWatch err → SetWatch returns WatchState{},err; http.go:300-306 propagates via statusFor (no swallow) — caller sees the error and can retry. Regression test genuine: TestSetWatchUnwatchCASFailureNoPhantom (cover2_test.go:571) FAILS on the pre-fix order at line 624 ('phantom watcher stranded in watcher_list') and PASSES with the fix. Retry-convergence asserted (629-637). Hygiene: no new imports/locks (diff reorders two statements + comment); gofmt clean; go vet clean; go test -race ./internal/notify/... green; coverage 97.3% (≥95% gate holds). Doc entry (06_notifications.md:466) accurate — matches code, law-4 rationale correct. No changes pushed (nothing to fix). MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #103 (review: order, fail-closed, self-heal verified, negative control genuine; 97.3% coverage), merged. Closing.

Fixed by PR #103 (review: order, fail-closed, self-heal verified, negative control genuine; 97.3% coverage), merged. Closing.
crueber added this to the v1 milestone 2026-09-10 22:27:20 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#94
No description provided.