Files
AetherForge/agent/miner/fallback_chain_test.go
AetherForge 74c006a04b Close DV-01–04,07,09,13,14: unify operator-deck UX in server/web.
Standardize neon cyan on #00e8f5, wire SacredPageHeader across Builds/Path Tracer/LOTL, dedupe Pages.css, align Path Tracer chrome, drop dead wealth-deck CSS, add prefers-color-scheme shell + HelpTip, sidebar version from package.json build inject, and Vitest CSS regression guards.
2026-06-07 06:33:51 -07:00

381 lines
11 KiB
Go

package miner
import (
"context"
"errors"
"runtime"
"testing"
"time"
"crypto-miner-agent/config"
)
func TestDefaultFallbackChainAutoWithDocker(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "pool.example.com",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
want := []MiningMethod{MethodContainer, MethodInProcess, MethodStratumDirect}
if len(chain) < len(want) {
t.Fatalf("chain=%v want at least %v", chain, want)
}
for i := range want {
if chain[i] != want[i] {
t.Fatalf("chain[%d]=%q want %q full=%v", i, chain[i], want[i], chain)
}
}
}
func TestDefaultFallbackChainAutoWithoutRuntime(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{})
if chain[0] != MethodInProcess {
t.Fatalf("got %v want inprocess first", chain)
}
// Windows LOTL probe tiers trail stratum_direct in the legacy chain.
stratumIdx := -1
for i, m := range chain {
if m == MethodStratumDirect {
stratumIdx = i
}
}
if stratumIdx < 0 {
t.Fatalf("got %v want stratum_direct present", chain)
}
if runtime.GOOS != "windows" && chain[len(chain)-1] != MethodStratumDirect {
t.Fatalf("got %v want stratum last", chain)
}
}
func TestDefaultFallbackChainInProcessSkipsContainer(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionInProcess,
GPUEnabled: true,
RVNWallet: "wallet",
PoolHost: "p",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("inprocess mode must skip container")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainGPUIncludedWhenConfigured(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
GPUEnabled: true,
RVNWallet: "wallet",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
foundGPU := false
for _, m := range chain {
if m == MethodGPUSubprocess {
foundGPU = true
}
}
if !foundGPU {
t.Fatalf("expected gpu in chain, got %v", chain)
}
}
func TestTryChainOrderAndSkipMissingRuntime(t *testing.T) {
var started []MiningMethod
hooks := ChainHooks{
StartContainer: func() error {
started = append(started, MethodContainer)
return errors.New("container start blocked")
},
StartInProcess: func() error {
started = append(started, MethodInProcess)
return nil
},
StartGPU: func() error { return nil },
IsGPUSupported: func() bool { return false },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "p",
},
}, hooks, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = DefaultFallbackChain(ctrl.cfg, ctrl.runtime)
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(started) != 2 || started[0] != MethodContainer || started[1] != MethodInProcess {
t.Fatalf("start order=%v", started)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodContainer {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainExhausted(t *testing.T) {
hooks := ChainHooks{
StartContainer: func() error { return errors.New("no container") },
StartInProcess: func() error { return errors.New("no inprocess") },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
_, err := ctrl.TryChain(context.Background())
if !errors.Is(err, ErrChainExhausted) && err.Error() != "no inprocess" {
t.Fatalf("got err=%v", err)
}
if !ctrl.Status().ChainExhausted {
t.Fatal("expected chain exhausted flag")
}
}
func TestAdvancePrimaryFromContainer(t *testing.T) {
var inprocessStarted bool
hooks := ChainHooks{
StartInProcess: func() error {
inprocessStarted = true
return nil
},
StopContainer: func() {},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodContainer, MethodInProcess}
ctrl.activePrimary = MethodContainer
ctrl.AdvancePrimary("container exited")
if !inprocessStarted {
t.Fatal("expected inprocess start after container failure")
}
if ctrl.Status().ActiveMethod != MethodInProcess {
t.Fatalf("active=%q", ctrl.Status().ActiveMethod)
}
}
func TestDefaultChainCooldownConstant(t *testing.T) {
if DefaultChainCooldown != 30*time.Second {
t.Fatalf("DefaultChainCooldown = %v want 30s", DefaultChainCooldown)
}
}
func TestDefaultFallbackChainForcedContainerWithoutRuntime(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionContainer,
PoolHost: "p",
},
}, ContainerRuntimeInfo{})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("container mode without runtime must skip container method")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainDockerLoadBeforeContainer(t *testing.T) {
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "")
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/policy/worker.tar", nil
})
defer SetImageTarFetcher(nil)
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
if chain[0] != MethodDockerLoad {
t.Fatalf("chain=%v want docker_load first when tar policy set", chain)
}
if chain[1] != MethodContainer {
t.Fatalf("chain=%v want container second", chain)
}
}
func TestTryChainDockerLoadFailsReportsAttempt(t *testing.T) {
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/tmp/worker.tar", nil
})
defer SetImageTarFetcher(nil)
var failed []MiningMethod
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ChainHooks{
StartDockerLoad: func() error { failed = append(failed, MethodDockerLoad); return errors.New("docker missing") },
StartInProcess: func() error { return nil },
}, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = []MiningMethod{MethodDockerLoad, MethodInProcess}
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodDockerLoad {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) {
hooks := ChainHooks{
StartInProcess: func() error { return nil },
RunTierProbes: func() TierReport {
return TierReport{
Attempts: []TierAttempt{{
Tier: TierWebView2Probe,
OK: true,
Wallet: "same-wallet",
}},
WebGPUReady: true,
}
},
RunTierChain: func() (LOTLTier, error) {
return TierCPUInprocess, nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatalf("TryChain: %v", err)
}
st := ctrl.Status()
if st.LOTLTier != TierCPUInprocess {
t.Fatalf("lotl_tier=%q want cpu_inprocess", st.LOTLTier)
}
if len(st.LOTLAttempts) != 1 || st.LOTLAttempts[0].Tier != TierWebView2Probe {
t.Fatalf("lotl_attempts=%v", st.LOTLAttempts)
}
if !st.WebGPUReady {
t.Fatal("expected webgpu_ready from tier probes")
}
}
func TestOnMethodFailedReporterIncludesFailedMethods(t *testing.T) {
var events []string
var last MiningStatus
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
}, ChainHooks{}, func(status MiningStatus, eventType string) {
events = append(events, eventType)
last = status
})
ctrl.OnMethodFailed(MethodContainer, "docker blocked")
if len(last.FailedMethods) != 1 {
t.Fatalf("failed_methods=%v", last.FailedMethods)
}
if last.FailedMethods[0].Method != MethodContainer || last.FailedMethods[0].Reason != "docker blocked" {
t.Fatalf("failure=%+v", last.FailedMethods[0])
}
if last.FailedMethods[0].At == "" {
t.Fatal("expected RFC3339 timestamp on failure")
}
if len(events) != 1 || events[0] != "mining_fallback" {
t.Fatalf("events=%v", events)
}
}
func TestRestartChainBypassesCooldown(t *testing.T) {
attempts := 0
hooks := ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Fatalf("attempts=%d want 1 before restart", attempts)
}
ctrl.RestartChain(context.Background())
if attempts != 2 {
t.Fatalf("attempts=%d want 2 after RestartChain clears cooldown", attempts)
}
}
func TestTryChainRespectsCooldown(t *testing.T) {
attempts := 0
hooks := ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Fatalf("attempts=%d want 1 (cooldown)", attempts)
}
}