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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal 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
38
.gitignore
vendored
Normal 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
30
CHANGELOG.md
Normal 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
114
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
122
README.md
Normal 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
9
compliance_test.go
Normal 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
33
doc.go
Normal 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
|
||||||
49
docs/adr/ADR-001-three-phase-lifecycle.md
Normal file
49
docs/adr/ADR-001-three-phase-lifecycle.md
Normal 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`.
|
||||||
48
docs/adr/ADR-002-reverse-order-shutdown.md
Normal file
48
docs/adr/ADR-002-reverse-order-shutdown.md
Normal 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.
|
||||||
43
docs/adr/ADR-003-before-start-hooks.md
Normal file
43
docs/adr/ADR-003-before-start-hooks.md
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
182
launcher.go
Normal 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
387
launcher_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user