fix(tui): stop held peer message Allow/Deny from deadlocking Update - #1111
PierrunoYT wants to merge 1 commit into
Conversation
The peer approval prompt's decide closure called runtimeMessageSink from inside Update. The sink ends in program.Send, which blocks on bubbletea's unbuffered message channel; the only reader is the event loop that is running Update, so answering a held cross-session message hung the TUI. Give pendingPermissionPrompt a decideCmd that returns a tea.Cmd yielding the peerDecisionMsg, and have resolvePermissionWithReason return it instead of calling the sink. Tests now drive the decision through the returned command and a blocking sink, which hangs on the old code. Fixes Twigpine#1099 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Asynchronous decisions can act after expiration or release, causing stale or duplicate delivery.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Prevents held peer-message decisions from deadlocking the TUI event loop.
Changes:
- Returns peer decisions through a Bubble Tea command.
- Updates permission resolution to propagate decision commands.
- Adds Allow/Deny regression tests.
| File | Description |
|---|---|
internal/tui/peer_messages.go |
Emits peer decisions through commands. |
internal/tui/model.go |
Supports command-based permission decisions. |
internal/tui/peer_messages_test.go |
Tests nonblocking decision handling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // runtimeMessageSink blocks on the event loop that is running Update. | ||
| decideCmd: func(decision agent.PermissionDecision) tea.Cmd { | ||
| decided := peerDecisionMsg{message: message, allow: decision.Action == agent.PermissionDecisionAllow} | ||
| return func() tea.Msg { return decided } |
| // decideCmd is the Update-safe alternative to decide for prompts the TUI | ||
| // itself raises: decide forwards through runtimeMessageSink, which blocks on | ||
| // the program's unbuffered message channel when called from Update, so these | ||
| // prompts return a command that yields the follow-up message instead. |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: Twigpine/zero/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 2 remain after this review. WalkthroughPeer permission decisions now return Bubble Tea commands instead of being sent synchronously through the runtime message sink. Permission resolution returns or batches the command, and tests check the resulting decision and message ID. ChangesPeer permission resolution
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~12 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant Update as TUI Update
participant Resolve as resolvePermissionWithReason
participant BubbleTea as Bubble Tea runtime
Update->>Resolve: Resolve peer permission
Resolve-->>Update: Return decision command
Update->>BubbleTea: Execute command
BubbleTea-->>Update: Emit peerDecisionMsg
Suggested reviewers: Merge Risk: ⚪ Minimal · up to Peer decisions can be delivered without calling the blocking runtime sink during permission resolution. No issue requiring a change before merge was established. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The fix preserves explicit approval and existing permission controls while removing the blocking delivery path. However, a deferred decision can arrive after its approval has expired or been released, potentially causing stale or duplicate work in the receiving session. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |


Summary
Answering Allow or Deny on a held cross-session (peer) message hung the TUI. The prompt's
decideclosure calledm.runtimeMessageSink(peerDecisionMsg{...})from insideUpdate(viaresolvePermissionWithReason). The sink ends inprogram.Send, which in bubbletea v2.0.9 doesp.msgs <- msgon an unbuffered channel whose only reader is the event loop currently runningUpdate, so it never returned (and Ctrl+C queued behind it).This change adds a
decideCmdfield topendingPermissionPrompt. The peer prompt uses it to return atea.Cmdthat yieldspeerDecisionMsg, andresolvePermissionWithReasonreturns that command instead of calling the sink. The existingpeerDecisionMsghandling inUpdateis unchanged. Agent permission prompts keep usingdecide, which is called from the agent goroutine, not fromUpdate.Not included: the issue's optional "debug guard against sink calls on the Update goroutine".
Linked issue
Fixes #1099
Note: #1099 does not currently carry the
issue-approvedlabel. Opening this anyway at the author's request; it can be held until the issue is approved.Verification
TestPeerApprovalDecisionDoesNotCallBlockingSinkFromUpdateinstalls a sink that blocks and resolves the held prompt with Allow and Deny. Without the fix it fails after the 5 s guard ("resolving a held peer prompt blocked on the runtime sink"); with the fix it passes.TestPermissionMismatchHoldsPeerMessageForExplicitDecisionandTestPeerApprovalDecisionWaitsForCompletionBeforeOpeningNextpreviously read the decision from a capturing sink, which is why they could not see the bug. They now take the decision from the returned command, and the first also asserts the sink is not called. They fail on the old code and pass on the new.go build ./...,go vet ./internal/tui/,gofmt -l internal/tui,git diff HEAD --checkclean;go test ./internal/tui/passes.go test -race(cgo unavailable here),go test ./..., smoke,maketargets. Relying on CI for those.Checklist
issue-approvedlabel. (not yet)go build ./...,go vet ./..., andgo test ./...pass locally. (build passes; vet and tests only for./internal/tui)gofmtclean.-racewhere relevant). (tests added;-racenot run locally)🤖 Generated with Claude Code
Summary by CodeRabbit