Files
llmgw/internal/agent/agent_test.go
Ilker ManapandClaude Opus 5.5 0bd0d881c5 A replica says what its engine computes on, and llama.cpp's word on the CPU refuses a GPU placement
llama.cpp's start-up output is read as a shard becomes ready: the devices it
used, its backend, and the layers it offloaded. That goes up with READY in a
new ShardStatus.compute, is kept on the shard, and is shown by the admin
API, ctl replicas (ON) and the console (COMPUTE). A shard given GPUs whose
engine says it found no usable GPU or offloaded no layer is stopped and
failed, even where nvidia-smi cannot be asked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:02:17 +02:00

928 lines
29 KiB
Go

package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
)
// statusRecorder captures what the control plane hears about shards, and what
// it is asked to adopt.
type statusRecorder struct {
ch chan status
mu sync.Mutex
calls []adoptCall
adopted []reconciler.AdoptedShard
unclaim []string // shard ids to report back as unclaimed
fetches []fetchReport
computes []reconciler.Compute
}
type status struct {
replicaID, shardID string
state supervisor.State
err string
}
func newRecorder() *statusRecorder { return &statusRecorder{ch: make(chan status, 128)} }
func (r *statusRecorder) OnShardStatus(replicaID, shardID string, st supervisor.State, errMsg string) {
select {
case r.ch <- status{replicaID, shardID, st, errMsg}:
default:
}
}
// fetches records download progress, so a test can assert that a fetch is
// visible from the control plane rather than only in the node's log.
func (r *statusRecorder) OnShardCompute(replicaID, shardID string, c reconciler.Compute) {
r.mu.Lock()
defer r.mu.Unlock()
r.computes = append(r.computes, c)
}
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
r.mu.Lock()
defer r.mu.Unlock()
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
}
func (r *statusRecorder) fetchReports() []fetchReport {
r.mu.Lock()
defer r.mu.Unlock()
return append([]fetchReport(nil), r.fetches...)
}
type fetchReport struct {
replicaID, shardID, source string
done, total int64
}
func (r *statusRecorder) Adopt(nodeID string, shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, adoptCall{nodeID: nodeID, shards: shards})
r.adopted = append(r.adopted, shards...)
var unclaimed []reconciler.AdoptedShard
for _, s := range shards {
if slices.Contains(r.unclaim, s.ShardID) {
unclaimed = append(unclaimed, s)
}
}
return unclaimed
}
// adoptCall records that the service reached the reconciler at all, which an
// empty shard list used to prevent.
type adoptCall struct {
nodeID string
shards []reconciler.AdoptedShard
}
func (r *statusRecorder) adoptCalls() []adoptCall {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.calls)
}
func (r *statusRecorder) adoptedShards() []reconciler.AdoptedShard {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.adopted)
}
func (r *statusRecorder) markUnclaimed(ids ...string) {
r.mu.Lock()
r.unclaim = append(r.unclaim, ids...)
r.mu.Unlock()
}
func (r *statusRecorder) await(t *testing.T, limit time.Duration, pred func(status) bool) status {
t.Helper()
deadline := time.After(limit)
var seen []status
for {
select {
case s := <-r.ch:
seen = append(seen, s)
if pred(s) {
return s
}
case <-deadline:
t.Fatalf("no matching status within %v; saw %+v", limit, seen)
return status{}
}
}
}
// fakeEngineScript writes a script that stands in for vLLM.
//
// It parses the real --port flag the vLLM adapter generates rather than being
// told out of band, so the adapter's argument construction is exercised too.
func fakeEngineScript(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-vllm")
script := `#!/bin/sh
port=""
while [ $# -gt 0 ]; do
case "$1" in
--port) port="$2"; shift 2 ;;
*) shift ;;
esac
done
` + body + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return path
}
// servingEngine answers /health and /v1/models, which is what readiness needs.
func servingEngine(t *testing.T) string {
t.Helper()
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is required for the fake engine")
}
return fakeEngineScript(t, `exec `+py+` -c '
import http.server, json, sys
port = int(sys.argv[1])
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200); self.end_headers(); return
if self.path == "/v1/models":
b = json.dumps({"object":"list","data":[{"id":"test-model"}]}).encode()
self.send_response(200)
self.send_header("content-type","application/json")
self.send_header("content-length", str(len(b)))
self.end_headers(); self.wfile.write(b); return
self.send_response(404); self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
' "$port"`)
}
type harness struct {
svc *agentsvc.Service
inv *inventory.Store
rec *statusRecorder
srv *httptest.Server
ag *Agent
ln *dropListener
// streams counts how many times an agent has opened the Connect stream,
// which is the only honest way to tell a reconnect from a stale entry.
streams *atomic.Int64
}
// dropListener tracks accepted connections so a test can sever them.
//
// httptest's CloseClientConnections does not work here: h2c with prior
// knowledge hijacks the connection, and httptest stops tracking hijacked
// connections. A test relying on it would pass without ever reconnecting.
type dropListener struct {
net.Listener
mu sync.Mutex
conns []net.Conn
}
func (l *dropListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
l.mu.Lock()
l.conns = append(l.conns, c)
l.mu.Unlock()
return c, nil
}
// drop severs every connection, as a control plane restart would.
func (l *dropListener) drop() {
l.mu.Lock()
conns := l.conns
l.conns = nil
l.mu.Unlock()
for _, c := range conns {
_ = c.Close()
}
}
// start wires a real agent to a real service over h2c, so this exercises the
// protocol rather than two structs calling each other.
func start(t *testing.T, cfgFns ...func(*Config)) *harness {
t.Helper()
return startWith(t, slog.New(slog.DiscardHandler), cfgFns...)
}
// startLogged is start with logging on, for diagnosing protocol behaviour.
func startLogged(t *testing.T, cfgFns ...func(*Config)) *harness {
t.Helper()
return startWith(t, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), cfgFns...)
}
func startWith(t *testing.T, log *slog.Logger, cfgFns ...func(*Config)) *harness {
t.Helper()
inv := inventory.NewStore()
rec := newRecorder()
svc := agentsvc.New(inv, rec, false, log)
path, handler := svc.Handler()
mux := http.NewServeMux()
mux.Handle(path, handler)
var streams atomic.Int64
counting := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
streams.Add(1)
mux.ServeHTTP(w, r)
})
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ln := &dropListener{Listener: raw}
// connect's bidirectional streams need HTTP/2; h2c provides it without TLS.
srv := httptest.NewUnstartedServer(h2c.NewHandler(counting, &http2.Server{}))
srv.Listener = ln
srv.Start()
cfg := DefaultConfig()
cfg.NodeID = "n1"
cfg.Server = srv.URL
cfg.MgmtAddr = "127.0.0.1"
cfg.HeartbeatInterval = 50 * time.Millisecond
cfg.ReconnectBase = 20 * time.Millisecond
cfg.ReconnectMax = 100 * time.Millisecond
cfg.Discovery.SkipGPUs = true
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
cfg.Superv = supervisor.Config{
WorkDir: t.TempDir(), LogLines: 64,
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
}
for _, f := range cfgFns {
f(&cfg)
}
client := &http.Client{Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
},
}}
ag, err := New(cfg, client, log)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() { _ = ag.Run(ctx) }()
h := &harness{svc: svc, inv: inv, rec: rec, srv: srv, ag: ag, ln: ln, streams: &streams}
t.Cleanup(func() {
cancel()
ag.Supervisor().StopAll(context.Background())
srv.Close()
})
h.waitRegistered(t)
return h
}
func (h *harness) waitRegistered(t *testing.T) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if _, ok := h.inv.Get("n1"); ok && len(h.svc.ConnectedNodes()) == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the agent never registered")
}
// shardCommand builds a single-node vLLM shard on a free port.
//
// The model path is a real file. It used to be "/models/x", which stopped
// working once the agent began checking that weights exist before launching an
// engine — the check doing exactly what it is for, on a test that was relying
// on nobody looking.
func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
t.Helper()
modelPath := filepath.Join(t.TempDir(), "model.gguf")
if err := os.WriteFile(modelPath, []byte("weights"), 0o644); err != nil {
t.Fatal(err)
}
return reconciler.ShardCommand{
ReplicaID: "rep-1", ShardID: shardID, NodeID: "n1",
Spec: engines.ShardSpec{
ReplicaID: "rep-1", ShardID: shardID, Rank: 0, NNodes: 1,
GPUIndices: []int{0}, HTTPPort: freePort(t),
Profile: engines.Profile{
Name: "p", Engine: "vllm", TP: 1,
ModelPath: modelPath, ServedModelName: "test-model",
},
},
StartupTimeout: 15 * time.Second,
}
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func TestRegistrationCarriesInventory(t *testing.T) {
// The node must actually have the adapters it declares. It used to declare
// mlx5_0 and mlx5_1 on a node whose sysfs held neither, which passed
// because nothing compared the two — and that is the gap a real cluster
// would have found as a replica hanging on its first all-reduce.
ibRoot := ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
h := start(t, func(c *Config) {
c.Hostname = "gpu-a-01"
c.Labels = map[string]string{"rack": "r12"}
c.Discovery.IBRoot = ibRoot
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}, RateGbps: 200},
}
})
n, ok := h.inv.Get("n1")
if !ok {
t.Fatal("node missing from inventory")
}
if n.Hostname != "gpu-a-01" || n.MgmtAddr != "127.0.0.1" {
t.Errorf("identity wrong: %+v", n)
}
if n.Labels["rack"] != "r12" {
t.Errorf("labels lost: %v", n.Labels)
}
// The fabric label cannot be inferred from the wire, so losing it in
// transit would silently disable multi-node placement.
if !n.HasFabric("ib-a") {
t.Fatalf("fabric attachment lost: %+v", n.Fabrics)
}
if got := n.HCAsFor("ib-a"); !slices.Equal(got, []string{"mlx5_0", "mlx5_1"}) {
t.Errorf("HCAs=%v", got)
}
}
func TestHeartbeatKeepsNodeAlive(t *testing.T) {
h := start(t)
n, _ := h.inv.Get("n1")
first := n.LastSeen
// Waited for rather than slept through: four heartbeat intervals was not
// always enough on a loaded runner for one to have landed.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if n, _ = h.inv.Get("n1"); n.LastSeen.After(first) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
}
func TestStartAndStopShardEndToEnd(t *testing.T) {
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = servingEngine(t)
})
cmd := shardCommand(t, "shard-a")
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
got := h.rec.await(t, 30*time.Second, func(s status) bool {
return s.shardID == "shard-a" && s.state == supervisor.Ready
})
if got.replicaID != "rep-1" {
t.Errorf("replica id lost in transit: %q", got.replicaID)
}
if err := h.svc.StopShard(t.Context(), "n1", "shard-a"); err != nil {
t.Fatal(err)
}
h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-a" && s.state == supervisor.Stopped
})
}
func TestFailedShardCarriesDiagnosis(t *testing.T) {
// When a model fails to load the engine's output is the only diagnosis, so
// it has to survive the trip to the control plane.
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `echo 'CUDA out of memory' >&2; exit 1`)
})
if err := h.svc.StartShard(t.Context(), shardCommand(t, "shard-bad")); err != nil {
t.Fatal(err)
}
got := h.rec.await(t, 30*time.Second, func(s status) bool {
return s.shardID == "shard-bad" && s.state == supervisor.Failed
})
if got.err == "" {
t.Error("the failure carried no error message")
}
}
func TestEngineThatNeverBecomesReady(t *testing.T) {
// A process that runs but never serves must time out rather than hang the
// replica forever.
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
})
cmd := shardCommand(t, "shard-stuck")
cmd.StartupTimeout = 500 * time.Millisecond
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
h.rec.await(t, 20*time.Second, func(s status) bool {
return s.shardID == "shard-stuck" && s.state == supervisor.Failed
})
}
func TestValidationRejectsBeforeLaunch(t *testing.T) {
// An engine that refuses a profile does so after minutes of loading;
// catching it at dispatch costs nothing.
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = servingEngine(t)
})
cmd := shardCommand(t, "shard-invalid")
cmd.Spec.Profile.ModelPath = "" // validation requires one
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
got := h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-invalid" && s.state == supervisor.Failed
})
if got.err == "" {
t.Error("validation failure carried no reason")
}
}
func TestDispatchToUnconnectedNode(t *testing.T) {
h := start(t)
err := h.svc.StartShard(t.Context(), reconciler.ShardCommand{NodeID: "nowhere", ShardID: "x"})
if err == nil {
t.Fatal("dispatching to an unconnected node succeeded")
}
if !errors.Is(err, agentsvc.ErrNotConnected) {
t.Errorf("err=%v, expected ErrNotConnected", err)
}
}
func TestAgentReconnects(t *testing.T) {
// A control plane restart must not require touching every GPU node.
h := start(t)
before := h.streams.Load()
h.ln.drop()
// Counting streams, not connected nodes: a stale entry in the connection
// map would make a node look connected without any reconnect having
// happened, and an assertion on it would pass vacuously.
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if h.streams.Load() > before && len(h.svc.ConnectedNodes()) == 1 {
if err := h.svc.DrainShard(t.Context(), "n1", "nothing"); err != nil {
t.Fatalf("the reconnected stream does not accept commands: %v", err)
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("the agent never reconnected (streams %d -> %d)", before, h.streams.Load())
}
func TestIdempotentStart(t *testing.T) {
// The control plane retries commands; a repeat must not start a second
// process (plan §5.6).
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 30`)
})
cmd := shardCommand(t, "shard-idem")
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-idem" && s.state == supervisor.Starting
})
sup := h.ag.Supervisor()
sh, err := sup.Get("shard-idem")
if err != nil {
t.Fatal(err)
}
pid := sh.Status().PID
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
time.Sleep(400 * time.Millisecond)
sh2, err := sup.Get("shard-idem")
if err != nil {
t.Fatal(err)
}
if sh2.Status().PID != pid {
t.Errorf("a second process was started: pid %d then %d", pid, sh2.Status().PID)
}
}
// TestRunningShardsReportedOnReconnect covers the control plane restart hole
// (plan §6.5): a reconnecting agent has to describe what it is already running,
// or the control plane concludes the cluster is empty and places duplicates
// while the originals keep holding GPUs.
func TestRunningShardsReportedOnReconnect(t *testing.T) {
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
})
cmd := shardCommand(t, "shard-live")
cmd.Pool = "ib-a/h100-80g"
cmd.Fabric = "ib-a"
cmd.Spec.HCAs = []string{"mlx5_0"}
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-live" && s.state == supervisor.Starting
})
// Drop the stream, as a control plane restart would.
h.ln.drop()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if got := h.rec.adoptedShards(); len(got) > 0 {
a := got[0]
if a.ShardID != "shard-live" || a.ReplicaID != "rep-1" {
t.Fatalf("wrong shard reported: %+v", a)
}
// Everything the control plane needs to rebuild the replica.
if a.Model != "test-model" || a.Profile != "p" {
t.Errorf("catalog identity lost: model=%q profile=%q", a.Model, a.Profile)
}
if a.Pool != "ib-a/h100-80g" || a.Fabric != "ib-a" {
t.Errorf("pool/fabric lost: %q %q", a.Pool, a.Fabric)
}
if !slices.Equal(a.HCAs, []string{"mlx5_0"}) {
t.Errorf("HCAs lost: %v", a.HCAs)
}
if a.Endpoint == "" {
t.Error("rank 0 reported no endpoint; the router would have nowhere to send")
}
if len(a.GPUIndices) == 0 {
t.Error("GPU indices lost; the inventory could not reclaim them")
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("the reconnecting agent never reported its running shard")
}
// TestUnclaimedShardIsStopped covers the other half: a shard whose model left
// the catalog is holding GPUs nothing will ever route to.
func TestUnclaimedShardIsStopped(t *testing.T) {
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
})
cmd := shardCommand(t, "shard-orphaned")
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
t.Fatal(err)
}
h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-orphaned" && s.state == supervisor.Starting
})
sh, err := h.ag.Supervisor().Get("shard-orphaned")
if err != nil {
t.Fatal(err)
}
pid := sh.Status().PID
// The control plane will refuse to claim it on reconnect.
h.rec.markUnclaimed("shard-orphaned")
h.ln.drop()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if !processAliveForTest(pid) {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Error("an unclaimed shard kept running and holding its GPUs")
}
// processAliveForTest mirrors the supervisor's liveness check.
func processAliveForTest(pid int) bool {
return syscall.Kill(pid, 0) == nil
}
func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
// The commonest setup mistake on a fresh node. Left to the engine it
// surfaces minutes into a load, in the engine's own words, on a machine
// nobody is watching — so it is caught here, where the message can say
// what is actually wrong.
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
if err == nil {
t.Fatal("a missing weights file was accepted")
}
if !strings.Contains(err.Error(), "not-there.gguf") {
t.Errorf("the error does not name the file: %v", err)
}
}
func TestTheCacheDefaultsToTheFirstModelDirectory(t *testing.T) {
// A node that has said where its models live has already answered this;
// asking twice is a second thing to get out of step.
dir := t.TempDir()
a := &Agent{cfg: Config{ModelDirs: []string{dir, "/elsewhere"}}, log: slog.New(slog.DiscardHandler)}
if got := a.weights().CacheDir; got != dir {
t.Errorf("cache = %q, want the first model directory %q", got, dir)
}
}
// ibTreeWithActivePorts builds a sysfs InfiniBand tree with the named adapters,
// each with one active port, shaped like the real thing.
func ibTreeWithActivePorts(t *testing.T, names ...string) string {
t.Helper()
root := t.TempDir()
for _, name := range names {
dir := filepath.Join(root, name, "ports", "1")
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
t.Fatal(err)
}
for file, content := range map[string]string{
"state": "4: ACTIVE",
"phys_state": "5: LinkUp",
"rate": "200 Gb/sec (4X HDR)",
"link_layer": "InfiniBand",
"lid": "0x1a",
"gids/0": "fec0:0000:0000:000a:0002:c903:00a0:1234",
} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
return root
}
func TestAFabricWhoseAdaptersAreAbsentIsNotAdvertised(t *testing.T) {
// The failure this prevents: a typo, or a card that enumerated differently
// after a reboot, produced NCCL_IB_HCA naming an adapter that is not there.
// The replica starts, passes its health checks, and hangs on its first
// all-reduce — the exact case the fabric invariant exists to prevent,
// arriving through a door nobody was watching.
h := start(t, func(c *Config) {
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0")
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-typo", HCAs: []string{"mlx5_9"}, RateGbps: 200},
}
})
n, ok := h.inv.Get("n1")
if !ok {
t.Fatal("node missing from inventory")
}
if n.HasFabric("ib-typo") {
t.Errorf("a fabric with no usable adapter was advertised: %+v", n.Fabrics)
}
}
func TestAFabricRateIsTheSlowestActiveLink(t *testing.T) {
// A collective runs at the speed of its slowest link, so reporting the
// fastest would have the scheduler believe a fabric is quicker than it is.
root := t.TempDir()
for name, rate := range map[string]string{"mlx5_0": "200", "mlx5_1": "100"} {
dir := filepath.Join(root, name, "ports", "1")
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
t.Fatal(err)
}
for file, content := range map[string]string{
"state": "4: ACTIVE", "phys_state": "5: LinkUp",
"rate": rate + " Gb/sec (4X HDR)", "link_layer": "InfiniBand",
"lid": "0x1a", "gids/0": "fec0::1",
} {
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
h := start(t, func(c *Config) {
c.Discovery.IBRoot = root
c.Fabrics = []inventory.FabricAttachment{
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}},
}
})
n, _ := h.inv.Get("n1")
for _, f := range n.Fabrics {
if f.Fabric == "ib-a" && f.RateGbps != 100 {
t.Errorf("fabric rate = %d, want the slower link's 100", f.RateGbps)
}
}
}
func TestAPortGoingDownIsToldToTheControlPlaneOnce(t *testing.T) {
// Checked only at registration, a port going down went unnoticed until
// the agent restarted, and the node went on being offered multi-node
// work that would hang on its first collective.
root := ibTreeWithActivePorts(t, "mlx5_0")
h := start(t, func(c *Config) {
c.Discovery.IBRoot = root
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0"}}}
})
if n, _ := h.inv.Get("n1"); !n.HasFabric("ib-a") {
t.Fatalf("the fabric should be advertised at registration: %+v", n.Fabrics)
}
hb := h.ag.heartbeat().GetHeartbeat()
if hb.GetFabricsChanged() || len(hb.GetIbPorts()) != 1 || hb.GetIbPorts()[0].GetState() != "ACTIVE" {
t.Fatalf("a quiet heartbeat should carry the port and no change: %+v", hb)
}
if err := os.WriteFile(filepath.Join(root, "mlx5_0", "ports", "1", "state"), []byte("1: DOWN\n"), 0o644); err != nil {
t.Fatal(err)
}
hb = h.ag.heartbeat().GetHeartbeat()
if !hb.GetFabricsChanged() || len(hb.GetFabrics()) != 0 {
t.Errorf("the port going down should withdraw the fabric: changed=%v fabrics=%v",
hb.GetFabricsChanged(), hb.GetFabrics())
}
if hb = h.ag.heartbeat().GetHeartbeat(); hb.GetFabricsChanged() {
t.Error("the change should be sent once, not on every heartbeat")
}
}
func TestAShardUsesThisNodesInterfaceAndRoCEIndex(t *testing.T) {
// The control plane sends one interface name for every node; a node whose
// management network is on another had NCCL bind nothing. And which RoCE
// GID entry routes is a property of this node's adapters.
a := &Agent{cfg: Config{MgmtInterface: "ens3"}}
a.inv.HCAs = []discovery.HCA{{Name: "mlx5_0", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkEthernet, RoCEv2GID: 3}}}}
spec := engines.ShardSpec{MgmtInterface: "eth0", HCAs: []string{"mlx5_0"}}
a.localizeSpec(&spec)
if spec.MgmtInterface != "ens3" {
t.Errorf("interface = %q", spec.MgmtInterface)
}
if spec.GIDIndex == nil || *spec.GIDIndex != 3 {
t.Errorf("GID index = %v", spec.GIDIndex)
}
ib := engines.ShardSpec{HCAs: []string{"mlx5_1"}}
a.inv.HCAs = append(a.inv.HCAs, discovery.HCA{Name: "mlx5_1", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkInfiniBand, RoCEv2GID: -1}}})
a.localizeSpec(&ib)
if ib.GIDIndex != nil {
t.Error("an InfiniBand shard was given a GID index")
}
}
func TestTheManagementInterfaceIsFoundNotAssumed(t *testing.T) {
// It was defaulted to eth0 before it could be looked for, so it never
// was: a node on ens3 or bond0 had NCCL bootstrap over nothing.
cfg := DefaultConfig()
cfg.Server = "http://127.0.0.1:7443"
cfg.NodeID = "n1"
cfg.MgmtAddr = "127.0.0.1"
if err := cfg.normalize(); err != nil {
t.Fatal(err)
}
if cfg.MgmtInterface == "" || cfg.MgmtInterface == "eth0" {
t.Errorf("interface = %q, want the loopback interface holding 127.0.0.1", cfg.MgmtInterface)
}
}
func TestEachFabricsSubnetManagersAreAskedOnce(t *testing.T) {
// Two adapters on one fabric see one master. Its managers are listed
// through one of them — the same list twice is a query too many — and
// every port says which master it sees.
oldSM, oldSMs := querySM, querySMs
t.Cleanup(func() { querySM, querySMs = oldSM, oldSMs })
querySM = func(string, int) (*discovery.SMInfo, error) {
return &discovery.SMInfo{GUID: "0x01", LID: 1, State: "SMINFO_MASTER"}, nil
}
lists := 0
querySMs = func(string, int) ([]discovery.SMRecord, error) {
lists++
return []discovery.SMRecord{{GUID: "0x01", State: "master"}, {GUID: "0x02", State: "standby"}}, nil
}
h := start(t, func(c *Config) {
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}}}
})
hb := h.ag.heartbeat().GetHeartbeat()
withList := 0
for _, p := range hb.GetIbPorts() {
if p.GetSmGuid() != "0x01" {
t.Errorf("%s sees master %q", p.GetHca(), p.GetSmGuid())
}
if len(p.GetSubnetManagers()) > 0 {
withList++
}
}
if lists != 1 || withList != 1 {
t.Errorf("listed %d times, carried on %d ports; want once, on one", lists, withList)
}
}
func TestAFabricScanIsAskedForAndAnswered(t *testing.T) {
// One node per fabric maps it; the control plane asks and parses.
old := scanFabric
t.Cleanup(func() { scanFabric = old })
var asked string
scanFabric = func(_ context.Context, hca string, port int) (string, error) {
asked = fmt.Sprintf("%s/%d", hca, port)
return "Switch\t36 \"S-00000000000000a1\"\t\t# \"MF0;leaf-a:QM8700/U1\" enhanced port 0 lid 1 lmc 0\n", nil
}
h := start(t)
r, err := h.svc.ScanFabric(t.Context(), "n1", "mlx5_0", 1)
if err != nil {
t.Fatal(err)
}
if asked != "mlx5_0/1" || !strings.Contains(r.GetOutput(), "leaf-a") || r.GetError() != "" {
t.Errorf("asked %q, got %+v", asked, r)
}
}
func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
oldRun, oldHave := runWriteBW, haveWriteBW
t.Cleanup(func() { runWriteBW, haveWriteBW = oldRun, oldHave })
haveWriteBW = func() bool { return true }
var got discovery.WriteBW
runWriteBW = func(_ context.Context, w discovery.WriteBW) (float64, string, error) {
got = w
return 388.2, " #bytes #iterations BW peak[Gb/sec] BW average[Gb/sec]\n", nil
}
h := start(t, func(c *Config) { c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0") })
r, err := h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{
Role: "client", Hca: "mlx5_0", Port: 1, Peer: "10.0.0.2", TcpPort: 18515, Seconds: 3})
if err != nil {
t.Fatal(err)
}
if r.GetGbps() != 388.2 || r.GetError() != "" {
t.Errorf("reply %+v", r)
}
if got.HCA != "mlx5_0" || got.Peer != "10.0.0.2" || got.TCPPort != 18515 || got.GIDIndex != -1 {
t.Errorf("ran %+v", got)
}
// An adapter the node does not have is said so, without running anything.
got = discovery.WriteBW{}
r, _ = h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{Role: "server", Hca: "mlx5_9", Port: 1, Seconds: 1})
if !strings.Contains(r.GetError(), "no port mlx5_9/1") || got.HCA != "" {
t.Errorf("reply %+v, ran %+v", r, got)
}
}