mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 21:49:08 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user