Add concurrency guards to harness.Run: single-flight, semaphore and timeouts
harness.Run now takes a scope key. Concurrent runs of one skill with one scope share a single in-flight run and its result, errors included, so only one Generate call is made; different scopes run independently. A process-wide Guard (NewGuard, DefaultMaxCalls = 2) bounds concurrent LLM calls with a context-aware semaphore. Each skill declares Timeout() (DefaultTimeout = 60 s), enforced via context over the whole run; a run past it fails with an error matching llm.ErrTimeout. A caller whose context ends gets the context error; the shared run is cancelled, and its slot freed, once no caller waits for it. llm.Fake results can now block (Release) and signal entry (Started), so tests hold calls in flight. Maestro task: 8f533721-32c9-4f73-af9f-9a9f0e94abf8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+27
-5
@@ -11,7 +11,9 @@ import (
|
||||
// Fake is a scripted Provider for tests. Each method returns its queued
|
||||
// results in order, one per call, and records every Request Generate
|
||||
// receives. A call with nothing left in its queue fails, so an unexpected
|
||||
// call is never silently answered. It is safe for concurrent use.
|
||||
// call is never silently answered. A Generate result can block until the
|
||||
// test releases it, so tests can hold calls in flight. It is safe for
|
||||
// concurrent use.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
generate []GenerateResult
|
||||
@@ -26,6 +28,13 @@ var _ Provider = (*Fake)(nil)
|
||||
type GenerateResult struct {
|
||||
Response Response
|
||||
Err error
|
||||
// Started, when set, is closed once the call has taken this result and
|
||||
// recorded its Request, before it blocks on Release.
|
||||
Started chan struct{}
|
||||
// Release, when set, makes the call block until it is closed. When
|
||||
// the call's context is done first, the call returns ctx.Err() instead
|
||||
// of Response and Err, as a real provider does.
|
||||
Release <-chan struct{}
|
||||
}
|
||||
|
||||
// ListModelsResult is one scripted ListModels outcome.
|
||||
@@ -55,16 +64,29 @@ func (f *Fake) QueueVerify(results ...error) {
|
||||
f.verify = append(f.verify, results...)
|
||||
}
|
||||
|
||||
// Generate records req and returns the next scripted result.
|
||||
func (f *Fake) Generate(_ context.Context, req Request) (Response, error) {
|
||||
// Generate records req and returns the next scripted result, blocking
|
||||
// first when the result has a Release channel.
|
||||
func (f *Fake) Generate(ctx context.Context, req Request) (Response, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests = append(f.requests, cloneRequest(req))
|
||||
if len(f.generate) == 0 {
|
||||
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", len(f.requests))
|
||||
n := len(f.requests)
|
||||
f.mu.Unlock()
|
||||
return Response{}, fmt.Errorf("llm: fake: unscripted Generate call %d", n)
|
||||
}
|
||||
r := f.generate[0]
|
||||
f.generate = f.generate[1:]
|
||||
f.mu.Unlock()
|
||||
if r.Started != nil {
|
||||
close(r.Started)
|
||||
}
|
||||
if r.Release != nil {
|
||||
select {
|
||||
case <-r.Release:
|
||||
case <-ctx.Done():
|
||||
return Response{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
return r.Response, r.Err
|
||||
}
|
||||
|
||||
|
||||
@@ -136,3 +136,37 @@ func TestFakeConcurrentUse(t *testing.T) {
|
||||
t.Fatalf("%d Generate results unused", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeGenerateBlocksUntilReleased(t *testing.T) {
|
||||
var f Fake
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
f.QueueGenerate(GenerateResult{Response: Response{Text: "done"}, Started: started, Release: release})
|
||||
got := make(chan string)
|
||||
go func() {
|
||||
resp, err := f.Generate(context.Background(), Request{})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
got <- resp.Text
|
||||
}()
|
||||
<-started
|
||||
select {
|
||||
case text := <-got:
|
||||
t.Fatalf("Generate returned %q before release", text)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if text := <-got; text != "done" {
|
||||
t.Fatalf("Generate = %q, want the scripted reply", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeBlockedGenerateHonoursContext(t *testing.T) {
|
||||
var f Fake
|
||||
f.QueueGenerate(GenerateResult{Response: Response{Text: "never"}, Release: make(chan struct{})})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := f.Generate(ctx, Request{}); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user