From 6b06cb1237071a0c22f86821db5aaa6135996e98 Mon Sep 17 00:00:00 2001 From: Patricio Whittingslow Date: Sun, 8 Mar 2026 19:42:48 -0300 Subject: [PATCH] claude: add tests for tcp and ntp to consolidate functionality --- ntp/client_test.go | 202 +++++++++++++++++++++++++++++++++++ tcp/conn_test.go | 162 ++++++++++++++++++++++++++++ tcp/rst_test.go | 165 ++++++++++++++++++++++++++++ x/xnet/xnet_deadline_test.go | 103 ++++++++++++++++++ x/xnet/xnet_listener_test.go | 75 +++++++++++++ 5 files changed, 707 insertions(+) create mode 100644 ntp/client_test.go create mode 100644 tcp/conn_test.go create mode 100644 tcp/rst_test.go create mode 100644 x/xnet/xnet_deadline_test.go diff --git a/ntp/client_test.go b/ntp/client_test.go new file mode 100644 index 0000000..8460cbe --- /dev/null +++ b/ntp/client_test.go @@ -0,0 +1,202 @@ +package ntp + +import ( + "testing" + "time" +) + +func TestClient_FullExchange(t *testing.T) { + // Simulate a NTP client-server exchange without network. + baseTime := BaseTime() + clientStart := baseTime.Add(10 * time.Second) + serverOffset := 500 * time.Millisecond // server is 500ms ahead + + clockTime := clientStart + client := Client{} + client.Reset(-18, func() time.Time { return clockTime }) + + if client.IsDone() { + t.Fatal("client should not be done before exchange") + } + + // Step 1: Client encapsulates request. + reqBuf := make([]byte, SizeHeader) + n, err := client.Encapsulate(reqBuf, 0, 0) + if err != nil { + t.Fatal(err) + } + if n != SizeHeader { + t.Fatalf("expected %d bytes, got %d", SizeHeader, n) + } + + // Verify request frame fields. + reqFrm, err := NewFrame(reqBuf) + if err != nil { + t.Fatal(err) + } + mode, version, _ := reqFrm.Flags() + if mode != ModeClient { + t.Errorf("request mode = %d; want ModeClient (%d)", mode, ModeClient) + } + if version != Version4 { + t.Errorf("request version = %d; want %d", version, Version4) + } + if reqFrm.Stratum() != StratumUnsync { + t.Errorf("request stratum = %d; want StratumUnsync", reqFrm.Stratum()) + } + + // Step 2: Simulate server response. + // Server receives at clientStart + serverOffset, sends response at clientStart + serverOffset + 10ms processing. + serverRecvTime := clientStart.Add(serverOffset) + serverXmtTime := serverRecvTime.Add(10 * time.Millisecond) + + respBuf := make([]byte, SizeHeader) + respFrm, _ := NewFrame(respBuf) + respFrm.SetFlags(ModeServer, Version4, LeapNoWarning) + respFrm.SetStratum(StratumPrimary) + respFrm.SetPrecision(-20) + + // Echo client's origin time. + respFrm.SetOriginTime(reqFrm.OriginTime()) + + // Set server timestamps. + recvTS, err := TimestampFromTime(serverRecvTime) + if err != nil { + t.Fatal(err) + } + xmtTS, err := TimestampFromTime(serverXmtTime) + if err != nil { + t.Fatal(err) + } + respFrm.SetReceiveTime(recvTS) + respFrm.SetTransmitTime(xmtTS) + + // Advance client clock to simulate network delay. + clockTime = clientStart.Add(100 * time.Millisecond) + + // Step 3: Client demuxes response. + err = client.Demux(respBuf, 0) + if err != nil { + t.Fatal(err) + } + + if !client.IsDone() { + t.Fatal("client should be done after exchange") + } + + // Step 4: Verify results. + if client.ServerStratum() != StratumPrimary { + t.Errorf("server stratum = %s; want primary", client.ServerStratum()) + } + + offset := client.Offset() + if offset == 0 { + t.Fatal("offset should be non-zero") + } + + rtd := client.RoundTripDelay() + if rtd < 0 { + t.Fatalf("round trip delay should be non-negative, got %s", rtd) + } + + // Verify Now() returns a reasonable time. + ntpNow := client.Now() + if ntpNow.Before(baseTime) { + t.Errorf("NTP-corrected time %v is before base time %v", ntpNow, baseTime) + } +} + +func TestClient_Reset(t *testing.T) { + var c Client + c.Reset(-18, time.Now) + if c.IsDone() { + t.Fatal("should not be done after Reset") + } + if c.LocalPort() != ClientPort { + t.Fatalf("expected port %d, got %d", ClientPort, c.LocalPort()) + } + if c.Protocol() != 0 { + t.Fatalf("expected protocol 0, got %d", c.Protocol()) + } + id1 := *c.ConnectionID() + + c.Reset(-18, time.Now) + id2 := *c.ConnectionID() + if id2 <= id1 { + t.Fatal("ConnectionID should increment on Reset") + } +} + +func TestClient_Encapsulate_WhenDone(t *testing.T) { + var c Client + // Not reset, state is closed/done. + buf := make([]byte, SizeHeader) + n, err := c.Encapsulate(buf, 0, 0) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("expected 0 bytes when done, got %d", n) + } +} + +func TestClient_Demux_WhenDone(t *testing.T) { + var c Client + buf := make([]byte, SizeHeader) + err := c.Demux(buf, 0) + if err != nil { + t.Fatal("Demux when done should be no-op") + } +} + +func TestClient_Demux_ShortBuffer(t *testing.T) { + var c Client + c.Reset(-18, time.Now) + // Trigger encapsulate first to move to stateAwait1. + buf := make([]byte, SizeHeader) + c.Encapsulate(buf, 0, 0) + + // Short buffer should error. + err := c.Demux(make([]byte, 10), 0) + if err == nil { + t.Fatal("expected error for short buffer") + } +} + +func TestClient_OffsetBeforeDone(t *testing.T) { + var c Client + c.Reset(-18, time.Now) + if c.Offset() != 0 { + t.Fatal("Offset should be 0 before exchange completes") + } + if c.RoundTripDelay() != -1 { + t.Fatal("RoundTripDelay should be -1 before done") + } +} + +func TestClient_DemuxRejectsBogusResponse(t *testing.T) { + var c Client + clockTime := BaseTime().Add(time.Second) + c.Reset(-18, func() time.Time { return clockTime }) + + // Encapsulate to move to stateAwait1. + buf := make([]byte, SizeHeader) + c.Encapsulate(buf, 0, 0) + + // Build response with wrong origin time (not echoed correctly). + resp := make([]byte, SizeHeader) + frm, _ := NewFrame(resp) + frm.SetFlags(ModeServer, Version4, LeapNoWarning) + frm.SetOriginTime(TimestampFromUint64(99999)) // wrong origin + xmt, _ := TimestampFromTime(clockTime.Add(time.Second)) + frm.SetTransmitTime(xmt) + frm.SetReceiveTime(xmt) + + err := c.Demux(resp, 0) + if err == nil { + t.Fatal("should reject response with mismatched origin time") + } + if c.IsDone() { + t.Fatal("should not be done after rejected response") + } +} diff --git a/tcp/conn_test.go b/tcp/conn_test.go new file mode 100644 index 0000000..3620d04 --- /dev/null +++ b/tcp/conn_test.go @@ -0,0 +1,162 @@ +package tcp + +import ( + "net" + "net/netip" + "testing" + "time" +) + +func newConfiguredConn(t *testing.T) *Conn { + t.Helper() + var conn Conn + err := conn.Configure(ConnConfig{ + RxBuf: make([]byte, 512), + TxBuf: make([]byte, 512), + TxPacketQueueSize: 4, + }) + if err != nil { + t.Fatal(err) + } + return &conn +} + +func TestConn_SetDeadline_Closed(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.SetDeadline(time.Now().Add(time.Second)) + if err == nil { + t.Fatal("SetDeadline on closed conn should fail") + } +} + +func TestConn_SetReadDeadline_Closed(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.SetReadDeadline(time.Now().Add(time.Second)) + if err == nil { + t.Fatal("SetReadDeadline on closed conn should fail") + } +} + +func TestConn_SetWriteDeadline_Closed(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.SetWriteDeadline(time.Now().Add(time.Second)) + if err == nil { + t.Fatal("SetWriteDeadline on closed conn should fail") + } +} + +func TestConn_OpenActive_InvalidAddr(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.OpenActive(1234, netip.AddrPort{}, 100) + if err == nil { + t.Fatal("OpenActive with invalid addr should fail") + } +} + +func TestConn_OpenListen(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.OpenListen(8080, 100) + if err != nil { + t.Fatal(err) + } + if conn.State() != StateListen { + t.Fatalf("expected StateListen, got %s", conn.State()) + } + if conn.LocalPort() != 8080 { + t.Fatalf("expected port 8080, got %d", conn.LocalPort()) + } +} + +func TestConn_Close_Abort(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.OpenListen(8080, 100) + if err != nil { + t.Fatal(err) + } + conn.Abort() + if conn.State() != StateClosed { + t.Fatalf("expected StateClosed after Abort, got %s", conn.State()) + } +} + +func TestConn_ReadWrite_Closed(t *testing.T) { + conn := newConfiguredConn(t) + _, err := conn.Write([]byte("hello")) + if err == nil { + t.Fatal("Write on closed conn should fail") + } + buf := make([]byte, 64) + _, err = conn.Read(buf) + if err == nil { + t.Fatal("Read on closed conn should fail") + } +} + +func TestConn_Flush_Closed(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.Flush() + if err == nil { + t.Fatal("Flush on closed conn should fail") + } +} + +func TestConn_BufferedUnsent(t *testing.T) { + conn := newConfiguredConn(t) + if conn.BufferedUnsent() != 0 { + t.Fatalf("expected 0, got %d", conn.BufferedUnsent()) + } +} + +func TestConn_InternalHandler(t *testing.T) { + conn := newConfiguredConn(t) + h := conn.InternalHandler() + if h == nil { + t.Fatal("InternalHandler returned nil") + } +} + +func TestConn_Configure_Twice(t *testing.T) { + conn := newConfiguredConn(t) + err := conn.Configure(ConnConfig{ + RxBuf: make([]byte, 1024), + TxBuf: make([]byte, 1024), + TxPacketQueueSize: 8, + }) + if err != nil { + t.Fatalf("reconfigure should succeed: %v", err) + } +} + +func TestConn_OpenActive_IPv6(t *testing.T) { + conn := newConfiguredConn(t) + addr6 := netip.AddrFrom16([16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}) + remote := netip.AddrPortFrom(addr6, 443) + err := conn.OpenActive(1234, remote, 200) + if err != nil { + t.Fatal(err) + } + raddr := conn.RemoteAddr() + if len(raddr) != 16 { + t.Fatalf("expected 16-byte remote addr, got %d", len(raddr)) + } +} + +func TestConn_Protocol(t *testing.T) { + var conn Conn + if conn.Protocol() != 6 { // TCP protocol number + t.Fatalf("expected protocol 6, got %d", conn.Protocol()) + } +} + +func TestConn_ImplementsNetConn(t *testing.T) { + conn := newConfiguredConn(t) + var _ interface { + SetDeadline(time.Time) error + SetReadDeadline(time.Time) error + SetWriteDeadline(time.Time) error + Read([]byte) (int, error) + Write([]byte) (int, error) + Close() error + } = conn + _ = net.ErrClosed +} diff --git a/tcp/rst_test.go b/tcp/rst_test.go new file mode 100644 index 0000000..55f2435 --- /dev/null +++ b/tcp/rst_test.go @@ -0,0 +1,165 @@ +package tcp + +import "testing" + +// setIPv4Version sets the IP version nibble to 4 at the given offset, +// as required by Drain's call to internal.SetIPAddrs. +func setIPv4Version(carrier []byte, offsetToIP int) { + carrier[offsetToIP] = 0x45 // version=4, IHL=5 (20 bytes) +} + +func TestRSTQueue_QueueAndDrain(t *testing.T) { + var q RSTQueue + if q.Pending() != 0 { + t.Fatal("new queue should be empty") + } + + srcAddr := [4]byte{10, 0, 0, 1} + q.Queue(srcAddr[:], 8080, 1234, 100, 200, FlagRST|FlagACK) + if q.Pending() != 1 { + t.Fatalf("expected 1 pending, got %d", q.Pending()) + } + + carrier := make([]byte, 256) + const offsetToIP = 14 + const offsetToTCP = 34 + setIPv4Version(carrier, offsetToIP) + + n, err := q.Drain(carrier, offsetToIP, offsetToTCP) + if err != nil { + t.Fatal(err) + } + if n != sizeHeaderTCP { + t.Fatalf("expected %d bytes written, got %d", sizeHeaderTCP, n) + } + if q.Pending() != 0 { + t.Fatal("queue should be empty after drain") + } + + tfrm, err := NewFrame(carrier[offsetToTCP:]) + if err != nil { + t.Fatal(err) + } + if tfrm.SourcePort() != 1234 { + t.Errorf("source port = %d; want 1234", tfrm.SourcePort()) + } + if tfrm.DestinationPort() != 8080 { + t.Errorf("dest port = %d; want 8080", tfrm.DestinationPort()) + } + seg := tfrm.Segment(0) + if seg.SEQ != 100 { + t.Errorf("SEQ = %d; want 100", seg.SEQ) + } + if seg.ACK != 200 { + t.Errorf("ACK = %d; want 200", seg.ACK) + } + if !seg.Flags.HasAll(FlagRST | FlagACK) { + t.Errorf("flags = %s; want RST|ACK", seg.Flags) + } +} + +func TestRSTQueue_DrainEmpty(t *testing.T) { + var q RSTQueue + carrier := make([]byte, 256) + n, err := q.Drain(carrier, 14, 34) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("drain of empty queue should return 0, got %d", n) + } +} + +func TestRSTQueue_DrainNegativeOffset(t *testing.T) { + var q RSTQueue + q.Queue([]byte{10, 0, 0, 1}, 80, 1234, 0, 0, FlagRST) + carrier := make([]byte, 256) + n, err := q.Drain(carrier, -1, 34) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("drain with negative offsetToIP should return 0, got %d", n) + } +} + +func TestRSTQueue_Full(t *testing.T) { + var q RSTQueue + addr := []byte{10, 0, 0, 1} + for i := 0; i < 4; i++ { + q.Queue(addr, uint16(i), 1234, Value(i), 0, FlagRST) + } + if q.Pending() != 4 { + t.Fatalf("expected 4 pending, got %d", q.Pending()) + } + + // Overflow should be silently dropped. + q.Queue(addr, 9999, 1234, 0, 0, FlagRST) + if q.Pending() != 4 { + t.Fatalf("expected 4 pending after overflow, got %d", q.Pending()) + } +} + +func TestRSTQueue_NonIPv4Dropped(t *testing.T) { + var q RSTQueue + addr6 := make([]byte, 16) + q.Queue(addr6, 80, 1234, 0, 0, FlagRST) + if q.Pending() != 0 { + t.Fatalf("non-IPv4 should be dropped, got %d pending", q.Pending()) + } +} + +func TestRSTQueue_LIFO(t *testing.T) { + var q RSTQueue + addr := []byte{10, 0, 0, 1} + q.Queue(addr, 1000, 1234, 0, 0, FlagRST) + q.Queue(addr, 2000, 1234, 0, 0, FlagRST) + + carrier := make([]byte, 256) + const offsetToIP = 14 + const offsetToTCP = 34 + setIPv4Version(carrier, offsetToIP) + + // Drain returns last-in first (LIFO). + n, err := q.Drain(carrier, offsetToIP, offsetToTCP) + if err != nil || n == 0 { + t.Fatal("drain failed") + } + tfrm, _ := NewFrame(carrier[offsetToTCP:]) + if tfrm.DestinationPort() != 2000 { + t.Errorf("expected LIFO order: first drain dest port = %d; want 2000", tfrm.DestinationPort()) + } +} + +func TestRSTQueue_MultipleDrains(t *testing.T) { + var q RSTQueue + addr := []byte{10, 0, 0, 1} + q.Queue(addr, 1000, 100, 0, 0, FlagRST) + q.Queue(addr, 2000, 200, 0, 0, FlagRST) + q.Queue(addr, 3000, 300, 0, 0, FlagRST) + + carrier := make([]byte, 256) + const offsetToIP = 14 + const offsetToTCP = 34 + + // Drain all 3 entries. + for i := 0; i < 3; i++ { + setIPv4Version(carrier, offsetToIP) + n, err := q.Drain(carrier, offsetToIP, offsetToTCP) + if err != nil { + t.Fatal(err) + } + if n != sizeHeaderTCP { + t.Fatalf("drain %d: expected %d bytes, got %d", i, sizeHeaderTCP, n) + } + } + if q.Pending() != 0 { + t.Fatal("queue should be empty") + } + + // Fourth drain should return 0. + n, _ := q.Drain(carrier, offsetToIP, offsetToTCP) + if n != 0 { + t.Fatal("expected 0 from empty queue") + } +} diff --git a/x/xnet/xnet_deadline_test.go b/x/xnet/xnet_deadline_test.go new file mode 100644 index 0000000..0fcc4dc --- /dev/null +++ b/x/xnet/xnet_deadline_test.go @@ -0,0 +1,103 @@ +package xnet + +import ( + "os" + "testing" + "time" + + "github.com/soypat/lneto/tcp" +) + +func TestTCPConn_SetDeadline_Established(t *testing.T) { + const seed = 9999 + const MTU = 1500 + const svPort = 8080 + client, sv, clconn, svconn := newTCPStacks(t, seed, MTU) + tst := testerFrom(t, MTU) + + tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337) + + if clconn.State() != tcp.StateEstablished { + t.Fatalf("expected StateEstablished, got %s", clconn.State()) + } + + // SetDeadline should succeed on established connection. + err := clconn.SetDeadline(time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("SetDeadline on established conn failed: %v", err) + } + + // Clear deadlines. + err = clconn.SetDeadline(time.Time{}) + if err != nil { + t.Fatalf("SetDeadline(zero) failed: %v", err) + } + _ = sv +} + +func TestTCPConn_ReadDeadlineExceeded(t *testing.T) { + const seed = 10001 + const MTU = 1500 + const svPort = 8080 + client, sv, clconn, svconn := newTCPStacks(t, seed, MTU) + tst := testerFrom(t, MTU) + + tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337) + + // Set read deadline in the past. + err := svconn.SetReadDeadline(time.Now().Add(-time.Second)) + if err != nil { + t.Fatal(err) + } + + // Read should fail immediately with deadline exceeded. + buf := make([]byte, 64) + _, rerr := svconn.Read(buf) + if rerr == nil { + t.Fatal("Read with past deadline should fail") + } + if rerr != os.ErrDeadlineExceeded { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", rerr) + } +} + +func TestTCPConn_WriteDeadlineExceeded(t *testing.T) { + const seed = 10002 + const MTU = 1500 + const svPort = 8080 + client, sv, clconn, svconn := newTCPStacks(t, seed, MTU) + tst := testerFrom(t, MTU) + + tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337) + + // Set write deadline in the past. + err := clconn.SetWriteDeadline(time.Now().Add(-time.Second)) + if err != nil { + t.Fatal(err) + } + + // Write should fail immediately with deadline exceeded. + _, werr := clconn.Write([]byte("hello")) + if werr == nil { + t.Fatal("Write with past deadline should fail") + } + if werr != os.ErrDeadlineExceeded { + t.Fatalf("expected os.ErrDeadlineExceeded, got %v", werr) + } +} + +func TestTCPConn_FlushEmptyNoop(t *testing.T) { + const seed = 10003 + const MTU = 1500 + const svPort = 8080 + client, sv, clconn, svconn := newTCPStacks(t, seed, MTU) + tst := testerFrom(t, MTU) + + tst.TestTCPSetupAndEstablish(sv, client, svconn, clconn, svPort, 1337) + + // Flush with no unsent data should return nil immediately. + err := clconn.Flush() + if err != nil { + t.Fatalf("Flush with no unsent data should succeed: %v", err) + } +} diff --git a/x/xnet/xnet_listener_test.go b/x/xnet/xnet_listener_test.go index e6036f4..317606a 100644 --- a/x/xnet/xnet_listener_test.go +++ b/x/xnet/xnet_listener_test.go @@ -228,3 +228,78 @@ func TestStackAsyncListener_MultiSequentialConn(t *testing.T) { doRequest(netip.AddrPortFrom(caddr, uint16(sv.Prand32())), 0, []byte("HTTP 1.0\r\n")) } } + +func TestListener_Close(t *testing.T) { + const svPort uint16 = 80 + + pool, err := NewTCPPool(TCPPoolConfig{ + PoolSize: 1, + QueueSize: 4, + TxBufSize: 512, + RxBufSize: 512, + EstablishedTimeout: 10e9, + ClosingTimeout: 10e9, + }) + if err != nil { + t.Fatal(err) + } + + var listener tcp.Listener + err = listener.Reset(svPort, pool) + if err != nil { + t.Fatal(err) + } + if listener.LocalPort() != svPort { + t.Fatalf("expected port %d, got %d", svPort, listener.LocalPort()) + } + + err = listener.Close() + if err != nil { + t.Fatalf("Close failed: %v", err) + } + if listener.LocalPort() != 0 { + t.Fatalf("port should be 0 after Close, got %d", listener.LocalPort()) + } + + // Double close should return net.ErrClosed. + err = listener.Close() + if err == nil { + t.Fatal("double Close should return error") + } +} + +func TestListener_ResetAfterClose(t *testing.T) { + const svPort uint16 = 80 + + pool, err := NewTCPPool(TCPPoolConfig{ + PoolSize: 1, + QueueSize: 4, + TxBufSize: 512, + RxBufSize: 512, + EstablishedTimeout: 10e9, + ClosingTimeout: 10e9, + }) + if err != nil { + t.Fatal(err) + } + + var listener tcp.Listener + err = listener.Reset(svPort, pool) + if err != nil { + t.Fatal(err) + } + + err = listener.Close() + if err != nil { + t.Fatal(err) + } + + // Should be able to Reset after Close. + err = listener.Reset(svPort, pool) + if err != nil { + t.Fatalf("Reset after Close failed: %v", err) + } + if listener.LocalPort() != svPort { + t.Fatalf("expected port %d after re-Reset, got %d", svPort, listener.LocalPort()) + } +}