fix(19.7): RA-WR-01 never wait on a session lock under the router lock

Fan-out matching snapshots the sessions (and stateless entries) under the
router-wide lock, releases it, and reads each identity through a small
identity box that is never held across I/O, so an event no longer waits on
a session that is re-filtering while it holds sr.mu or sp.mu.

Widening and the fan-out recomputes run their guard checks with
guardContext: not cancelled with the triggering request, but bounded by
mcp.call_timeout, so a provider that never answers cannot hold a session
lock forever. README and docs/services/mcp.md describe the wider meaning of
mcp.call_timeout.

TestLockScopeUnderWidening blocks a widening guard check, fires a
permission event for that session and asserts that another session's call
still finishes, and that a never-answering check ends at call_timeout.
This commit is contained in:
2026-10-23 20:02:13 +02:00
parent f53668a3e8
commit cccf4025ea
7 changed files with 198 additions and 28 deletions

View File

@@ -159,3 +159,123 @@ type statusError struct{ got, want int }
func (e *statusError) Error() string {
return "status " + http.StatusText(e.got) + ", want " + http.StatusText(e.want)
}
// widenGuard is the acme guard whose first MCPAllows for user 8 after arm
// blocks until release or until the check's context ends (then it errors,
// as a database call would).
type widenGuard struct {
acmeGuard
armed atomic.Bool
entered chan struct{}
release chan struct{}
}
func (g *widenGuard) MCPAllows(ctx context.Context, s pact.MCPSubject, _ pact.MCPRequirements, _ []string) (bool, error) {
if s.UserID == 8 && g.armed.CompareAndSwap(true, false) {
select {
case g.entered <- struct{}{}:
default:
}
select {
case <-g.release:
return true, nil
case <-ctx.Done():
return false, ctx.Err()
}
}
return true, nil
}
const listToolsBody = `{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}`
// TestLockScopeUnderWidening pins RA-WR-01: a session re-filtered on return
// (its identity's fingerprint changed) runs its guard checks under its own
// lock only, with mcp.call_timeout as their deadline. A fan-out event that
// concerns the blocked session never stalls the router, so another
// session's call still finishes, and a check that never answers ends at the
// deadline instead of holding the session forever.
func TestLockScopeUnderWidening(t *testing.T) {
setup := func(t *testing.T, mcpYAML string) (*instance, *fakeVerifier, *widenGuard) {
t.Helper()
guard := &widenGuard{entered: make(chan struct{}, 1), release: make(chan struct{})}
verifier := newFakeVerifier(nil)
in := startInstance(t, newApp(t, appSpec{
mcp: mcpYAML,
guards: []pact.MCPGuard{guard},
verifier: verifier,
}), acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}})
t.Cleanup(in.pier().StopReaper)
return in, verifier, guard
}
// widenBlocked narrows user 8's grant, so the next request on its
// session re-filters, arms the guard and sends that request; it answers
// the request's status channel once the guard check has started.
widenBlocked := func(t *testing.T, ctx context.Context, in *instance, verifier *fakeVerifier, guard *widenGuard, sid string) <-chan int {
t.Helper()
verifier.setGrant("oas_at_other", grant(8, "c2", 2, "read"))
guard.armed.Store(true)
done := make(chan int, 1)
go func() {
status, _ := rawStatus(ctx, http.MethodPost, in.url(), "oas_at_other", sid, "2025-11-25", listToolsBody)
done <- status
}()
select {
case <-guard.entered:
case <-time.After(10 * time.Second):
t.Fatal("the widening request never reached the guard")
}
return done
}
t.Run("an event for the blocked session does not stall another session", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
in, verifier, guard := setup(t, "enabled: false\nsessions:\n store: memory\n")
active := openSession(t, ctx, in, "oas_at_test")
slow := openSession(t, ctx, in, "oas_at_other")
blocked := widenBlocked(t, ctx, in, verifier, guard, slow)
defer func() {
close(guard.release)
<-blocked
}()
// The event recomputes user 8's session, so it waits for that
// session's lock; it must not hold the router lock meanwhile.
fireSubjectChanged(t, in.app, "acme", 8, pact.MCPSubjectPermissions)
time.Sleep(200 * time.Millisecond)
done := make(chan error, 1)
go func() {
got, err := rawStatus(ctx, http.MethodPost, in.url(), "oas_at_test", active, "2025-11-25", callBody)
if err == nil && got != http.StatusOK {
err = &statusError{got: got, want: http.StatusOK}
}
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("tools/call on another session: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("another session's call waited for the blocked widening")
}
})
t.Run("a blocked widening ends at mcp.call_timeout", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
in, verifier, guard := setup(t, "enabled: false\ncall_timeout: 1s\nsessions:\n store: memory\n")
slow := openSession(t, ctx, in, "oas_at_other")
blocked := widenBlocked(t, ctx, in, verifier, guard, slow)
defer close(guard.release)
select {
case status := <-blocked:
if status != http.StatusOK {
t.Fatalf("the widening request = %d", status)
}
case <-time.After(10 * time.Second):
t.Fatal("the widening guard check outlived mcp.call_timeout")
}
})
}