11package integration_test
22
33import (
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
1620type 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+
82104func 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