package agent import ( "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" ) // V2.0.1 ① integration: the master serves a sing-box config but the // node has no core and the fetch fails — the switch must NOT latch // (xray stays the runtime and the next poll retries), because the // B5 switch retires xray the moment it engages. func TestSwitchGateStaysOpenWhenCoreFetchFails(t *testing.T) { binDir := t.TempDir() t.Setenv("XUI_BIN_FOLDER", binDir) var hits atomic.Int32 coreSrv := fakeCoreServer(t, &hits, http.StatusBadGateway) defer coreSrv.Close() useCoreBase(t, coreSrv.URL) master := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/config") { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"config_hash":"x1","singbox_config_hash":"sb1",` + `"singbox_config":{"inbounds":[]}}`)) return } http.NotFound(w, r) })) defer master.Close() a := &Agent{ config: &Config{MasterURL: master.URL, Token: "tok", NodeName: "n"}, httpClient: http.DefaultClient, probes: newProbeRunner(), } if err := a.fetchAndApplyConfig(); err != nil { t.Fatalf("fetchAndApplyConfig: %v", err) } if a.sbSwitched { t.Fatal("switch latched although the sing-box core could not be fetched") } if hits.Load() != 1 { t.Errorf("core fetch attempts = %d, want exactly 1", hits.Load()) } // A second poll inside the backoff window must not re-download. if err := a.fetchAndApplyConfig(); err != nil { t.Fatalf("second fetchAndApplyConfig: %v", err) } if hits.Load() != 1 { t.Errorf("backoff violated on the config path: hits = %d", hits.Load()) } if a.sbSwitched { t.Fatal("switch latched during backoff") } }