- 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
207 lines
7.1 KiB
Go
207 lines
7.1 KiB
Go
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)
|
||
}
|
||
}
|