feat: lifecycle context — Core.Context() for cooperative shutdown

- Core holds context.Context + CancelFunc
- New() creates background context
- ServiceStartup creates fresh context from caller's ctx (restart safe)
- ServiceShutdown cancels context before draining tasks
- c.Context() accessor lets task handlers check Done() for graceful exit

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-20 17:59:43 +00:00
parent 629adb056b
commit bde8d4c7cc
3 changed files with 7 additions and 2 deletions

View file

@ -86,6 +86,7 @@ func New(opts ...Options) *Core {
services: &serviceRegistry{services: make(map[string]*Service)},
commands: &commandRegistry{commands: make(map[string]*Command)},
}
c.ctx, c.cancel = context.WithCancel(context.Background())
if len(opts) > 0 {
cp := make(Options, len(opts[0]))

View file

@ -6,6 +6,7 @@
package core
import (
"context"
"sync"
"sync/atomic"
)
@ -29,6 +30,8 @@ type Core struct {
ipc *Ipc // c.IPC() — Message bus for IPC
i18n *I18n // c.I18n() — Internationalisation and locale collection
ctx context.Context
cancel context.CancelFunc
taskIDCounter atomic.Uint64
wg sync.WaitGroup
shutdown atomic.Bool
@ -48,6 +51,7 @@ func (c *Core) Log() *ErrorLog { return c.log }
func (c *Core) Cli() *Cli { return c.cli }
func (c *Core) IPC() *Ipc { return c.ipc }
func (c *Core) I18n() *I18n { return c.i18n }
func (c *Core) Context() context.Context { return c.ctx }
func (c *Core) Core() *Core { return c }
// --- IPC (uppercase aliases) ---

View file

@ -34,6 +34,7 @@ func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() }
// ServiceStartup runs OnStart for all registered services that have one.
func (c *Core) ServiceStartup(ctx context.Context, options any) Result {
c.shutdown.Store(false)
c.ctx, c.cancel = context.WithCancel(ctx)
startables := c.Startables()
if startables.OK {
for _, s := range startables.Value.([]*Service) {
@ -53,11 +54,10 @@ func (c *Core) ServiceStartup(ctx context.Context, options any) Result {
// ServiceShutdown drains background tasks, then stops all registered services.
func (c *Core) ServiceShutdown(ctx context.Context) Result {
c.shutdown.Store(true)
c.cancel() // signal all context-aware tasks to stop
c.ACTION(ActionServiceShutdown{})
// Drain background tasks before stopping services.
// On timeout, the waiter goroutine persists until tasks complete —
// this is inherent to sync.WaitGroup (no cancel mechanism).
done := make(chan struct{})
go func() {
c.wg.Wait()