승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
700 lines
27 KiB
Go
700 lines
27 KiB
Go
package transport_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"iop/apps/node/internal/transport"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
type noopHandler struct{}
|
|
|
|
func (h *noopHandler) OnRunRequest(_ context.Context, _ *transport.Session, _ *iop.RunRequest) error {
|
|
return nil
|
|
}
|
|
func (h *noopHandler) OnCancel(_ context.Context, _ *transport.Session, _ *iop.CancelRequest) error {
|
|
return nil
|
|
}
|
|
func (h *noopHandler) OnCommandRequest(_ context.Context, _ *transport.Session, _ *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
|
|
return nil, nil
|
|
}
|
|
func (h *noopHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
|
|
return &iop.NodeConfigRefreshResponse{
|
|
RequestId: req.GetRequestId(),
|
|
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
|
|
}, nil
|
|
}
|
|
func (h *noopHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, _ *iop.ProviderTunnelRequest) error {
|
|
return nil
|
|
}
|
|
|
|
type workspaceHandler struct{ noopHandler }
|
|
|
|
func (h *workspaceHandler) OnWorkspaceOpen(_ context.Context, _ *transport.Session, req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
|
|
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
|
|
}
|
|
|
|
func (h *workspaceHandler) OnWorkspaceTool(_ context.Context, _ *transport.Session, req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
|
|
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
|
|
}
|
|
|
|
func (h *workspaceHandler) OnWorkspaceCancel(_ context.Context, _ *transport.Session, req *iop.WorkspaceCancelRequest) (*iop.WorkspaceCancelResponse, error) {
|
|
return &iop.WorkspaceCancelResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED}, nil
|
|
}
|
|
|
|
func (h *workspaceHandler) OnWorkspaceCleanup(_ context.Context, _ *transport.Session, req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
|
|
return &iop.WorkspaceCleanupResponse{RequestId: req.GetRequestId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, CleanedArtifacts: 1}, nil
|
|
}
|
|
|
|
func TestSession_SetHandler_ConcurrentSafe(t *testing.T) {
|
|
var s transport.Session
|
|
var wg sync.WaitGroup
|
|
h := &noopHandler{}
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
t.Helper()
|
|
defer wg.Done()
|
|
s.SetHandler(h)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestSessionWorkspaceRequest(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
sess.SetHandler(&workspaceHandler{})
|
|
|
|
open, err := toki.SendRequestTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&edgeSide.Communicator, &iop.WorkspaceOpenRequest{RequestId: "request-1", WorkspaceRef: "workspace-1"}, 2*time.Second)
|
|
if err != nil || open.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || open.GetRequestId() != "request-1" {
|
|
t.Fatalf("open = %+v, %v", open, err)
|
|
}
|
|
tool, err := toki.SendRequestTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&edgeSide.Communicator, &iop.WorkspaceToolRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1", Operation: iop.WorkspaceOperation_WORKSPACE_OPERATION_READ, Input: &iop.WorkspaceToolRequest_RelativePath{RelativePath: "README.md"}}, 2*time.Second)
|
|
if err != nil || tool.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || tool.GetToolCallId() != "tool-1" {
|
|
t.Fatalf("tool = %+v, %v", tool, err)
|
|
}
|
|
cancel, err := toki.SendRequestTyped[*iop.WorkspaceCancelRequest, *iop.WorkspaceCancelResponse](&edgeSide.Communicator, &iop.WorkspaceCancelRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1"}, 2*time.Second)
|
|
if err != nil || cancel.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED || cancel.GetRequestId() != "request-1" {
|
|
t.Fatalf("cancel = %+v, %v", cancel, err)
|
|
}
|
|
cleanup, err := toki.SendRequestTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&edgeSide.Communicator, &iop.WorkspaceCleanupRequest{RequestId: "request-1"}, 2*time.Second)
|
|
if err != nil || cleanup.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || cleanup.GetCleanedArtifacts() != 1 {
|
|
t.Fatalf("cleanup = %+v, %v", cleanup, err)
|
|
}
|
|
}
|
|
|
|
func TestSessionWorkspaceRequestWithoutOptionalHandler(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
sess.SetHandler(&noopHandler{})
|
|
|
|
response, err := toki.SendRequestTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&edgeSide.Communicator, &iop.WorkspaceToolRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1"}, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("workspace request: %v", err)
|
|
}
|
|
if response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED || response.GetErrorCode() != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY || response.GetRequestId() != "request-1" {
|
|
t.Fatalf("unexpected unsupported response: %+v", response)
|
|
}
|
|
}
|
|
|
|
// blockingWorkspaceHandler blocks OnWorkspaceTool until OnWorkspaceCancel runs,
|
|
// so a test can prove the cancel request is dispatched while the tool handler is
|
|
// still in flight. Open and cleanup inherit the success responses of the embedded
|
|
// workspaceHandler.
|
|
type blockingWorkspaceHandler struct {
|
|
workspaceHandler
|
|
toolStarted chan struct{}
|
|
cancelDone chan struct{}
|
|
}
|
|
|
|
func (h *blockingWorkspaceHandler) OnWorkspaceTool(ctx context.Context, _ *transport.Session, req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
|
|
close(h.toolStarted)
|
|
select {
|
|
case <-h.cancelDone:
|
|
case <-ctx.Done():
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
return &iop.WorkspaceToolResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
|
|
}
|
|
|
|
func (h *blockingWorkspaceHandler) OnWorkspaceCancel(_ context.Context, _ *transport.Session, req *iop.WorkspaceCancelRequest) (*iop.WorkspaceCancelResponse, error) {
|
|
close(h.cancelDone)
|
|
return &iop.WorkspaceCancelResponse{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED}, nil
|
|
}
|
|
|
|
// TestSessionWorkspaceConcurrentCancel proves the Node dispatches workspace
|
|
// requests off the single receive coordinator: a cancel sent while the tool
|
|
// handler is blocked is handled and answered before the tool handler returns.
|
|
func TestSessionWorkspaceConcurrentCancel(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
h := &blockingWorkspaceHandler{toolStarted: make(chan struct{}), cancelDone: make(chan struct{})}
|
|
sess.SetHandler(h)
|
|
|
|
toolResp := make(chan *iop.WorkspaceToolResponse, 1)
|
|
toolErr := make(chan error, 1)
|
|
go func() {
|
|
resp, err := toki.SendRequestTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&edgeSide.Communicator, &iop.WorkspaceToolRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1"}, 3*time.Second)
|
|
toolResp <- resp
|
|
toolErr <- err
|
|
}()
|
|
|
|
select {
|
|
case <-h.toolStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tool handler did not start")
|
|
}
|
|
|
|
cancel, err := toki.SendRequestTyped[*iop.WorkspaceCancelRequest, *iop.WorkspaceCancelResponse](&edgeSide.Communicator, &iop.WorkspaceCancelRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1"}, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("cancel while tool in flight: %v", err)
|
|
}
|
|
if cancel.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED || cancel.GetRequestId() != "request-1" || cancel.GetStageId() != "work" || cancel.GetToolCallId() != "tool-1" {
|
|
t.Fatalf("cancel response = %+v", cancel)
|
|
}
|
|
|
|
if err := <-toolErr; err != nil {
|
|
t.Fatalf("tool response after cancel: %v", err)
|
|
}
|
|
if resp := <-toolResp; resp.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || resp.GetToolCallId() != "tool-1" {
|
|
t.Fatalf("tool response = %+v", resp)
|
|
}
|
|
}
|
|
|
|
// erroringWorkspaceHandler returns an error from OnWorkspaceTool so a test can
|
|
// confirm the concurrent listener still emits the generic failure response
|
|
// without leaking the raw handler error.
|
|
type erroringWorkspaceHandler struct{ workspaceHandler }
|
|
|
|
func (h *erroringWorkspaceHandler) OnWorkspaceTool(_ context.Context, _ *transport.Session, _ *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
|
|
return nil, errors.New("tool handler boom")
|
|
}
|
|
|
|
// TestSessionWorkspaceHandlerErrorReturnsGenericFailure proves the concurrent
|
|
// listener path still translates a handler error into the generic typed failure
|
|
// response with echoed identities and no raw handler text.
|
|
func TestSessionWorkspaceHandlerErrorReturnsGenericFailure(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
sess.SetHandler(&erroringWorkspaceHandler{})
|
|
|
|
resp, err := toki.SendRequestTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&edgeSide.Communicator, &iop.WorkspaceToolRequest{RequestId: "request-1", StageId: "work", ToolCallId: "tool-1"}, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if resp.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR || resp.GetErrorCode() != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL {
|
|
t.Fatalf("generic failure response = %+v", resp)
|
|
}
|
|
if resp.GetRequestId() != "request-1" || resp.GetStageId() != "work" || resp.GetToolCallId() != "tool-1" {
|
|
t.Fatalf("failed response identity = %+v", resp)
|
|
}
|
|
if resp.GetError() != "workspace handler failed" {
|
|
t.Fatalf("raw handler error leaked in %q", resp.GetError())
|
|
}
|
|
}
|
|
|
|
// TestSessionHealthObservationSeqIsMonotonicPerConnection verifies a new Session
|
|
// starts at zero, so the first finalized observation receives one and each
|
|
// subsequent call increments by one.
|
|
func TestSessionHealthObservationSeqIsMonotonicPerConnection(t *testing.T) {
|
|
var s transport.Session
|
|
for want := uint64(1); want <= 4; want++ {
|
|
if got := s.NextHealthObservationSeq(); got != want {
|
|
t.Fatalf("NextHealthObservationSeq() = %d, want %d", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionHealthObservationSeqUniqueUnderConcurrency verifies concurrent
|
|
// normalized and tunnel attempts sharing one Session each receive a unique,
|
|
// contiguous value with no collisions or zeros.
|
|
func TestSessionHealthObservationSeqUniqueUnderConcurrency(t *testing.T) {
|
|
var s transport.Session
|
|
const workers = 64
|
|
values := make(chan uint64, workers)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
values <- s.NextHealthObservationSeq()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(values)
|
|
seen := make(map[uint64]bool, workers)
|
|
var maxSeq uint64
|
|
for v := range values {
|
|
if v == 0 {
|
|
t.Fatal("finalized observation received sequence zero")
|
|
}
|
|
if seen[v] {
|
|
t.Fatalf("health observation sequence %d issued twice", v)
|
|
}
|
|
seen[v] = true
|
|
if v > maxSeq {
|
|
maxSeq = v
|
|
}
|
|
}
|
|
if len(seen) != workers || maxSeq != workers {
|
|
t.Fatalf("concurrent sequence = %d distinct values, max %d; want %d contiguous", len(seen), maxSeq, workers)
|
|
}
|
|
}
|
|
|
|
// TestSessionHealthObservationSeqResetsPerNewSession verifies the counter is
|
|
// connection-scoped: a second Session starts its own sequence at one regardless
|
|
// of how far the first advanced.
|
|
func TestSessionHealthObservationSeqResetsPerNewSession(t *testing.T) {
|
|
var first, second transport.Session
|
|
if got := first.NextHealthObservationSeq(); got != 1 {
|
|
t.Fatalf("first session initial seq = %d, want 1", got)
|
|
}
|
|
first.NextHealthObservationSeq()
|
|
first.NextHealthObservationSeq()
|
|
if got := second.NextHealthObservationSeq(); got != 1 {
|
|
t.Fatalf("second session initial seq = %d, want 1 (new connection starts at zero)", got)
|
|
}
|
|
}
|
|
|
|
// TestSessionHealthObservationSeqWrapsMonotonically documents the overflow
|
|
// policy: the counter is monotonic within the uint64 space and wraps only after
|
|
// 2^64 observations on a single connection, which is unreachable in practice.
|
|
func TestSessionHealthObservationSeqWrapsMonotonically(t *testing.T) {
|
|
var s transport.Session
|
|
s.ExportSeedHealthObservationSeq(^uint64(0)) // 2^64 - 1
|
|
if got := s.NextHealthObservationSeq(); got != 0 {
|
|
t.Fatalf("wrap boundary seq = %d, want 0 after 2^64-1", got)
|
|
}
|
|
if got := s.NextHealthObservationSeq(); got != 1 {
|
|
t.Fatalf("post-wrap seq = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// buildSessionTestPipe creates a net.Pipe-based pair: one side acts as "edge"
|
|
// (sends requests) and the other side acts as the node session under test.
|
|
// The edge side parser map must include the response type; the node side must
|
|
// include the request type (handled by nodeParserMap via DialEdge, but for
|
|
// unit tests we wire it manually).
|
|
func buildSessionTestPipe(t *testing.T) (edgeSide *toki.TcpClient, nodeSide *toki.TcpClient) {
|
|
t.Helper()
|
|
edgeConn, nodeConn := net.Pipe()
|
|
edgeParserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunEvent{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.NodeConfigRefreshResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeConfigRefreshResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.ProviderTunnelFrame{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelFrame{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceOpenResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceOpenResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceToolResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceToolResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceCancelResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceCancelResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceCleanupResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceCleanupResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
nodeParserMap := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.RunRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeConfigRefreshRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.ProviderTunnelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceOpenRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceOpenRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceToolRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceToolRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceCancelRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceCancelRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
toki.TypeNameOf(&iop.WorkspaceCleanupRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.WorkspaceCleanupRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeSide = toki.NewTcpClient(edgeConn, 0, 0, edgeParserMap)
|
|
nodeSide = toki.NewTcpClient(nodeConn, 0, 0, nodeParserMap)
|
|
t.Cleanup(func() { edgeSide.Close(); nodeSide.Close() })
|
|
return edgeSide, nodeSide
|
|
}
|
|
|
|
// appliedHandler always returns applied.
|
|
type appliedHandler struct{ noopHandler }
|
|
|
|
func (h *appliedHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
|
|
return &iop.NodeConfigRefreshResponse{
|
|
RequestId: req.GetRequestId(),
|
|
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED,
|
|
}, nil
|
|
}
|
|
|
|
// errorHandler returns an error from OnConfigRefresh.
|
|
type errorHandler struct{ noopHandler }
|
|
|
|
func (h *errorHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
|
|
return nil, errors.New("refresh failed")
|
|
}
|
|
|
|
// TestSessionConfigRefreshRequestReturnsHandlerResponse verifies that a
|
|
// NodeConfigRefreshRequest pushed by the edge reaches the handler and the
|
|
// response is returned to the edge.
|
|
func TestSessionConfigRefreshRequestReturnsHandlerResponse(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
sess.SetHandler(&appliedHandler{})
|
|
|
|
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
|
|
&edgeSide.Communicator,
|
|
&iop.NodeConfigRefreshRequest{RequestId: "req-1", ChangedPaths: []string{"nodes.0.providers.0.capacity"}},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendRequestTyped: %v", err)
|
|
}
|
|
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED {
|
|
t.Fatalf("expected status=applied, got %v", resp.GetStatus())
|
|
}
|
|
if resp.GetRequestId() != "req-1" {
|
|
t.Fatalf("expected request_id=req-1, got %q", resp.GetRequestId())
|
|
}
|
|
}
|
|
|
|
// TestSessionConfigRefreshRequestHandlerErrorReturnsFailure verifies that a
|
|
// handler error is translated to a failed protocol response.
|
|
func TestSessionConfigRefreshRequestHandlerErrorReturnsFailure(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
sess.SetHandler(&errorHandler{})
|
|
|
|
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
|
|
&edgeSide.Communicator,
|
|
&iop.NodeConfigRefreshRequest{RequestId: "req-err"},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendRequestTyped: %v", err)
|
|
}
|
|
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED {
|
|
t.Fatalf("expected status=failed, got %v", resp.GetStatus())
|
|
}
|
|
if resp.GetError() == "" {
|
|
t.Fatal("expected non-empty error message")
|
|
}
|
|
}
|
|
|
|
// TestSessionConfigRefreshRequestNoHandlerReturnsFailure verifies that when no
|
|
// handler is set, the session returns a failed response with an informative message.
|
|
func TestSessionConfigRefreshRequestNoHandlerReturnsFailure(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
|
|
_ = transport.ExportNewSession(nodeSide, zap.NewNop(), "node-nohandler", "")
|
|
// handler intentionally NOT set
|
|
|
|
resp, err := toki.SendRequestTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](
|
|
&edgeSide.Communicator,
|
|
&iop.NodeConfigRefreshRequest{RequestId: "req-nohandler"},
|
|
2*time.Second,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendRequestTyped: %v", err)
|
|
}
|
|
if resp.GetStatus() != iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED {
|
|
t.Fatalf("expected status=failed when handler is nil, got %v", resp.GetStatus())
|
|
}
|
|
}
|
|
|
|
type tunnelHandler struct {
|
|
noopHandler
|
|
mu sync.Mutex
|
|
requests []*iop.ProviderTunnelRequest
|
|
done chan struct{}
|
|
}
|
|
|
|
func (h *tunnelHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, req *iop.ProviderTunnelRequest) error {
|
|
h.mu.Lock()
|
|
h.requests = append(h.requests, req)
|
|
h.mu.Unlock()
|
|
close(h.done)
|
|
return nil
|
|
}
|
|
|
|
func TestSessionProviderTunnelRequest(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test")
|
|
handler := &tunnelHandler{done: make(chan struct{})}
|
|
sess.SetHandler(handler)
|
|
|
|
req := &iop.ProviderTunnelRequest{
|
|
RunId: "run-1",
|
|
TunnelId: "tunnel-1",
|
|
Adapter: "openai_compat",
|
|
Target: "target-1",
|
|
}
|
|
|
|
if err := edgeSide.Send(req); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-handler.done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting for tunnel request")
|
|
}
|
|
|
|
handler.mu.Lock()
|
|
defer handler.mu.Unlock()
|
|
if len(handler.requests) != 1 {
|
|
t.Fatalf("expected 1 request, got %d", len(handler.requests))
|
|
}
|
|
got := handler.requests[0]
|
|
if got.GetRunId() != "run-1" || got.GetTunnelId() != "tunnel-1" {
|
|
t.Errorf("unexpected request fields: %+v", got)
|
|
}
|
|
}
|
|
|
|
type lifetimeHandler struct {
|
|
noopHandler
|
|
runStarted chan struct{}
|
|
runCanceled chan error
|
|
runSendResult chan error
|
|
tunnelStarted chan struct{}
|
|
tunnelCanceled chan error
|
|
tunnelSendResult chan error
|
|
}
|
|
|
|
func newLifetimeHandler() *lifetimeHandler {
|
|
return &lifetimeHandler{
|
|
runStarted: make(chan struct{}), runCanceled: make(chan error, 1), runSendResult: make(chan error, 1),
|
|
tunnelStarted: make(chan struct{}), tunnelCanceled: make(chan error, 1), tunnelSendResult: make(chan error, 1),
|
|
}
|
|
}
|
|
|
|
func (h *lifetimeHandler) OnRunRequest(ctx context.Context, sess *transport.Session, req *iop.RunRequest) error {
|
|
close(h.runStarted)
|
|
<-ctx.Done()
|
|
h.runCanceled <- ctx.Err()
|
|
h.runSendResult <- sess.Send(&iop.RunEvent{RunId: req.GetRunId(), Type: "error", Error: "must not reach dead session"})
|
|
return ctx.Err()
|
|
}
|
|
|
|
func (h *lifetimeHandler) OnProviderTunnelRequest(ctx context.Context, sess *transport.Session, req *iop.ProviderTunnelRequest) error {
|
|
close(h.tunnelStarted)
|
|
<-ctx.Done()
|
|
h.tunnelCanceled <- ctx.Err()
|
|
h.tunnelSendResult <- sess.Send(&iop.ProviderTunnelFrame{RunId: req.GetRunId(), TunnelId: req.GetTunnelId(), Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR, Error: "must not reach dead session"})
|
|
return ctx.Err()
|
|
}
|
|
|
|
func TestSessionLifetimeCancelsRunHandler(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-run-lifetime", "alias")
|
|
handler := newLifetimeHandler()
|
|
sess.SetHandler(handler)
|
|
if err := edgeSide.Send(&iop.RunRequest{RunId: "run-lifetime"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-handler.runStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("run handler did not start")
|
|
}
|
|
if err := edgeSide.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-handler.runCanceled:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("run context error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("run handler context was not canceled on disconnect")
|
|
}
|
|
if err := <-handler.runSendResult; err == nil {
|
|
t.Fatal("run terminal Send unexpectedly succeeded on dead session")
|
|
}
|
|
}
|
|
|
|
func TestSessionLifetimeCancelsTunnelHandler(t *testing.T) {
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-tunnel-lifetime", "alias")
|
|
handler := newLifetimeHandler()
|
|
sess.SetHandler(handler)
|
|
if err := edgeSide.Send(&iop.ProviderTunnelRequest{RunId: "run-tunnel-lifetime", TunnelId: "tunnel-lifetime"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-handler.tunnelStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tunnel handler did not start")
|
|
}
|
|
if err := edgeSide.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-handler.tunnelCanceled:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("tunnel context error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("tunnel handler context was not canceled on disconnect")
|
|
}
|
|
if err := <-handler.tunnelSendResult; err == nil {
|
|
t.Fatal("tunnel terminal Send unexpectedly succeeded on dead session")
|
|
}
|
|
}
|
|
|
|
// Compile check: Session must export a way to create instances for tests.
|
|
// ExportNewSession is expected in session_export_test.go or a separate test helper file.
|
|
var _ = fmt.Sprintf
|
|
|
|
type errorTunnelHandler struct {
|
|
noopHandler
|
|
done chan struct{}
|
|
}
|
|
|
|
func (h *errorTunnelHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, req *iop.ProviderTunnelRequest) error {
|
|
defer close(h.done)
|
|
return errors.New("tunnel error")
|
|
}
|
|
|
|
func TestSessionProviderTunnelRequest_NilAndErrHandler(t *testing.T) {
|
|
// 1. Nil handler test
|
|
{
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test-nil", "alias-test")
|
|
// handler is nil
|
|
|
|
req := &iop.ProviderTunnelRequest{
|
|
RunId: "run-nil",
|
|
TunnelId: "tunnel-nil",
|
|
Adapter: "openai_compat",
|
|
Target: "target-nil",
|
|
}
|
|
|
|
if err := edgeSide.Send(req); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
|
|
// wait a bit to ensure no panic
|
|
time.Sleep(100 * time.Millisecond)
|
|
sess.Close()
|
|
}
|
|
|
|
// 2. Error handler test
|
|
{
|
|
edgeSide, nodeSide := buildSessionTestPipe(t)
|
|
sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test-err", "alias-test")
|
|
handler := &errorTunnelHandler{done: make(chan struct{})}
|
|
sess.SetHandler(handler)
|
|
|
|
req := &iop.ProviderTunnelRequest{
|
|
RunId: "run-err",
|
|
TunnelId: "tunnel-err",
|
|
Adapter: "openai_compat",
|
|
Target: "target-err",
|
|
}
|
|
|
|
if err := edgeSide.Send(req); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-handler.done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting for tunnel request")
|
|
}
|
|
sess.Close()
|
|
}
|
|
}
|
|
|
|
// TestSessionSignalReady verifies the node→edge dispatch-ready handshake:
|
|
// SignalReady sends a NodeReadyRequest carrying the session's node id and returns
|
|
// nil only when the edge acks ready, and an error when the edge rejects the
|
|
// signal (stale connection) so the caller can tear down and reconnect.
|
|
func TestSessionSignalReady(t *testing.T) {
|
|
newReadyPipe := func(t *testing.T, ready bool, reason string) *transport.Session {
|
|
t.Helper()
|
|
edgeConn, nodeConn := net.Pipe()
|
|
edgeParser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.NodeReadyRequest{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeReadyRequest{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
nodeParser := toki.ParserMap{
|
|
toki.TypeNameOf(&iop.NodeReadyResponse{}): func(b []byte) (proto.Message, error) {
|
|
m := &iop.NodeReadyResponse{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
edgeSide := toki.NewTcpClient(edgeConn, 0, 0, edgeParser)
|
|
nodeSide := toki.NewTcpClient(nodeConn, 0, 0, nodeParser)
|
|
t.Cleanup(func() { edgeSide.Close(); nodeSide.Close() })
|
|
|
|
toki.AddRequestListenerTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&edgeSide.Communicator,
|
|
func(req *iop.NodeReadyRequest) (*iop.NodeReadyResponse, error) {
|
|
if req.GetNodeId() != "node-ready-test" {
|
|
return &iop.NodeReadyResponse{Ready: false, Reason: "unexpected node id"}, nil
|
|
}
|
|
return &iop.NodeReadyResponse{Ready: ready, Reason: reason}, nil
|
|
},
|
|
)
|
|
return transport.ExportNewSession(nodeSide, zap.NewNop(), "node-ready-test", "alias")
|
|
}
|
|
|
|
t.Run("ready ack succeeds", func(t *testing.T) {
|
|
sess := newReadyPipe(t, true, "")
|
|
if err := sess.SignalReady(2 * time.Second); err != nil {
|
|
t.Fatalf("SignalReady on ready ack: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("non-ready ack errors", func(t *testing.T) {
|
|
sess := newReadyPipe(t, false, "superseded")
|
|
err := sess.SignalReady(2 * time.Second)
|
|
if err == nil {
|
|
t.Fatal("SignalReady must error when edge rejects the ready signal")
|
|
}
|
|
})
|
|
}
|