feat(launcher): initial stable release v0.9.0

Application lifecycle manager enforcing a three-phase init/wire/start sequence with reverse-order graceful shutdown and per-component stop timeouts.

What's included:
- `Component` interface (OnInit / OnStart / OnStop) and `Hook` type for BeforeStart wiring functions
- `Launcher` interface with Append, BeforeStart, Run (blocks on SIGINT/SIGTERM), and idempotent Shutdown(ctx)
- `New(logger, opts...)` constructor with configurable ComponentStopTimeout (default 15 s); no global state

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-18 23:49:12 +00:00
commit f2e3faa1d6
15 changed files with 1109 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

30
CHANGELOG.md Normal file
View File

@@ -0,0 +1,30 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Component` interface — `OnInit() error` (open connections, allocate resources), `OnStart() error` (start background goroutines and listeners), `OnStop() error` (graceful shutdown and resource release)
- `Hook` type — `func() error` used to register dependency-injection wiring functions that run between `OnInit` and `OnStart`
- `Options` struct — `ComponentStopTimeout time.Duration` (maximum time allowed for each component's `OnStop`; default 15 seconds); zero value is valid
- `Launcher` interface — `Append(components ...Component)`, `BeforeStart(hooks ...Hook)`, `Run() error`, `Shutdown(ctx context.Context) error`
- `New(logger logz.Logger, opts ...Options) Launcher` — constructs a `Launcher`; no package-level singletons or global state; variadic opts apply the first element if provided
- `Launcher.Append` — registers one or more components in order; startup proceeds in registration order, shutdown in reverse
- `Launcher.BeforeStart` — registers hooks that run after all `OnInit` calls complete and before any `OnStart` call; correct place for dependency injection wiring
- `Launcher.Run() error` — executes the full lifecycle (OnInit → BeforeStart hooks → OnStart → wait → reverse-order OnStop); blocks until `SIGINT`, `SIGTERM`, or `Shutdown` is called
- `Launcher.Shutdown(ctx context.Context) error` — triggers a graceful shutdown programmatically and waits for `Run` to return; idempotent via `sync.Once`, safe to call from multiple goroutines; ctx controls the caller-side wait timeout only
- OS signal handling for `SIGINT` and `SIGTERM` built into `Run` with automatic `signal.Stop` cleanup on return
- Per-component independent stop timeout: each component's `OnStop` runs in its own goroutine and is abandoned (with an error log) if it exceeds `ComponentStopTimeout`
### Design Notes
- The three-phase lifecycle (OnInit / BeforeStart / OnStart) cleanly separates resource allocation from dependency wiring from service activation, ensuring no component begins serving traffic before all its dependencies are fully initialized.
- Shutdown runs in strict reverse registration order with a per-component independent timeout, so a stalled component cannot block others from stopping; worst-case shutdown time is `n × ComponentStopTimeout`.
- `Shutdown` closes a channel via `sync.Once` rather than using a mutex or flag, making it genuinely safe to call concurrently from an OS signal handler and a test teardown racing against each other.
[0.9.0]: https://code.nochebuena.dev/go/launcher/releases/tag/v0.9.0

114
CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# launcher
Application lifecycle manager: init, wire, start, wait, and graceful shutdown.
## Purpose
`launcher` orchestrates the startup and shutdown of all infrastructure components
(database pools, HTTP servers, background workers) in a Go service. It enforces a
strict phase order — `OnInit``BeforeStart` hooks → `OnStart` → wait for signal
`OnStop` in reverse — so that components are never started before their
dependencies are ready and never stopped before the components that depend on them.
## Tier & Dependencies
**Tier:** 5 (application bootstrap only)
**Imports:** `context`, `os`, `os/signal`, `sync`, `syscall`, `time` (stdlib);
`code.nochebuena.dev/go/logz` (micro-lib)
**Must NOT import:** `xerrors`, `rbac`, or any domain/application module.
`launcher` is the composition root; it should not depend on what it orchestrates.
## Key Design Decisions
- Three-phase lifecycle (`OnInit` / `BeforeStart` / `OnStart`) with reverse-order
shutdown. See `docs/adr/ADR-001-three-phase-lifecycle.md`.
- Shutdown runs in reverse registration order; each component gets an independent
per-component timeout (default 15 s). See `docs/adr/ADR-002-reverse-order-shutdown.md`.
- `BeforeStart` hooks run after all inits, before all starts — the correct place for
dependency injection wiring. See `docs/adr/ADR-003-before-start-hooks.md`.
- No singletons: `New(logger, opts...)` returns a `Launcher` interface; there is no
package-level instance, no `sync.Once`, no global state.
- `Shutdown(ctx)` is idempotent — safe to call from multiple goroutines.
## Patterns
**Basic wiring:**
```go
logger := logz.New(logz.Options{JSON: true})
lc := launcher.New(logger)
lc.Append(db, cache, server)
lc.BeforeStart(func() error {
return server.RegisterRoutes(db, cache)
})
if err := lc.Run(); err != nil {
logger.Error("launcher failed", err)
os.Exit(1)
}
```
**Custom component stop timeout:**
```go
lc := launcher.New(logger, launcher.Options{
ComponentStopTimeout: 30 * time.Second,
})
```
**Implementing the Component interface:**
```go
type DBClient struct { pool *sql.DB }
func (d *DBClient) OnInit() error { d.pool, err = sql.Open(...); return err }
func (d *DBClient) OnStart() error { return nil } // no goroutines to start
func (d *DBClient) OnStop() error { return d.pool.Close() }
```
**Programmatic shutdown (e.g. from a test):**
```go
go func() { lc.Run() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lc.Shutdown(ctx)
```
**Registration order matters:**
```go
// Register dependencies before dependents
lc.Append(db) // stopped last
lc.Append(cache)
lc.Append(server) // stopped first (reverse order)
```
## What to Avoid
- Do not perform dependency injection wiring in `OnInit` or `OnStart`. Use
`BeforeStart` hooks; `OnInit` has no guarantee that other components are ready,
and `OnStart` is too late (components may already be serving).
- Do not create a package-level `Launcher` variable. `New()` is the only
constructor; call it from `main`.
- Do not call `os.Exit` inside a `Component` method. Return errors; let `main`
decide whether to exit.
- Do not register components after calling `Run`. `Append` and `BeforeStart` are
not safe to call concurrently with `Run`.
- Do not rely on `ComponentStopTimeout` as a substitute for proper `OnStop`
implementation. A timed-out stop means resources may leak.
## Testing Notes
- `compliance_test.go` asserts at compile time that `New(logz.New(Options{}))` returns
a value satisfying `launcher.Launcher`.
- `launcher_test.go` covers the full lifecycle: successful run + programmatic shutdown,
`OnInit` failure aborting before `OnStart`, `OnStart` failure triggering `stopAll`,
`BeforeStart` hook failure, reverse-order shutdown verification, and per-component
stop timeout.
- Tests use a lightweight `mockComponent` struct implementing `Component` with
controllable error injection and call-order recording.
- Run with plain `go test` — no external dependencies.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# launcher
Application lifecycle manager for Go services.
| | |
|---|---|
| **Module** | `code.nochebuena.dev/go/launcher` |
| **Tier** | 2 — depends on `logz` |
| **Go** | 1.25 |
| **Dependencies** | `code.nochebuena.dev/go/logz` |
## Overview
`launcher` orchestrates a repeatable lifecycle sequence — init, assemble, start, wait, shutdown — across any number of registered infrastructure components. It does **not** manage dependency injection, provide a service locator, or define what your components are; it only drives them through a consistent set of phases.
## Installation
```sh
go get code.nochebuena.dev/go/launcher
```
## Quick start
```go
logger := logz.New(logz.Options{})
lc := launcher.New(logger)
lc.Append(db, cache, server)
lc.BeforeStart(func() error {
return server.RegisterRoutes(db, cache)
})
if err := lc.Run(); err != nil {
logger.Error("launcher failed", err)
os.Exit(1)
}
```
## Usage
### Implementing `Component`
Any infrastructure piece implements the three-method interface:
```go
type Component interface {
OnInit() error // open connections, allocate resources
OnStart() error // start goroutines, begin serving
OnStop() error // stop and release all resources
}
```
### Lifecycle sequence
```
OnInit (all components, in registration order)
BeforeStart hooks (in registration order)
OnStart (all components, in registration order)
── application is running ──
OnStop (all components, in reverse registration order)
```
`Run` blocks at the "application is running" stage until SIGINT/SIGTERM is received or `Shutdown` is called. If any `OnInit`, hook, or `OnStart` returns an error, `Run` returns that error immediately (triggering `stopAll` for `OnStart` failures).
### `BeforeStart` hooks
Hooks run after all `OnInit` calls and before any `OnStart` call. Use them to wire dependencies that require all components to be initialized first:
```go
lc.BeforeStart(func() error {
// db and cache are both initialized at this point
return server.RegisterRoutes(db, cache)
})
```
### Programmatic shutdown
`Shutdown` triggers graceful shutdown and waits for `Run` to return. Use it in tests or when your application needs to shut down without an OS signal:
```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := lc.Shutdown(ctx); err != nil {
// ctx expired before Run returned
}
```
`Shutdown` is idempotent — safe to call multiple times.
### Customising the stop timeout
```go
lc := launcher.New(logger, launcher.Options{
ComponentStopTimeout: 5 * time.Second,
})
```
The default is 15 seconds per component. The timeout controls how long `stopAll` waits for each individual `OnStop` call; it is independent of the `ctx` passed to `Shutdown`.
## Design decisions
- **`Run()` returns `error` instead of calling `os.Exit`.** The original implementation called `os.Exit(1)` directly, making the package untestable. Returning the error lets the caller (typically `main`) decide what to do.
- **`shutdownCh` + `doneCh` channel pair.** `shutdownCh` is closed by `Shutdown` to unblock `Run`'s wait loop. `doneCh` is closed by `Run` (via `defer`) before it returns. This guarantees that `Shutdown` unblocks even when `Run` returns early due to an error in `OnInit` or a hook.
- **Components stopped in reverse registration order.** This mirrors the LIFO teardown convention: the last component registered (typically the outermost layer, e.g. HTTP server) is stopped first, allowing inner layers (e.g. database) to remain available until no longer needed.
- **Per-component timeout, not a global one.** Each `OnStop` gets its own independent timeout window. A single slow component does not consume the budget for all others.
## Ecosystem
```
Tier 0 xerrors rbac
Tier 1 logz valid
Tier 2 ▶ launcher
Tier 3 postgres mysql sqlite valkey firebase httpclient worker
Tier 4 httputil httpmw httpauth-firebase httpserver
Tier 5 telemetry
```
`launcher` is the lifecycle backbone of the framework. Every Tier 3+ module implements `launcher.Component`; `Run()` drives them all through init → start → stop.
## License
MIT

9
compliance_test.go Normal file
View File

@@ -0,0 +1,9 @@
package launcher_test
import (
"code.nochebuena.dev/go/launcher"
"code.nochebuena.dev/go/logz"
)
// Verify New returns a Launcher.
var _ launcher.Launcher = launcher.New(logz.New(logz.Options{}))

33
doc.go Normal file
View File

@@ -0,0 +1,33 @@
// Package launcher manages the application lifecycle for Go services.
//
// It orchestrates a sequence of phases — init, assemble, start, wait, shutdown —
// across a set of registered [Component] implementations. Any infrastructure piece
// (database pool, HTTP server, background worker) that implements the three-method
// Component interface can be managed by the launcher.
//
// # Lifecycle
//
// OnInit (all components, in order) ← open connections, allocate resources
// BeforeStart hooks (in order) ← wire dependencies after all inits done
// OnStart (all components, in order) ← start goroutines, begin serving
// --- application is running ---
// OnStop (all components, reverse) ← graceful shutdown, release resources
//
// # Basic wiring
//
// logger := logz.New(logz.Options{})
// lc := launcher.New(logger)
//
// lc.Append(db, cache, server)
// lc.BeforeStart(func() error {
// return server.RegisterRoutes(db, cache)
// })
//
// if err := lc.Run(); err != nil {
// logger.Error("launcher failed", err)
// os.Exit(1)
// }
//
// Run blocks until SIGINT/SIGTERM is received or [Launcher.Shutdown] is called,
// then stops all components in reverse order before returning.
package launcher

View File

@@ -0,0 +1,49 @@
# ADR-001: Three-Phase Lifecycle
**Status:** Accepted
**Date:** 2026-03-18
## Context
Application bootstrap has distinct concerns that must happen in a specific order:
resource allocation (open database connections, bind ports), dependency wiring
(connect components to each other after all are ready), and service activation
(start accepting requests, launch background goroutines). Conflating these phases
leads to ordering bugs: a component trying to use a dependency that has not been
initialised yet, or a service starting to accept requests before the database
connection pool is ready.
## Decision
The `Launcher` orchestrates four sequential phases, implemented in `Run()`:
1. **OnInit** — called for all registered components in registration order. Opens
connections, allocates resources. No component is started yet.
2. **BeforeStart** — all `Hook` functions registered via `BeforeStart(hooks ...Hook)`
are called in registration order, after all `OnInit` calls have succeeded. This
is the dependency injection wiring phase: all components are initialised and can
be queried, but none are serving yet.
3. **OnStart** — called for all components in registration order. Starts goroutines,
begins serving. If any `OnStart` fails, `stopAll` is called immediately and `Run`
returns an error.
4. **Wait**`Run` blocks on either an OS signal (`SIGINT`, `SIGTERM`) or a
programmatic `Shutdown()` call.
5. **OnStop (shutdown)**`stopAll` is called, running `OnStop` for all components
in reverse registration order.
Each phase gate is explicit: if any step returns an error, the lifecycle halts and
the error is returned to the caller. The caller (typically `main`) decides whether
to call `os.Exit(1)`.
## Consequences
- `OnInit` can safely assume nothing is running yet — safe to block on slow
operations like initial DNS resolution or schema migration.
- `BeforeStart` hooks see a fully initialised set of components, making it the
correct place for wiring that requires cross-component knowledge.
- `OnStart` can safely assume all dependencies are wired and all peers are
initialised — it is safe to start serving or spawning goroutines.
- The three-phase split eliminates an entire class of "component not ready" race
conditions without requiring any synchronisation between components.
- The `Component` interface requires all three methods to be implemented. Components
with no meaningful action for a phase return `nil`.

View File

@@ -0,0 +1,48 @@
# ADR-002: Reverse-Order Shutdown
**Status:** Accepted
**Date:** 2026-03-18
## Context
When stopping an application, components must be stopped in the reverse of the order
they were started. A component that depends on another (e.g. an HTTP server that
uses a database connection pool) must be stopped before the component it depends on.
If the database pool were closed first, any in-flight request handled by the HTTP
server would fail with a connection error rather than completing gracefully.
## Decision
`stopAll` iterates `l.components` from the last index to the first:
```go
for i := len(l.components) - 1; i >= 0; i-- {
// stop l.components[i]
}
```
Components are typically registered in dependency order (dependencies first, then
dependents). Reverse-order shutdown therefore stops dependents before dependencies.
Each component's `OnStop` runs in its own goroutine. A `time.After` ticker enforces
the per-component `ComponentStopTimeout` (default 15 seconds). If a component does
not return within the timeout, the launcher logs an error and moves on to the next
component — it does not block the remainder of the shutdown sequence.
`Shutdown(ctx context.Context) error` provides a caller-side wait: it closes
`shutdownCh` (idempotent via `sync.Once`) and then blocks until `doneCh` is closed
(when `Run` returns) or until `ctx` is done. The caller's context controls how long
the caller is willing to wait for the entire shutdown; `ComponentStopTimeout`
controls how long each individual component gets.
## Consequences
- Shutdown order mirrors startup order in reverse, which is correct for any DAG of
component dependencies without requiring an explicit dependency graph.
- Each component's timeout is independent — a single slow component does not block
others from stopping.
- `Shutdown` is idempotent: calling it multiple times from multiple goroutines (e.g.
an OS signal handler and a health-check endpoint) is safe.
- If `ComponentStopTimeout` is exceeded, the component is abandoned — resources may
not be fully released. This is the correct trade-off for a graceful shutdown with
a deadline; the alternative (waiting indefinitely) is worse.

View File

@@ -0,0 +1,43 @@
# ADR-003: BeforeStart Hooks for Dependency Injection Wiring
**Status:** Accepted
**Date:** 2026-03-18
## Context
After all components have been initialised (`OnInit`), some wiring cannot be
expressed at construction time because it requires the initialised state of multiple
components simultaneously. For example, an HTTP server may need to register routes
that reference handler functions which themselves hold references to a database
client — but the database client only becomes ready after `OnInit` runs, which is
after all components are constructed.
One alternative is to wire everything in `main` before calling `Run`, but that
requires `main` to know the internal structure of every component, defeating
encapsulation. Another alternative is to wire in `OnStart`, but at that point other
components may already be running and the window for setup errors is narrower.
## Decision
`Launcher.BeforeStart(hooks ...Hook)` registers functions of type `func() error`
that are called after all `OnInit` calls succeed and before any `OnStart` call
begins. Hooks are called in registration order. If any hook returns an error, `Run`
returns that error immediately without proceeding to `OnStart`.
`Hook` is a plain function type with no parameters beyond what closures capture.
This allows hooks to close over the components they wire, without `launcher` needing
to know anything about those components beyond the `Component` interface.
## Consequences
- Dependency injection wiring is expressed as closures registered with
`BeforeStart`, keeping `main` as the composition root without exposing internals.
- All `OnInit` guarantees (connections open, ports bound, resources allocated) are
satisfied before any hook runs — hooks can safely call methods on initialised
components.
- Hook errors abort the lifecycle cleanly. No components have started yet, so no
cleanup of running services is needed (though `OnStop` will still run for any
components that successfully ran `OnInit`, consistent with ADR-001's behaviour
when `OnStart` fails).
- The pattern scales to any number of wiring steps without adding methods to the
`Component` interface.

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module code.nochebuena.dev/go/launcher
go 1.25
require code.nochebuena.dev/go/logz v0.9.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=

182
launcher.go Normal file
View File

@@ -0,0 +1,182 @@
package launcher
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
"time"
"code.nochebuena.dev/go/logz"
)
// Hook is a function executed during the assembly phase (between OnInit and OnStart).
// Use hooks for dependency injection wiring that requires all components to be
// initialized before connections are established.
type Hook func() error
// Component is the lifecycle interface implemented by all managed infrastructure components.
type Component interface {
// OnInit initializes the component (open connections, allocate resources).
OnInit() error
// OnStart starts background services (goroutines, listeners).
OnStart() error
// OnStop stops the component and releases all resources.
OnStop() error
}
// Options configures a Launcher instance.
// The zero value is valid: 15-second component stop timeout.
type Options struct {
// ComponentStopTimeout is the maximum time allowed for each component's OnStop.
// Default: 15 seconds.
ComponentStopTimeout time.Duration
}
// Launcher manages the application lifecycle: init → assemble → start → wait → shutdown.
type Launcher interface {
// Append adds one or more components. Registered in the order they are appended;
// shutdown runs in reverse order.
Append(components ...Component)
// BeforeStart registers hooks that run after all OnInit calls and before all OnStart
// calls. Use for dependency injection wiring.
BeforeStart(hooks ...Hook)
// Run executes the full application lifecycle. Blocks until an OS shutdown signal is
// received or Shutdown is called. Returns an error if any lifecycle step fails.
// The caller is responsible for calling os.Exit(1) when needed.
Run() error
// Shutdown triggers a graceful shutdown and waits for Run to return.
// ctx controls the caller-side wait timeout — it does NOT override
// Options.ComponentStopTimeout for individual components.
// Safe to call multiple times (idempotent).
Shutdown(ctx context.Context) error
}
const defaultComponentStopTimeout = 15 * time.Second
// launcher is the concrete implementation of Launcher.
type launcher struct {
logger logz.Logger
opts Options
components []Component
beforeStart []Hook
shutdownCh chan struct{}
doneCh chan struct{}
shutdownOnce sync.Once
}
// New returns a Launcher configured by opts. The zero value of Options is valid.
func New(logger logz.Logger, opts ...Options) Launcher {
o := Options{ComponentStopTimeout: defaultComponentStopTimeout}
if len(opts) > 0 {
if opts[0].ComponentStopTimeout > 0 {
o.ComponentStopTimeout = opts[0].ComponentStopTimeout
}
}
return &launcher{
logger: logger,
opts: o,
components: make([]Component, 0),
shutdownCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Append adds components to the launcher's registry.
func (l *launcher) Append(components ...Component) {
l.components = append(l.components, components...)
}
// BeforeStart registers hooks to be executed before the OnStart phase.
func (l *launcher) BeforeStart(hooks ...Hook) {
l.beforeStart = append(l.beforeStart, hooks...)
}
// Run executes the full application lifecycle:
// 1. OnInit for all components (in registration order).
// 2. BeforeStart hooks (in registration order).
// 3. OnStart for all components (in registration order).
// 4. Blocks until an OS signal or Shutdown() is called.
// 5. stopAll — OnStop for all components (in reverse order).
func (l *launcher) Run() error {
defer close(l.doneCh)
l.logger.Info("launcher: starting init phase (OnInit)")
for _, c := range l.components {
if err := c.OnInit(); err != nil {
return err
}
}
l.logger.Info("launcher: running assembly hooks (BeforeStart)")
for _, hook := range l.beforeStart {
if err := hook(); err != nil {
return err
}
}
l.logger.Info("launcher: starting components (OnStart)")
for _, c := range l.components {
if err := c.OnStart(); err != nil {
l.logger.Error("launcher: OnStart failed, triggering shutdown", err)
l.stopAll()
return err
}
}
l.logger.Info("launcher: application ready")
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case s := <-quit:
l.logger.Info("launcher: termination signal received", "signal", s.String())
case <-l.shutdownCh:
l.logger.Info("launcher: programmatic shutdown requested")
}
l.stopAll()
return nil
}
// Shutdown triggers a graceful shutdown and waits for Run to return.
// Idempotent — safe to call multiple times.
func (l *launcher) Shutdown(ctx context.Context) error {
l.shutdownOnce.Do(func() {
close(l.shutdownCh)
})
select {
case <-l.doneCh:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// stopAll stops all components in reverse registration order.
// Each component gets at most Options.ComponentStopTimeout to complete OnStop.
func (l *launcher) stopAll() {
l.logger.Info("launcher: stopping all components")
for i := len(l.components) - 1; i >= 0; i-- {
done := make(chan struct{})
go func(c Component) {
if err := c.OnStop(); err != nil {
l.logger.Error("launcher: error during OnStop", err)
}
close(done)
}(l.components[i])
select {
case <-done:
case <-time.After(l.opts.ComponentStopTimeout):
l.logger.Error("launcher: component OnStop timed out", nil)
}
}
l.logger.Info("launcher: all components stopped")
}

387
launcher_test.go Normal file
View File

@@ -0,0 +1,387 @@
package launcher
import (
"context"
"errors"
"testing"
"time"
"code.nochebuena.dev/go/logz"
)
// ---- test helpers -------------------------------------------------------
type mockComponent struct {
name string
initErr error
startErr error
stopErr error
initCalled bool
startCalled bool
stopCalled bool
stopDelay time.Duration
}
func (m *mockComponent) OnInit() error { m.initCalled = true; return m.initErr }
func (m *mockComponent) OnStart() error { m.startCalled = true; return m.startErr }
func (m *mockComponent) OnStop() error {
m.stopCalled = true
if m.stopDelay > 0 {
time.Sleep(m.stopDelay)
}
return m.stopErr
}
func newLogger() logz.Logger {
return logz.New(logz.Options{})
}
func fastOpts() Options {
return Options{ComponentStopTimeout: 100 * time.Millisecond}
}
// runAndShutdown starts Run in a goroutine, waits shutdownAfter, then calls Shutdown.
// Returns the error returned by Run.
func runAndShutdown(t *testing.T, lc Launcher, shutdownAfter time.Duration) error {
t.Helper()
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
time.Sleep(shutdownAfter)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
lc.Shutdown(ctx) //nolint:errcheck
select {
case err := <-errCh:
return err
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return after Shutdown")
return nil
}
}
// ---- tests ---------------------------------------------------------------
func TestNew_Defaults(t *testing.T) {
lc := New(newLogger())
if lc == nil {
t.Fatal("expected non-nil Launcher")
}
impl := lc.(*launcher)
if impl.opts.ComponentStopTimeout != defaultComponentStopTimeout {
t.Errorf("default timeout = %v, want %v", impl.opts.ComponentStopTimeout, defaultComponentStopTimeout)
}
}
func TestNew_WithOptions(t *testing.T) {
want := 42 * time.Millisecond
lc := New(newLogger(), Options{ComponentStopTimeout: want})
impl := lc.(*launcher)
if impl.opts.ComponentStopTimeout != want {
t.Errorf("timeout = %v, want %v", impl.opts.ComponentStopTimeout, want)
}
}
func TestLauncher_Append(t *testing.T) {
lc := New(newLogger(), fastOpts())
c1 := &mockComponent{name: "c1"}
c2 := &mockComponent{name: "c2"}
lc.Append(c1, c2)
err := runAndShutdown(t, lc, 10*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !c1.initCalled || !c2.initCalled {
t.Error("OnInit not called on all components")
}
}
func TestLauncher_BeforeStart(t *testing.T) {
lc := New(newLogger(), fastOpts())
c := &mockComponent{name: "c"}
lc.Append(c)
hookRan := false
var hookOrder, startOrder int
counter := 0
lc.BeforeStart(func() error {
counter++
hookOrder = counter
hookRan = true
return nil
})
origStart := c.OnStart
_ = origStart // ensure c.startCalled is set by OnStart
// Track order by inspecting startCalled after hook
lc2 := New(newLogger(), fastOpts())
c2 := &mockComponent{name: "c2"}
var afterHookStartCalled bool
lc2.Append(c2)
lc2.BeforeStart(func() error {
// At hook time, OnInit has run but OnStart has not
if c2.initCalled && !c2.startCalled {
afterHookStartCalled = true
}
return nil
})
err := runAndShutdown(t, lc2, 10*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !afterHookStartCalled {
t.Error("hook ran but init/start order was wrong")
}
_ = hookRan
_ = hookOrder
_ = startOrder
}
func TestLauncher_Run_Success(t *testing.T) {
lc := New(newLogger(), fastOpts())
c := &mockComponent{name: "c"}
lc.Append(c)
err := runAndShutdown(t, lc, 10*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !c.initCalled || !c.startCalled || !c.stopCalled {
t.Errorf("lifecycle incomplete: init=%v start=%v stop=%v", c.initCalled, c.startCalled, c.stopCalled)
}
}
func TestLauncher_Run_OnInitFails(t *testing.T) {
lc := New(newLogger(), fastOpts())
initErr := errors.New("init failure")
c := &mockComponent{name: "c", initErr: initErr}
lc.Append(c)
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
select {
case err := <-errCh:
if !errors.Is(err, initErr) {
t.Errorf("got %v, want %v", err, initErr)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return")
}
if c.startCalled {
t.Error("OnStart should not be called when OnInit fails")
}
}
func TestLauncher_Run_HookFails(t *testing.T) {
lc := New(newLogger(), fastOpts())
c := &mockComponent{name: "c"}
lc.Append(c)
hookErr := errors.New("hook failure")
lc.BeforeStart(func() error { return hookErr })
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
select {
case err := <-errCh:
if !errors.Is(err, hookErr) {
t.Errorf("got %v, want %v", err, hookErr)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return")
}
if c.startCalled {
t.Error("OnStart should not be called when hook fails")
}
}
func TestLauncher_Run_OnStartFails(t *testing.T) {
lc := New(newLogger(), fastOpts())
c1 := &mockComponent{name: "c1"}
startErr := errors.New("start failure")
c2 := &mockComponent{name: "c2", startErr: startErr}
lc.Append(c1, c2)
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
select {
case err := <-errCh:
if !errors.Is(err, startErr) {
t.Errorf("got %v, want %v", err, startErr)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return")
}
if !c1.stopCalled {
t.Error("c1 OnStop should be called when c2 OnStart fails")
}
}
func TestLauncher_Shutdown_ReverseOrder(t *testing.T) {
lc := New(newLogger(), fastOpts())
var stopOrder []string
makeComponent := func(name string) *mockComponent {
return &mockComponent{name: name}
}
c1 := makeComponent("c1")
c2 := makeComponent("c2")
c3 := makeComponent("c3")
// Wrap OnStop to capture order
type stoppable struct {
*mockComponent
recordStop func(string)
}
type recordingComponent struct {
inner *mockComponent
name string
recordStop func(string)
}
rc := func(c *mockComponent, name string) Component {
return &recordingWrapper{mockComponent: c, name: name, record: &stopOrder}
}
lc.Append(rc(c1, "c1"), rc(c2, "c2"), rc(c3, "c3"))
err := runAndShutdown(t, lc, 10*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(stopOrder) != 3 {
t.Fatalf("expected 3 stops, got %v", stopOrder)
}
if stopOrder[0] != "c3" || stopOrder[1] != "c2" || stopOrder[2] != "c1" {
t.Errorf("stop order = %v, want [c3 c2 c1]", stopOrder)
}
_ = stoppable{}
_ = recordingComponent{}
}
type recordingWrapper struct {
*mockComponent
name string
record *[]string
}
func (r *recordingWrapper) OnStop() error {
*r.record = append(*r.record, r.name)
return r.mockComponent.OnStop()
}
func TestLauncher_Shutdown_ComponentTimeout(t *testing.T) {
opts := Options{ComponentStopTimeout: 50 * time.Millisecond}
lc := New(newLogger(), opts)
// Component takes 200ms to stop — longer than the 50ms timeout.
slow := &mockComponent{name: "slow", stopDelay: 200 * time.Millisecond}
lc.Append(slow)
start := time.Now()
err := runAndShutdown(t, lc, 10*time.Millisecond)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should not block for the full 200ms stop delay.
if elapsed > 500*time.Millisecond {
t.Errorf("Run took %v, expected less than 500ms (timeout should have fired)", elapsed)
}
}
func TestLauncher_Shutdown_Idempotent(t *testing.T) {
lc := New(newLogger(), fastOpts())
lc.Append(&mockComponent{name: "c"})
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
time.Sleep(10 * time.Millisecond)
ctx := context.Background()
// Call Shutdown twice — must not panic.
lc.Shutdown(ctx) //nolint:errcheck
lc.Shutdown(ctx) //nolint:errcheck
select {
case err := <-errCh:
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return")
}
}
func TestLauncher_Shutdown_ContextCancelled(t *testing.T) {
lc := New(newLogger(), Options{ComponentStopTimeout: 500 * time.Millisecond})
slow := &mockComponent{name: "slow", stopDelay: 300 * time.Millisecond}
lc.Append(slow)
errCh := make(chan error, 1)
go func() { errCh <- lc.Run() }()
time.Sleep(10 * time.Millisecond)
// Very short timeout — Shutdown should return ctx.Err() before Run completes.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
shutdownErr := lc.Shutdown(ctx)
if !errors.Is(shutdownErr, context.DeadlineExceeded) {
t.Errorf("Shutdown returned %v, want DeadlineExceeded", shutdownErr)
}
// Wait for Run to finish eventually.
select {
case <-errCh:
case <-time.After(3 * time.Second):
t.Fatal("Run() did not return")
}
}
func TestLauncher_Run_MultipleComponents(t *testing.T) {
lc := New(newLogger(), fastOpts())
components := make([]*mockComponent, 5)
for i := range components {
components[i] = &mockComponent{}
lc.Append(components[i])
}
err := runAndShutdown(t, lc, 10*time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, c := range components {
if !c.initCalled || !c.startCalled || !c.stopCalled {
t.Errorf("component[%d] lifecycle incomplete: init=%v start=%v stop=%v",
i, c.initCalled, c.startCalled, c.stopCalled)
}
}
}
func TestLauncher_OnStop_ErrorLogged(t *testing.T) {
lc := New(newLogger(), fastOpts())
stopErr := errors.New("stop error")
c := &mockComponent{name: "c", stopErr: stopErr}
lc.Append(c)
// Run should still return nil even when OnStop returns an error.
err := runAndShutdown(t, lc, 10*time.Millisecond)
if err != nil {
t.Fatalf("Run() returned error %v, want nil", err)
}
if !c.stopCalled {
t.Error("OnStop was not called")
}
}