oto/services/core/internal/runnersocket/server_test.go
toki 88c8ff3b07 feat: runner proto socket transport hardening and related updates
- Add runner-proto-socket-transport-hardening milestone and SDD docs
- Add runnersocket package for Go service
- Update agent config, runner, and job client (Dart)
- Update Bootstrap scripts (PowerShell, shell)
- Update Go service HTTP server handlers and routes
- Add CICD state store updates
- Update agent-ops domain rules and phase roadmap
2026-06-20 18:23:30 +09:00

207 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package runnersocket
import (
"net"
"testing"
"time"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func testServer(registry *runnerregistry.Registry) *Server {
return New("127.0.0.1:0", registry, cicdstate.NewStore())
}
// pipeClient creates a TcpClient backed by one end of a net.Pipe.
// heartbeat is disabled (intervalSec=0) to avoid background timer interference.
// Cleanup closes both ends.
func pipeClient(t *testing.T) (*protoSocket.TcpClient, net.Conn) {
t.Helper()
a, b := net.Pipe()
client := protoSocket.NewTcpClient(a, 0, 0, parserMap())
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
return client, b
}
// TestServerSetClientReplacesDuplicate verifies that registering a second client
// for the same runner ID closes the old one and stores the new one.
func TestServerReplacesDuplicateRunnerClient(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-A", client1)
s.setClient("runner-A", client2)
// Allow Close() to propagate through connCloseOnce.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement, but it is still alive")
}
if got := s.clientFor("runner-A"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
}
// TestServerIgnoresStaleClientDisconnect verifies that removeClient ignores
// a client that is not the current entry for the given runner ID.
func TestServerIgnoresStaleClientDisconnect(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-B", client1)
// client2 is a different object simulates a stale disconnect listener.
removed := s.removeClient("runner-B", client2)
if removed {
t.Error("removeClient must return false for a client that is not the current one")
}
if got := s.clientFor("runner-B"); got != client1 {
t.Errorf("current client must still be client1 after stale removeClient; got %v", got)
}
}
// TestServerStaleClientDisconnectDoesNotDisconnectRegistry verifies the complete
// stale-disconnect protection chain:
//
// 1. setClient replaces the old client.
// 2. removeClient returns false for the stale old client.
// 3. Because removeClient returns false, registry.Disconnect is NOT called,
// so the runner remains in a healthy (non-disconnected) state.
func TestServerStaleClientDisconnectDoesNotDisconnectRegistry(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-C",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
registry.Heartbeat("runner-C", otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-C", client1)
// Duplicate connection: client2 replaces client1.
s.setClient("runner-C", client2)
// Simulate stale client1's disconnect listener.
removed := s.removeClient("runner-C", client1)
if removed {
t.Error("stale removeClient should return false")
}
// Because removeClient returned false the disconnect listener guard skips
// registry.Disconnect. The runner must NOT be in disconnected state.
runner, ok := registry.Snapshot("runner-C")
if !ok {
t.Fatal("runner-C not found in registry")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner status must not be %q after stale disconnect; got %q",
runnerregistry.StatusDisconnected, runner.Status)
}
if got := s.clientFor("runner-C"); got != client2 {
t.Errorf("current client must still be client2; got %v", got)
}
}
// TestServerDuplicateReplaceWithListenerDoesNotDeadlock verifies that replacing
// a client that carries a production-style disconnect listener does not deadlock.
//
// Before the fix, setClient held s.mu while calling old.Close(), which fired
// the listener synchronously. The listener called removeClient → tried to
// acquire s.mu again → deadlock. After the fix, old.Close() is called after
// s.mu is released, so the re-entry is safe.
func TestServerDuplicateReplaceWithListenerDoesNotDeadlock(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-E",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
// Mirror the production disconnect listener from attachClientHandlers.
// This is the re-entry path: removeClient acquires s.mu, which would
// deadlock if setClient still held s.mu during old.Close().
client1.AddDisconnectListener(func(c *protoSocket.TcpClient) {
if s.removeClient("runner-E", c) {
s.registry.Disconnect("runner-E")
}
})
s.setClient("runner-E", client1)
// This must complete without hanging. If the lock-order bug is present the
// goroutine stalls here and the test times out via go test -timeout.
s.setClient("runner-E", client2)
// Allow Close() and the disconnect listener to propagate.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement")
}
if got := s.clientFor("runner-E"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
// The stale listener must not have disconnected the runner.
runner, ok := registry.Snapshot("runner-E")
if !ok {
t.Fatal("runner-E not found in registry after replacement")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner must not be disconnected after stale listener fires; got %q", runner.Status)
}
}
// TestServerHeartbeatUsesRegisteredRunnerID verifies that connectedRunnerIDs
// reflects only currently active clients and that clientFor returns nil after
// the current client is removed.
func TestServerHeartbeatUsesRegisteredRunnerID(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners before any setClient; got %v", ids)
}
s.setClient("runner-D", client1)
ids := s.connectedRunnerIDs()
if len(ids) != 1 || ids[0] != "runner-D" {
t.Errorf("expected [runner-D] after setClient; got %v", ids)
}
// Current client removed → runner no longer in connected list.
s.removeClient("runner-D", client1)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners after removeClient; got %v", ids)
}
if got := s.clientFor("runner-D"); got != nil {
t.Errorf("expected nil from clientFor after removeClient; got %v", got)
}
}