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