apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
228 lines
5.8 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|