package proto_socket_test import ( "encoding/binary" "errors" "net" "strings" "sync" "testing" "time" "google.golang.org/protobuf/proto" toki "git.toki-labs.com/toki/proto-socket/go" "git.toki-labs.com/toki/proto-socket/go/packets" ) type fakeTransport struct { mu sync.Mutex packets []*packets.PacketBase } func (f *fakeTransport) WritePacket(base *packets.PacketBase) error { f.mu.Lock() defer f.mu.Unlock() f.packets = append(f.packets, base) return nil } func (f *fakeTransport) Close() error { return nil } func (f *fakeTransport) sent() []*packets.PacketBase { f.mu.Lock() defer f.mu.Unlock() return append([]*packets.PacketBase{}, f.packets...) } func testParserMap() toki.ParserMap { return toki.ParserMap{ toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) { m := &packets.TestData{} return m, proto.Unmarshal(b, m) }, } } func TestSendRequestTypeMismatch(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() errCh := make(chan error, 1) go func() { _, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData]( communicator, &packets.TestData{Index: 1, Message: "hello"}, time.Second, ) errCh <- err }() var requestNonce int32 for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); { sent := transport.sent() if len(sent) == 1 { requestNonce = sent[0].GetNonce() break } time.Sleep(time.Millisecond) } if requestNonce == 0 { t.Fatal("request packet was not sent") } hb, err := proto.Marshal(&packets.HeartBeat{}) if err != nil { t.Fatal(err) } communicator.OnReceivedData(toki.TypeNameOf(&packets.HeartBeat{}), hb, 0, requestNonce) err = <-errCh if err == nil { t.Fatal("expected response type mismatch error") } } func TestSendRequestTimeout(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() _, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData]( communicator, &packets.TestData{Index: 1, Message: "no response"}, 25*time.Millisecond, ) if err == nil { t.Fatal("expected request timeout error") } if !strings.Contains(err.Error(), "timeout") { t.Fatalf("expected timeout error, got %v", err) } if len(transport.sent()) != 1 { t.Fatalf("expected one request packet to be sent, got %d", len(transport.sent())) } } func TestListenerAndRequestListenerConflict(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() toki.AddListenerTyped[*packets.TestData](communicator, func(*packets.TestData) {}) defer func() { if recover() == nil { t.Fatal("expected panic") } }() toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](communicator, func(req *packets.TestData) (*packets.TestData, error) { return req, nil }) } // TestInboundQueueFIFO: onReceivedData가 여러 메시지를 순서대로 전달한다. func TestInboundQueueFIFO(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() var mu sync.Mutex var received []int32 toki.AddListenerTyped[*packets.TestData](communicator, func(m *packets.TestData) { mu.Lock() received = append(received, m.GetIndex()) mu.Unlock() }) for i := int32(1); i <= 5; i++ { data, _ := proto.Marshal(&packets.TestData{Index: i}) communicator.OnReceivedData(toki.TypeNameOf(&packets.TestData{}), data, int32(i), 0) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { mu.Lock() n := len(received) mu.Unlock() if n == 5 { break } time.Sleep(time.Millisecond) } mu.Lock() defer mu.Unlock() if len(received) != 5 { t.Fatalf("expected 5 messages, got %d", len(received)) } for i, idx := range received { if idx != int32(i+1) { t.Fatalf("FIFO violated: index %d at position %d", idx, i) } } } // TestSlowRequestHandlerResponseOrder: 느린 request handler라도 자동 응답이 FIFO를 보존한다. func TestSlowRequestHandlerResponseOrder(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](communicator, func(req *packets.TestData) (*packets.TestData, error) { // 첫 번째 요청은 100ms 지연 if req.GetIndex() == 1 { time.Sleep(100 * time.Millisecond) } return &packets.TestData{Index: req.GetIndex() + 100, Message: "echo"}, nil }) data1, _ := proto.Marshal(&packets.TestData{Index: 1, Message: "first"}) data2, _ := proto.Marshal(&packets.TestData{Index: 2, Message: "second"}) communicator.OnReceivedData(toki.TypeNameOf(&packets.TestData{}), data1, 10, 0) communicator.OnReceivedData(toki.TypeNameOf(&packets.TestData{}), data2, 20, 0) // 두 응답이 전송될 때까지 대기 deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if len(transport.sent()) >= 2 { break } time.Sleep(5 * time.Millisecond) } sent := transport.sent() if len(sent) < 2 { t.Fatalf("expected 2 responses, got %d", len(sent)) } // 첫 번째 응답이 nonce=10에 대응, 두 번째가 nonce=20에 대응 (FIFO 순서) if sent[0].GetResponseNonce() != 10 { t.Errorf("expected first response for nonce 10, got %d", sent[0].GetResponseNonce()) } if sent[1].GetResponseNonce() != 20 { t.Errorf("expected second response for nonce 20, got %d", sent[1].GetResponseNonce()) } } // TestCloseCanelsPendingRequests: Close 시 pending sendRequest가 ErrNotConnected로 완료된다. func TestCloseCancelsPendingRequests(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) errCh := make(chan error, 1) go func() { _, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData]( communicator, &packets.TestData{Index: 99, Message: "pending"}, 5*time.Second, ) errCh <- err }() // 요청 패킷이 전송될 때까지 대기 deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(transport.sent()) == 1 { break } time.Sleep(time.Millisecond) } communicator.Close() select { case err := <-errCh: if !errors.Is(err, toki.ErrNotConnected) { t.Fatalf("expected ErrNotConnected, got %v", err) } case <-time.After(time.Second): t.Fatal("timed out waiting for pending request to be cancelled") } } type fakeGatewayItem struct { seq int typeName string data []byte nonce int32 } func TestWorkerGatewayReorderAndDispatch(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) defer communicator.Close() var mu sync.Mutex var received []int32 toki.AddListenerTyped[*packets.TestData](communicator, func(m *packets.TestData) { mu.Lock() received = append(received, m.GetIndex()) mu.Unlock() }) // Fake gateway: out-of-order 아이템들을 seq 오름차순으로 정렬 후 coordinator로 밀어넣음 items := []fakeGatewayItem{ {seq: 2, typeName: toki.TypeNameOf(&packets.TestData{}), data: func() []byte { d, _ := proto.Marshal(&packets.TestData{Index: 2}); return d }(), nonce: 2}, {seq: 1, typeName: toki.TypeNameOf(&packets.TestData{}), data: func() []byte { d, _ := proto.Marshal(&packets.TestData{Index: 1}); return d }(), nonce: 1}, } // Reorder by seq for i := 0; i < len(items); i++ { for j := i + 1; j < len(items); j++ { if items[i].seq > items[j].seq { items[i], items[j] = items[j], items[i] } } } // Dispatch to coordinator in reordered sequence for _, item := range items { communicator.OnReceivedData(item.typeName, item.data, item.nonce, 0) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { mu.Lock() n := len(received) mu.Unlock() if n == 2 { break } time.Sleep(time.Millisecond) } mu.Lock() defer mu.Unlock() if len(received) != 2 { t.Fatalf("expected 2 messages, got %d", len(received)) } if received[0] != 1 || received[1] != 2 { t.Fatalf("seq ordering was not preserved, got: %v", received) } } // TestInboundGatewayPreservesDispatchOrder: with the worker-pool gateway // attached, a burst of raw frames decoded in parallel is still dispatched to the // listener in input order, proving the coordinator keeps FIFO ownership. func TestInboundGatewayPreservesDispatchOrder(t *testing.T) { transport := &fakeTransport{} communicator := toki.NewCommunicator(transport, testParserMap()) communicator.EnableInboundGateway(4, 64) defer communicator.Close() var mu sync.Mutex var received []int32 toki.AddListenerTyped[*packets.TestData](communicator, func(m *packets.TestData) { mu.Lock() received = append(received, m.GetIndex()) mu.Unlock() }) const total = 200 typeName := toki.TypeNameOf(&packets.TestData{}) for i := int32(1); i <= total; i++ { data, _ := proto.Marshal(&packets.TestData{Index: i}) frame, err := proto.Marshal(&packets.PacketBase{TypeName: typeName, Nonce: i, Data: data}) if err != nil { t.Fatal(err) } communicator.OnReceivedFrame(frame) } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { mu.Lock() n := len(received) mu.Unlock() if n == total { break } time.Sleep(time.Millisecond) } mu.Lock() defer mu.Unlock() if len(received) != total { t.Fatalf("expected %d messages, got %d", total, len(received)) } for i, idx := range received { if idx != int32(i+1) { t.Fatalf("gateway dispatch order violated at position %d: got index %d", i, idx) } } } // frameTCP builds a TCP wire frame: 4-byte big-endian length prefix + PacketBase. func frameTCP(t *testing.T, base *packets.PacketBase) []byte { t.Helper() b, err := proto.Marshal(base) if err != nil { t.Fatal(err) } out := make([]byte, 4+len(b)) binary.BigEndian.PutUint32(out[:4], uint32(len(b))) copy(out[4:], b) return out } // countingGateway is a test InboundGateway that records every Submit and then // forwards the decoded frame to the supplied sink. It lets a transport read-path // test prove the read loop actually routes raw frames through the gateway rather // than decoding inline and bypassing it. type countingGateway struct { onSubmit func(toki.InboundFrame) } func (g *countingGateway) Submit(frame toki.InboundFrame) { g.onSubmit(frame) } func (g *countingGateway) Close() {} // TestTcpReadLoopRoutesThroughGateway: a real TcpClient.readLoop must hand raw // frames to the attached gateway. The counting gateway fails the test if the // read loop decodes inline and bypasses the gateway (the original wiring gap). func TestTcpReadLoopRoutesThroughGateway(t *testing.T) { serverConn, clientConn := net.Pipe() // intervalSec=0 disables heartbeats, so the client never writes to the pipe. client := toki.NewTcpClient(clientConn, 0, 0, testParserMap()) defer client.Close() var mu sync.Mutex var received []int32 submitted := 0 gw := &countingGateway{onSubmit: func(frame toki.InboundFrame) { mu.Lock() submitted++ mu.Unlock() base := &packets.PacketBase{} if err := proto.Unmarshal(frame.Bytes, base); err != nil { return } client.OnReceivedData(base.GetTypeName(), base.GetData(), base.GetNonce(), base.GetResponseNonce()) }} client.AttachInboundGateway(gw) toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(m *packets.TestData) { mu.Lock() received = append(received, m.GetIndex()) mu.Unlock() }) const total = 20 typeName := toki.TypeNameOf(&packets.TestData{}) frames := make([][]byte, 0, total) for i := int32(1); i <= total; i++ { data, err := proto.Marshal(&packets.TestData{Index: i}) if err != nil { t.Fatal(err) } frames = append(frames, frameTCP(t, &packets.PacketBase{TypeName: typeName, Nonce: i, Data: data})) } go func() { for _, f := range frames { if _, err := serverConn.Write(f); err != nil { return } } }() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { mu.Lock() n := len(received) mu.Unlock() if n == total { break } time.Sleep(time.Millisecond) } mu.Lock() defer mu.Unlock() if submitted != total { t.Fatalf("read loop bypassed gateway: submitted=%d, want %d", submitted, total) } if len(received) != total { t.Fatalf("expected %d messages, got %d", total, len(received)) } for i, idx := range received { if idx != int32(i+1) { t.Fatalf("dispatch order violated at position %d: got index %d", i, idx) } } } // TestTcpReadLoopWorkerGatewayPreservesOrder: a real TcpClient.readLoop with the // production worker-pool gateway (EnableInboundGateway) still dispatches frames // to the listener in input order despite parallel decode. func TestTcpReadLoopWorkerGatewayPreservesOrder(t *testing.T) { serverConn, clientConn := net.Pipe() client := toki.NewTcpClient(clientConn, 0, 0, testParserMap()) defer client.Close() client.EnableInboundGateway(4, 64) var mu sync.Mutex var received []int32 toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(m *packets.TestData) { mu.Lock() received = append(received, m.GetIndex()) mu.Unlock() }) const total = 100 typeName := toki.TypeNameOf(&packets.TestData{}) frames := make([][]byte, 0, total) for i := int32(1); i <= total; i++ { data, err := proto.Marshal(&packets.TestData{Index: i}) if err != nil { t.Fatal(err) } frames = append(frames, frameTCP(t, &packets.PacketBase{TypeName: typeName, Nonce: i, Data: data})) } go func() { for _, f := range frames { if _, err := serverConn.Write(f); err != nil { return } } }() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { mu.Lock() n := len(received) mu.Unlock() if n == total { break } time.Sleep(time.Millisecond) } mu.Lock() defer mu.Unlock() if len(received) != total { t.Fatalf("expected %d messages, got %d", total, len(received)) } for i, idx := range received { if idx != int32(i+1) { t.Fatalf("dispatch order violated at position %d: got index %d", i, idx) } } } // TestTcpReadLoopParseErrorDisconnects: with no gateway, a malformed frame in // the real read loop must still trigger the parse-error disconnect. func TestTcpReadLoopParseErrorDisconnects(t *testing.T) { serverConn, clientConn := net.Pipe() client := toki.NewTcpClient(clientConn, 0, 0, testParserMap()) defer client.Close() // length=3 prefix followed by bytes that are not valid protobuf wire format. bad := []byte{0, 0, 0, 3, 0xff, 0xff, 0xff} go func() { _, _ = serverConn.Write(bad) }() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if r := client.DisconnectInfo().Reason; r != toki.DisconnectReasonUnknown { break } time.Sleep(time.Millisecond) } info := client.DisconnectInfo() if info.Reason != toki.DisconnectReasonTCPPacketParse { t.Fatalf("expected %s disconnect, got %q", toki.DisconnectReasonTCPPacketParse, info.Reason) } } // TestTcpReadLoopGatewayParseErrorDisconnects: with the gateway attached, a // malformed frame decoded off the read loop must still surface as the same // parse-error disconnect via the gateway error path. func TestTcpReadLoopGatewayParseErrorDisconnects(t *testing.T) { serverConn, clientConn := net.Pipe() client := toki.NewTcpClient(clientConn, 0, 0, testParserMap()) defer client.Close() client.EnableInboundGateway(2, 16) // length=3 prefix followed by bytes that are not valid protobuf wire format. bad := []byte{0, 0, 0, 3, 0xff, 0xff, 0xff} go func() { _, _ = serverConn.Write(bad) }() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if r := client.DisconnectInfo().Reason; r != toki.DisconnectReasonUnknown { break } time.Sleep(time.Millisecond) } info := client.DisconnectInfo() if info.Reason != toki.DisconnectReasonTCPPacketParse { t.Fatalf("expected %s disconnect via gateway error path, got %q", toki.DisconnectReasonTCPPacketParse, info.Reason) } }