Skip to content

Python gRPC Bindings - Phase 1: Core gRPC Service Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build working gRPC service (sysml-grpc) exposing OpenSysML's parser and symbol query capabilities.

Architecture: Stateless gRPC service with LRU cache for parsed models. Thin wrapper over existing internal/core/* packages. Service keyed by content hash for cache lookup.

Tech Stack: Go 1.23+, gRPC, Protocol Buffers


File Structure

New files: - api/proto/sysml.proto - Service definition and message types - api/proto/generate.go - Code generation directive - cmd/sysml-grpc/main.go - Server binary - internal/grpc/service.go - RPC implementations - internal/grpc/cache.go - LRU cache - internal/grpc/convert.go - Type conversions - internal/grpc/errors.go - Error handling - internal/grpc/service_test.go - Service unit tests - internal/grpc/cache_test.go - Cache unit tests - internal/grpc/convert_test.go - Conversion unit tests - internal/grpc/integration_test.go - Full round-trip tests

Modified files: - Makefile - Add build-grpc target - go.mod - Add gRPC dependencies


Tasks

Task 1: Setup Dependencies and Protobuf Schema

Files: - Modify: go.mod - Create: api/proto/sysml.proto - Create: api/proto/generate.go

  • [ ] Step 1: Add gRPC dependencies to go.mod

Run:

go get google.golang.org/grpc@latest
go get google.golang.org/protobuf/cmd/protoc-gen-go@latest
go get google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Expected: go.mod updated with grpc and protobuf packages

  • [ ] Step 2: Verify dependencies installed

Run: go mod tidy && go build ./... Expected: Clean build, no errors

  • [ ] Step 3: Create api/proto directory

Run:

mkdir -p api/proto

  • [ ] Step 4: Write protobuf service definition

Create api/proto/sysml.proto:

syntax = "proto3";

package sysml;

option go_package = "github.com/Open-MBEE/OpenSysML/api/proto";

// SysMLService provides programmatic access to OpenSysML's parser and runtime
service SysMLService {
  // Parse a SysML file and return model hash for subsequent queries
  rpc ParseFile(ParseFileRequest) returns (ParseFileResponse);

  // Get symbol information by qualified name
  rpc GetSymbol(GetSymbolRequest) returns (SymbolResponse);

  // Get all diagnostics for a parsed model
  rpc GetDiagnostics(DiagnosticsRequest) returns (DiagnosticsResponse);
}

// ParseFileRequest specifies the source to parse
message ParseFileRequest {
  oneof source {
    string file_path = 1;
    string content = 2;
  }
  string content_hash = 3;  // sha256 for cache lookup
}

// ParseFileResponse contains parsed model info
message ParseFileResponse {
  string model_hash = 1;     // Cache key for subsequent requests
  SymbolInfo root = 2;       // Root namespace
  repeated Diagnostic diagnostics = 3;
  string error = 4;          // Critical failure message if any
}

// GetSymbolRequest queries for a specific symbol
message GetSymbolRequest {
  string model_hash = 1;
  string symbol_id = 2;  // Fully qualified name
}

// SymbolResponse contains symbol information
message SymbolResponse {
  SymbolInfo symbol = 1;
  string error = 2;
}

// DiagnosticsRequest gets all diagnostics for a model
message DiagnosticsRequest {
  string model_hash = 1;
}

// DiagnosticsResponse contains diagnostic list
message DiagnosticsResponse {
  repeated Diagnostic diagnostics = 1;
  string error = 2;
}

// SymbolInfo represents any SysML element
message SymbolInfo {
  string id = 1;             // Unique identifier (fully qualified name)
  string name = 2;
  string kind = 3;           // "PartDefinition", "AttributeUsage", etc
  map<string, string> metadata = 4;  // multiplicity, type, etc
  repeated string child_ids = 5;     // References to children
  repeated AttributeInfo attributes = 6;
}

// AttributeInfo represents an attribute with its value
message AttributeInfo {
  string name = 1;
  string type = 2;
  Value value = 3;
  string unit = 4;
}

// Value represents a literal value
message Value {
  oneof value {
    double number = 1;
    string text = 2;
    bool boolean = 3;
    int64 integer = 4;
  }
}

// Diagnostic represents a parse/semantic error or warning
message Diagnostic {
  string severity = 1;  // "error", "warning", "info"
  string message = 2;
  Span span = 3;
}

// Span represents a source location
message Span {
  string file = 1;
  int32 start_line = 2;
  int32 start_col = 3;
  int32 end_line = 4;
  int32 end_col = 5;
}

  • [ ] Step 5: Create generate.go with protoc directive

Create api/proto/generate.go:

package proto

//go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative sysml.proto

  • [ ] Step 6: Generate Go code from protobuf

Run:

cd api/proto
go generate
cd ../..

Expected: Creates api/proto/sysml.pb.go and api/proto/sysml_grpc.pb.go

  • [ ] Step 7: Verify generated code compiles

Run: go build ./api/proto Expected: Clean build

  • [ ] Step 8: Commit
git add go.mod go.sum api/proto/
git commit -m "feat(grpc): add protobuf schema and dependencies"

Task 2: Implement LRU Cache

Files: - Create: internal/grpc/cache.go - Create: internal/grpc/cache_test.go

  • [ ] Step 1: Write failing test for cache Put/Get

Create internal/grpc/cache_test.go:

package grpc

import (
    "testing"

    "github.com/Open-MBEE/OpenSysML/internal/core/ast"
    "github.com/Open-MBEE/OpenSysML/internal/core/symbols"
)

func TestCachePutGet(t *testing.T) {
    cache := NewCache(2) // Max size 2

    hash1 := "abc123"
    model1 := &CachedModel{
        Root:    &ast.RootNamespace{},
        SymTab:  symbols.NewTable(),
    }

    cache.Put(hash1, model1)

    got, ok := cache.Get(hash1)
    if !ok {
        t.Fatal("expected cache hit")
    }
    if got != model1 {
        t.Error("got different model")
    }
}

func TestCacheMiss(t *testing.T) {
    cache := NewCache(2)

    _, ok := cache.Get("nonexistent")
    if ok {
        t.Error("expected cache miss")
    }
}

func TestCacheLRUEviction(t *testing.T) {
    cache := NewCache(2)

    model1 := &CachedModel{Root: &ast.RootNamespace{}}
    model2 := &CachedModel{Root: &ast.RootNamespace{}}
    model3 := &CachedModel{Root: &ast.RootNamespace{}}

    cache.Put("hash1", model1)
    cache.Put("hash2", model2)
    cache.Put("hash3", model3) // Should evict hash1

    _, ok := cache.Get("hash1")
    if ok {
        t.Error("expected hash1 to be evicted")
    }

    _, ok = cache.Get("hash2")
    if !ok {
        t.Error("expected hash2 to still be cached")
    }

    _, ok = cache.Get("hash3")
    if !ok {
        t.Error("expected hash3 to be cached")
    }
}

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/grpc -run TestCache -v Expected: FAIL with "undefined: NewCache"

  • [ ] Step 3: Implement cache.go

Create internal/grpc/cache.go:

package grpc

import (
    "container/list"
    "sync"

    "github.com/Open-MBEE/OpenSysML/internal/core/ast"
    "github.com/Open-MBEE/OpenSysML/internal/core/symbols"
)

// CachedModel holds parsed model data
type CachedModel struct {
    Root   *ast.RootNamespace
    SymTab *symbols.SymbolTable
}

// Cache is an LRU cache for parsed models keyed by content hash
type Cache struct {
    mu       sync.RWMutex
    maxSize  int
    items    map[string]*list.Element
    lruList  *list.List
}

type cacheEntry struct {
    key   string
    value *CachedModel
}

// NewCache creates a cache with the specified max size
func NewCache(maxSize int) *Cache {
    return &Cache{
        maxSize: maxSize,
        items:   make(map[string]*list.Element),
        lruList: list.New(),
    }
}

// Get retrieves a model from cache, returns (model, true) on hit
func (c *Cache) Get(hash string) (*CachedModel, bool) {
    c.mu.RLock()
    elem, ok := c.items[hash]
    c.mu.RUnlock()

    if !ok {
        return nil, false
    }

    // Move to front (most recently used)
    c.mu.Lock()
    c.lruList.MoveToFront(elem)
    c.mu.Unlock()

    entry := elem.Value.(*cacheEntry)
    return entry.value, true
}

// Put adds a model to cache, evicting LRU if at capacity
func (c *Cache) Put(hash string, model *CachedModel) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // Check if already exists
    if elem, ok := c.items[hash]; ok {
        c.lruList.MoveToFront(elem)
        elem.Value.(*cacheEntry).value = model
        return
    }

    // Evict if at capacity
    if c.lruList.Len() >= c.maxSize {
        oldest := c.lruList.Back()
        if oldest != nil {
            c.lruList.Remove(oldest)
            delete(c.items, oldest.Value.(*cacheEntry).key)
        }
    }

    // Add new entry
    entry := &cacheEntry{key: hash, value: model}
    elem := c.lruList.PushFront(entry)
    c.items[hash] = elem
}

  • [ ] Step 4: Run tests to verify they pass

Run: go test ./internal/grpc -run TestCache -v Expected: PASS for all 3 tests

  • [ ] Step 5: Commit
git add internal/grpc/cache.go internal/grpc/cache_test.go
git commit -m "feat(grpc): implement LRU cache for parsed models"

Task 3: Implement Type Conversion (Go → Protobuf)

Files: - Create: internal/grpc/convert.go - Create: internal/grpc/convert_test.go

  • [ ] Step 1: Write failing test for symbol conversion

Create internal/grpc/convert_test.go:

package grpc

import (
    "testing"

    "github.com/Open-MBEE/OpenSysML/internal/core/ast"
    "github.com/Open-MBEE/OpenSysML/internal/core/symbols"
    "github.com/Open-MBEE/OpenSysML/internal/core/source"
    pb "github.com/Open-MBEE/OpenSysML/api/proto"
)

func TestSymbolToProto(t *testing.T) {
    sym := &symbols.Symbol{
        Name: "TestPart",
        Kind: symbols.SymbolPartDef,
        Decl: &ast.PartDefinition{},
        DeclSpan: source.Span{File: "test.sysml", Start: 10, End: 20},
    }

    symTab := symbols.NewTable()

    proto := SymbolToProto(sym, symTab)

    if proto.Name != "TestPart" {
        t.Errorf("expected name TestPart, got %s", proto.Name)
    }
    if proto.Kind != "partDef" {
        t.Errorf("expected kind partDef, got %s", proto.Kind)
    }
    if proto.Id == "" {
        t.Error("expected non-empty ID")
    }
}

func TestDiagnosticToProto(t *testing.T) {
    diag := source.Diagnostic{
        Severity: source.Error,
        Message:  "test error",
        Span: source.Span{
            File: "test.sysml",
            Start: 10,
            End: 20,
        },
    }

    src := source.NewFile("test.sysml", []byte("part Test { }"))
    proto := DiagnosticToProto(diag, src)

    if proto.Severity != "error" {
        t.Errorf("expected severity error, got %s", proto.Severity)
    }
    if proto.Message != "test error" {
        t.Errorf("expected message 'test error', got %s", proto.Message)
    }
    if proto.Span.File != "test.sysml" {
        t.Error("expected file test.sysml")
    }
}

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/grpc -run TestSymbolToProto -v Expected: FAIL with "undefined: SymbolToProto"

  • [ ] Step 3: Implement convert.go

Create internal/grpc/convert.go:

package grpc

import (
    "fmt"

    pb "github.com/Open-MBEE/OpenSysML/api/proto"
    "github.com/Open-MBEE/OpenSysML/internal/core/source"
    "github.com/Open-MBEE/OpenSysML/internal/core/symbols"
)

// SymbolToProto converts a Symbol to protobuf SymbolInfo
func SymbolToProto(sym *symbols.Symbol, symTab *symbols.SymbolTable) *pb.SymbolInfo {
    info := &pb.SymbolInfo{
        Id:       symTab.QualifiedName(sym),
        Name:     sym.Name,
        Kind:     sym.Kind.String(),
        Metadata: make(map[string]string),
    }

    // Add visibility to metadata
    info.Metadata["visibility"] = sym.Visibility.String()

    // Collect child IDs (lazy loading)
    if sym.Scope != nil {
        childIDs := []string{}
        for _, child := range sym.Scope.Members() {
            childIDs = append(childIDs, symTab.QualifiedName(child))
        }
        info.ChildIds = childIDs
    }

    // TODO: Populate attributes when semantic layer ready
    info.Attributes = []*pb.AttributeInfo{}

    return info
}

// DiagnosticToProto converts a source.Diagnostic to protobuf
func DiagnosticToProto(diag source.Diagnostic, src *source.File) *pb.Diagnostic {
    span := diag.Span
    startLine, startCol := src.LineCol(span.Start)
    endLine, endCol := src.LineCol(span.End)

    return &pb.Diagnostic{
        Severity: severityToString(diag.Severity),
        Message:  diag.Message,
        Span: &pb.Span{
            File:      span.File,
            StartLine: int32(startLine),
            StartCol:  int32(startCol),
            EndLine:   int32(endLine),
            EndCol:    int32(endCol),
        },
    }
}

func severityToString(sev source.Severity) string {
    switch sev {
    case source.Error:
        return "error"
    case source.Warning:
        return "warning"
    case source.Info:
        return "info"
    default:
        return "info"
    }
}

  • [ ] Step 4: Run tests to verify they pass

Run: go test ./internal/grpc -run Test.*ToProto -v Expected: PASS for both tests

  • [ ] Step 5: Commit
git add internal/grpc/convert.go internal/grpc/convert_test.go
git commit -m "feat(grpc): implement type conversion Go → protobuf"

Task 4: Implement ParseFile RPC

Files: - Create: internal/grpc/service.go - Create: internal/grpc/service_test.go

  • [ ] Step 1: Write failing test for ParseFile

Create internal/grpc/service_test.go:

package grpc

import (
    "context"
    "os"
    "testing"

    pb "github.com/Open-MBEE/OpenSysML/api/proto"
)

func TestParseFileFromContent(t *testing.T) {
    cache := NewCache(10)
    svc := &service{cache: cache}

    ctx := context.Background()
    req := &pb.ParseFileRequest{
        Source:      &pb.ParseFileRequest_Content{Content: "package Test { }"},
        ContentHash: "abc123",
    }

    resp, err := svc.ParseFile(ctx, req)
    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }

    if resp.ModelHash != "abc123" {
        t.Errorf("expected model_hash abc123, got %s", resp.ModelHash)
    }

    if resp.Root == nil {
        t.Fatal("expected root symbol")
    }

    if resp.Root.Name == "" {
        t.Error("expected root to have name")
    }
}

func TestParseFileCache(t *testing.T) {
    cache := NewCache(10)
    svc := &service{cache: cache}

    ctx := context.Background()
    content := "package Test { }"
    hash := "hash123"

    req := &pb.ParseFileRequest{
        Source:      &pb.ParseFileRequest_Content{Content: content},
        ContentHash: hash,
    }

    // First call - cache miss
    resp1, err := svc.ParseFile(ctx, req)
    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }

    // Second call - cache hit
    resp2, err := svc.ParseFile(ctx, req)
    if err != nil {
        t.Fatalf("ParseFile failed on second call: %v", err)
    }

    // Should return same model hash
    if resp1.ModelHash != resp2.ModelHash {
        t.Error("cache should return same model hash")
    }
}

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/grpc -run TestParseFile -v Expected: FAIL with "undefined: service"

  • [ ] Step 3: Implement service.go

Create internal/grpc/service.go:

package grpc

import (
    "context"
    "crypto/sha256"
    "fmt"
    "os"

    pb "github.com/Open-MBEE/OpenSysML/api/proto"
    "github.com/Open-MBEE/OpenSysML/internal/core/parser"
    "github.com/Open-MBEE/OpenSysML/internal/core/source"
    "github.com/Open-MBEE/OpenSysML/internal/core/symbols"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// service implements pb.SysMLServiceServer
type service struct {
    pb.UnimplementedSysMLServiceServer
    cache *Cache
}

// NewService creates a new gRPC service instance
func NewService(cacheSize int) pb.SysMLServiceServer {
    return &service{
        cache: NewCache(cacheSize),
    }
}

// ParseFile parses a SysML file and caches the result
func (s *service) ParseFile(ctx context.Context, req *pb.ParseFileRequest) (*pb.ParseFileResponse, error) {
    // Extract content and hash
    var content []byte
    var filePath string
    var err error

    switch src := req.Source.(type) {
    case *pb.ParseFileRequest_FilePath:
        filePath = src.FilePath
        content, err = os.ReadFile(filePath)
        if err != nil {
            return nil, status.Errorf(codes.NotFound, "failed to read file: %v", err)
        }
    case *pb.ParseFileRequest_Content:
        content = []byte(src.Content)
        filePath = "<inline>"
    default:
        return nil, status.Error(codes.InvalidArgument, "source must be file_path or content")
    }

    // Compute hash if not provided
    hash := req.ContentHash
    if hash == "" {
        h := sha256.Sum256(content)
        hash = fmt.Sprintf("%x", h)
    }

    // Check cache
    if cached, ok := s.cache.Get(hash); ok {
        return &pb.ParseFileResponse{
            ModelHash:   hash,
            Root:        SymbolToProto(cached.SymTab.Root(), cached.SymTab),
            Diagnostics: []*pb.Diagnostic{}, // TODO: cache diagnostics
        }, nil
    }

    // Parse
    srcFile := source.NewFile(filePath, content)
    p := parser.New(srcFile)
    root := p.ParseFile()

    // Build symbol table
    idx := symbols.NewIndex()
    idx.AddDocument(filePath, root)

    // Get diagnostics
    diags := p.Diagnostics()
    protoDiags := make([]*pb.Diagnostic, len(diags))
    for i, d := range diags {
        protoDiags[i] = DiagnosticToProto(d, srcFile)
    }

    // Cache model
    rootSym := idx.DocumentRoot(filePath)
    symTab := &symbols.SymbolTable{} // Simplified; real impl needs proper symbol table
    cached := &CachedModel{
        Root:   root,
        SymTab: symTab,
    }
    s.cache.Put(hash, cached)

    return &pb.ParseFileResponse{
        ModelHash:   hash,
        Root:        SymbolToProto(rootSym, symTab),
        Diagnostics: protoDiags,
    }, nil
}

// GetSymbol retrieves symbol info by qualified name
func (s *service) GetSymbol(ctx context.Context, req *pb.GetSymbolRequest) (*pb.SymbolResponse, error) {
    // Retrieve cached model
    cached, ok := s.cache.Get(req.ModelHash)
    if !ok {
        return nil, status.Error(codes.NotFound, "model not found in cache")
    }

    // Look up symbol by ID
    sym := cached.SymTab.Lookup(req.SymbolId)
    if sym == nil {
        return &pb.SymbolResponse{
            Error: fmt.Sprintf("symbol not found: %s", req.SymbolId),
        }, nil
    }

    return &pb.SymbolResponse{
        Symbol: SymbolToProto(sym, cached.SymTab),
    }, nil
}

// GetDiagnostics retrieves all diagnostics for a model
func (s *service) GetDiagnostics(ctx context.Context, req *pb.DiagnosticsRequest) (*pb.DiagnosticsResponse, error) {
    // Check cache
    _, ok := s.cache.Get(req.ModelHash)
    if !ok {
        return nil, status.Error(codes.NotFound, "model not found in cache")
    }

    // TODO: Cache diagnostics with model
    return &pb.DiagnosticsResponse{
        Diagnostics: []*pb.Diagnostic{},
    }, nil
}

  • [ ] Step 4: Fix compilation errors

The service implementation above has a simplified symbol table reference. Need to adjust convert.go and service.go to work with symbols.Index properly:

Update internal/grpc/cache.go:

// CachedModel holds parsed model data
type CachedModel struct {
    Root   *ast.RootNamespace
    Index  *symbols.Index
    Source *source.File
    Diags  []source.Diagnostic
}

Update internal/grpc/convert.go - change function signature:

// SymbolToProto converts a Symbol to protobuf SymbolInfo
func SymbolToProto(sym *symbols.Symbol, idx *symbols.Index) *pb.SymbolInfo {
    info := &pb.SymbolInfo{
        Id:       idx.QualifiedName(sym),
        Name:     sym.Name,
        Kind:     sym.Kind.String(),
        Metadata: make(map[string]string),
    }

    // Add visibility to metadata
    info.Metadata["visibility"] = sym.Visibility.String()

    // Collect child IDs (lazy loading)
    if sym.Scope != nil {
        childIDs := []string{}
        for _, child := range sym.Scope.Members() {
            childIDs = append(childIDs, idx.QualifiedName(child))
        }
        info.ChildIds = childIDs
    }

    info.Attributes = []*pb.AttributeInfo{}

    return info
}

Update internal/grpc/service.go ParseFile implementation:

// Parse
srcFile := source.NewFile(filePath, content)
p := parser.New(srcFile)
root := p.ParseFile()

// Build symbol index
idx := symbols.NewIndex()
idx.AddDocument(filePath, root)

// Get diagnostics
diags := p.Diagnostics()
protoDiags := make([]*pb.Diagnostic, len(diags))
for i, d := range diags {
    protoDiags[i] = DiagnosticToProto(d, srcFile)
}

// Cache model
rootSym := idx.DocumentRoot(filePath)
cached := &CachedModel{
    Root:   root,
    Index:  idx,
    Source: srcFile,
    Diags:  diags,
}
s.cache.Put(hash, cached)

return &pb.ParseFileResponse{
    ModelHash:   hash,
    Root:        SymbolToProto(rootSym, idx),
    Diagnostics: protoDiags,
}, nil

Update GetSymbol:

func (s *service) GetSymbol(ctx context.Context, req *pb.GetSymbolRequest) (*pb.SymbolResponse, error) {
    cached, ok := s.cache.Get(req.ModelHash)
    if !ok {
        return nil, status.Error(codes.NotFound, "model not found in cache")
    }

    // Look up symbol by qualified name
    syms := cached.Index.Lookup(req.SymbolId)
    if len(syms) == 0 {
        return &pb.SymbolResponse{
            Error: fmt.Sprintf("symbol not found: %s", req.SymbolId),
        }, nil
    }

    return &pb.SymbolResponse{
        Symbol: SymbolToProto(syms[0], cached.Index),
    }, nil
}

  • [ ] Step 5: Update test files to match new signatures

Update internal/grpc/convert_test.go:

func TestSymbolToProto(t *testing.T) {
    sym := &symbols.Symbol{
        Name: "TestPart",
        Kind: symbols.SymbolPartDef,
        Decl: &ast.PartDefinition{},
        DeclSpan: source.Span{File: "test.sysml", Start: 10, End: 20},
    }

    idx := symbols.NewIndex()

    proto := SymbolToProto(sym, idx)

    if proto.Name != "TestPart" {
        t.Errorf("expected name TestPart, got %s", proto.Name)
    }
    if proto.Kind != "partDef" {
        t.Errorf("expected kind partDef, got %s", proto.Kind)
    }
}

  • [ ] Step 6: Run tests to verify they pass

Run: go test ./internal/grpc -run TestParseFile -v Expected: PASS

  • [ ] Step 7: Commit
git add internal/grpc/service.go internal/grpc/service_test.go internal/grpc/cache.go internal/grpc/convert.go internal/grpc/convert_test.go
git commit -m "feat(grpc): implement ParseFile and GetSymbol RPCs"

Task 5: Implement Server Binary

Files: - Create: cmd/sysml-grpc/main.go

  • [ ] Step 1: Create cmd/sysml-grpc directory

Run:

mkdir -p cmd/sysml-grpc

  • [ ] Step 2: Write main.go

Create cmd/sysml-grpc/main.go:

package main

import (
    "flag"
    "fmt"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    pb "github.com/Open-MBEE/OpenSysML/api/proto"
    grpcService "github.com/Open-MBEE/OpenSysML/internal/grpc"
    "google.golang.org/grpc"
)

var (
    port       = flag.Int("port", 50051, "gRPC listen port")
    healthPort = flag.Int("health-port", 0, "HTTP health check port (default: grpc port + 1)")
    cacheSize  = flag.Int("cache-size", 100, "max cached models")
    logLevel   = flag.String("log-level", "info", "logging level (debug|info|warn|error)")

    // Version information (set via ldflags)
    Version   = "dev"
    Commit    = "unknown"
    BuildTime = "unknown"
    GoVersion = "unknown"
)

func main() {
    flag.Parse()

    // Default health port to grpc port + 1
    if *healthPort == 0 {
        *healthPort = *port + 1
    }

    log.Printf("sysml-grpc %s (commit %s, built %s with %s)", Version, Commit, BuildTime, GoVersion)
    log.Printf("Starting gRPC server on port %d", *port)
    log.Printf("Health check endpoint on port %d", *healthPort)
    log.Printf("Cache size: %d models", *cacheSize)

    // Start health check server
    go startHealthServer(*healthPort)

    // Create gRPC server
    listener, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }

    grpcServer := grpc.NewServer()
    pb.RegisterSysMLServiceServer(grpcServer, grpcService.NewService(*cacheSize))

    // Graceful shutdown on signals
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

    go func() {
        <-sigChan
        log.Println("Received shutdown signal, stopping gracefully...")
        grpcServer.GracefulStop()
    }()

    log.Printf("Server ready, accepting connections")
    if err := grpcServer.Serve(listener); err != nil {
        log.Fatalf("Failed to serve: %v", err)
    }
}

func startHealthServer(port int) {
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, "OK\n")
    })

    addr := fmt.Sprintf(":%d", port)
    log.Printf("Health check listening on %s", addr)
    if err := http.ListenAndServe(addr, nil); err != nil {
        log.Fatalf("Health server failed: %v", err)
    }
}

  • [ ] Step 3: Build binary

Run: go build -o bin/sysml-grpc ./cmd/sysml-grpc Expected: Binary created at bin/sysml-grpc

  • [ ] Step 4: Test server startup

Run in one terminal:

./bin/sysml-grpc --port 50051

Expected output:

sysml-grpc dev (commit unknown, built unknown with go1.23...)
Starting gRPC server on port 50051
Health check endpoint on port 50052
Cache size: 100 models
Health check listening on :50052
Server ready, accepting connections

  • [ ] Step 5: Test health check

In another terminal:

curl http://localhost:50052/health

Expected: OK

  • [ ] Step 6: Stop server (Ctrl+C)

Expected: Graceful shutdown message

  • [ ] Step 7: Commit
git add cmd/sysml-grpc/main.go
git commit -m "feat(grpc): add sysml-grpc server binary"

Task 6: Integration Tests

Files: - Create: internal/grpc/integration_test.go

  • [ ] Step 1: Write integration test

Create internal/grpc/integration_test.go:

package grpc

import (
    "context"
    "net"
    "testing"

    pb "github.com/Open-MBEE/OpenSysML/api/proto"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/test/bufconn"
)

const bufSize = 1024 * 1024

// setupTestServer starts a test gRPC server in-memory
func setupTestServer(t *testing.T) (*grpc.Server, *bufconn.Listener, pb.SysMLServiceClient) {
    lis := bufconn.Listen(bufSize)

    server := grpc.NewServer()
    pb.RegisterSysMLServiceServer(server, NewService(10))

    go func() {
        if err := server.Serve(lis); err != nil {
            t.Errorf("Server exited with error: %v", err)
        }
    }()

    // Create client
    conn, err := grpc.DialContext(
        context.Background(),
        "bufnet",
        grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
            return lis.Dial()
        }),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        t.Fatalf("Failed to dial bufnet: %v", err)
    }

    client := pb.NewSysMLServiceClient(conn)

    return server, lis, client
}

func TestEndToEndParse(t *testing.T) {
    server, lis, client := setupTestServer(t)
    defer server.Stop()
    defer lis.Close()

    ctx := context.Background()

    // Parse a simple model
    parseResp, err := client.ParseFile(ctx, &pb.ParseFileRequest{
        Source: &pb.ParseFileRequest_Content{
            Content: `
package TestPackage {
    part def TestPart {
        attribute mass : Real;
    }
}
            `,
        },
        ContentHash: "test123",
    })

    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }

    if parseResp.ModelHash != "test123" {
        t.Errorf("expected model_hash test123, got %s", parseResp.ModelHash)
    }

    if parseResp.Root == nil {
        t.Fatal("expected root symbol")
    }

    t.Logf("Root symbol: name=%s, kind=%s, children=%d", 
        parseResp.Root.Name, parseResp.Root.Kind, len(parseResp.Root.ChildIds))

    // Query for a symbol (if any children exist)
    if len(parseResp.Root.ChildIds) > 0 {
        childID := parseResp.Root.ChildIds[0]

        symResp, err := client.GetSymbol(ctx, &pb.GetSymbolRequest{
            ModelHash: parseResp.ModelHash,
            SymbolId:  childID,
        })

        if err != nil {
            t.Fatalf("GetSymbol failed: %v", err)
        }

        if symResp.Symbol == nil {
            t.Fatal("expected symbol in response")
        }

        t.Logf("Child symbol: name=%s, kind=%s", symResp.Symbol.Name, symResp.Symbol.Kind)
    }
}

func TestCacheHit(t *testing.T) {
    server, lis, client := setupTestServer(t)
    defer server.Stop()
    defer lis.Close()

    ctx := context.Background()
    content := "package Test { }"
    hash := "cachehit123"

    // First call
    resp1, err := client.ParseFile(ctx, &pb.ParseFileRequest{
        Source:      &pb.ParseFileRequest_Content{Content: content},
        ContentHash: hash,
    })
    if err != nil {
        t.Fatalf("First ParseFile failed: %v", err)
    }

    // Second call - should hit cache
    resp2, err := client.ParseFile(ctx, &pb.ParseFileRequest{
        Source:      &pb.ParseFileRequest_Content{Content: content},
        ContentHash: hash,
    })
    if err != nil {
        t.Fatalf("Second ParseFile failed: %v", err)
    }

    if resp1.ModelHash != resp2.ModelHash {
        t.Error("expected same model hash from cache")
    }
}

func TestSymbolNotFound(t *testing.T) {
    server, lis, client := setupTestServer(t)
    defer server.Stop()
    defer lis.Close()

    ctx := context.Background()

    // Parse model
    parseResp, err := client.ParseFile(ctx, &pb.ParseFileRequest{
        Source:      &pb.ParseFileRequest_Content{Content: "package Test { }"},
        ContentHash: "test456",
    })
    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }

    // Query for nonexistent symbol
    symResp, err := client.GetSymbol(ctx, &pb.GetSymbolRequest{
        ModelHash: parseResp.ModelHash,
        SymbolId:  "NonExistent::Foo",
    })

    // Should not return gRPC error, but error field in response
    if err != nil {
        t.Fatalf("GetSymbol should not error: %v", err)
    }

    if symResp.Error == "" {
        t.Error("expected error field to be set for missing symbol")
    }
}

  • [ ] Step 2: Run integration tests

Run: go test ./internal/grpc -run TestEndToEnd -v Expected: PASS

Run: go test ./internal/grpc -run TestCacheHit -v Expected: PASS

Run: go test ./internal/grpc -run TestSymbolNotFound -v Expected: PASS

  • [ ] Step 3: Run all grpc tests

Run: go test ./internal/grpc/... -v Expected: All tests PASS

  • [ ] Step 4: Commit
git add internal/grpc/integration_test.go
git commit -m "test(grpc): add end-to-end integration tests"

Task 7: Update Build System

Files: - Modify: Makefile

  • [ ] Step 1: Add build-grpc target to Makefile

Edit Makefile, add after build-lsp target:

build-grpc: ## Build sysml-grpc binary
    @echo "Building sysml-grpc..."
    @mkdir -p $(BIN_DIR)
    go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/sysml-grpc ./cmd/sysml-grpc
    @echo "✓ Built $(BIN_DIR)/sysml-grpc ($(VERSION))"

Update build target to include build-grpc:

build: build-sysml build-lsp build-grpc ## Build all binaries

Update clean target to remove sysml-grpc:

clean: ## Remove build artifacts
    @echo "Cleaning..."
    rm -rf $(BIN_DIR)
    rm -f coverage.txt
    rm -f sysml sysml-lsp sysml-grpc
    @echo "✓ Cleaned"

Update install target:

install: build ## Install binaries to $GOPATH/bin
    @echo "Installing to $(shell go env GOPATH)/bin..."
    go install -ldflags "$(LDFLAGS)" ./cmd/sysml
    go install -ldflags "$(LDFLAGS)" ./cmd/sysml-lsp
    go install -ldflags "$(LDFLAGS)" ./cmd/sysml-grpc
    @echo "✓ Installed"
  • [ ] Step 2: Test build target

Run: make build-grpc Expected: Binary created at bin/sysml-grpc with version info

  • [ ] Step 3: Test full build

Run: make build Expected: All three binaries built (sysml, sysml-lsp, sysml-grpc)

  • [ ] Step 4: Test version info

Run: ./bin/sysml-grpc --help Expected: Shows flags and help

  • [ ] Step 5: Verify tests still pass

Run: make test Expected: All tests PASS including new grpc tests

  • [ ] Step 6: Commit
git add Makefile
git commit -m "build: add sysml-grpc to build system"

Verification

After completing all tasks, verify Phase 1 is complete:

  • [ ] Run full build
make clean
make build

Expected: Three binaries in bin/: sysml, sysml-lsp, sysml-grpc

  • [ ] Run all tests
make test

Expected: All tests PASS, including: - Cache tests (LRU eviction, hit/miss) - Conversion tests (Symbol→Proto, Diagnostic→Proto) - Service tests (ParseFile, GetSymbol) - Integration tests (end-to-end, cache behavior)

  • [ ] Manual smoke test

Terminal 1:

./bin/sysml-grpc --port 50051

Terminal 2:

# Health check
curl http://localhost:50052/health

# TODO: Add grpcurl test once we have a test file

  • [ ] Verify against design spec

Check docs/design/python-grpc-bindings.md Phase 1 requirements: - ✅ Protobuf schema defined with ParseFile, GetSymbol, GetDiagnostics - ✅ Go stubs generated - ✅ sysml-grpc binary with startup/flags - ✅ ParseFile RPC with cache - ✅ GetSymbol RPC for symbol lookup - ✅ Unit tests for cache and conversion - ✅ Integration test with real gRPC client - ✅ All tests pass


Next Steps

Phase 1 complete. Ready for Phase 2: Basic Python Client.