mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
0d8df38dc1
When IPv6 is available locally but the selected IPv6 DoH endpoint is unroutable (e.g. dialing [2606:1a40::22]:443 returns "no route to host" while IPv4 stays usable), ctrld re-bootstrapped and re-dialed the endpoint every ~2s. A weekend soak produced ~46.7k "no route to host" lines, with the dial/health-check loop dominating the log during bad windows. Add bounded backoff/suppression for network-unreachable endpoints at two levels: - ParallelDialer (internal/net): track dial addresses that fail with ENETUNREACH/EHOSTUNREACH and skip them for an exponentially growing, bounded window (5s -> 60s). A successful dial clears the entry immediately, so recovery is preserved when the route returns. When every candidate is suppressed the dial fails fast and quietly instead of hammering known-unroutable addresses. - Upstream recovery loop (cmd/cli): demote unreachable check failures to debug and back off the retry cadence (2s -> 60s) for an unreachable streak; any other failure resets to the base cadence. The new IsUnreachable classifier lives in internal/net and is reused by cmd/cli's errNetworkError, so the unreachable-errno matching has a single definition. Note the explicit winsock constants (10051/10065) are required on Windows: syscall.ENETUNREACH/EHOSTUNREACH are Go's portable "invented" values and never equal the raw WSA codes a failing connect surfaces. Suppression and backoff are always bounded, so IPv6 is never disabled until restart and recovers on its own once the route is back. Split-stack selection and the #549 macOS intercept recovery work are untouched. Adds unit tests for the classifier, the dialer's suppression tracker, and the recovery backoff schedule.
306 lines
8.4 KiB
Go
306 lines
8.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/url"
|
|
"runtime"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Masterminds/semver/v3"
|
|
"github.com/rs/zerolog"
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
func TestErrNetworkErrorTreatsNoRouteAsNetworkError(t *testing.T) {
|
|
err := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH}
|
|
assert.True(t, errNetworkError(err))
|
|
assert.True(t, errUrlNetworkError(&url.Error{Op: "Get", URL: "https://dns.controld.com", Err: err}))
|
|
}
|
|
|
|
func TestSleepWithContext(t *testing.T) {
|
|
assert.True(t, sleepWithContext(context.Background(), time.Millisecond))
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
start := time.Now()
|
|
assert.False(t, sleepWithContext(ctx, time.Minute))
|
|
assert.Less(t, time.Since(start), 100*time.Millisecond)
|
|
}
|
|
|
|
func TestUnreachableRecoveryBackoff(t *testing.T) {
|
|
// Streak starts at the base cadence and doubles each attempt, capped at the max.
|
|
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(0))
|
|
assert.Equal(t, checkUpstreamBackoffSleep, unreachableRecoveryBackoff(1))
|
|
assert.Equal(t, 2*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(2))
|
|
assert.Equal(t, 4*checkUpstreamBackoffSleep, unreachableRecoveryBackoff(3))
|
|
assert.Equal(t, checkUpstreamUnreachableBackoffMax, unreachableRecoveryBackoff(100))
|
|
}
|
|
|
|
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
|
|
p := &prog{cfg: &ctrld.Config{}}
|
|
|
|
// Default value is true.
|
|
assert.True(t, p.dnsWatchdogEnabled())
|
|
|
|
tests := []struct {
|
|
name string
|
|
enabled bool
|
|
}{
|
|
{"enabled", true},
|
|
{"disabled", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p.cfg.Service.DnsWatchdogEnabled = &tc.enabled
|
|
assert.Equal(t, tc.enabled, p.dnsWatchdogEnabled())
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_prog_dnsWatchdogInterval(t *testing.T) {
|
|
p := &prog{cfg: &ctrld.Config{}}
|
|
|
|
// Default value is 20s.
|
|
assert.Equal(t, dnsWatchdogDefaultInterval, p.dnsWatchdogDuration())
|
|
|
|
tests := []struct {
|
|
name string
|
|
duration time.Duration
|
|
expected time.Duration
|
|
}{
|
|
{"valid", time.Minute, time.Minute},
|
|
{"zero", 0, dnsWatchdogDefaultInterval},
|
|
{"nagative", time.Duration(-1 * time.Minute), dnsWatchdogDefaultInterval},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p.cfg.Service.DnsWatchdogInvterval = &tc.duration
|
|
assert.Equal(t, tc.expected, p.dnsWatchdogDuration())
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_shouldUpgrade(t *testing.T) {
|
|
// Helper function to create a version
|
|
makeVersion := func(v string) *semver.Version {
|
|
ver, err := semver.NewVersion(v)
|
|
if err != nil {
|
|
t.Fatalf("failed to create version %s: %v", v, err)
|
|
}
|
|
return ver
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
versionTarget string
|
|
currentVersion *semver.Version
|
|
shouldUpgrade bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "empty version target",
|
|
versionTarget: "",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should skip upgrade when version target is empty",
|
|
},
|
|
{
|
|
name: "invalid version target",
|
|
versionTarget: "invalid-version",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should skip upgrade when version target is invalid",
|
|
},
|
|
{
|
|
name: "same version",
|
|
versionTarget: "v1.0.0",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should skip upgrade when target version equals current version",
|
|
},
|
|
{
|
|
name: "older version",
|
|
versionTarget: "v1.0.0",
|
|
currentVersion: makeVersion("v1.1.0"),
|
|
shouldUpgrade: false,
|
|
description: "should skip upgrade when target version is older than current version",
|
|
},
|
|
{
|
|
name: "patch upgrade allowed",
|
|
versionTarget: "v1.0.1",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: true,
|
|
description: "should allow patch version upgrade within same major version",
|
|
},
|
|
{
|
|
name: "minor upgrade allowed",
|
|
versionTarget: "v1.1.0",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: true,
|
|
description: "should allow minor version upgrade within same major version",
|
|
},
|
|
{
|
|
name: "major upgrade blocked",
|
|
versionTarget: "v2.0.0",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should block major version upgrade",
|
|
},
|
|
{
|
|
name: "major downgrade blocked",
|
|
versionTarget: "v1.0.0",
|
|
currentVersion: makeVersion("v2.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should block major version downgrade",
|
|
},
|
|
{
|
|
name: "version without v prefix",
|
|
versionTarget: "1.0.1",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: true,
|
|
description: "should handle version target without v prefix",
|
|
},
|
|
{
|
|
name: "complex version upgrade allowed",
|
|
versionTarget: "v1.5.3",
|
|
currentVersion: makeVersion("v1.4.2"),
|
|
shouldUpgrade: true,
|
|
description: "should allow complex version upgrade within same major version",
|
|
},
|
|
{
|
|
name: "complex major upgrade blocked",
|
|
versionTarget: "v3.1.0",
|
|
currentVersion: makeVersion("v2.5.3"),
|
|
shouldUpgrade: false,
|
|
description: "should block complex major version upgrade",
|
|
},
|
|
{
|
|
name: "pre-release version upgrade allowed",
|
|
versionTarget: "v1.0.1-beta.1",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: true,
|
|
description: "should allow pre-release version upgrade within same major version",
|
|
},
|
|
{
|
|
name: "pre-release major upgrade blocked",
|
|
versionTarget: "v2.0.0-alpha.1",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should block pre-release major version upgrade",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create test logger
|
|
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
|
|
|
// Call the function and capture the result
|
|
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
|
|
|
|
// Assert the expected result
|
|
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_selfUpgradeCheck(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
|
}
|
|
|
|
// Helper function to create a version
|
|
makeVersion := func(v string) *semver.Version {
|
|
ver, err := semver.NewVersion(v)
|
|
if err != nil {
|
|
t.Fatalf("failed to create version %s: %v", v, err)
|
|
}
|
|
return ver
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
versionTarget string
|
|
currentVersion *semver.Version
|
|
shouldUpgrade bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "upgrade allowed",
|
|
versionTarget: "v1.0.1",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: true,
|
|
description: "should allow upgrade and attempt to perform it",
|
|
},
|
|
{
|
|
name: "upgrade blocked",
|
|
versionTarget: "v2.0.0",
|
|
currentVersion: makeVersion("v1.0.0"),
|
|
shouldUpgrade: false,
|
|
description: "should block upgrade and not attempt to perform it",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create test logger
|
|
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
|
|
|
|
// Call the function and capture the result
|
|
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
|
|
|
|
// Assert the expected result
|
|
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_performUpgrade(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipped due to Windows file locking issue on Github Action runners")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
versionTarget string
|
|
expectedResult bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "valid version target",
|
|
versionTarget: "v1.0.1",
|
|
expectedResult: true,
|
|
description: "should attempt to perform upgrade with valid version target",
|
|
},
|
|
{
|
|
name: "empty version target",
|
|
versionTarget: "",
|
|
expectedResult: true,
|
|
description: "should attempt to perform upgrade even with empty version target",
|
|
},
|
|
}
|
|
|
|
// newUpgradeCmd is stubbed in TestMain so performUpgrade does not re-exec
|
|
// (and fork-bomb) the test binary; see the comment there.
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Call the function and capture the result
|
|
result := performUpgrade(tc.versionTarget)
|
|
assert.Equal(t, tc.expectedResult, result, tc.description)
|
|
})
|
|
}
|
|
}
|