package core import ( "context" "encoding/json" "errors" "net" "net/http" "net/netip" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/metacubex/mihomo/adapter/inbound" "github.com/metacubex/mihomo/component/updater" "github.com/metacubex/mihomo/config" "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/hub/route" authStore "github.com/metacubex/mihomo/listener/auth" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/tunnel" ) func withCurrentConfig(t *testing.T, cfg *config.Config) { t.Helper() previous := currentConfig currentConfig = cfg t.Cleanup(func() { currentConfig = previous }) } func TestRouteConfigCarriesControllerCredentials(t *testing.T) { cfg := &config.Config{ General: &config.General{LogLevel: log.DEBUG}, Controller: &config.Controller{ ExternalController: "127.0.0.1:9090", ExternalControllerTLS: "127.0.0.1:9443", ExternalControllerUnix: "/tmp/flclash.sock", ExternalControllerPipe: `\\.\pipe\flclash`, ExternalControllerRoutingMark: 1234, ExternalDohServer: "/dns-query", Secret: "s3cret", Cors: config.Cors{ AllowOrigins: []string{"https://example.test"}, AllowPrivateNetwork: true, }, }, TLS: &config.TLS{ Certificate: "cert", PrivateKey: "key", ClientAuthType: "require", ClientAuthCert: "clientCert", EchKey: "ech", }, } got := routeConfig(cfg) if got.Secret != "s3cret" { t.Errorf("Secret = %q, want it preserved; an empty secret unauthenticates the controller", got.Secret) } if got.Addr != "127.0.0.1:9090" || got.TLSAddr != "127.0.0.1:9443" { t.Errorf("addresses = %q/%q, want the controller's own listen addresses", got.Addr, got.TLSAddr) } if got.UnixAddr != "/tmp/flclash.sock" || got.PipeAddr != `\\.\pipe\flclash` { t.Errorf("local addresses = %q/%q, want them preserved", got.UnixAddr, got.PipeAddr) } if got.RoutingMark != 1234 || got.DohServer != "/dns-query" { t.Errorf("routingMark/dohServer = %d/%q, want 1234//dns-query", got.RoutingMark, got.DohServer) } if got.Certificate != "cert" || got.PrivateKey != "key" || got.ClientAuthType != "require" || got.ClientAuthCert != "clientCert" || got.EchKey != "ech" { t.Errorf("TLS material = %+v, want it carried over from cfg.TLS", got) } if len(got.Cors.AllowOrigins) != 1 || got.Cors.AllowOrigins[0] != "https://example.test" || !got.Cors.AllowPrivateNetwork { t.Errorf("Cors = %+v, want the configured origins", got.Cors) } if !got.IsDebug { t.Error("IsDebug = false, want it derived from a DEBUG log level") } } func TestRouteConfigToleratesMissingTLSSection(t *testing.T) { cfg := &config.Config{ General: &config.General{}, Controller: &config.Controller{ExternalController: "127.0.0.1:9090"}, } got := routeConfig(cfg) if got.Certificate != "" || got.PrivateKey != "" { t.Errorf("TLS material = %q/%q, want empty when cfg.TLS is absent", got.Certificate, got.PrivateKey) } } func TestUpdateConfigRejectsAnUnappliedConfig(t *testing.T) { withCurrentConfig(t, nil) if err := updateConfig(&UpdateParams{}); err != errConfigNotApplied { t.Errorf("updateConfig error = %v, want errConfigNotApplied", err) } } // The host reads updateConfig's result as a message, so the failure has to // arrive as one rather than as the envelope's error. func TestUpdateConfigMethodReportsAnUnappliedConfigAsAMessage(t *testing.T) { withCurrentConfig(t, nil) frame := handleMethodCall(&MethodCall{ ID: "1", Method: updateConfigMethod, Arguments: json.RawMessage(`{}`), }) var envelope struct { Result string `json:"result"` Error *MethodError `json:"error"` } if err := json.Unmarshal(frame, &envelope); err != nil { t.Fatalf("response is not valid JSON: %v", err) } if envelope.Error != nil || envelope.Result != errConfigNotApplied.Error() { t.Errorf("envelope = %+v, want the message %q as the result", envelope, errConfigNotApplied) } } func TestUpdateConfigAppliesAllowLan(t *testing.T) { withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) allowLan := true if err := updateConfig(&UpdateParams{AllowLan: &allowLan}); err != nil { t.Fatalf("updateConfig error: %v", err) } if !currentConfig.General.AllowLan { t.Error("AllowLan stayed false; the patched value never reached the general config") } } func TestUpdateConfigAppliesGeoResourceUrls(t *testing.T) { withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) requested := map[string]string{ "mmdb": "https://example.test/geoip.metadb", "asn": "https://example.test/GeoLite2-ASN.mmdb", "geoip": "https://example.test/geoip.dat", "geosite": "https://example.test/geosite.dat", } for _, resource := range geoResources { resource, previous := resource, resource.url() t.Cleanup(func() { resource.setUrl(previous) }) } if err := updateConfig(&UpdateParams{GeoXUrl: requested}); err != nil { t.Fatalf("updateConfig error: %v", err) } for key, want := range requested { if got := geoResources[strings.ToUpper(key)].url(); got != want { t.Errorf("%s url = %q, want %q; sync and the auto updater would keep the old link", key, got, want) } } } func TestSetGeoResourceUrlWritesOnlyAChangedLink(t *testing.T) { const current = "https://example.test/geosite.dat" var writes []string previous := geoResources geoResources = map[string]geoResource{ "GEOSITE": { url: func() string { return current }, setUrl: func(link string) { writes = append(writes, link) }, }, } t.Cleanup(func() { geoResources = previous }) setGeoResourceUrl("geosite", current) setGeoResourceUrl("geosite", "") setGeoResourceUrl("nope", "https://example.test/nope.dat") if len(writes) != 0 { t.Fatalf("writes = %q, want none for an unchanged, empty or unknown link", writes) } setGeoResourceUrl("geosite", "https://example.test/next.dat") if len(writes) != 1 || writes[0] != "https://example.test/next.dat" { t.Errorf("writes = %q, want the changed link once", writes) } } func TestUpdateConfigAppliesAuthenticationAndClearsLoopbackExemptions(t *testing.T) { withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) currentConfig.General.SkipAuthPrefixes = []netip.Prefix{ netip.MustParsePrefix("127.0.0.1/32"), } inbound.SetSkipAuthPrefixes(currentConfig.General.SkipAuthPrefixes) t.Cleanup(func() { authStore.Default.SetAuthenticator(nil) inbound.SetSkipAuthPrefixes(nil) }) users := []string{"user:pass"} if err := updateConfig(&UpdateParams{Authentication: &users}); err != nil { t.Fatalf("updateConfig error: %v", err) } authenticator := authStore.Default.Authenticator() if authenticator == nil || !authenticator.Verify("user", "pass") { t.Error("authenticator missing or rejecting the configured credentials") } if inbound.SkipAuthRemoteAddress("127.0.0.1:1234") { t.Error("loopback stayed exempt; local apps could bypass the credentials") } empty := []string{} if err := updateConfig(&UpdateParams{Authentication: &empty}); err != nil { t.Fatalf("updateConfig error: %v", err) } if authStore.Default.Authenticator() != nil { t.Error("authenticator survived an empty authentication list") } } func TestUpdateConfigPatchesOnlyTheTunFieldsItWasGiven(t *testing.T) { withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) currentConfig.General.Tun.Device = "keep-me" device := "flclash-tun" if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true}}); err != nil { t.Fatalf("updateConfig error: %v", err) } if !currentConfig.General.Tun.Enable { t.Error("Tun.Enable stayed false") } if currentConfig.General.Tun.Device != "keep-me" { t.Errorf("Tun.Device = %q, want an omitted field left untouched", currentConfig.General.Tun.Device) } if err := updateConfig(&UpdateParams{Tun: &tunSchema{Enable: true, Device: &device}}); err != nil { t.Fatalf("updateConfig error: %v", err) } if currentConfig.General.Tun.Device != device { t.Errorf("Tun.Device = %q, want %q", currentConfig.General.Tun.Device, device) } } func TestUpdateConfigDecodesTheTunSettingsTheHostSends(t *testing.T) { withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) currentConfig.General.Tun.MTU = 1400 var params UpdateParams payload := `{"tun":{"enable":true,"device":"FlClash","stack":"gvisor","auto-route":true,"mtu":9000}}` if err := json.Unmarshal([]byte(payload), ¶ms); err != nil { t.Fatalf("decode: %v", err) } if err := updateConfig(¶ms); err != nil { t.Fatalf("updateConfig error: %v", err) } tun := currentConfig.General.Tun if !tun.Enable || !tun.AutoRoute || tun.Device != "FlClash" || tun.Stack != constant.TunGvisor { t.Errorf("Enable = %v, AutoRoute = %v, Device = %q, Stack = %v", tun.Enable, tun.AutoRoute, tun.Device, tun.Stack) } if tun.MTU != 1400 { t.Errorf("MTU = %d, want the profile's value kept", tun.MTU) } } func TestUpdateConfigLeavesTheControllerAloneWhenTheAddressIsUnchanged(t *testing.T) { withCurrentConfig(t, &config.Config{ General: &config.General{}, Controller: &config.Controller{ExternalController: "127.0.0.1:9090", Secret: "s3cret"}, }) address := "127.0.0.1:9090" if err := updateConfig(&UpdateParams{ExternalController: &address}); err != nil { t.Fatalf("updateConfig error: %v", err) } if currentConfig.Controller.Secret != "s3cret" { t.Errorf("Secret = %q, want the controller left untouched", currentConfig.Controller.Secret) } } func TestUpdateConfigReplacesTheSecretTheProfileCarried(t *testing.T) { withCurrentConfig(t, &config.Config{ General: &config.General{}, Controller: &config.Controller{Secret: "from-the-profile"}, }) closed, secret := "", "generated" if err := updateConfig(&UpdateParams{ExternalController: &closed, Secret: &secret}); err != nil { t.Fatalf("updateConfig error: %v", err) } if currentConfig.Controller.Secret != secret { t.Errorf("Secret = %q, want %q", currentConfig.Controller.Secret, secret) } } func TestUpdateConfigServesTheControllerBehindTheSecretItWasGiven(t *testing.T) { probe, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserve a port: %v", err) } address := probe.Addr().String() _ = probe.Close() withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}}) t.Cleanup(func() { route.ReCreateServer(&route.Config{}) }) secret := "generated" if err := updateConfig(&UpdateParams{ExternalController: &address, Secret: &secret}); err != nil { t.Fatalf("updateConfig error: %v", err) } status := func(authorization string) int { request, _ := http.NewRequest(http.MethodGet, "http://"+address+"/version", nil) if authorization != "" { request.Header.Set("Authorization", authorization) } deadline := time.Now().Add(3 * time.Second) for { response, err := http.DefaultClient.Do(request) if err == nil { _ = response.Body.Close() return response.StatusCode } if time.Now().After(deadline) { t.Fatalf("the controller never came up: %v", err) } time.Sleep(50 * time.Millisecond) } } if got := status(""); got != http.StatusUnauthorized { t.Errorf("without the secret: status %d, want %d", got, http.StatusUnauthorized) } if got := status("Bearer " + secret); got != http.StatusOK { t.Errorf("with the secret: status %d, want %d", got, http.StatusOK) } } // geoUpdaterCalls counts the reconciliation the code under test asked for. The // real calls spawn a goroutine that downloads the GEO databases, which is not // something a unit test should reach for. type geoUpdaterCalls struct { registered int stopped int } func stubGeoUpdater(t *testing.T) *geoUpdaterCalls { t.Helper() calls := &geoUpdaterCalls{} previousRegister, previousStop := registerGeoUpdater, stopGeoUpdater previousAuto, previousInterval := updater.GeoAutoUpdate(), updater.GeoUpdateInterval() registerGeoUpdater = func() { calls.registered++ } stopGeoUpdater = func() { calls.stopped++ } t.Cleanup(func() { registerGeoUpdater, stopGeoUpdater = previousRegister, previousStop updater.SetGeoAutoUpdate(previousAuto) updater.SetGeoUpdateInterval(previousInterval) }) return calls } func TestSyncGeoUpdaterStopsTheUpdaterWhenDisabled(t *testing.T) { calls := stubGeoUpdater(t) updater.SetGeoAutoUpdate(true) updater.SetGeoUpdateInterval(24) disabled := false syncGeoUpdater(&disabled, nil) if updater.GeoAutoUpdate() { t.Error("GeoAutoUpdate stayed on after the setting was turned off") } if updater.GeoUpdateInterval() != 24 { t.Errorf("GeoUpdateInterval = %d, want the configured 24 left untouched by the stop", updater.GeoUpdateInterval()) } if calls.stopped != 1 || calls.registered != 0 { t.Errorf( "updater calls = %d registered/%d stopped, want the running updater cancelled", calls.registered, calls.stopped, ) } } func TestSyncGeoUpdaterIgnoresUnchangedParameters(t *testing.T) { calls := stubGeoUpdater(t) updater.SetGeoAutoUpdate(false) updater.SetGeoUpdateInterval(12) stillDisabled := false sameInterval := 12 syncGeoUpdater(&stillDisabled, &sameInterval) if updater.GeoAutoUpdate() || updater.GeoUpdateInterval() != 12 { t.Errorf( "geo settings = %v/%d, want them untouched", updater.GeoAutoUpdate(), updater.GeoUpdateInterval(), ) } if calls.registered != 0 || calls.stopped != 0 { t.Errorf( "updater calls = %d registered/%d stopped, want the running updater left alone", calls.registered, calls.stopped, ) } } // A profile apply reloads the setting out of config.yaml, so the core can end // up with the updater running while the flag reads off, and syncGeoUpdater sees // nothing to change because the flag already matches what the app sends next. func TestReconcileGeoUpdaterStopsAnUpdaterTheProfileTurnedOff(t *testing.T) { calls := stubGeoUpdater(t) updater.SetGeoAutoUpdate(false) reconcileGeoUpdater() if calls.stopped != 1 || calls.registered != 0 { t.Errorf( "updater calls = %d registered/%d stopped, want the updater cancelled", calls.registered, calls.stopped, ) } } func TestReconcileGeoUpdaterRegistersWhenTheSettingIsOn(t *testing.T) { calls := stubGeoUpdater(t) updater.SetGeoAutoUpdate(true) reconcileGeoUpdater() if calls.registered != 1 || calls.stopped != 0 { t.Errorf( "updater calls = %d registered/%d stopped, want the updater registered", calls.registered, calls.stopped, ) } } func TestCurrentTestURLFallsBackToTheBuiltInProbe(t *testing.T) { previous := testURL.Load() t.Cleanup(func() { testURL.Store(previous) }) testURL.Store(nil) if got := currentTestURL(); got != defaultTestURL { t.Errorf("currentTestURL = %q, want the built-in probe", got) } empty := "" testURL.Store(&empty) if got := currentTestURL(); got != defaultTestURL { t.Errorf("currentTestURL = %q, want an empty setting to fall back", got) } custom := "https://example.test/generate_204" setTestURL(custom) if got := currentTestURL(); got != custom { t.Errorf("currentTestURL = %q, want %q", got, custom) } } func TestDelayTestSlotsBoundConcurrency(t *testing.T) { if cap(delayTestSlots) != delayTestConcurrency { t.Fatalf("delayTestSlots capacity = %d, want %d", cap(delayTestSlots), delayTestConcurrency) } for i := 0; i < delayTestConcurrency; i++ { if !delayTestSlots.acquire(context.Background()) { t.Fatalf("slot %d was refused while the semaphore still had room", i) } } blocked := make(chan struct{}) go func() { delayTestSlots.acquire(context.Background()) close(blocked) }() select { case <-blocked: t.Fatal("delayTestSlots handed out more slots than the configured concurrency") case <-time.After(20 * time.Millisecond): } delayTestSlots.release() select { case <-blocked: case <-time.After(time.Second): t.Fatal("a released slot never reached the waiting caller") } for i := 0; i < delayTestConcurrency; i++ { delayTestSlots.release() } } func TestHandleUpdateGeoDataRejectsAnUnknownResource(t *testing.T) { if err := handleUpdateGeoData("NOPE"); err == nil { t.Error("an unknown geo resource reported success and silently did nothing") } if _, exists := geoResources["MMDB"]; !exists { t.Error("MMDB is missing from the geo resource table") } } func TestSetTestURLIgnoresAnEmptyValue(t *testing.T) { const configured = "https://example.test/generate_204" previousDefault := constant.DefaultTestURL previousStored := testURL.Load() t.Cleanup(func() { constant.DefaultTestURL = previousDefault testURL.Store(previousStored) }) setTestURL(configured) setTestURL("") if constant.DefaultTestURL != configured { t.Errorf("DefaultTestURL = %q, an empty setup param cleared mihomo's own default", constant.DefaultTestURL) } if got := currentTestURL(); got != configured { t.Errorf("currentTestURL() = %q, want %q", got, configured) } } func TestTestDelayRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) { for i := 0; i < delayTestConcurrency; i++ { delayTestSlots <- struct{}{} } t.Cleanup(func() { for i := 0; i < delayTestConcurrency; i++ { <-delayTestSlots } }) done := make(chan *Delay, 1) go func() { done <- handleTestDelay(&TestDelayParams{ProxyName: "missing", Timeout: 1}) }() select { case delay := <-done: if delay.Value != delayFailed { t.Errorf("value = %d, want %d", delay.Value, delayFailed) } case <-time.After(time.Second): t.Fatal("an unknown proxy queued behind a saturated delay-test semaphore") } } func settleMessageBatcher() { time.Sleep(3 * messageBatchInterval) } func withGeoResourceUpdaters(t *testing.T, updaters map[string]func() error) { t.Helper() previous := geoResources geoResources = make(map[string]geoResource, len(updaters)) for name, update := range updaters { geoResources[name] = geoResource{update: update} } t.Cleanup(func() { geoResources = previous for name := range updaters { releaseGeoUpdate(name) } }) } func TestHandleUpdateGeoDataRunsOneUpdatePerResource(t *testing.T) { const resource = "TEST" started := make(chan struct{}, 2) release := make(chan struct{}) var calls atomic.Int32 withGeoResourceUpdaters(t, map[string]func() error{ resource: func() error { calls.Add(1) started <- struct{}{} <-release return nil }, }) if err := handleUpdateGeoData(resource); err != nil { t.Fatalf("handleUpdateGeoData = %v, want no error", err) } select { case <-started: case <-time.After(time.Second): t.Fatal("the first update never started") } if err := handleUpdateGeoData(resource); err == nil { t.Fatal("the duplicate request reported no error while an update was already in flight") } select { case <-started: t.Fatal("a second update started while the first was still running; both close the mmap'd database") case <-time.After(100 * time.Millisecond): } close(release) if got := calls.Load(); got != 1 { t.Errorf("the updater ran %d times, want 1", got) } deadline := time.Now().Add(time.Second) for !claimGeoUpdate(resource) { if time.Now().After(deadline) { t.Fatal("the in-flight claim was never released, so the resource can never be updated again") } time.Sleep(5 * time.Millisecond) } releaseGeoUpdate(resource) } func TestGeoUpdateHookBlocksAManualUpdateOfTheSameResource(t *testing.T) { const resource = "MMDB" ran := make(chan struct{}, 1) withGeoResourceUpdaters(t, map[string]func() error{ resource: func() error { ran <- struct{}{} return nil }, }) updater.GeoUpdateHook(resource, true, false, nil) if err := handleUpdateGeoData(resource); err == nil { t.Fatal("handleUpdateGeoData reported no error while the hook already claimed this resource") } select { case <-ran: t.Fatal("a manual update ran while the auto updater was already updating the same resource") case <-time.After(100 * time.Millisecond): } updater.GeoUpdateHook(resource, false, false, nil) if !claimGeoUpdate(resource) { t.Fatal("the hook never cleared the claim, so the resource stays blocked for the rest of the process") } releaseGeoUpdate(resource) settleMessageBatcher() } // The hook fires for the automatic updater, which can start and finish inside a // manual update of the same resource. Releasing the manual claim there lets a // second manual update run alongside the first. func TestGeoUpdateHookDoesNotReleaseAManualClaim(t *testing.T) { const resource = "GEOIP" started := make(chan struct{}, 2) release := make(chan struct{}) var calls atomic.Int32 withGeoResourceUpdaters(t, map[string]func() error{ resource: func() error { calls.Add(1) started <- struct{}{} <-release return nil }, }) if err := handleUpdateGeoData(resource); err != nil { t.Fatalf("handleUpdateGeoData = %v, want no error", err) } select { case <-started: case <-time.After(time.Second): t.Fatal("the manual update never started") } updater.GeoUpdateHook(resource, true, false, nil) updater.GeoUpdateHook(resource, false, false, nil) if err := handleUpdateGeoData(resource); err == nil { t.Fatal("the duplicate request reported no error while the manual claim was still held") } select { case <-started: t.Fatal("a second manual update started while the first was still running") case <-time.After(100 * time.Millisecond): } close(release) deadline := time.Now().Add(time.Second) for !claimGeoUpdate(resource) { if time.Now().After(deadline) { t.Fatal("the manual claim was never released") } time.Sleep(5 * time.Millisecond) } releaseGeoUpdate(resource) if got := calls.Load(); got != 1 { t.Errorf("the updater ran %d times, want 1", got) } settleMessageBatcher() } // An empty config.yaml is how the app expresses "no profile selected". mihomo's // own reader rejects it. func TestLoadConfigTreatsAnEmptyProfileAsTheDefaults(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, nil, 0o600); err != nil { t.Fatalf("write config: %v", err) } cfg, err := loadConfig(path) if err != nil { t.Fatalf("loadConfig = %v, want no error for an empty profile", err) } if cfg == nil { t.Fatal("loadConfig returned no config for an empty profile") } } func TestLoadConfigReportsAMissingFile(t *testing.T) { _, err := loadConfig(filepath.Join(t.TempDir(), "absent.yaml")) if err == nil { t.Fatal("loadConfig accepted a path that does not exist") } if !strings.Contains(err.Error(), "absent.yaml") { t.Errorf("loadConfig = %v, want it to name the missing file", err) } } func TestLoadConfigReportsMalformedYaml(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil { t.Fatalf("write config: %v", err) } if _, err := loadConfig(path); err == nil { t.Fatal("loadConfig accepted malformed yaml") } } func TestLoadConfigKeepsTheSystemTimeWriteOnlyWhereItIsAllowed(t *testing.T) { previous := systemTimeWritable t.Cleanup(func() { systemTimeWritable = previous }) path := filepath.Join(t.TempDir(), "config.yaml") profile := "ntp:\n enable: true\n write-to-system: true\n" if err := os.WriteFile(path, []byte(profile), 0o600); err != nil { t.Fatalf("write config: %v", err) } for _, writable := range []bool{true, false} { systemTimeWritable = writable cfg, err := loadConfig(path) if err != nil { t.Fatalf("loadConfig = %v", err) } if cfg.NTP.WriteToSystem != writable { t.Errorf("writable %t: WriteToSystem = %t", writable, cfg.NTP.WriteToSystem) } if !cfg.NTP.Enable { t.Errorf("writable %t: NTP was disabled, want only the system write dropped", writable) } } } func withSetupConfig(t *testing.T, apply func(*SetupParams) error) { t.Helper() previous := setupConfig setupConfig = apply t.Cleanup(func() { setupConfig = previous }) isInit.Store(true) isRunning.Store(true) t.Cleanup(func() { isInit.Store(false) isRunning.Store(false) }) } // applyConfig rolls back to the default config when the profile fails to parse. // That rollback is the whole recovery: the error reaches the host and the // listeners keep serving while the user picks another profile. func TestHandleSetupConfigKeepsTheListenerWhenTheProfileFails(t *testing.T) { withSetupConfig(t, func(*SetupParams) error { return errors.New("bad profile") }) err := handleSetupConfig(defaultSetupParams()) if err == nil || err.Error() != "bad profile" { t.Fatalf("handleSetupConfig = %v, want the apply error", err) } if !isRunning.Load() { t.Error("a failed apply stopped the listeners") } } func TestHandleSetupConfigLeavesTheListenerAloneOnSuccess(t *testing.T) { withSetupConfig(t, func(*SetupParams) error { return nil }) if err := handleSetupConfig(defaultSetupParams()); err != nil { t.Fatalf("handleSetupConfig = %v, want no error", err) } if !isRunning.Load() { t.Error("a successful apply stopped the listeners") } } func TestSlotsAcquireGivesUpOnTheDeadline(t *testing.T) { for i := 0; i < delayTestConcurrency; i++ { delayTestSlots <- struct{}{} } t.Cleanup(func() { for i := 0; i < delayTestConcurrency; i++ { <-delayTestSlots } }) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() refused := make(chan bool, 1) go func() { refused <- delayTestSlots.acquire(ctx) }() select { case granted := <-refused: if granted { t.Fatal("acquire handed out a slot the semaphore did not have") } case <-time.After(time.Second): t.Fatal("acquire ignored the deadline and kept queueing") } } func TestTimeoutFromMillisFallsBackWhenUnset(t *testing.T) { if got := timeoutFromMillis(250, defaultDelayTestTimeout); got != 250*time.Millisecond { t.Errorf("timeoutFromMillis(250) = %v, want 250ms", got) } for _, unset := range []int64{0, -1} { if got := timeoutFromMillis(unset, defaultDelayTestTimeout); got != defaultDelayTestTimeout { t.Errorf("timeoutFromMillis(%d) = %v, want the default %v", unset, got, defaultDelayTestTimeout) } } } func TestTestDelayQueuesWhenTheTimeoutIsUnset(t *testing.T) { for i := 0; i < delayTestConcurrency; i++ { delayTestSlots <- struct{}{} } t.Cleanup(func() { for i := 0; i < delayTestConcurrency; i++ { <-delayTestSlots } }) tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) done := make(chan *Delay, 1) go func() { // A refused port rather than the default URL: the probe this is proving // will eventually happen should not leave the machine. done <- handleTestDelay(&TestDelayParams{ ProxyName: "queued", TestUrl: "http://127.0.0.1:1", }) }() select { case <-done: t.Fatal("handleTestDelay gave up immediately on an unset timeout, want it queueing for the default") case <-time.After(200 * time.Millisecond): } // Hand over a slot and join, so nothing is still reading the tunnel when // the cleanup below replaces it. tunnel.Proxies and tunnel.Providers are // unsynchronised reads of maps UpdateProxies writes, so an unjoined reader // is a reported race whatever the timing. <-delayTestSlots delay := <-done delayTestSlots <- struct{}{} if delay.Value != delayFailed { t.Errorf("value = %d, want %d for a probe against a refused port", delay.Value, delayFailed) } } // blackHoleServer accepts connections and then says nothing, so a probe against // it connects and waits out its deadline rather than failing fast the way a // refused port would. func blackHoleServer(t *testing.T) net.Addr { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } var conns []net.Conn accepting := make(chan struct{}) go func() { defer close(accepting) for { conn, err := listener.Accept() if err != nil { return } conns = append(conns, conn) } }() t.Cleanup(func() { _ = listener.Close() // Join the accept loop before touching conns: the close above is what // ends it, and the receive is the only ordering between the two. <-accepting for _, conn := range conns { _ = conn.Close() } }) return listener.Addr() } // Queueing for a slot and probing the node must not share one deadline: a node // that waited out most of its timeout behind a saturated semaphore would have // only the remainder to connect in and report Timeout while perfectly healthy - // which is what a bulk test of a large subscription does to everything at the // back of the queue. func TestTestDelayDoesNotSpendTheProbeBudgetQueueing(t *testing.T) { const ( timeout = 200 * time.Millisecond queueFor = 150 * time.Millisecond ) addr := blackHoleServer(t) for i := 0; i < delayTestConcurrency; i++ { delayTestSlots <- struct{}{} } t.Cleanup(func() { for i := 0; i < delayTestConcurrency; i++ { <-delayTestSlots } }) tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) done := make(chan *Delay, 1) start := time.Now() go func() { done <- handleTestDelay(&TestDelayParams{ ProxyName: "queued", TestUrl: "http://" + addr.String(), Timeout: timeout.Milliseconds(), }) }() time.Sleep(queueFor) <-delayTestSlots select { case delay := <-done: elapsed := time.Since(start) if delay == nil { t.Fatal("handleTestDelay gave up queueing even though a slot came free") } if elapsed < queueFor+timeout { t.Errorf( "handleTestDelay returned after %v, want at least %v: the probe inherited the deadline the wait had already spent", elapsed, queueFor+timeout, ) } if delay.Value != delayTimedOut { t.Errorf("value = %d, want %d for a probe that waited out its deadline", delay.Value, delayTimedOut) } case <-time.After(5 * time.Second): t.Fatal("handleTestDelay never returned") } // handleTestDelay released the slot it was handed; put it back so the // cleanup above drains what it put in. delayTestSlots <- struct{}{} } func TestTestDelayStopsQueueingOnceTheTimeoutIsSpent(t *testing.T) { for i := 0; i < delayTestConcurrency; i++ { delayTestSlots <- struct{}{} } t.Cleanup(func() { for i := 0; i < delayTestConcurrency; i++ { <-delayTestSlots } }) // A proxy the lookup actually resolves, so the call reaches the semaphore // instead of bailing out on an unknown name. tunnel.UpdateProxies(map[string]constant.Proxy{"queued": namedProxy("queued")}, nil) t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) }) done := make(chan *Delay, 1) go func() { done <- handleTestDelay(&TestDelayParams{ProxyName: "queued", Timeout: 20}) }() select { case delay := <-done: if delay != nil { t.Errorf("delay = %+v, want no result for a test that never got a slot", delay) } case <-time.After(time.Second): t.Fatal("handleTestDelay outlived the timeout it was given, waiting for a slot") } }