feat: IPC, task, lifecycle all return Result

Action, Query, QueryAll, Perform → Result
QueryHandler, TaskHandler → func returning Result
RegisterAction/RegisterActions → handler returns Result
ServiceStartup, ServiceShutdown → Result
LogError, LogWarn → Result
ACTION, QUERY, QUERYALL, PERFORM aliases → Result

Tests updated to match new signatures.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-20 13:59:45 +00:00
parent f5611b1002
commit 94f2e54abe
9 changed files with 119 additions and 112 deletions

View file

@ -24,11 +24,11 @@ type TaskWithID interface {
GetTaskID() string
}
// QueryHandler handles Query requests. Returns (result, handled, error).
type QueryHandler func(*Core, Query) (any, bool, error)
// QueryHandler handles Query requests. Returns Result{Value, OK}.
type QueryHandler func(*Core, Query) Result
// TaskHandler handles Task requests. Returns (result, handled, error).
type TaskHandler func(*Core, Task) (any, bool, error)
// TaskHandler handles Task requests. Returns Result{Value, OK}.
type TaskHandler func(*Core, Task) Result
// Startable is implemented by services that need startup initialisation.
type Startable interface {

View file

@ -52,21 +52,29 @@ func (c *Core) Core() *Core { return c }
// --- IPC (uppercase aliases) ---
func (c *Core) ACTION(msg Message) error { return c.Action(msg) }
func (c *Core) QUERY(q Query) (any, bool, error) { return c.Query(q) }
func (c *Core) QUERYALL(q Query) ([]any, error) { return c.QueryAll(q) }
func (c *Core) PERFORM(t Task) (any, bool, error) { return c.Perform(t) }
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
func (c *Core) PERFORM(t Task) Result { return c.Perform(t) }
// --- Error+Log ---
// LogError logs an error and returns a wrapped error.
func (c *Core) LogError(err error, op, msg string) error {
return c.log.Error(err, op, msg)
// LogError logs an error and returns a Result with the wrapped error.
func (c *Core) LogError(err error, op, msg string) Result {
wrapped := c.log.Error(err, op, msg)
if wrapped == nil {
return Result{OK: true}
}
return Result{Value: wrapped}
}
// LogWarn logs a warning and returns a wrapped error.
func (c *Core) LogWarn(err error, op, msg string) error {
return c.log.Warn(err, op, msg)
// LogWarn logs a warning and returns a Result with the wrapped error.
func (c *Core) LogWarn(err error, op, msg string) Result {
wrapped := c.log.Warn(err, op, msg)
if wrapped == nil {
return Result{OK: true}
}
return Result{Value: wrapped}
}
// Must logs and panics if err is not nil.

View file

@ -7,7 +7,6 @@
package core
import (
"errors"
"slices"
"sync"
)
@ -15,7 +14,7 @@ import (
// Ipc holds IPC dispatch data.
type Ipc struct {
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) error
ipcHandlers []func(*Core, Message) Result
queryMu sync.RWMutex
queryHandlers []QueryHandler
@ -24,51 +23,46 @@ type Ipc struct {
taskHandlers []TaskHandler
}
func (c *Core) Action(msg Message) error {
func (c *Core) Action(msg Message) Result {
c.ipc.ipcMu.RLock()
handlers := slices.Clone(c.ipc.ipcHandlers)
c.ipc.ipcMu.RUnlock()
var agg error
for _, h := range handlers {
if err := h(c, msg); err != nil {
agg = errors.Join(agg, err)
if r := h(c, msg); !r.OK {
return r
}
}
return agg
return Result{OK: true}
}
func (c *Core) Query(q Query) (any, bool, error) {
func (c *Core) Query(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(c, q)
if handled {
return result, true, err
r := h(c, q)
if r.OK {
return r
}
}
return nil, false, nil
return Result{}
}
func (c *Core) QueryAll(q Query) ([]any, error) {
func (c *Core) QueryAll(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
var results []any
var agg error
for _, h := range handlers {
result, handled, err := h(c, q)
if err != nil {
agg = errors.Join(agg, err)
}
if handled && result != nil {
results = append(results, result)
r := h(c, q)
if r.OK && r.Value != nil {
results = append(results, r.Value)
}
}
return results, agg
return Result{Value: results, OK: true}
}
func (c *Core) RegisterQuery(handler QueryHandler) {

View file

@ -32,29 +32,27 @@ func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() }
// --- Lifecycle ---
// ServiceStartup runs OnStart for all registered services that have one.
func (c *Core) ServiceStartup(ctx context.Context, options any) error {
func (c *Core) ServiceStartup(ctx context.Context, options any) Result {
for _, s := range c.Startables() {
if err := ctx.Err(); err != nil {
return err
return Result{Value: err}
}
r := s.OnStart()
if !r.OK {
if err, ok := r.Value.(error); ok {
return err
}
return r
}
}
_ = c.ACTION(ActionServiceStartup{})
return nil
c.ACTION(ActionServiceStartup{})
return Result{OK: true}
}
// ServiceShutdown runs OnStop for all registered services that have one.
func (c *Core) ServiceShutdown(ctx context.Context) error {
func (c *Core) ServiceShutdown(ctx context.Context) Result {
c.shutdown.Store(true)
_ = c.ACTION(ActionServiceShutdown{})
c.ACTION(ActionServiceShutdown{})
for _, s := range c.Stoppables() {
if err := ctx.Err(); err != nil {
return err
return Result{Value: err}
}
s.OnStop()
}
@ -66,9 +64,9 @@ func (c *Core) ServiceShutdown(ctx context.Context) error {
select {
case <-done:
case <-ctx.Done():
return ctx.Err()
return Result{Value: ctx.Err()}
}
return nil
return Result{OK: true}
}
// --- Runtime DTO (GUI binding) ---
@ -110,12 +108,12 @@ func NewRuntime(app any) Result {
}
func (r *Runtime) ServiceName() string { return "Core" }
func (r *Runtime) ServiceStartup(ctx context.Context, options any) error {
func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result {
return r.Core.ServiceStartup(ctx, options)
}
func (r *Runtime) ServiceShutdown(ctx context.Context) error {
func (r *Runtime) ServiceShutdown(ctx context.Context) Result {
if r.Core != nil {
return r.Core.ServiceShutdown(ctx)
}
return nil
return Result{OK: true}
}

View file

@ -27,43 +27,48 @@ func (c *Core) PerformAsync(t Task) string {
if tid, ok := t.(TaskWithID); ok {
tid.SetTaskID(taskID)
}
_ = c.ACTION(ActionTaskStarted{TaskID: taskID, Task: t})
c.ACTION(ActionTaskStarted{TaskID: taskID, Task: t})
c.wg.Go(func() {
result, handled, err := c.PERFORM(t)
if !handled && err == nil {
err = E("core.PerformAsync", Join(" ", "no handler found for task type", reflect.TypeOf(t).String()), nil)
r := c.PERFORM(t)
var err error
if !r.OK {
if e, ok := r.Value.(error); ok {
err = e
} else {
err = E("core.PerformAsync", Join(" ", "no handler found for task type", reflect.TypeOf(t).String()), nil)
}
}
_ = c.ACTION(ActionTaskCompleted{TaskID: taskID, Task: t, Result: result, Error: err})
c.ACTION(ActionTaskCompleted{TaskID: taskID, Task: t, Result: r.Value, Error: err})
})
return taskID
}
// Progress broadcasts a progress update for a background task.
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
_ = c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message})
c.ACTION(ActionTaskProgress{TaskID: taskID, Task: t, Progress: progress, Message: message})
}
func (c *Core) Perform(t Task) (any, bool, error) {
func (c *Core) Perform(t Task) Result {
c.ipc.taskMu.RLock()
handlers := slices.Clone(c.ipc.taskHandlers)
c.ipc.taskMu.RUnlock()
for _, h := range handlers {
result, handled, err := h(c, t)
if handled {
return result, true, err
r := h(c, t)
if r.OK {
return r
}
}
return nil, false, nil
return Result{}
}
func (c *Core) RegisterAction(handler func(*Core, Message) error) {
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
c.ipc.ipcMu.Unlock()
}
func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) {
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
c.ipc.ipcMu.Unlock()

View file

@ -67,15 +67,19 @@ func TestOptions_Accessor_Nil(t *testing.T) {
func TestCore_LogError_Good(t *testing.T) {
c := New()
cause := assert.AnError
err := c.LogError(cause, "test.Op", "something broke")
assert.Error(t, err)
r := c.LogError(cause, "test.Op", "something broke")
assert.False(t, r.OK)
err, ok := r.Value.(error)
assert.True(t, ok)
assert.ErrorIs(t, err, cause)
}
func TestCore_LogWarn_Good(t *testing.T) {
c := New()
err := c.LogWarn(assert.AnError, "test.Op", "heads up")
assert.Error(t, err)
r := c.LogWarn(assert.AnError, "test.Op", "heads up")
assert.False(t, r.OK)
_, ok := r.Value.(error)
assert.True(t, ok)
}
func TestCore_Must_Ugly(t *testing.T) {

View file

@ -14,67 +14,66 @@ type testMessage struct{ payload string }
func TestAction_Good(t *testing.T) {
c := New()
var received Message
c.RegisterAction(func(_ *Core, msg Message) error {
c.RegisterAction(func(_ *Core, msg Message) Result {
received = msg
return nil
return Result{OK: true}
})
err := c.ACTION(testMessage{payload: "hello"})
assert.NoError(t, err)
r := c.ACTION(testMessage{payload: "hello"})
assert.True(t, r.OK)
assert.Equal(t, testMessage{payload: "hello"}, received)
}
func TestAction_Multiple_Good(t *testing.T) {
c := New()
count := 0
handler := func(_ *Core, _ Message) error { count++; return nil }
handler := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
c.RegisterActions(handler, handler, handler)
_ = c.ACTION(nil)
c.ACTION(nil)
assert.Equal(t, 3, count)
}
func TestAction_None_Good(t *testing.T) {
c := New()
// No handlers registered — should not error
err := c.ACTION(nil)
assert.NoError(t, err)
// No handlers registered — should succeed
r := c.ACTION(nil)
assert.True(t, r.OK)
}
// --- IPC: Queries ---
func TestQuery_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
c.RegisterQuery(func(_ *Core, q Query) Result {
if q == "ping" {
return "pong", true, nil
return Result{Value: "pong", OK: true}
}
return nil, false, nil
return Result{}
})
result, handled, err := c.QUERY("ping")
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "pong", result)
r := c.QUERY("ping")
assert.True(t, r.OK)
assert.Equal(t, "pong", r.Value)
}
func TestQuery_Unhandled_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, q Query) (any, bool, error) {
return nil, false, nil
c.RegisterQuery(func(_ *Core, q Query) Result {
return Result{}
})
_, handled, err := c.QUERY("unknown")
assert.NoError(t, err)
assert.False(t, handled)
r := c.QUERY("unknown")
assert.False(t, r.OK)
}
func TestQueryAll_Good(t *testing.T) {
c := New()
c.RegisterQuery(func(_ *Core, _ Query) (any, bool, error) {
return "a", true, nil
c.RegisterQuery(func(_ *Core, _ Query) Result {
return Result{Value: "a", OK: true}
})
c.RegisterQuery(func(_ *Core, _ Query) (any, bool, error) {
return "b", true, nil
c.RegisterQuery(func(_ *Core, _ Query) Result {
return Result{Value: "b", OK: true}
})
results, err := c.QUERYALL("anything")
assert.NoError(t, err)
r := c.QUERYALL("anything")
assert.True(t, r.OK)
results := r.Value.([]any)
assert.Len(t, results, 2)
assert.Contains(t, results, "a")
assert.Contains(t, results, "b")
@ -84,14 +83,13 @@ func TestQueryAll_Good(t *testing.T) {
func TestPerform_Good(t *testing.T) {
c := New()
c.RegisterTask(func(_ *Core, t Task) (any, bool, error) {
c.RegisterTask(func(_ *Core, t Task) Result {
if t == "compute" {
return 42, true, nil
return Result{Value: 42, OK: true}
}
return nil, false, nil
return Result{}
})
result, handled, err := c.PERFORM("compute")
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, 42, result)
r := c.PERFORM("compute")
assert.True(t, r.OK)
assert.Equal(t, 42, r.Value)
}

View file

@ -70,7 +70,7 @@ func TestRuntime_Lifecycle_Good(t *testing.T) {
assert.True(t, r.OK)
rt := r.Value.(*Runtime)
err := rt.ServiceStartup(context.Background(), nil)
assert.NoError(t, err)
result := rt.ServiceStartup(context.Background(), nil)
assert.True(t, result.OK)
assert.True(t, started)
}

View file

@ -16,11 +16,11 @@ func TestPerformAsync_Good(t *testing.T) {
var mu sync.Mutex
var result string
c.RegisterTask(func(_ *Core, task Task) (any, bool, error) {
c.RegisterTask(func(_ *Core, task Task) Result {
mu.Lock()
result = "done"
mu.Unlock()
return "completed", true, nil
return Result{Value: "completed", OK: true}
})
taskID := c.PerformAsync("work")
@ -35,8 +35,8 @@ func TestPerformAsync_Good(t *testing.T) {
func TestPerformAsync_Progress_Good(t *testing.T) {
c := New()
c.RegisterTask(func(_ *Core, task Task) (any, bool, error) {
return nil, true, nil
c.RegisterTask(func(_ *Core, task Task) Result {
return Result{OK: true}
})
taskID := c.PerformAsync("work")
@ -48,19 +48,19 @@ func TestPerformAsync_Progress_Good(t *testing.T) {
func TestRegisterAction_Good(t *testing.T) {
c := New()
called := false
c.RegisterAction(func(_ *Core, _ Message) error {
c.RegisterAction(func(_ *Core, _ Message) Result {
called = true
return nil
return Result{OK: true}
})
_ = c.Action(nil)
c.Action(nil)
assert.True(t, called)
}
func TestRegisterActions_Good(t *testing.T) {
c := New()
count := 0
h := func(_ *Core, _ Message) error { count++; return nil }
h := func(_ *Core, _ Message) Result { count++; return Result{OK: true} }
c.RegisterActions(h, h)
_ = c.Action(nil)
c.Action(nil)
assert.Equal(t, 2, count)
}