05ed8adf
test(syncsvc): a deadline nothing outlives proves nothing
a73x 2026-08-23 11:11
Commit message
internal/server/syncsvc/network_test.go
| Old | New | ||
|---|---|---|---|
| @@ -141,24 +141,45 @@ func TestApplyReportKeepsTheLastKnownLease(t *testing.T) { | |||
| 141 | assert.Equal(t, "192.168.0.99", vm.NetworkIP, "a good address after junk still lands") | 141 | assert.Equal(t, "192.168.0.99", vm.NetworkIP, "a good address after junk still lands") |
| 142 | } | 142 | } |
| 143 | 143 | ||
| 144 | // TestApplyReportSkipsUnchangedLeases proves the per-VM dedup: a bridged guest | 144 | // TestApplyReportRoutesLeasesThroughTheDedupCache pins the half of the skip |
| 145 | // re-reports the same address every tick forever, and the store runs on one | 145 | // that lives in applyReport: every reported address goes through netIPTrack, |
| 146 | // connection. Only a change may cost a write. | 146 | // keyed by VM. A bridged guest re-reports the same lease every tick forever and |
| 147 | func TestApplyReportSkipsUnchangedLeases(t *testing.T) { | 147 | // the store runs on one connection, so a direct RecordVMNetworkIP here would |
| 148 | // spend a round trip per bridged guest per tick for the life of the fleet — and | ||
| 149 | // leave no trace, because the write succeeds every time. | ||
| 150 | // | ||
| 151 | // The skip itself — repeat writes nothing, change writes — belongs to | ||
| 152 | // TestNetTrackerWritesOnceThenStaysQuiet, which counts writes. This test can | ||
| 153 | // only see the cache, so it asserts what the cache proves: that applyReport | ||
| 154 | // consults it, with the reported address under the reporting VM's id. | ||
| 155 | func TestApplyReportRoutesLeasesThroughTheDedupCache(t *testing.T) { | ||
| 148 | f := setup(t) | 156 | f := setup(t) |
| 149 | networkedVM(t, f, "vm1") | 157 | networkedVM(t, f, "vm1") |
| 150 | rep := &pb.Report{Vms: []*pb.VMStatus{{ | 158 | report := func(ip string) *pb.Report { |
| 151 | VmId: "vm1", PowerState: "running", Phase: "ready", Ip: "10.77.1.2", NetworkIp: "192.168.0.42", | 159 | return &pb.Report{Vms: []*pb.VMStatus{{ |
| 152 | }}} | 160 | VmId: "vm1", PowerState: "running", Phase: "ready", Ip: "10.77.1.2", NetworkIp: ip, |
| 161 | }}} | ||
| 162 | } | ||
| 153 | 163 | ||
| 154 | f.svc.applyReport(f.host.ID, rep) | 164 | f.svc.applyReport(f.host.ID, report("192.168.0.42")) |
| 155 | f.svc.applyReport(f.host.ID, rep) | 165 | f.svc.applyReport(f.host.ID, report("192.168.0.42")) |
| 156 | f.svc.applyReport(f.host.ID, rep) | 166 | f.svc.applyReport(f.host.ID, report("192.168.0.42")) |
| 167 | assertLeaseCache(t, f, map[string]string{"vm1": "192.168.0.42"}, | ||
| 168 | "a lease that never reached the cache is a lease re-written every tick forever") | ||
| 169 | |||
| 170 | // A renewal to a different address must land in the cache, or the guest's | ||
| 171 | // new lease is remembered as the old one and never written again. | ||
| 172 | f.svc.applyReport(f.host.ID, report("192.168.0.99")) | ||
| 173 | assertLeaseCache(t, f, map[string]string{"vm1": "192.168.0.99"}, | ||
| 174 | "the cache must hold the newest reported lease, not the first one seen") | ||
| 175 | } | ||
| 157 | 176 | ||
| 177 | // assertLeaseCache reads netIPTrack under its own lock and compares it to want. | ||
| 178 | func assertLeaseCache(t *testing.T, f *fixture, want map[string]string, msg string) { | ||
| 179 | t.Helper() | ||
| 158 | f.svc.netIPTrack.mu.Lock() | 180 | f.svc.netIPTrack.mu.Lock() |
| 159 | defer f.svc.netIPTrack.mu.Unlock() | 181 | defer f.svc.netIPTrack.mu.Unlock() |
| 160 | assert.Equal(t, map[string]string{"vm1": "192.168.0.42"}, f.svc.netIPTrack.last, | 182 | assert.Equal(t, want, f.svc.netIPTrack.last, msg) |
| 161 | "the address is remembered once, so the repeats write nothing") | ||
| 162 | } | 183 | } |
| 163 | 184 | ||
| 164 | // TestApplyReportForgetsAReapedVMsLease bounds the cache: a VM whose row is | 185 | // TestApplyReportForgetsAReapedVMsLease bounds the cache: a VM whose row is |
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -64,6 +64,11 @@ type Service struct { | |||
| 64 | maxCredAge time.Duration | 64 | maxCredAge time.Duration |
| 65 | // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout). | 65 | // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout). |
| 66 | writeTimeout time.Duration | 66 | writeTimeout time.Duration |
| 67 | // handshakeTimeout bounds the open/opened exchange on a server-initiated | ||
| 68 | // stream (see consoleHandshakeTimeout, its production value). A field, not | ||
| 69 | // the constant, so a test can inject a timeout it can actually outlive: | ||
| 70 | // nothing can prove the deadline is set — or cleared — against ten seconds. | ||
| 71 | handshakeTimeout time.Duration | ||
| 67 | // consoleMu guards conns: the live QUIC connection per agent, registered | 72 | // consoleMu guards conns: the live QUIC connection per agent, registered |
| 68 | // after auth in handleConn and deregistered when the session ends. The | 73 | // after auth in handleConn and deregistered when the session ends. The |
| 69 | // console broker opens per-session streams on it. | 74 | // console broker opens per-session streams on it. |
| @@ -103,7 +108,8 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 103 | writeTimeout = defaultWriteTimeout | 108 | writeTimeout = defaultWriteTimeout |
| 104 | } | 109 | } |
| 105 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, | 110 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 106 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), | 111 | handshakeTimeout: consoleHandshakeTimeout, |
| 112 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), | ||
| 107 | uplinkTrack: newNetTracker(), certTrack: newNetTracker(), netIPTrack: newNetTracker(), | 113 | uplinkTrack: newNetTracker(), certTrack: newNetTracker(), netIPTrack: newNetTracker(), |
| 108 | offers: map[string]offer{}, now: time.Now} | 114 | offers: map[string]offer{}, now: time.Now} |
| 109 | } | 115 | } |
| @@ -664,9 +670,10 @@ func toRegistryMetrics(m *pb.HostMetrics) registry.Metrics { | |||
| 664 | // ErrAgentOffline reports that the target host has no live sync connection. | 670 | // ErrAgentOffline reports that the target host has no live sync connection. |
| 665 | var ErrAgentOffline = errors.New("agent not connected") | 671 | var ErrAgentOffline = errors.New("agent not connected") |
| 666 | 672 | ||
| 667 | // consoleHandshakeTimeout bounds the ConsoleOpen/ConsoleOpened exchange so a | 673 | // consoleHandshakeTimeout is the production value of Service.handshakeTimeout: |
| 668 | // wedged agent cannot pin the WS handler. The bridged session itself has no | 674 | // it bounds the ConsoleOpen/ConsoleOpened exchange so a wedged agent cannot pin |
| 669 | // deadline — consoles are long-lived. | 675 | // the WS handler. The bridged session itself has no deadline — consoles are |
| 676 | // long-lived. | ||
| 670 | const consoleHandshakeTimeout = 10 * time.Second | 677 | const consoleHandshakeTimeout = 10 * time.Second |
| 671 | 678 | ||
| 672 | // openStream is the shared server-initiated stream procedure behind OpenConsole | 679 | // openStream is the shared server-initiated stream procedure behind OpenConsole |
| @@ -678,8 +685,8 @@ const consoleHandshakeTimeout = 10 * time.Second | |||
| 678 | // returned Close tears down both directions. | 685 | // returned Close tears down both directions. |
| 679 | // | 686 | // |
| 680 | // ctx bounds stream OPENING only; the handshake that follows is bounded by | 687 | // ctx bounds stream OPENING only; the handshake that follows is bounded by |
| 681 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by | 688 | // s.handshakeTimeout instead, so a call can outlive ctx cancellation by up to |
| 682 | // up to that long (10s) before returning. | 689 | // that long (10s in production) before returning. |
| 683 | func (s *Service) openStream(ctx context.Context, hostID, label string, open *pb.ServerMessage, | 690 | func (s *Service) openStream(ctx context.Context, hostID, label string, open *pb.ServerMessage, |
| 684 | checkReply func(*pb.AgentMessage) (ok bool, reason string, present bool)) (io.ReadWriteCloser, error) { | 691 | checkReply func(*pb.AgentMessage) (ok bool, reason string, present bool)) (io.ReadWriteCloser, error) { |
| 685 | s.consoleMu.Lock() | 692 | s.consoleMu.Lock() |
| @@ -693,7 +700,7 @@ func (s *Service) openStream(ctx context.Context, hostID, label string, open *pb | |||
| 693 | return nil, fmt.Errorf("open %s stream: %w", label, err) | 700 | return nil, fmt.Errorf("open %s stream: %w", label, err) |
| 694 | } | 701 | } |
| 695 | cs := consoleStream{st} | 702 | cs := consoleStream{st} |
| 696 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { | 703 | if err := st.SetDeadline(time.Now().Add(s.handshakeTimeout)); err != nil { |
| 697 | cs.Close() | 704 | cs.Close() |
| 698 | return nil, err | 705 | return nil, err |
| 699 | } | 706 | } |
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -699,12 +699,16 @@ func TestOpenTCPNoAgent(t *testing.T) { | |||
| 699 | } | 699 | } |
| 700 | 700 | ||
| 701 | // TestOpenTCPBridgesBytes drives the full TCPOpen/TCPOpened handshake against a | 701 | // TestOpenTCPBridgesBytes drives the full TCPOpen/TCPOpened handshake against a |
| 702 | // fake agent that answers ok=true, then asserts a usable RWC is returned, bytes | 702 | // fake agent that answers ok=true, then asserts a usable RWC is returned and |
| 703 | // pass both directions, and — after the handshake — the stream carries no | 703 | // bytes pass both directions. |
| 704 | // residual read deadline (a delayed read still succeeds, proving the session is | 704 | // |
| 705 | // long-lived, mirroring OpenConsole's SetDeadline(time.Time{}) clear). | 705 | // The round trip happens after a pause LONGER than the injected handshake |
| 706 | // timeout, which is what makes it evidence that the deadline was cleared: a | ||
| 707 | // residual deadline would kill every console and every tunnel a fixed time | ||
| 708 | // after it opened, and sessions here are long-lived by definition. | ||
| 706 | func TestOpenTCPBridgesBytes(t *testing.T) { | 709 | func TestOpenTCPBridgesBytes(t *testing.T) { |
| 707 | f := setup(t) | 710 | f := setup(t) |
| 711 | f.svc.handshakeTimeout = 200 * time.Millisecond | ||
| 708 | c := mustDial(t, f) | 712 | c := mustDial(t, f) |
| 709 | c.recv(t) // initial snapshot | 713 | c.recv(t) // initial snapshot |
| 710 | 714 | ||
| @@ -744,17 +748,56 @@ func TestOpenTCPBridgesBytes(t *testing.T) { | |||
| 744 | 748 | ||
| 745 | assert.Equal(t, uint32(8080), <-gotPort, "agent must receive the requested port") | 749 | assert.Equal(t, uint32(8080), <-gotPort, "agent must receive the requested port") |
| 746 | 750 | ||
| 747 | // The handshake deadline (10s) must have been cleared: a delayed write/read | 751 | // Outlive the handshake deadline, then round-trip. |
| 748 | // well short of that still round-trips. | 752 | time.Sleep(300 * time.Millisecond) |
| 749 | time.Sleep(50 * time.Millisecond) | ||
| 750 | _, err = rwc.Write([]byte("ping")) | 753 | _, err = rwc.Write([]byte("ping")) |
| 751 | require.NoError(t, err) | 754 | require.NoError(t, err, "the handshake deadline must be cleared: a session that dies once it elapses is a console that drops mid-use") |
| 752 | buf := make([]byte, 4) | 755 | buf := make([]byte, 4) |
| 753 | _, err = io.ReadFull(rwc, buf) | 756 | _, err = io.ReadFull(rwc, buf) |
| 754 | require.NoError(t, err) | 757 | require.NoError(t, err, "the handshake deadline must be cleared: a session that dies once it elapses is a console that drops mid-use") |
| 755 | assert.Equal(t, "ping", string(buf)) | 758 | assert.Equal(t, "ping", string(buf)) |
| 756 | } | 759 | } |
| 757 | 760 | ||
| 761 | // TestOpenTCPAbandonsAWedgedAgent pins the other half of the deadline's life: | ||
| 762 | // an agent that accepts the stream and never answers must not hold the caller. | ||
| 763 | // Without the deadline the WS handler goroutine waits forever on a read no one | ||
| 764 | // will satisfy — a silent leak, one goroutine per attempted console. | ||
| 765 | func TestOpenTCPAbandonsAWedgedAgent(t *testing.T) { | ||
| 766 | f := setup(t) | ||
| 767 | f.svc.handshakeTimeout = 100 * time.Millisecond | ||
| 768 | c := mustDial(t, f) | ||
| 769 | c.recv(t) // initial snapshot | ||
| 770 | |||
| 771 | accepted := make(chan struct{}) | ||
| 772 | go func() { | ||
| 773 | st, err := c.conn.AcceptStream(context.Background()) | ||
| 774 | if err != nil { | ||
| 775 | return | ||
| 776 | } | ||
| 777 | var open pb.ServerMessage | ||
| 778 | if transport.ReadMsg(st, &open, transport.DefaultMaxFrame) != nil { | ||
| 779 | return | ||
| 780 | } | ||
| 781 | close(accepted) // the open arrived; the reply never will | ||
| 782 | <-time.After(10 * time.Second) | ||
| 783 | }() | ||
| 784 | |||
| 785 | done := make(chan error, 1) | ||
| 786 | go func() { | ||
| 787 | _, err := f.svc.OpenTCP(context.Background(), f.host.ID, "vm-1", 22) | ||
| 788 | done <- err | ||
| 789 | }() | ||
| 790 | |||
| 791 | <-accepted | ||
| 792 | select { | ||
| 793 | case err := <-done: | ||
| 794 | require.Error(t, err, "a silent agent must fail the open, not return a stream nobody is on the far end of") | ||
| 795 | assert.Contains(t, err.Error(), "tcp reply", "the failure must name the read that timed out") | ||
| 796 | case <-time.After(3 * time.Second): | ||
| 797 | t.Fatal("OpenTCP is still waiting on a wedged agent: without the handshake deadline this goroutine never returns, and one leaks per attempted session") | ||
| 798 | } | ||
| 799 | } | ||
| 800 | |||
| 758 | // TestOpenTCPRefused pins the ok=false path: the agent replies with an error | 801 | // TestOpenTCPRefused pins the ok=false path: the agent replies with an error |
| 759 | // string and OpenTCP surfaces it (not a usable stream). | 802 | // string and OpenTCP surfaces it (not a usable stream). |
| 760 | func TestOpenTCPRefused(t *testing.T) { | 803 | func TestOpenTCPRefused(t *testing.T) { |