feat(coredeno): wire Tier 1 boot sequence — gRPC listener, manifest loading, sidecar launch

Service.OnStartup now creates sandboxed I/O medium, opens SQLite store,
starts gRPC listener on Unix socket, loads .core/view.yml manifest, and
launches Deno sidecar with CORE_SOCKET env var. Full shutdown in reverse.

New files: listener.go (Unix socket gRPC server), runtime/main.ts (Deno
entry point), integration_test.go (full boot with real Deno).

34 tests pass (33 unit + 1 integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-17 21:39:49 +00:00
parent 7d047fbdcc
commit 2f246ad053
No known key found for this signature in database
GPG key ID: AF404715446AEB41
12 changed files with 590 additions and 25 deletions

4
go.mod
View file

@ -28,6 +28,8 @@ require (
golang.org/x/net v0.50.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
)
@ -113,8 +115,6 @@ require (
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

21
go.sum
View file

@ -52,6 +52,8 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@ -245,21 +247,16 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -302,12 +299,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View file

@ -2,6 +2,7 @@ package coredeno
import (
"context"
"crypto/ed25519"
"fmt"
"os"
"os/exec"
@ -14,6 +15,10 @@ import (
type Options struct {
DenoPath string // path to deno binary (default: "deno")
SocketPath string // Unix socket path for gRPC
AppRoot string // app root directory (sandboxed I/O)
StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db)
PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional)
SidecarArgs []string // args passed to the sidecar process
}
// Permissions declares per-module Deno permission flags.
@ -69,5 +74,8 @@ func NewSidecar(opts Options) *Sidecar {
if opts.SocketPath == "" {
opts.SocketPath = DefaultSocketPath()
}
if opts.StoreDBPath == "" && opts.AppRoot != "" {
opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db")
}
return &Sidecar{opts: opts}
}

View file

@ -44,6 +44,34 @@ func TestSidecar_PermissionFlags_Empty(t *testing.T) {
assert.Empty(t, flags)
}
func TestOptions_AppRoot_Good(t *testing.T) {
opts := Options{
DenoPath: "deno",
SocketPath: "/tmp/test.sock",
AppRoot: "/app",
StoreDBPath: "/app/.core/store.db",
}
sc := NewSidecar(opts)
assert.Equal(t, "/app", sc.opts.AppRoot)
assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath)
}
func TestOptions_StoreDBPath_Default_Good(t *testing.T) {
opts := Options{AppRoot: "/app"}
sc := NewSidecar(opts)
assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath,
"StoreDBPath should default to AppRoot/.core/store.db")
}
func TestOptions_SidecarArgs_Good(t *testing.T) {
opts := Options{
DenoPath: "deno",
SidecarArgs: []string{"run", "--allow-env", "main.ts"},
}
sc := NewSidecar(opts)
assert.Equal(t, []string{"run", "--allow-env", "main.ts"}, sc.opts.SidecarArgs)
}
func TestDefaultSocketPath_XDG(t *testing.T) {
orig := os.Getenv("XDG_RUNTIME_DIR")
defer os.Setenv("XDG_RUNTIME_DIR", orig)

View file

@ -0,0 +1,110 @@
//go:build integration
package coredeno
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
core "forge.lthn.ai/core/go/pkg/framework/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestIntegration_FullBoot_Good(t *testing.T) {
denoPath, err := exec.LookPath("deno")
if err != nil {
// Check ~/.deno/bin/deno
home, _ := os.UserHomeDir()
denoPath = filepath.Join(home, ".deno", "bin", "deno")
if _, err := os.Stat(denoPath); err != nil {
t.Skip("deno not installed")
}
}
tmpDir := t.TempDir()
sockPath := filepath.Join(tmpDir, "core.sock")
// Write a manifest
coreDir := filepath.Join(tmpDir, ".core")
require.NoError(t, os.MkdirAll(coreDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
code: integration-test
name: Integration Test
version: "1.0"
permissions:
read: ["./data/"]
`), 0644))
// Copy the runtime entry point
runtimeDir := filepath.Join(coreDir, "runtime")
require.NoError(t, os.MkdirAll(runtimeDir, 0755))
src, err := os.ReadFile("runtime/main.ts")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(runtimeDir, "main.ts"), src, 0644))
entryPoint := filepath.Join(runtimeDir, "main.ts")
opts := Options{
DenoPath: denoPath,
SocketPath: sockPath,
AppRoot: tmpDir,
StoreDBPath: ":memory:",
SidecarArgs: []string{"run", "--allow-env", entryPoint},
}
c, err := core.New()
require.NoError(t, err)
factory := NewServiceFactory(opts)
result, err := factory(c)
require.NoError(t, err)
svc := result.(*Service)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = svc.OnStartup(ctx)
require.NoError(t, err)
// Verify gRPC is working
require.Eventually(t, func() bool {
_, err := os.Stat(sockPath)
return err == nil
}, 5*time.Second, 50*time.Millisecond, "socket should appear")
conn, err := grpc.NewClient(
"unix://"+sockPath,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
defer conn.Close()
client := pb.NewCoreServiceClient(conn)
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
Group: "integration", Key: "boot", Value: "ok",
})
require.NoError(t, err)
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
Group: "integration", Key: "boot",
})
require.NoError(t, err)
assert.Equal(t, "ok", resp.Value)
assert.True(t, resp.Found)
// Verify sidecar is running
assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running")
// Clean shutdown
err = svc.OnShutdown(context.Background())
assert.NoError(t, err)
assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped")
}

View file

@ -28,6 +28,7 @@ func (s *Sidecar) Start(ctx context.Context, args ...string) error {
s.ctx, s.cancel = context.WithCancel(ctx)
s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...)
s.cmd.Env = append(os.Environ(), "CORE_SOCKET="+s.opts.SocketPath)
s.done = make(chan struct{})
if err := s.cmd.Start(); err != nil {
s.cmd = nil

View file

@ -36,6 +36,38 @@ func TestStop_Good_NotStarted(t *testing.T) {
assert.NoError(t, err, "stopping a not-started sidecar should be a no-op")
}
func TestStart_Good_EnvPassedToChild(t *testing.T) {
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "test.sock")
sc := NewSidecar(Options{
DenoPath: "sleep",
SocketPath: sockPath,
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := sc.Start(ctx, "10")
require.NoError(t, err)
defer sc.Stop()
// Verify the child process has CORE_SOCKET in its environment
sc.mu.RLock()
env := sc.cmd.Env
sc.mu.RUnlock()
found := false
expected := "CORE_SOCKET=" + sockPath
for _, e := range env {
if e == expected {
found = true
break
}
}
assert.True(t, found, "child process should receive CORE_SOCKET=%s", sockPath)
}
func TestSocketDirCreated_Good(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "sub", "deno.sock")

47
pkg/coredeno/listener.go Normal file
View file

@ -0,0 +1,47 @@
package coredeno
import (
"context"
"net"
"os"
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
"google.golang.org/grpc"
)
// ListenGRPC starts a gRPC server on a Unix socket, serving the CoreService.
// It blocks until ctx is cancelled, then performs a graceful stop.
func ListenGRPC(ctx context.Context, socketPath string, srv *Server) error {
// Clean up stale socket
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return err
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
gs := grpc.NewServer()
pb.RegisterCoreServiceServer(gs, srv)
// Graceful stop when context cancelled
go func() {
<-ctx.Done()
gs.GracefulStop()
}()
if err := gs.Serve(listener); err != nil {
select {
case <-ctx.Done():
return nil // Expected shutdown
default:
return err
}
}
return nil
}

View file

@ -0,0 +1,112 @@
package coredeno
import (
"context"
"os"
"path/filepath"
"testing"
"time"
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestListenGRPC_Good(t *testing.T) {
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "test.sock")
medium := io.NewMockMedium()
st, err := store.New(":memory:")
require.NoError(t, err)
defer st.Close()
srv := NewServer(medium, st)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- ListenGRPC(ctx, sockPath, srv)
}()
// Wait for socket to appear
require.Eventually(t, func() bool {
_, err := os.Stat(sockPath)
return err == nil
}, 2*time.Second, 10*time.Millisecond, "socket should appear")
// Connect as gRPC client
conn, err := grpc.NewClient(
"unix://"+sockPath,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
defer conn.Close()
client := pb.NewCoreServiceClient(conn)
// StoreSet + StoreGet round-trip
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
Group: "test", Key: "k", Value: "v",
})
require.NoError(t, err)
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
Group: "test", Key: "k",
})
require.NoError(t, err)
assert.True(t, resp.Found)
assert.Equal(t, "v", resp.Value)
// Cancel ctx to stop listener
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("listener did not stop")
}
}
func TestListenGRPC_Bad_StaleSocket(t *testing.T) {
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "stale.sock")
// Create a stale socket file
require.NoError(t, os.WriteFile(sockPath, []byte("stale"), 0644))
medium := io.NewMockMedium()
st, err := store.New(":memory:")
require.NoError(t, err)
defer st.Close()
srv := NewServer(medium, st)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- ListenGRPC(ctx, sockPath, srv)
}()
// Should replace stale file and start listening
require.Eventually(t, func() bool {
info, err := os.Stat(sockPath)
if err != nil {
return false
}
// Socket file type, not regular file
return info.Mode()&os.ModeSocket != 0
}, 2*time.Second, 10*time.Millisecond, "socket should replace stale file")
cancel()
<-errCh
}

View file

@ -0,0 +1,30 @@
// CoreDeno Runtime Entry Point
// Connects to CoreGO via gRPC over Unix socket.
// Implements DenoService for module lifecycle management.
const socketPath = Deno.env.get("CORE_SOCKET");
if (!socketPath) {
console.error("FATAL: CORE_SOCKET environment variable not set");
Deno.exit(1);
}
console.error(`CoreDeno: connecting to ${socketPath}`);
// Tier 1: signal readiness and stay alive.
// Tier 2 adds the gRPC client and DenoService implementation.
console.error("CoreDeno: ready");
// Keep alive until parent sends SIGTERM
const ac = new AbortController();
Deno.addSignalListener("SIGTERM", () => {
console.error("CoreDeno: shutting down");
ac.abort();
});
try {
await new Promise((_resolve, reject) => {
ac.signal.addEventListener("abort", () => reject(new Error("shutdown")));
});
} catch {
// Clean exit on SIGTERM
}

View file

@ -2,8 +2,12 @@ package coredeno
import (
"context"
"fmt"
core "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/manifest"
"forge.lthn.ai/core/go/pkg/store"
)
// Service wraps the CoreDeno sidecar as a framework service.
@ -15,6 +19,10 @@ import (
type Service struct {
*core.ServiceRuntime[Options]
sidecar *Sidecar
grpcServer *Server
store *store.Store
grpcCancel context.CancelFunc
grpcDone chan error
}
// NewServiceFactory returns a factory function for framework registration via WithService.
@ -27,17 +35,95 @@ func NewServiceFactory(opts Options) func(*core.Core) (any, error) {
}
}
// OnStartup starts the Deno sidecar. Called by the framework on app startup.
// OnStartup boots the CoreDeno subsystem. Called by the framework on app startup.
//
// Sequence: medium → store → server → manifest → gRPC listener → sidecar.
func (s *Service) OnStartup(ctx context.Context) error {
opts := s.Opts()
// 1. Create sandboxed Medium (or mock if no AppRoot)
var medium io.Medium
if opts.AppRoot != "" {
var err error
medium, err = io.NewSandboxed(opts.AppRoot)
if err != nil {
return fmt.Errorf("coredeno: medium: %w", err)
}
} else {
medium = io.NewMockMedium()
}
// 2. Create Store
dbPath := opts.StoreDBPath
if dbPath == "" {
dbPath = ":memory:"
}
var err error
s.store, err = store.New(dbPath)
if err != nil {
return fmt.Errorf("coredeno: store: %w", err)
}
// 3. Create gRPC Server
s.grpcServer = NewServer(medium, s.store)
// 4. Load manifest if AppRoot set (non-fatal if missing)
if opts.AppRoot != "" {
m, loadErr := manifest.Load(medium, ".")
if loadErr == nil && m != nil {
if opts.PublicKey != nil {
if ok, verr := manifest.Verify(m, opts.PublicKey); verr == nil && ok {
s.grpcServer.RegisterModule(m)
}
} else {
s.grpcServer.RegisterModule(m)
}
}
}
// 5. Start gRPC listener in background
grpcCtx, grpcCancel := context.WithCancel(ctx)
s.grpcCancel = grpcCancel
s.grpcDone = make(chan error, 1)
go func() {
s.grpcDone <- ListenGRPC(grpcCtx, opts.SocketPath, s.grpcServer)
}()
// 6. Start sidecar (if args provided)
if len(opts.SidecarArgs) > 0 {
if err := s.sidecar.Start(ctx, opts.SidecarArgs...); err != nil {
return fmt.Errorf("coredeno: sidecar: %w", err)
}
}
return nil
}
// OnShutdown stops the Deno sidecar. Called by the framework on app shutdown.
// OnShutdown stops the CoreDeno subsystem. Called by the framework on app shutdown.
func (s *Service) OnShutdown(_ context.Context) error {
return s.sidecar.Stop()
// Stop sidecar first
_ = s.sidecar.Stop()
// Stop gRPC listener
if s.grpcCancel != nil {
s.grpcCancel()
<-s.grpcDone
}
// Close store
if s.store != nil {
s.store.Close()
}
return nil
}
// Sidecar returns the underlying sidecar for direct access.
func (s *Service) Sidecar() *Sidecar {
return s.sidecar
}
// GRPCServer returns the gRPC server for direct access.
func (s *Service) GRPCServer() *Server {
return s.grpcServer
}

View file

@ -2,11 +2,17 @@ package coredeno
import (
"context"
"os"
"path/filepath"
"testing"
"time"
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
core "forge.lthn.ai/core/go/pkg/framework/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestNewServiceFactory_Good(t *testing.T) {
@ -37,18 +43,27 @@ func TestService_WithService_Good(t *testing.T) {
}
func TestService_Lifecycle_Good(t *testing.T) {
tmpDir := t.TempDir()
sockPath := filepath.Join(tmpDir, "lifecycle.sock")
c, err := core.New()
require.NoError(t, err)
factory := NewServiceFactory(Options{DenoPath: "echo"})
factory := NewServiceFactory(Options{
DenoPath: "echo",
SocketPath: sockPath,
})
result, _ := factory(c)
svc := result.(*Service)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Verify Startable
err = svc.OnStartup(context.Background())
err = svc.OnStartup(ctx)
assert.NoError(t, err)
// Verify Stoppable (not started, should be no-op)
// Verify Stoppable
err = svc.OnShutdown(context.Background())
assert.NoError(t, err)
}
@ -63,3 +78,106 @@ func TestService_Sidecar_Good(t *testing.T) {
assert.NotNil(t, svc.Sidecar())
}
func TestService_OnStartup_Good(t *testing.T) {
tmpDir := t.TempDir()
sockPath := filepath.Join(tmpDir, "core.sock")
// Write a minimal manifest
coreDir := filepath.Join(tmpDir, ".core")
require.NoError(t, os.MkdirAll(coreDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
code: test-app
name: Test App
version: "1.0"
permissions:
read: ["./data/"]
write: ["./data/"]
`), 0644))
opts := Options{
DenoPath: "sleep",
SocketPath: sockPath,
AppRoot: tmpDir,
StoreDBPath: ":memory:",
SidecarArgs: []string{"60"},
}
c, err := core.New()
require.NoError(t, err)
factory := NewServiceFactory(opts)
result, err := factory(c)
require.NoError(t, err)
svc := result.(*Service)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err = svc.OnStartup(ctx)
require.NoError(t, err)
// Verify socket appeared
require.Eventually(t, func() bool {
_, err := os.Stat(sockPath)
return err == nil
}, 2*time.Second, 10*time.Millisecond, "gRPC socket should appear after startup")
// Verify gRPC responds
conn, err := grpc.NewClient(
"unix://"+sockPath,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
defer conn.Close()
client := pb.NewCoreServiceClient(conn)
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
Group: "boot", Key: "ok", Value: "true",
})
require.NoError(t, err)
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
Group: "boot", Key: "ok",
})
require.NoError(t, err)
assert.True(t, resp.Found)
assert.Equal(t, "true", resp.Value)
// Verify sidecar is running
assert.True(t, svc.sidecar.IsRunning(), "sidecar should be running")
// Shutdown
err = svc.OnShutdown(context.Background())
assert.NoError(t, err)
assert.False(t, svc.sidecar.IsRunning(), "sidecar should be stopped")
}
func TestService_OnStartup_Good_NoManifest(t *testing.T) {
tmpDir := t.TempDir()
sockPath := filepath.Join(tmpDir, "core.sock")
opts := Options{
DenoPath: "sleep",
SocketPath: sockPath,
AppRoot: tmpDir,
StoreDBPath: ":memory:",
}
c, err := core.New()
require.NoError(t, err)
factory := NewServiceFactory(opts)
result, _ := factory(c)
svc := result.(*Service)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Should succeed even without .core/view.yml
err = svc.OnStartup(ctx)
require.NoError(t, err)
err = svc.OnShutdown(context.Background())
assert.NoError(t, err)
}