From d982193ed3477ee9f431da4d8900e8e917215412 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 24 Mar 2026 22:42:36 +0000 Subject: [PATCH] test: add _Bad/_Ugly tests + fix per-Core lock isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: Run, RegisterService, ServiceFor, MustServiceFor _Bad/_Ugly variants. Fix: Lock map is now per-Core instance, not package-level global. This prevents deadlocks when multiple Core instances exist (e.g. tests). Coverage: 82.4% → 83.6% Co-Authored-By: Virgil --- core_test.go | 76 +++++++++++++++++++++++++++++++++ lock.go | 20 ++++----- service_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 10 deletions(-) diff --git a/core_test.go b/core_test.go index 7132e30..17ee587 100644 --- a/core_test.go +++ b/core_test.go @@ -2,6 +2,9 @@ package core_test import ( "context" + "os" + "os/exec" + "path/filepath" "testing" . "dappco.re/go/core" @@ -143,3 +146,76 @@ func TestCore_Must_Nil_Good(t *testing.T) { c.Must(nil, "test.Operation", "no error") }) } + +func TestCore_Run_HelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + switch os.Getenv("CORE_RUN_MODE") { + case "startup-fail": + c := New( + WithService(func(c *Core) Result { + return c.Service("broken", Service{ + OnStart: func() Result { + return Result{Value: NewError("startup failed"), OK: false} + }, + }) + }), + ) + c.Run() + case "cli-fail": + shutdownFile := os.Getenv("CORE_RUN_SHUTDOWN_FILE") + c := New( + WithService(func(c *Core) Result { + return c.Service("cleanup", Service{ + OnStop: func() Result { + if err := os.WriteFile(shutdownFile, []byte("stopped"), 0o600); err != nil { + return Result{Value: err, OK: false} + } + return Result{OK: true} + }, + }) + }), + ) + c.Command("explode", Command{ + Action: func(_ Options) Result { + return Result{Value: NewError("cli failed"), OK: false} + }, + }) + os.Args = []string{"core-test", "explode"} + c.Run() + default: + os.Exit(2) + } +} + +func TestCore_Run_Bad(t *testing.T) { + err := runCoreRunHelper(t, "startup-fail") + var exitErr *exec.ExitError + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } +} + +func TestCore_Run_Ugly(t *testing.T) { + shutdownFile := filepath.Join(t.TempDir(), "shutdown.txt") + err := runCoreRunHelper(t, "cli-fail", "CORE_RUN_SHUTDOWN_FILE="+shutdownFile) + var exitErr *exec.ExitError + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } + + data, readErr := os.ReadFile(shutdownFile) + assert.NoError(t, readErr) + assert.Equal(t, "stopped", string(data)) +} + +func runCoreRunHelper(t *testing.T, mode string, extraEnv ...string) error { + t.Helper() + + cmd := exec.Command(os.Args[0], "-test.run=^TestCore_Run_HelperProcess$") + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "CORE_RUN_MODE="+mode) + cmd.Env = append(cmd.Env, extraEnv...) + return cmd.Run() +} diff --git a/lock.go b/lock.go index a87181d..539aaab 100644 --- a/lock.go +++ b/lock.go @@ -8,27 +8,27 @@ import ( "sync" ) -// package-level mutex infrastructure -var ( - lockMu sync.Mutex - lockMap = make(map[string]*sync.RWMutex) -) - // Lock is the DTO for a named mutex. type Lock struct { Name string Mutex *sync.RWMutex + mu sync.Mutex // protects locks map + locks map[string]*sync.RWMutex // per-Core named mutexes } // Lock returns a named Lock, creating the mutex if needed. +// Locks are per-Core — separate Core instances do not share mutexes. func (c *Core) Lock(name string) *Lock { - lockMu.Lock() - m, ok := lockMap[name] + c.lock.mu.Lock() + if c.lock.locks == nil { + c.lock.locks = make(map[string]*sync.RWMutex) + } + m, ok := c.lock.locks[name] if !ok { m = &sync.RWMutex{} - lockMap[name] = m + c.lock.locks[name] = m } - lockMu.Unlock() + c.lock.mu.Unlock() return &Lock{Name: name, Mutex: m} } diff --git a/service_test.go b/service_test.go index e0aba91..6bc2617 100644 --- a/service_test.go +++ b/service_test.go @@ -1,6 +1,7 @@ package core_test import ( + "context" "testing" . "dappco.re/go/core" @@ -77,3 +78,113 @@ func TestService_Lifecycle_Good(t *testing.T) { stoppables[0].OnStop() assert.True(t, stopped) } + +type autoLifecycleService struct { + started bool + stopped bool + messages []Message +} + +func (s *autoLifecycleService) OnStartup(_ context.Context) error { + s.started = true + return nil +} + +func (s *autoLifecycleService) OnShutdown(_ context.Context) error { + s.stopped = true + return nil +} + +func (s *autoLifecycleService) HandleIPCEvents(_ *Core, msg Message) Result { + s.messages = append(s.messages, msg) + return Result{OK: true} +} + +func TestService_RegisterService_Bad(t *testing.T) { + t.Run("EmptyName", func(t *testing.T) { + c := New() + r := c.RegisterService("", "value") + assert.False(t, r.OK) + + err, ok := r.Value.(error) + if assert.True(t, ok) { + assert.Equal(t, "core.RegisterService", Operation(err)) + } + }) + + t.Run("DuplicateName", func(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("svc", "first").OK) + + r := c.RegisterService("svc", "second") + assert.False(t, r.OK) + }) + + t.Run("LockedRegistry", func(t *testing.T) { + c := New() + c.LockEnable() + c.LockApply() + + r := c.RegisterService("blocked", "value") + assert.False(t, r.OK) + }) +} + +func TestService_RegisterService_Ugly(t *testing.T) { + t.Run("AutoDiscoversLifecycleAndIPCHandlers", func(t *testing.T) { + c := New() + svc := &autoLifecycleService{} + + r := c.RegisterService("auto", svc) + assert.True(t, r.OK) + assert.True(t, c.ServiceStartup(context.Background(), nil).OK) + assert.True(t, c.ACTION("ping").OK) + assert.True(t, c.ServiceShutdown(context.Background()).OK) + assert.True(t, svc.started) + assert.True(t, svc.stopped) + assert.Contains(t, svc.messages, Message("ping")) + }) + + t.Run("NilInstanceReturnsServiceDTO", func(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("nil", nil).OK) + + r := c.Service("nil") + if assert.True(t, r.OK) { + svc, ok := r.Value.(*Service) + if assert.True(t, ok) { + assert.Equal(t, "nil", svc.Name) + assert.Nil(t, svc.Instance) + } + } + }) +} + +func TestService_ServiceFor_Bad(t *testing.T) { + typed, ok := ServiceFor[string](New(), "missing") + assert.False(t, ok) + assert.Equal(t, "", typed) +} + +func TestService_ServiceFor_Ugly(t *testing.T) { + c := New() + assert.True(t, c.RegisterService("value", "hello").OK) + + typed, ok := ServiceFor[int](c, "value") + assert.False(t, ok) + assert.Equal(t, 0, typed) +} + +func TestService_MustServiceFor_Bad(t *testing.T) { + c := New() + assert.PanicsWithError(t, `core.MustServiceFor: service "missing" not found or wrong type`, func() { + _ = MustServiceFor[string](c, "missing") + }) +} + +func TestService_MustServiceFor_Ugly(t *testing.T) { + var c *Core + assert.Panics(t, func() { + _ = MustServiceFor[string](c, "missing") + }) +}