d518b6b2
fix(sync): one agent per identity — a second launch is refused, a second session evicted
a73x 2026-08-10 15:30
Commit message
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -248,6 +248,7 @@ | |||
| 248 | "internal/agent/seed", | 248 | "internal/agent/seed", |
| 249 | "internal/agent/serialpump", | 249 | "internal/agent/serialpump", |
| 250 | "internal/agent/state", | 250 | "internal/agent/state", |
| 251 | "internal/agent/statelock", | ||
| 251 | "internal/agent/syncclient", | 252 | "internal/agent/syncclient", |
| 252 | "internal/covsnap", | 253 | "internal/covsnap", |
| 253 | "internal/joinblob" | 254 | "internal/joinblob" |
| @@ -278,6 +279,12 @@ | |||
| 278 | "imports": [] | 279 | "imports": [] |
| 279 | }, | 280 | }, |
| 280 | { | 281 | { |
| 282 | "importPath": "internal/agent/statelock", | ||
| 283 | "plane": "data", | ||
| 284 | "synopsis": "Package statelock enforces one running agent per identity.", | ||
| 285 | "imports": [] | ||
| 286 | }, | ||
| 287 | { | ||
| 281 | "importPath": "internal/agent/syncclient", | 288 | "importPath": "internal/agent/syncclient", |
| 282 | "plane": "data", | 289 | "plane": "data", |
| 283 | "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", | 290 | "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -197,6 +197,7 @@ | |||
| 197 | "internal/agent/seed", | 197 | "internal/agent/seed", |
| 198 | "internal/agent/serialpump", | 198 | "internal/agent/serialpump", |
| 199 | "internal/agent/state", | 199 | "internal/agent/state", |
| 200 | "internal/agent/statelock", | ||
| 200 | "internal/agent/syncclient", | 201 | "internal/agent/syncclient", |
| 201 | "internal/covsnap", | 202 | "internal/covsnap", |
| 202 | "internal/joinblob" | 203 | "internal/joinblob" |
| @@ -227,6 +228,12 @@ | |||
| 227 | "imports": [] | 228 | "imports": [] |
| 228 | }, | 229 | }, |
| 229 | { | 230 | { |
| 231 | "importPath": "internal/agent/statelock", | ||
| 232 | "plane": "data", | ||
| 233 | "synopsis": "Package statelock enforces one running agent per identity.", | ||
| 234 | "imports": [] | ||
| 235 | }, | ||
| 236 | { | ||
| 230 | "importPath": "internal/agent/syncclient", | 237 | "importPath": "internal/agent/syncclient", |
| 231 | "plane": "data", | 238 | "plane": "data", |
| 232 | "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", | 239 | "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", |
internal/agent/run/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,7 @@ import ( | |||
| 29 | "github.com/a73x/eitri/internal/agent/reconcile" | 29 | "github.com/a73x/eitri/internal/agent/reconcile" |
| 30 | "github.com/a73x/eitri/internal/agent/seed" | 30 | "github.com/a73x/eitri/internal/agent/seed" |
| 31 | "github.com/a73x/eitri/internal/agent/state" | 31 | "github.com/a73x/eitri/internal/agent/state" |
| 32 | "github.com/a73x/eitri/internal/agent/statelock" | ||
| 32 | "github.com/a73x/eitri/internal/agent/syncclient" | 33 | "github.com/a73x/eitri/internal/agent/syncclient" |
| 33 | "github.com/a73x/eitri/internal/covsnap" | 34 | "github.com/a73x/eitri/internal/covsnap" |
| 34 | "github.com/a73x/eitri/internal/joinblob" | 35 | "github.com/a73x/eitri/internal/joinblob" |
| @@ -213,6 +214,17 @@ func join(st *state.Store, cfg Config, blob string) error { | |||
| 213 | // serve handles the normal (no subcommand) run mode: it wires the reconcile | 214 | // serve handles the normal (no subcommand) run mode: it wires the reconcile |
| 214 | // engine and sync client and blocks until SIGINT/SIGTERM. | 215 | // engine and sync client and blocks until SIGINT/SIGTERM. |
| 215 | func serve(st *state.Store, cfg Config) error { | 216 | func serve(st *state.Store, cfg Config) error { |
| 217 | // First, before this agent touches the network, a bridge, or a VM record: | ||
| 218 | // claim the state directory. Everything below assumes this process is the | ||
| 219 | // only one driving this identity — the VM records it reconciles, the epoch | ||
| 220 | // fence it advances, and the one sync session the control plane keeps per | ||
| 221 | // host. Held until the process exits. | ||
| 222 | lk, err := statelock.Acquire(cfg.StateDir) | ||
| 223 | if err != nil { | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | defer func() { _ = lk.Release() }() | ||
| 227 | |||
| 216 | id, ok := st.Identity() | 228 | id, ok := st.Identity() |
| 217 | if !ok { | 229 | if !ok { |
| 218 | return errors.New("not enrolled — run with 'join <blob>' subcommand first") | 230 | return errors.New("not enrolled — run with 'join <blob>' subcommand first") |
internal/agent/statelock/statelock.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,102 @@ | |||
| 1 | // Package statelock enforces one running agent per identity. | ||
| 2 | // | ||
| 3 | // An agent's identity is its state directory: identity.json, the host | ||
| 4 | // credential in it, the epoch fence, and every VM record. Two agents sharing | ||
| 5 | // one of those are not two agents — they are one host answering the control | ||
| 6 | // plane twice, each reconciling the other's VMs from a half-observed view of | ||
| 7 | // the disk, and each connecting as the same host so that only one of them is | ||
| 8 | // ever heard. It happens by accident, most often by launching a second agent | ||
| 9 | // without repeating --state-dir and landing back on the default. | ||
| 10 | // | ||
| 11 | // So the state directory is the thing locked, and deliberately not the machine: | ||
| 12 | // one host may legitimately run several agents, each with its own directory and | ||
| 13 | // its own identity in the fleet. Locking anything global would forbid that. | ||
| 14 | package statelock | ||
| 15 | |||
| 16 | import ( | ||
| 17 | "errors" | ||
| 18 | "fmt" | ||
| 19 | "os" | ||
| 20 | "path/filepath" | ||
| 21 | "strconv" | ||
| 22 | "strings" | ||
| 23 | |||
| 24 | "golang.org/x/sys/unix" | ||
| 25 | ) | ||
| 26 | |||
| 27 | // lockName is the file inside the state directory whose flock IS the claim. It | ||
| 28 | // carries the holder's pid as its contents, which is diagnostic only — the lock | ||
| 29 | // itself is what the kernel enforces, so a stale pid can never wrongly refuse | ||
| 30 | // anyone. | ||
| 31 | const lockName = "agent.lock" | ||
| 32 | |||
| 33 | // mode matches every other file in the state directory: the agent's alone. | ||
| 34 | const mode = 0o600 | ||
| 35 | |||
| 36 | // Lock is a held claim on one state directory. It owns an open file whose flock | ||
| 37 | // lives for as long as the file stays open, so the value must outlive every | ||
| 38 | // caller that relies on the claim. | ||
| 39 | type Lock struct{ f *os.File } | ||
| 40 | |||
| 41 | // Acquire claims dir for this process, and fails rather than waits when another | ||
| 42 | // agent already holds it — a second launch is a mistake to report, not a queue | ||
| 43 | // to join. | ||
| 44 | // | ||
| 45 | // The lock is released by exec, on purpose. flock ownership rides the open file | ||
| 46 | // description, and the agent replaces its own binary in place: it renames the | ||
| 47 | // new image over itself and re-execs, keeping its pid (KillMode=process in the | ||
| 48 | // unit exists to let that happen). An fd that survived exec would carry the | ||
| 49 | // claim into the new image, which would then be unable to take a lock it | ||
| 50 | // already holds — and could not tell that deadlock apart from a real second | ||
| 51 | // agent. Every fd Go opens is close-on-exec, which is what makes the re-exec'd | ||
| 52 | // binary land on a free lock and re-take it immediately; the gap is the width | ||
| 53 | // of an execve, with no other launcher racing for it. The flag is spelled out | ||
| 54 | // here so that this stays true if the open is ever rewritten. | ||
| 55 | func Acquire(dir string) (*Lock, error) { | ||
| 56 | path := filepath.Join(dir, lockName) | ||
| 57 | f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|unix.O_CLOEXEC, mode) | ||
| 58 | if err != nil { | ||
| 59 | return nil, fmt.Errorf("open agent lock: %w", err) | ||
| 60 | } | ||
| 61 | if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { | ||
| 62 | held := holder(path) | ||
| 63 | f.Close() | ||
| 64 | if errors.Is(err, unix.EWOULDBLOCK) { | ||
| 65 | if held <= 0 { | ||
| 66 | return nil, errors.New("an agent is already running with this identity") | ||
| 67 | } | ||
| 68 | return nil, fmt.Errorf("an agent is already running with this identity (pid %d)", held) | ||
| 69 | } | ||
| 70 | return nil, fmt.Errorf("lock agent state dir: %w", err) | ||
| 71 | } | ||
| 72 | // Ours now — say whose. Truncate first: a shorter pid than the last holder's | ||
| 73 | // would otherwise leave that one's trailing digits behind. | ||
| 74 | if err := f.Truncate(0); err != nil { | ||
| 75 | f.Close() | ||
| 76 | return nil, fmt.Errorf("truncate agent lock: %w", err) | ||
| 77 | } | ||
| 78 | if _, err := f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0); err != nil { | ||
| 79 | f.Close() | ||
| 80 | return nil, fmt.Errorf("write agent lock: %w", err) | ||
| 81 | } | ||
| 82 | return &Lock{f: f}, nil | ||
| 83 | } | ||
| 84 | |||
| 85 | // Release drops the claim. The agent calls it only on its way out — the lock is | ||
| 86 | // held for the whole run, not around any particular piece of work. | ||
| 87 | func (l *Lock) Release() error { return l.f.Close() } | ||
| 88 | |||
| 89 | // holder reads the pid the current holder wrote, or 0 when the file says | ||
| 90 | // nothing usable. Only ever used to make the refusal name a process; the | ||
| 91 | // refusal itself stands on the kernel's answer. | ||
| 92 | func holder(path string) int { | ||
| 93 | raw, err := os.ReadFile(path) | ||
| 94 | if err != nil { | ||
| 95 | return 0 | ||
| 96 | } | ||
| 97 | pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) | ||
| 98 | if err != nil { | ||
| 99 | return 0 | ||
| 100 | } | ||
| 101 | return pid | ||
| 102 | } | ||
internal/agent/statelock/statelock_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,86 @@ | |||
| 1 | package statelock | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // flock ownership belongs to the open file description, not the process, so two | ||
| 14 | // Acquires in this one test binary conflict exactly as two agents would. | ||
| 15 | |||
| 16 | func TestAcquireClaimsTheDirectoryAndNamesTheHolder(t *testing.T) { | ||
| 17 | dir := t.TempDir() | ||
| 18 | |||
| 19 | lk, err := Acquire(dir) | ||
| 20 | require.NoError(t, err) | ||
| 21 | t.Cleanup(func() { lk.Release() }) //nolint:errcheck | ||
| 22 | |||
| 23 | raw, err := os.ReadFile(filepath.Join(dir, lockName)) | ||
| 24 | require.NoError(t, err) | ||
| 25 | assert.Equal(t, fmt.Sprintf("%d\n", os.Getpid()), string(raw), | ||
| 26 | "the lock file names the process holding it") | ||
| 27 | } | ||
| 28 | |||
| 29 | func TestASecondAgentIsRefusedAndToldWhichProcessHasIt(t *testing.T) { | ||
| 30 | dir := t.TempDir() | ||
| 31 | lk, err := Acquire(dir) | ||
| 32 | require.NoError(t, err) | ||
| 33 | t.Cleanup(func() { lk.Release() }) //nolint:errcheck | ||
| 34 | |||
| 35 | second, err := Acquire(dir) | ||
| 36 | assert.Nil(t, second) | ||
| 37 | require.Error(t, err) | ||
| 38 | assert.Equal(t, fmt.Sprintf("an agent is already running with this identity (pid %d)", os.Getpid()), err.Error()) | ||
| 39 | } | ||
| 40 | |||
| 41 | func TestARefusalWithNoReadablePidStillRefuses(t *testing.T) { | ||
| 42 | dir := t.TempDir() | ||
| 43 | lk, err := Acquire(dir) | ||
| 44 | require.NoError(t, err) | ||
| 45 | t.Cleanup(func() { lk.Release() }) //nolint:errcheck | ||
| 46 | |||
| 47 | // An agent old enough to predate the pid, or one killed between taking the | ||
| 48 | // lock and writing it. The kernel still says the lock is held, which is the | ||
| 49 | // answer that counts; the message simply names nobody. | ||
| 50 | require.NoError(t, os.WriteFile(filepath.Join(dir, lockName), []byte("not a pid\n"), mode)) | ||
| 51 | |||
| 52 | _, err = Acquire(dir) | ||
| 53 | require.Error(t, err) | ||
| 54 | assert.Equal(t, "an agent is already running with this identity", err.Error()) | ||
| 55 | } | ||
| 56 | |||
| 57 | func TestReleasingLetsTheNextAgentIn(t *testing.T) { | ||
| 58 | dir := t.TempDir() | ||
| 59 | lk, err := Acquire(dir) | ||
| 60 | require.NoError(t, err) | ||
| 61 | require.NoError(t, lk.Release()) | ||
| 62 | |||
| 63 | // An agent that has exited leaves nothing behind but a file: a restart, or | ||
| 64 | // the re-exec of a self-upgrade, must not be refused by its own predecessor. | ||
| 65 | next, err := Acquire(dir) | ||
| 66 | require.NoError(t, err) | ||
| 67 | assert.NoError(t, next.Release()) | ||
| 68 | } | ||
| 69 | |||
| 70 | func TestTwoStateDirsAreTwoIdentities(t *testing.T) { | ||
| 71 | // One machine may legitimately run several agents, each enrolled separately. | ||
| 72 | // The lock is per state directory precisely so that stays possible. | ||
| 73 | a, err := Acquire(t.TempDir()) | ||
| 74 | require.NoError(t, err) | ||
| 75 | t.Cleanup(func() { a.Release() }) //nolint:errcheck | ||
| 76 | |||
| 77 | b, err := Acquire(t.TempDir()) | ||
| 78 | require.NoError(t, err) | ||
| 79 | assert.NoError(t, b.Release()) | ||
| 80 | } | ||
| 81 | |||
| 82 | func TestAcquireFailsWhenTheDirectoryIsNotThere(t *testing.T) { | ||
| 83 | _, err := Acquire(filepath.Join(t.TempDir(), "no-such-dir")) | ||
| 84 | require.Error(t, err) | ||
| 85 | assert.Contains(t, err.Error(), "open agent lock") | ||
| 86 | } | ||
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -646,9 +646,20 @@ func logGuestCIDR(cidr string, last *string) { | |||
| 646 | // Run() can log loudly and avoid a tight reconnect loop on a dead credential. | 646 | // Run() can log loudly and avoid a tight reconnect loop on a dead credential. |
| 647 | func classifyErr(err error) error { | 647 | func classifyErr(err error) error { |
| 648 | var appErr *quic.ApplicationError | 648 | var appErr *quic.ApplicationError |
| 649 | if errors.As(err, &appErr) && appErr.ErrorCode == transport.CodeAuthRejected { | 649 | if !errors.As(err, &appErr) { |
| 650 | return err | ||
| 651 | } | ||
| 652 | switch appErr.ErrorCode { | ||
| 653 | case transport.CodeAuthRejected: | ||
| 650 | slog.Error("host credential rejected by server — re-enroll this host", "detail", appErr.ErrorMessage) | 654 | slog.Error("host credential rejected by server — re-enroll this host", "detail", appErr.ErrorMessage) |
| 651 | return errPermanentAuth | 655 | return errPermanentAuth |
| 656 | case transport.CodeSuperseded: | ||
| 657 | // Transient on purpose: this agent reconnects on the normal backoff. Said | ||
| 658 | // loudly because the healthy cause (this agent was restarted and the | ||
| 659 | // server still held the old session) is indistinguishable on the wire | ||
| 660 | // from the unhealthy one — a second agent elsewhere carrying a copy of | ||
| 661 | // this host's identity, which leaves the two evicting each other forever. | ||
| 662 | slog.Warn("another agent connected as this host and took over the session; reconnecting — if this repeats, two agents are sharing one identity") | ||
| 652 | } | 663 | } |
| 653 | return err | 664 | return err |
| 654 | } | 665 | } |
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -130,6 +130,14 @@ var helloGrace = 30 * time.Second | |||
| 130 | // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and | 130 | // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and |
| 131 | // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+). | 131 | // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+). |
| 132 | func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | 132 | func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { |
| 133 | // Every way out of here closes the connection. A handler that returns while | ||
| 134 | // leaving the transport up leaves an agent writing reports nobody reads — | ||
| 135 | // and QUIC keepalives hold that socket open indefinitely, so the host's last | ||
| 136 | // contact freezes and it reads offline until someone breaks the connection by | ||
| 137 | // hand. quic-go closes once (closeOnce), so the specific codes the auth paths | ||
| 138 | // below send still reach the agent; this only catches whatever they miss. | ||
| 139 | defer conn.CloseWithError(0, "session ended") | ||
| 140 | |||
| 133 | // Up-stream: agent opens it and sends Hello first. Both the accept and the | 141 | // Up-stream: agent opens it and sends Hello first. Both the accept and the |
| 134 | // first read run under the hello grace so an unauthenticated peer that | 142 | // first read run under the hello grace so an unauthenticated peer that |
| 135 | // handshakes then stalls cannot park this goroutine indefinitely. | 143 | // handshakes then stalls cannot park this goroutine indefinitely. |
| @@ -203,9 +211,24 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 203 | // Console reachability: only now — with the down-stream open — is it safe | 211 | // Console reachability: only now — with the down-stream open — is it safe |
| 204 | // for OpenConsole to add streams to this connection (stream-order | 212 | // for OpenConsole to add streams to this connection (stream-order |
| 205 | // invariant: the snapshot down-stream is always the first accepted). | 213 | // invariant: the snapshot down-stream is always the first accepted). |
| 214 | // | ||
| 215 | // One host, one session: a second Hello for a host already connected wins, | ||
| 216 | // and the session it displaces is closed rather than abandoned. Two agents | ||
| 217 | // sharing an identity are a misconfiguration, and the newest connection is | ||
| 218 | // the one that just proved it holds the credential — but the displaced side | ||
| 219 | // must SEE the close, or it sits there reporting into a stream that has no | ||
| 220 | // reader. CodeSuperseded puts it on the normal reconnect backoff, so a host | ||
| 221 | // whose agent was merely restarted comes straight back. | ||
| 206 | s.consoleMu.Lock() | 222 | s.consoleMu.Lock() |
| 223 | displaced := s.conns[hostID] | ||
| 207 | s.conns[hostID] = conn | 224 | s.conns[hostID] = conn |
| 208 | s.consoleMu.Unlock() | 225 | s.consoleMu.Unlock() |
| 226 | if displaced != nil { | ||
| 227 | // Outside the lock: CloseWithError waits for the displaced connection to | ||
| 228 | // finish tearing down, and the console broker must not queue behind that. | ||
| 229 | slog.Info("newer session for this host; closing the older one", "host", hostID) | ||
| 230 | _ = displaced.CloseWithError(transport.CodeSuperseded, "superseded by a newer session for this host") | ||
| 231 | } | ||
| 209 | defer func() { | 232 | defer func() { |
| 210 | s.consoleMu.Lock() | 233 | s.consoleMu.Lock() |
| 211 | // Only deregister OUR conn: a reconnect may already have replaced it. | 234 | // Only deregister OUR conn: a reconnect may already have replaced it. |
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -659,10 +659,11 @@ func TestReconnectKeepsNewConnRegistered(t *testing.T) { | |||
| 659 | } | 659 | } |
| 660 | }() | 660 | }() |
| 661 | 661 | ||
| 662 | // Close A. Its handler's deferred deregistration runs asynchronously; the | 662 | // A has already been evicted by B's arrival; closing it from this end too |
| 663 | // guard (only delete if the map still holds OUR conn) must leave B alone — | 663 | // changes nothing and is what a real displaced agent does next. Either way |
| 664 | // so across the whole teardown window no attempt may ever see | 664 | // A's handler deregisters asynchronously, and the guard (only delete if the |
| 665 | // ErrAgentOffline. | 665 | // map still holds OUR conn) must leave B alone — so across the whole |
| 666 | // teardown window no attempt may ever see ErrAgentOffline. | ||
| 666 | a.conn.CloseWithError(0, "") | 667 | a.conn.CloseWithError(0, "") |
| 667 | require.Never(t, func() bool { | 668 | require.Never(t, func() bool { |
| 668 | stream, err := f.svc.OpenConsole(context.Background(), f.host.ID, "vm-1") | 669 | stream, err := f.svc.OpenConsole(context.Background(), f.host.ID, "vm-1") |
| @@ -905,3 +906,47 @@ func TestReportRecordsTheHostsGuestSubnet(t *testing.T) { | |||
| 905 | return err == nil && h.BridgeCIDR != "192.168.64.0/24" | 906 | return err == nil && h.BridgeCIDR != "192.168.64.0/24" |
| 906 | }, 500*time.Millisecond, 25*time.Millisecond, "an empty report must leave the record alone") | 907 | }, 500*time.Millisecond, 25*time.Millisecond, "an empty report must leave the record alone") |
| 907 | } | 908 | } |
| 909 | |||
| 910 | // TestSecondSessionEvictsTheFirst pins one host to one session. Before it, the | ||
| 911 | // hub silently displaced the elder's poke channel and the elder's handler | ||
| 912 | // returned without closing the connection: the agent on the other end kept | ||
| 913 | // writing reports into a stream with no reader, keepalives held the socket open | ||
| 914 | // indefinitely, and the host's last-seen froze until someone broke the | ||
| 915 | // connection by hand. The elder must SEE the close, and see it as transient so | ||
| 916 | // a live agent comes straight back rather than sitting out the auth backoff. | ||
| 917 | func TestSecondSessionEvictsTheFirst(t *testing.T) { | ||
| 918 | f := setup(t) | ||
| 919 | elder := mustDial(t, f) | ||
| 920 | elder.recv(t) // elder is the registered session | ||
| 921 | |||
| 922 | newer := mustDial(t, f) | ||
| 923 | newer.recv(t) // newer has taken over | ||
| 924 | |||
| 925 | require.NoError(t, elder.down.SetReadDeadline(time.Now().Add(5*time.Second))) | ||
| 926 | var discard pb.ServerMessage | ||
| 927 | err := transport.ReadMsg(elder.down, &discard, transport.DefaultMaxFrame) | ||
| 928 | require.Error(t, err, "the displaced session must be closed, not abandoned") | ||
| 929 | var appErr *quic.ApplicationError | ||
| 930 | require.True(t, errors.As(err, &appErr), "expected *quic.ApplicationError, got %T: %v", err, err) | ||
| 931 | assert.Equal(t, quic.ApplicationErrorCode(transport.CodeSuperseded), appErr.ErrorCode, | ||
| 932 | "the displaced agent must be told it was superseded, not that its credential is bad") | ||
| 933 | |||
| 934 | // The elder's teardown runs asynchronously and must take nothing of the | ||
| 935 | // newer's with it. Its console registration survives the whole window — | ||
| 936 | // deregistration is guarded on the map still holding the elder's own conn. | ||
| 937 | require.Never(t, func() bool { | ||
| 938 | f.svc.consoleMu.Lock() | ||
| 939 | defer f.svc.consoleMu.Unlock() | ||
| 940 | return f.svc.conns[f.host.ID] == nil | ||
| 941 | }, time.Second, 50*time.Millisecond, | ||
| 942 | "the evicted session's teardown deregistered the surviving one") | ||
| 943 | |||
| 944 | // And so does its hub subscription: a poke still reaches it. | ||
| 945 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 946 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 947 | f.hub.Poke(f.host.ID) | ||
| 948 | require.NoError(t, newer.down.SetReadDeadline(time.Now().Add(5*time.Second))) | ||
| 949 | snap := newer.recv(t).GetSnapshot() | ||
| 950 | require.NotNil(t, snap, "the surviving session must still be poked") | ||
| 951 | require.Len(t, snap.Vms, 1) | ||
| 952 | } | ||
internal/transport/tlsconf.go
| Old | New | ||
|---|---|---|---|
| @@ -25,6 +25,12 @@ const ALPN = "eitri-sync/1" | |||
| 25 | // the server dropped it. | 25 | // the server dropped it. |
| 26 | const ( | 26 | const ( |
| 27 | CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop | 27 | CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop |
| 28 | // CodeSuperseded closes the older of two sessions claiming one host: the | ||
| 29 | // newest Hello wins and the displaced agent is told so, rather than being | ||
| 30 | // left holding a connection nobody reads. It is transient by design — the | ||
| 31 | // displaced agent reconnects on its normal backoff, and only the auth code | ||
| 32 | // above earns the long one. | ||
| 33 | CodeSuperseded = 2 | ||
| 28 | ) | 34 | ) |
| 29 | 35 | ||
| 30 | // Sync QUIC keepalive/idle timings. The agent dialer and the server listener | 36 | // Sync QUIC keepalive/idle timings. The agent dialer and the server listener |
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,7 @@ cd "$(dirname "$0")/.." | |||
| 18 | declare -A FLOOR=( | 18 | declare -A FLOOR=( |
| 19 | [internal/agent/reconcile]=81 | 19 | [internal/agent/reconcile]=81 |
| 20 | [internal/agent/state]=50 | 20 | [internal/agent/state]=50 |
| 21 | [internal/agent/statelock]=75 | ||
| 21 | [internal/agent/seed]=79 | 22 | [internal/agent/seed]=79 |
| 22 | [internal/agent/ipalloc]=83 | 23 | [internal/agent/ipalloc]=83 |
| 23 | [internal/agent/hostinfo]=95 | 24 | [internal/agent/hostinfo]=95 |