iop/apps/node/internal/transport/session.go
toki 2d6fde0876 기능: IOP 모노레포 스캐폴드 초기 구현
apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture,
mock/cli adapter, fx DI, SQLite 실행 이력 저장.
edge/control-plane/worker는 cobra placeholder.
유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
2026-05-02 13:20:35 +09:00

228 lines
5.8 KiB
Go

package transport
import (
"context"
"fmt"
"net"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"iop/packages/protocol"
)
const (
heartbeatInterval = 30 * time.Second
heartbeatTimeout = 10 * time.Second
)
// Handler handles decoded messages arriving on a Session.
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *protocol.RunRequest) error
OnCapabilityRequest(ctx context.Context, sess *Session) (*protocol.CapabilityResponse, error)
OnCancel(ctx context.Context, sess *Session, req *protocol.CancelRequest) error
}
// Session represents one active TCP connection from a client.
type Session struct {
id string
conn net.Conn
handler Handler
logger *zap.Logger
writeMu sync.Mutex
closed atomic.Bool
cancelFns sync.Map // run_id → context.CancelFunc
}
func newSession(id string, conn net.Conn, handler Handler, logger *zap.Logger) *Session {
return &Session{
id: id,
conn: conn,
handler: handler,
logger: logger.With(zap.String("session_id", id)),
}
}
// ID returns the session identifier.
func (s *Session) ID() string { return s.id }
// Send encodes env as a frame and writes it to the connection.
func (s *Session) Send(env *protocol.Envelope) error {
if s.closed.Load() {
return fmt.Errorf("session %s: closed", s.id)
}
payload, err := encodeEnvelope(env)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
return writeFrame(s.conn, payload)
}
// Close terminates the session.
func (s *Session) Close() {
if s.closed.CompareAndSwap(false, true) {
s.conn.Close()
s.logger.Info("session closed")
}
}
// RegisterCancel stores a cancel function for a run, enabling CancelRequest handling.
func (s *Session) RegisterCancel(runID string, cancel context.CancelFunc) {
s.cancelFns.Store(runID, cancel)
}
// DeregisterCancel removes a run's cancel function after completion.
func (s *Session) DeregisterCancel(runID string) {
s.cancelFns.Delete(runID)
}
// CancelRun invokes the cancel function for a run if one is registered.
func (s *Session) CancelRun(runID string) {
if v, ok := s.cancelFns.Load(runID); ok {
v.(context.CancelFunc)()
}
}
// readLoop drives the session until the connection closes or an error occurs.
func (s *Session) readLoop(ctx context.Context) {
defer s.Close()
// Perform capability handshake: server sends capability request,
// client responds (optional — here we accept any client proactively).
go s.heartbeatLoop(ctx)
for {
if s.closed.Load() {
return
}
raw, err := readFrame(s.conn)
if err != nil {
if !s.closed.Load() {
s.logger.Debug("read frame error", zap.Error(err))
}
return
}
if len(raw) == 0 {
continue
}
env, err := decodeEnvelope(raw)
if err != nil {
s.logger.Warn("decode envelope failed", zap.Error(err))
continue
}
if err := s.dispatch(ctx, env); err != nil {
s.logger.Warn("dispatch error", zap.String("type", string(env.Type)), zap.Error(err))
}
}
}
func (s *Session) dispatch(ctx context.Context, env *protocol.Envelope) error {
switch env.Type {
case protocol.TypeHeartbeat:
return s.handleHeartbeat(env)
case protocol.TypeRunRequest:
var req protocol.RunRequest
if err := decodePayload(env.Payload, &req); err != nil {
return s.sendError(env.RequestID, "DECODE_ERROR", err.Error())
}
go func() {
if err := s.handler.OnRunRequest(ctx, s, &req); err != nil {
_ = s.sendError(env.RequestID, "RUN_ERROR", err.Error())
}
}()
case protocol.TypeCapabilityRequest:
resp, err := s.handler.OnCapabilityRequest(ctx, s)
if err != nil {
return s.sendError(env.RequestID, "CAPABILITY_ERROR", err.Error())
}
return s.sendCapabilityResponse(env.RequestID, resp)
case protocol.TypeCancelRequest:
var req protocol.CancelRequest
if err := decodePayload(env.Payload, &req); err != nil {
return s.sendError(env.RequestID, "DECODE_ERROR", err.Error())
}
return s.handler.OnCancel(ctx, s, &req)
default:
s.logger.Warn("unknown message type", zap.String("type", string(env.Type)))
}
return nil
}
func (s *Session) handleHeartbeat(env *protocol.Envelope) error {
// Echo heartbeat back
resp, err := encodePayload(protocol.Heartbeat{Timestamp: time.Now().UnixNano()})
if err != nil {
return err
}
return s.Send(&protocol.Envelope{
ProtocolVersion: protocol.ProtocolVersion,
RequestID: env.RequestID,
Type: protocol.TypeHeartbeat,
Payload: resp,
})
}
func (s *Session) sendError(requestID, code, message string) error {
payload, err := encodePayload(protocol.ErrorMsg{Code: code, Message: message})
if err != nil {
return err
}
return s.Send(&protocol.Envelope{
ProtocolVersion: protocol.ProtocolVersion,
RequestID: requestID,
Type: protocol.TypeError,
Payload: payload,
})
}
func (s *Session) sendCapabilityResponse(requestID string, resp *protocol.CapabilityResponse) error {
payload, err := encodePayload(resp)
if err != nil {
return err
}
return s.Send(&protocol.Envelope{
ProtocolVersion: protocol.ProtocolVersion,
RequestID: requestID,
Type: protocol.TypeCapabilityResponse,
Payload: payload,
})
}
// heartbeatLoop periodically sends heartbeat frames to detect dead connections.
func (s *Session) heartbeatLoop(ctx context.Context) {
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if s.closed.Load() {
return
}
payload, _ := encodePayload(protocol.Heartbeat{Timestamp: time.Now().UnixNano()})
if err := s.Send(&protocol.Envelope{
ProtocolVersion: protocol.ProtocolVersion,
Type: protocol.TypeHeartbeat,
Payload: payload,
}); err != nil {
s.logger.Debug("heartbeat send failed", zap.Error(err))
s.Close()
return
}
}
}
}