Refuse an ambiguous X display and settle the approval timeout race under one claim

Claude-Session: https://claude.ai/code/session_01QKDYfH4WKLbpNQHccpVo3P
This commit is contained in:
Viktor Liu
2026-08-29 17:25:02 +02:00
parent fecd7cfff5
commit 12040b1be0
8 changed files with 307 additions and 94 deletions
+69 -17
View File
@@ -128,19 +128,27 @@ type Decision struct {
ViewOnly bool
}
// pendingRequest is one in-flight prompt. resolved records that somebody has
// already claimed it, so the user's decision and the caller giving up race for
// the same entry under Broker.mu and exactly one of them wins.
type pendingRequest struct {
resp chan Decision
resolved bool
}
// Broker holds in-flight approval requests keyed by request ID.
type Broker struct {
pub EventPublisher
mu sync.Mutex
pending map[string]chan Decision
pending map[string]*pendingRequest
}
// New returns a broker that publishes prompts via pub.
func New(pub EventPublisher) *Broker {
return &Broker{
pub: pub,
pending: make(map[string]chan Decision),
pending: make(map[string]*pendingRequest),
}
}
@@ -161,7 +169,7 @@ func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
resp := make(chan Decision, 1)
b.mu.Lock()
b.pending[id] = resp
b.pending[id] = &pendingRequest{resp: resp}
b.mu.Unlock()
defer b.dropPending(id)
@@ -195,17 +203,61 @@ func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
select {
case d := <-resp:
if !d.Accept {
return zero, ErrDenied
}
return d, nil
return decisionResult(d)
case <-timer.C:
if d, answered := b.giveUp(id, resp); answered {
return decisionResult(d)
}
return zero, ErrTimeout
case <-ctx.Done():
if d, answered := b.giveUp(id, resp); answered {
return decisionResult(d)
}
return zero, ctx.Err()
}
}
// giveUp abandons the request and reports whether the user's decision landed
// first, in which case it is returned and must be honoured.
//
// Respond and this path claim the same entry under the same lock, so exactly
// one of them wins. Without that claim, a click arriving as the timer fires
// would be told it matched a live prompt while this caller had already denied
// the connection: the user would see their accept confirmed and the session
// dropped anyway.
func (b *Broker) giveUp(id string, resp <-chan Decision) (Decision, bool) {
if b.claim(id) {
return Decision{}, false
}
// Respond claimed the entry and sent while still holding the lock, so the
// decision is already buffered and this receive cannot block.
return <-resp, true
}
// claim marks the request resolved so nothing else can take it, reporting
// whether the caller got there first. Callers must not already hold b.mu.
func (b *Broker) claim(id string) bool {
b.mu.Lock()
defer b.mu.Unlock()
p, ok := b.pending[id]
if !ok || p.resolved {
return false
}
p.resolved = true
delete(b.pending, id)
return true
}
// decisionResult maps a decision the user actually made onto the Request
// contract: a deny is an error, an accept carries the view-only flag back.
func decisionResult(d Decision) (Decision, error) {
if !d.Accept {
return Decision{}, ErrDenied
}
return d, nil
}
// Respond delivers the user's decision for id. Returns true when a pending
// request matched and was woken, false when id was unknown or already done.
func (b *Broker) Respond(id string, d Decision) bool {
@@ -213,18 +265,18 @@ func (b *Broker) Respond(id string, d Decision) bool {
return false
}
b.mu.Lock()
ch, ok := b.pending[id]
if ok {
delete(b.pending, id)
}
b.mu.Unlock()
if !ok {
defer b.mu.Unlock()
p, ok := b.pending[id]
if !ok || p.resolved {
return false
}
select {
case ch <- d:
default:
}
p.resolved = true
delete(b.pending, id)
// The channel is buffered and claimed exactly once, so this never blocks.
// Sent under the lock so a Request that loses the claim race finds the
// decision already waiting instead of racing this send.
p.resp <- d
return true
}
+74
View File
@@ -458,3 +458,77 @@ func TestRequestViewOnly(t *testing.T) {
t.Fatal("view-only request did not resolve")
}
}
// TestGiveUpAndRespondAreMutuallyExclusive pins the claim that makes
// RespondApprovalResponse.matched truthful: for one request, exactly one of
// "the user answered" and "the caller gave up" wins, in either arrival order.
// Without it a click landing as the timer fires is told it matched a live
// prompt while the connection has already been denied.
func TestGiveUpAndRespondAreMutuallyExclusive(t *testing.T) {
t.Run("caller gives up first", func(t *testing.T) {
b := New(&fakePublisher{subscribers: true})
resp := make(chan Decision, 1)
b.pending["req"] = &pendingRequest{resp: resp}
d, answered := b.giveUp("req", resp)
assert.False(t, answered, "nothing had answered yet")
assert.False(t, d.Accept)
assert.False(t, b.Respond("req", Decision{Accept: true}),
"a response arriving after the caller gave up must not report a match")
})
t.Run("user answers first", func(t *testing.T) {
b := New(&fakePublisher{subscribers: true})
resp := make(chan Decision, 1)
b.pending["req"] = &pendingRequest{resp: resp}
require.True(t, b.Respond("req", Decision{Accept: true, ViewOnly: true}))
d, answered := b.giveUp("req", resp)
require.True(t, answered, "giving up after the user answered must surface their decision")
assert.True(t, d.Accept)
assert.True(t, d.ViewOnly, "the view-only grant must survive the race")
})
}
// TestRespondRacingTimeoutIsConsistent aims a Respond at the deadline and
// requires the reported match to agree with the outcome the caller saw. A
// stress check on the invariant above rather than a reproduction: the losing
// window is a few instructions wide, so this does not reliably fail without the
// claim, but it does catch an outcome pair that should never occur.
func TestRespondRacingTimeoutIsConsistent(t *testing.T) {
defaultTimeout(t, 20*time.Millisecond)
defer defaultTimeout(t, DefaultTimeout)
for i := 0; i < 50; i++ {
pub := &fakePublisher{subscribers: true}
b := New(pub)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
}()
id := waitForRequestID(t, pub)
matchedCh := make(chan bool, 1)
go func() {
// Aim at the deadline so the claim lands on either side of it.
time.Sleep(20 * time.Millisecond)
matchedCh <- b.Respond(id, Decision{Accept: true})
}()
var err error
select {
case err = <-done:
case <-time.After(2 * time.Second):
t.Fatal("prompt neither resolved nor timed out")
}
matched := <-matchedCh
if matched {
require.NoErrorf(t, err, "iteration %d: Respond reported the prompt matched, so the accept must be honoured", i)
continue
}
require.ErrorIsf(t, err, ErrTimeout, "iteration %d: Respond reported no match, so the request must have timed out", i)
}
}