Skip to content

Commit 7e17d2d

Browse files
committed
test(context): measure check-all concurrency at client boundary
1 parent 69198d4 commit 7e17d2d

1 file changed

Lines changed: 69 additions & 22 deletions

File tree

‎internal/integration/cli_additional_test.go‎

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package integration_test
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"fmt"
68
"io"
79
"net/http"
@@ -11,6 +13,8 @@ import (
1113
"sync/atomic"
1214
"testing"
1315
"time"
16+
17+
"github.com/langbot-app/langbot-cli/internal/command"
1418
)
1519

1620
type panicReader struct{}
@@ -79,42 +83,85 @@ func TestContextRemoveCurrentRequiresConfirmationAndClearsDefault(t *testing.T)
7983
}
8084
}
8185

86+
type checkAllTimeoutTransport struct {
87+
active, maxActive, calls atomic.Int32
88+
}
89+
90+
func (transport *checkAllTimeoutTransport) RoundTrip(request *http.Request) (*http.Response, error) {
91+
transport.calls.Add(1)
92+
current := transport.active.Add(1)
93+
defer transport.active.Add(-1)
94+
for {
95+
previous := transport.maxActive.Load()
96+
if current <= previous || transport.maxActive.CompareAndSwap(previous, current) {
97+
break
98+
}
99+
}
100+
<-request.Context().Done()
101+
return nil, request.Context().Err()
102+
}
103+
82104
func TestCheckAllUsesBoundedConcurrentIndependentTimeouts(t *testing.T) {
83105
path := filepath.Join(t.TempDir(), "config.yaml")
84-
var active, maxActive, started, calls atomic.Int32
85-
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86-
calls.Add(1)
87-
started.Add(1)
88-
current := active.Add(1)
89-
for {
90-
previous := maxActive.Load()
91-
if current <= previous || maxActive.CompareAndSwap(previous, current) {
92-
break
93-
}
106+
for i := 0; i < 6; i++ {
107+
requireSuccess(t, run(t, path, nil, "", "context", "add", fmt.Sprintf("ctx-%d", i), "--endpoint", "http://example.test"))
108+
}
109+
110+
// Measure client call lifetime. A remote handler can remain alive briefly
111+
// after cancellation, so its lifetime is not a proxy for occupied workers.
112+
transport := &checkAllTimeoutTransport{}
113+
var out, diagnostic bytes.Buffer
114+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
115+
defer cancel()
116+
code := command.Execute(ctx, []string{"-o", "json", "context", "check", "--all", "--timeout", "80ms"}, command.Dependencies{
117+
In: strings.NewReader(""), Out: &out, Err: &diagnostic, Transport: transport,
118+
LookupEnv: func(string) (string, bool) { return "", false },
119+
DefaultConfigPath: func() (string, error) { return path, nil },
120+
})
121+
if code == 0 || diagnostic.Len() != 0 || ctx.Err() != nil {
122+
t.Fatalf("unexpected batch outcome: exit=%d context=%v stderr=%s output=%s", code, ctx.Err(), diagnostic.String(), out.String())
123+
}
124+
var envelope map[string]any
125+
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
126+
t.Fatalf("invalid batch JSON: %v", err)
127+
}
128+
checks := envelope["data"].(map[string]any)["checks"].([]any)
129+
if len(checks) != 6 || transport.calls.Load() != 6 || transport.active.Load() != 0 {
130+
t.Fatalf("batch did not retain every outcome or finish calls: calls=%d active=%d result=%s", transport.calls.Load(), transport.active.Load(), out.String())
131+
}
132+
if transport.maxActive.Load() > 4 {
133+
t.Fatalf("check-all exceeded worker bound: max active=%d", transport.maxActive.Load())
134+
}
135+
for index, check := range checks {
136+
item := check.(map[string]any)
137+
if item["context"] != fmt.Sprintf("ctx-%d", index) || item["error"] == nil {
138+
t.Fatalf("independent timeout outcome missing: %#v", item)
94139
}
95-
<-r.Context().Done()
96-
active.Add(-1)
140+
}
141+
}
142+
143+
func TestCheckAllRealHTTPTimeoutsRetainEveryOutcome(t *testing.T) {
144+
path := filepath.Join(t.TempDir(), "config.yaml")
145+
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
146+
<-request.Context().Done()
97147
}))
98148
defer server.Close()
99149
for i := 0; i < 6; i++ {
100150
requireSuccess(t, run(t, path, nil, "", "context", "add", fmt.Sprintf("ctx-%d", i), "--endpoint", server.URL))
101151
}
102-
103-
begin := time.Now()
104152
checked := run(t, path, nil, "", "context", "check", "--all", "--timeout", "80ms")
105-
elapsed := time.Since(begin)
106153
if checked.code == 0 {
107154
t.Fatalf("timed out batch unexpectedly succeeded: %s", checked.out)
108155
}
109156
checks := checked.data["data"].(map[string]any)["checks"].([]any)
110-
if len(checks) != 6 || calls.Load() != 6 || started.Load() != 6 {
111-
t.Fatalf("batch did not retain every independent outcome: calls=%d started=%d result=%s", calls.Load(), started.Load(), checked.out)
112-
}
113-
if maxActive.Load() > 4 {
114-
t.Fatalf("check-all exceeded worker bound: max active=%d", maxActive.Load())
157+
if len(checks) != 6 {
158+
t.Fatalf("HTTP batch lost outcomes: %s", checked.out)
115159
}
116-
if elapsed > 2*time.Second {
117-
t.Fatalf("independent timeouts took too long: %s", elapsed)
160+
for index, check := range checks {
161+
item := check.(map[string]any)
162+
if item["context"] != fmt.Sprintf("ctx-%d", index) || item["error"] == nil {
163+
t.Fatalf("HTTP timeout outcome missing: %#v", item)
164+
}
118165
}
119166
}
120167

0 commit comments

Comments
 (0)