iop/apps/node/internal/transport/session.go
toki 4a76851254 feat(node): 연결 supervisor 및 CLI adaptors 개선
- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리
- 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16)
- finite 재연결 시도 고갈 시 노드 종료
- CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
2026-07-22 18:11:06 +09:00

281 lines
7.9 KiB
Go

package transport
import (
"context"
"fmt"
"sync"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"iop/packages/go/events"
iop "iop/proto/gen/iop"
)
// Handler processes IOP messages received from edge.
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error)
OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error)
OnProviderTunnelRequest(ctx context.Context, sess *Session, req *iop.ProviderTunnelRequest) error
}
// Session represents the node's persistent connection to edge.
type Session struct {
client *toki.TcpClient
logger *zap.Logger
nodeID string
alias string
mu sync.RWMutex
handler Handler
eventHandler func(*iop.EdgeNodeEvent)
closeReason string
disconnectCh chan struct{}
disconnectOnce sync.Once
}
func newSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session {
s := &Session{client: client, logger: logger, nodeID: nodeID, alias: alias, disconnectCh: make(chan struct{})}
toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) {
go func() {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnRunRequest(context.Background(), s, req); err != nil {
logger.Warn("run request error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
}()
})
toki.AddListenerTyped[*iop.CancelRequest](&client.Communicator, func(req *iop.CancelRequest) {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return
}
if err := h.OnCancel(context.Background(), s, req); err != nil {
logger.Warn("cancel error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
})
toki.AddListenerTyped[*iop.ProviderTunnelRequest](&client.Communicator, func(req *iop.ProviderTunnelRequest) {
go func() {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
logger.Warn("provider tunnel request ignored: handler not ready",
zap.String("run_id", req.GetRunId()),
)
return
}
if err := h.OnProviderTunnelRequest(context.Background(), s, req); err != nil {
logger.Warn("provider tunnel request error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
}()
})
toki.AddRequestListenerTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](&client.Communicator, func(req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return &iop.NodeCommandResponse{Error: "handler not ready"}, nil
}
resp, err := h.OnCommandRequest(context.Background(), s, req)
if err != nil {
return &iop.NodeCommandResponse{Error: err.Error()}, nil
}
return resp, nil
})
toki.AddRequestListenerTyped[*iop.NodeConfigRefreshRequest, *iop.NodeConfigRefreshResponse](&client.Communicator, func(req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) {
s.mu.RLock()
h := s.handler
s.mu.RUnlock()
if h == nil {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: "handler not ready",
}, nil
}
resp, err := h.OnConfigRefresh(context.Background(), s, req)
if err != nil {
return &iop.NodeConfigRefreshResponse{
RequestId: req.GetRequestId(),
Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_FAILED,
Error: err.Error(),
}, nil
}
return resp, nil
})
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(event *iop.EdgeNodeEvent) {
s.emitEvent(event)
})
client.AddDisconnectListener(func(_ *toki.TcpClient) {
transportInfo := client.DisconnectInfo()
logger.Info("disconnected from edge", transportDisconnectFields(transportInfo)...)
s.emitEvent(events.NewEdgeNodeEvent(
events.SourceNode,
events.TypeEdgeDisconnected,
nodeID,
alias,
s.disconnectReason(),
transportDisconnectMetadata(transportInfo),
))
s.disconnectOnce.Do(func() { close(s.disconnectCh) })
})
return s
}
// SetHandler attaches the message handler. Called after registration completes.
func (s *Session) SetHandler(h Handler) {
s.mu.Lock()
s.handler = h
s.mu.Unlock()
}
// SignalReady tells edge this node has applied the config from RegisterResponse
// and installed its message handler, so edge may now open dispatch eligibility
// and pump any waiters stranded while the node was offline. It MUST be called
// after SetHandler: edge dispatches run/tunnel requests in response to this
// signal, and a request arriving before the handler is installed would be
// dropped. A non-ready ack (stale connection) or a transport error is returned so
// the caller tears the session down and lets the supervisor reconnect.
func (s *Session) SignalReady(timeout time.Duration) error {
resp, err := toki.SendRequestTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
&s.client.Communicator,
&iop.NodeReadyRequest{NodeId: s.nodeID},
timeout,
)
if err != nil {
return err
}
if !resp.GetReady() {
return fmt.Errorf("edge rejected ready signal: %s", resp.GetReason())
}
return nil
}
// NodeID returns the session's node ID.
func (s *Session) NodeID() string {
return s.nodeID
}
// Alias returns the session's node alias.
func (s *Session) Alias() string {
return s.alias
}
func (s *Session) SetEventHandler(handler func(*iop.EdgeNodeEvent)) {
s.mu.Lock()
s.eventHandler = handler
s.mu.Unlock()
}
// Send transmits a proto message to edge.
func (s *Session) Send(m proto.Message) error {
return s.client.Send(m)
}
// IsAlive reports whether the connection is active.
func (s *Session) IsAlive() bool {
if s == nil || s.client == nil {
return false
}
return s.client.IsAlive()
}
// Done returns a channel that is closed when the session disconnects (local or remote).
func (s *Session) Done() <-chan struct{} {
return s.disconnectCh
}
// IsLocalShutdown reports whether the disconnect was initiated by a local Close call.
func (s *Session) IsLocalShutdown() bool {
return s.disconnectReason() == events.ReasonLocalShutdown
}
// Close terminates the connection to edge.
func (s *Session) Close() error {
s.setCloseReason(events.ReasonLocalShutdown)
return s.client.Close()
}
func (s *Session) emitEvent(event *iop.EdgeNodeEvent) {
s.mu.RLock()
handler := s.eventHandler
s.mu.RUnlock()
if handler != nil {
handler(event)
}
}
func (s *Session) setCloseReason(reason string) {
s.mu.Lock()
if s.closeReason == "" {
s.closeReason = reason
}
s.mu.Unlock()
}
func (s *Session) disconnectReason() string {
s.mu.RLock()
reason := s.closeReason
s.mu.RUnlock()
if reason == "" {
return events.ReasonTransportClosed
}
return reason
}
func transportDisconnectMetadata(info toki.DisconnectInfo) map[string]string {
metadata := make(map[string]string, 2)
if info.Reason != "" {
metadata[events.MetadataTransportCloseReason] = info.Reason
}
if info.Error != "" {
metadata[events.MetadataTransportCloseError] = info.Error
}
if len(metadata) == 0 {
return nil
}
return metadata
}
func transportDisconnectFields(info toki.DisconnectInfo) []zap.Field {
fields := make([]zap.Field, 0, 2)
if info.Reason != "" {
fields = append(fields, zap.String("transport_close_reason", info.Reason))
}
if info.Error != "" {
fields = append(fields, zap.String("transport_close_error", info.Error))
}
return fields
}
// ExportNewSession exposes newSession for black-box transport and node tests.
func ExportNewSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session {
return newSession(client, logger, nodeID, alias)
}