Implement lifecycle interfaces for Core services (#33)
* Implement Startable and Stoppable lifecycle interfaces * Refactor tests: remove redundant method overrides in MockLifecycle --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
parent
44dd023c43
commit
e4a77cb1ae
5 changed files with 279 additions and 8 deletions
49
core.go
49
core.go
|
|
@ -30,12 +30,7 @@ func New(opts ...Option) (*Core, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.once.Do(func() {
|
|
||||||
c.initErr = nil
|
|
||||||
})
|
|
||||||
if c.initErr != nil {
|
|
||||||
return nil, c.initErr
|
|
||||||
}
|
|
||||||
if c.serviceLock {
|
if c.serviceLock {
|
||||||
c.servicesLocked = true
|
c.servicesLocked = true
|
||||||
}
|
}
|
||||||
|
|
@ -138,13 +133,43 @@ func WithServiceLock() Option {
|
||||||
// ServiceStartup is the entry point for the Core service's startup lifecycle.
|
// ServiceStartup is the entry point for the Core service's startup lifecycle.
|
||||||
// It is called by Wails when the application starts.
|
// It is called by Wails when the application starts.
|
||||||
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||||
return c.ACTION(ActionServiceStartup{})
|
c.serviceMu.RLock()
|
||||||
|
startables := append([]Startable(nil), c.startables...)
|
||||||
|
c.serviceMu.RUnlock()
|
||||||
|
|
||||||
|
var agg error
|
||||||
|
for _, s := range startables {
|
||||||
|
if err := s.OnStartup(ctx); err != nil {
|
||||||
|
agg = errors.Join(agg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ACTION(ActionServiceStartup{}); err != nil {
|
||||||
|
agg = errors.Join(agg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return agg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
|
// ServiceShutdown is the entry point for the Core service's shutdown lifecycle.
|
||||||
// It is called by Wails when the application shuts down.
|
// It is called by Wails when the application shuts down.
|
||||||
func (c *Core) ServiceShutdown(ctx context.Context) error {
|
func (c *Core) ServiceShutdown(ctx context.Context) error {
|
||||||
return c.ACTION(ActionServiceShutdown{})
|
var agg error
|
||||||
|
if err := c.ACTION(ActionServiceShutdown{}); err != nil {
|
||||||
|
agg = errors.Join(agg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.serviceMu.RLock()
|
||||||
|
stoppables := append([]Stoppable(nil), c.stoppables...)
|
||||||
|
c.serviceMu.RUnlock()
|
||||||
|
|
||||||
|
for i := len(stoppables) - 1; i >= 0; i-- {
|
||||||
|
if err := stoppables[i].OnShutdown(ctx); err != nil {
|
||||||
|
agg = errors.Join(agg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACTION dispatches a message to all registered IPC handlers.
|
// ACTION dispatches a message to all registered IPC handlers.
|
||||||
|
|
@ -191,6 +216,14 @@ func (c *Core) RegisterService(name string, api any) error {
|
||||||
return fmt.Errorf("core: service %q already registered", name)
|
return fmt.Errorf("core: service %q already registered", name)
|
||||||
}
|
}
|
||||||
c.services[name] = api
|
c.services[name] = api
|
||||||
|
|
||||||
|
if s, ok := api.(Startable); ok {
|
||||||
|
c.startables = append(c.startables, s)
|
||||||
|
}
|
||||||
|
if s, ok := api.(Stoppable); ok {
|
||||||
|
c.stoppables = append(c.stoppables, s)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
core_extra_test.go
Normal file
43
core_extra_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockServiceWithIPC struct {
|
||||||
|
MockService
|
||||||
|
handled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockServiceWithIPC) HandleIPCEvents(c *Core, msg Message) error {
|
||||||
|
m.handled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_WithService_IPC(t *testing.T) {
|
||||||
|
svc := &MockServiceWithIPC{MockService: MockService{Name: "ipc-service"}}
|
||||||
|
factory := func(c *Core) (any, error) {
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
c, err := New(WithService(factory))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Trigger ACTION to verify handler was registered
|
||||||
|
err = c.ACTION(nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, svc.handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_ACTION_Bad(t *testing.T) {
|
||||||
|
c, err := New()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
errHandler := func(c *Core, msg Message) error {
|
||||||
|
return assert.AnError
|
||||||
|
}
|
||||||
|
c.RegisterAction(errHandler)
|
||||||
|
err = c.ACTION(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), assert.AnError.Error())
|
||||||
|
}
|
||||||
164
core_lifecycle_test.go
Normal file
164
core_lifecycle_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockStartable struct {
|
||||||
|
started bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStartable) OnStartup(ctx context.Context) error {
|
||||||
|
m.started = true
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockStoppable struct {
|
||||||
|
stopped bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStoppable) OnShutdown(ctx context.Context) error {
|
||||||
|
m.stopped = true
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockLifecycle struct {
|
||||||
|
MockStartable
|
||||||
|
MockStoppable
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_LifecycleInterfaces(t *testing.T) {
|
||||||
|
c, err := New()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
startable := &MockStartable{}
|
||||||
|
stoppable := &MockStoppable{}
|
||||||
|
lifecycle := &MockLifecycle{}
|
||||||
|
|
||||||
|
// Register services
|
||||||
|
err = c.RegisterService("startable", startable)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = c.RegisterService("stoppable", stoppable)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = c.RegisterService("lifecycle", lifecycle)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Startup
|
||||||
|
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, startable.started)
|
||||||
|
assert.True(t, lifecycle.started)
|
||||||
|
assert.False(t, stoppable.stopped)
|
||||||
|
|
||||||
|
// Shutdown
|
||||||
|
err = c.ServiceShutdown(context.Background())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, stoppable.stopped)
|
||||||
|
assert.True(t, lifecycle.stopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockLifecycleWithLog struct {
|
||||||
|
id string
|
||||||
|
log *[]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLifecycleWithLog) OnStartup(ctx context.Context) error {
|
||||||
|
*m.log = append(*m.log, "start-"+m.id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLifecycleWithLog) OnShutdown(ctx context.Context) error {
|
||||||
|
*m.log = append(*m.log, "stop-"+m.id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_LifecycleOrder(t *testing.T) {
|
||||||
|
c, err := New()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var callOrder []string
|
||||||
|
|
||||||
|
s1 := &MockLifecycleWithLog{id: "1", log: &callOrder}
|
||||||
|
s2 := &MockLifecycleWithLog{id: "2", log: &callOrder}
|
||||||
|
|
||||||
|
err = c.RegisterService("s1", s1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = c.RegisterService("s2", s2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Startup
|
||||||
|
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"start-1", "start-2"}, callOrder)
|
||||||
|
|
||||||
|
// Reset log
|
||||||
|
callOrder = nil
|
||||||
|
|
||||||
|
// Shutdown
|
||||||
|
err = c.ServiceShutdown(context.Background())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"stop-2", "stop-1"}, callOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_LifecycleErrors(t *testing.T) {
|
||||||
|
c, err := New()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
s1 := &MockStartable{err: assert.AnError}
|
||||||
|
s2 := &MockStoppable{err: assert.AnError}
|
||||||
|
|
||||||
|
c.RegisterService("s1", s1)
|
||||||
|
c.RegisterService("s2", s2)
|
||||||
|
|
||||||
|
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, assert.AnError)
|
||||||
|
|
||||||
|
err = c.ServiceShutdown(context.Background())
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, assert.AnError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCore_LifecycleErrors_Aggregated(t *testing.T) {
|
||||||
|
c, err := New()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Register action that fails
|
||||||
|
c.RegisterAction(func(c *Core, msg Message) error {
|
||||||
|
if _, ok := msg.(ActionServiceStartup); ok {
|
||||||
|
return errors.New("startup action error")
|
||||||
|
}
|
||||||
|
if _, ok := msg.(ActionServiceShutdown); ok {
|
||||||
|
return errors.New("shutdown action error")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register service that fails
|
||||||
|
s1 := &MockStartable{err: errors.New("startup service error")}
|
||||||
|
s2 := &MockStoppable{err: errors.New("shutdown service error")}
|
||||||
|
|
||||||
|
err = c.RegisterService("s1", s1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = c.RegisterService("s2", s2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Startup
|
||||||
|
err = c.ServiceStartup(context.Background(), application.ServiceOptions{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "startup action error")
|
||||||
|
assert.Contains(t, err.Error(), "startup service error")
|
||||||
|
|
||||||
|
// Shutdown
|
||||||
|
err = c.ServiceShutdown(context.Background())
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "shutdown action error")
|
||||||
|
assert.Contains(t, err.Error(), "shutdown service error")
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -46,6 +47,16 @@ type Option func(*Core) error
|
||||||
// Any struct can be a message, allowing for structured data to be passed between services.
|
// Any struct can be a message, allowing for structured data to be passed between services.
|
||||||
type Message interface{}
|
type Message interface{}
|
||||||
|
|
||||||
|
// Startable is an interface for services that need to perform initialization.
|
||||||
|
type Startable interface {
|
||||||
|
OnStartup(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stoppable is an interface for services that need to perform cleanup.
|
||||||
|
type Stoppable interface {
|
||||||
|
OnShutdown(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
// Core is the central application object that manages services, assets, and communication.
|
// Core is the central application object that manages services, assets, and communication.
|
||||||
type Core struct {
|
type Core struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
|
|
@ -59,6 +70,8 @@ type Core struct {
|
||||||
serviceMu sync.RWMutex
|
serviceMu sync.RWMutex
|
||||||
services map[string]any
|
services map[string]any
|
||||||
servicesLocked bool
|
servicesLocked bool
|
||||||
|
startables []Startable
|
||||||
|
stoppables []Stoppable
|
||||||
}
|
}
|
||||||
|
|
||||||
var instance *Core
|
var instance *Core
|
||||||
|
|
|
||||||
18
runtime_pkg_extra_test.go
Normal file
18
runtime_pkg_extra_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithFactories_EmptyName(t *testing.T) {
|
||||||
|
factories := map[string]ServiceFactory{
|
||||||
|
"": func() (any, error) {
|
||||||
|
return &MockService{Name: "test"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := NewWithFactories(nil, factories)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "service name cannot be empty")
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue