commit f2e3faa1d645cb53de4edc36d2a6fd87bdab878b Author: Rene Nochebuena Date: Wed Mar 18 23:49:12 2026 +0000 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c95d55f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5760658 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd6d5ee --- /dev/null +++ b/README.md @@ -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 diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..f36302c --- /dev/null +++ b/compliance_test.go @@ -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{})) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2fe1c9b --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-three-phase-lifecycle.md b/docs/adr/ADR-001-three-phase-lifecycle.md new file mode 100644 index 0000000..15b77f5 --- /dev/null +++ b/docs/adr/ADR-001-three-phase-lifecycle.md @@ -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`. diff --git a/docs/adr/ADR-002-reverse-order-shutdown.md b/docs/adr/ADR-002-reverse-order-shutdown.md new file mode 100644 index 0000000..dd89972 --- /dev/null +++ b/docs/adr/ADR-002-reverse-order-shutdown.md @@ -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. diff --git a/docs/adr/ADR-003-before-start-hooks.md b/docs/adr/ADR-003-before-start-hooks.md new file mode 100644 index 0000000..0290915 --- /dev/null +++ b/docs/adr/ADR-003-before-start-hooks.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6fe8c0a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.nochebuena.dev/go/launcher + +go 1.25 + +require code.nochebuena.dev/go/logz v0.9.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ebe0c90 --- /dev/null +++ b/go.sum @@ -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= diff --git a/launcher.go b/launcher.go new file mode 100644 index 0000000..f15386c --- /dev/null +++ b/launcher.go @@ -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") +} diff --git a/launcher_test.go b/launcher_test.go new file mode 100644 index 0000000..428b5f5 --- /dev/null +++ b/launcher_test.go @@ -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") + } +}