- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리 - 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16) - finite 재연결 시도 고갈 시 노드 종료 - CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
220 lines
7 KiB
Go
220 lines
7 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
type recordingConn struct {
|
|
writeDeadlines []time.Time
|
|
}
|
|
|
|
func (c *recordingConn) Read(_ []byte) (int, error) { return 0, nil }
|
|
func (c *recordingConn) Write(b []byte) (int, error) { return len(b), nil }
|
|
func (c *recordingConn) Close() error { return nil }
|
|
func (c *recordingConn) LocalAddr() net.Addr { return noopAddr("local") }
|
|
func (c *recordingConn) RemoteAddr() net.Addr { return noopAddr("remote") }
|
|
func (c *recordingConn) SetDeadline(_ time.Time) error { return nil }
|
|
func (c *recordingConn) SetReadDeadline(_ time.Time) error { return nil }
|
|
func (c *recordingConn) SetWriteDeadline(t time.Time) error {
|
|
c.writeDeadlines = append(c.writeDeadlines, t)
|
|
return nil
|
|
}
|
|
|
|
type noopAddr string
|
|
|
|
func (a noopAddr) Network() string { return string(a) }
|
|
func (a noopAddr) String() string { return string(a) }
|
|
|
|
// closedLocalAddr returns a loopback address that has no listener, so a dial to
|
|
// it is refused deterministically.
|
|
func closedLocalAddr(t *testing.T) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := l.Addr().String()
|
|
_ = l.Close()
|
|
return addr
|
|
}
|
|
|
|
func rejectingEdgeParserMap() toki.ParserMap {
|
|
return toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RegisterRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
}
|
|
|
|
// startRejectingEdge starts a mock edge that answers RegisterRequest with
|
|
// Accepted=false and the given reason.
|
|
func startRejectingEdge(t *testing.T, ctx context.Context, reason string) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := l.Addr().String()
|
|
host, portStr, _ := net.SplitHostPort(addr)
|
|
port, _ := strconv.Atoi(portStr)
|
|
_ = l.Close()
|
|
|
|
server := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
|
|
client := toki.NewTcpClient(conn, 30, 10, rejectingEdgeParserMap())
|
|
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client.Communicator,
|
|
func(_ *iop.RegisterRequest) (*iop.RegisterResponse, error) {
|
|
return &iop.RegisterResponse{Accepted: false, Reason: reason}, nil
|
|
},
|
|
)
|
|
return client
|
|
})
|
|
if err := server.Start(ctx); err != nil {
|
|
t.Fatalf("start rejecting edge: %v", err)
|
|
}
|
|
t.Cleanup(func() { server.Stop() })
|
|
return addr
|
|
}
|
|
|
|
// TestDialEdgeClassifiesConnectFailures verifies that DialEdge classifies its
|
|
// failures into fatal (invalid address, authoritative registration rejection)
|
|
// and retryable (Edge unavailable) while preserving the underlying cause. REFACTOR-2.
|
|
func TestDialEdgeClassifiesConnectFailures(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
|
|
t.Run("invalid address is fatal", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
_, err := DialEdge(ctx, "missing-port", "tok", logger)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid address")
|
|
}
|
|
if !IsFatalConnectError(err) {
|
|
t.Fatalf("invalid address must be fatal, got retryable: %v", err)
|
|
}
|
|
var ce *ConnectError
|
|
if !errors.As(err, &ce) || ce.Retryable() {
|
|
t.Fatalf("expected non-retryable ConnectError, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("non-numeric port is fatal", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
_, err := DialEdge(ctx, "127.0.0.1:not-a-port", "tok", logger)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid port")
|
|
}
|
|
if !IsFatalConnectError(err) {
|
|
t.Fatalf("invalid port must be fatal, got retryable: %v", err)
|
|
}
|
|
var numErr *strconv.NumError
|
|
if !errors.As(err, &numErr) || !errors.Is(err, strconv.ErrSyntax) {
|
|
t.Fatalf("expected preserved strconv syntax cause, got %v", err)
|
|
}
|
|
})
|
|
|
|
for _, port := range []string{"0", "-1", "65536"} {
|
|
port := port
|
|
t.Run("out-of-range port "+port+" is fatal", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
_, err := DialEdge(ctx, net.JoinHostPort("127.0.0.1", port), "tok", logger)
|
|
if err == nil {
|
|
t.Fatalf("expected error for out-of-range port %s", port)
|
|
}
|
|
if !IsFatalConnectError(err) {
|
|
t.Fatalf("out-of-range port %s must be fatal, got retryable: %v", port, err)
|
|
}
|
|
var ce *ConnectError
|
|
if !errors.As(err, &ce) || ce.Retryable() {
|
|
t.Fatalf("expected non-retryable ConnectError, got %v", err)
|
|
}
|
|
var numErr *strconv.NumError
|
|
if !errors.As(err, &numErr) || !errors.Is(err, strconv.ErrRange) {
|
|
t.Fatalf("expected preserved strconv range cause, got %v", err)
|
|
}
|
|
if numErr.Num != port {
|
|
t.Fatalf("range cause port = %q, want %q", numErr.Num, port)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("connection refused is retryable and preserves cause", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_, err := DialEdge(ctx, closedLocalAddr(t), "tok", logger)
|
|
if err == nil {
|
|
t.Fatal("expected error for refused connection")
|
|
}
|
|
if IsFatalConnectError(err) {
|
|
t.Fatalf("refused connection must be retryable, got fatal: %v", err)
|
|
}
|
|
var ce *ConnectError
|
|
if !errors.As(err, &ce) || !ce.Retryable() {
|
|
t.Fatalf("expected retryable ConnectError, got %v", err)
|
|
}
|
|
// The underlying network error must remain reachable through the wrap.
|
|
var opErr *net.OpError
|
|
if !errors.As(err, &opErr) {
|
|
t.Fatalf("expected wrapped *net.OpError cause, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("registration rejected is fatal and preserves reason", func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
addr := startRejectingEdge(t, ctx, "bad-token")
|
|
_, err := DialEdge(ctx, addr, "tok", logger)
|
|
if err == nil {
|
|
t.Fatal("expected error for rejected registration")
|
|
}
|
|
if !IsFatalConnectError(err) {
|
|
t.Fatalf("rejected registration must be fatal, got retryable: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "bad-token") {
|
|
t.Fatalf("expected rejection reason preserved in error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("unclassified error defaults to retryable", func(t *testing.T) {
|
|
if IsFatalConnectError(errors.New("plain")) {
|
|
t.Fatal("unclassified error must not be fatal")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestWriteDeadlineConnSetsAndClearsDeadline(t *testing.T) {
|
|
base := &recordingConn{}
|
|
conn := &writeDeadlineConn{Conn: base, timeout: 25 * time.Millisecond}
|
|
|
|
n, err := conn.Write([]byte("ping"))
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
if n != 4 {
|
|
t.Fatalf("Write bytes: got %d want 4", n)
|
|
}
|
|
if len(base.writeDeadlines) != 2 {
|
|
t.Fatalf("write deadline calls: got %d want 2", len(base.writeDeadlines))
|
|
}
|
|
if base.writeDeadlines[0].IsZero() {
|
|
t.Fatal("first write deadline must be non-zero")
|
|
}
|
|
if !base.writeDeadlines[1].IsZero() {
|
|
t.Fatalf("second write deadline must clear the deadline, got %v", base.writeDeadlines[1])
|
|
}
|
|
}
|