lifecycle

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 29, 2026 License: AGPL-3.0 Imports: 12 Imported by: 0

README

lifecycle

Go Report Card Go Doc License Release

lifecycle is a Go library for managing application shutdown signals and interactive terminal I/O robustly. It centralizes the "Dual Signal" logic and "Interruptible I/O" patterns originally extracted from Trellis and designed for any tool needing robust signal handling.

Vision

To provide a standard, leak-free way to handle CLI interruptions (Ctrl+C) and graceful shutdowns across Go CLI applications, handling OS idiosyncrasies (especially Windows CONIN$) transparently.

Installation

go get github.com/aretw0/lifecycle

Features

  • SignalContext: Differentiates between SIGINT (User Interrupt) and SIGTERM (System Shutdown).
    • SIGINT: Captured but doesn't cancel context immediately (allows "Wait, are you sure?" logic).
    • SIGTERM: Cancels context immediately (standard graceful shutdown).
  • TermIO:
    • InterruptibleReader: Wraps io.Reader to allow Read() calls to be abandoned when a context is cancelled (avoids goroutine leaks).
    • Platform Aware: Automatically uses CONIN$ on Windows.
      • Why? On Windows, standard os.Stdin closes immediately upon receiving a signal (like Ctrl+C), causing a fatal EOF before the application can gracefully handle the signal. lifecycle switches to CONIN$, which keeps the handle open, allowing the SignalContext to process the event.
    • UpgradeTerminal: Helper to upgrade an arbitrary existing io.Reader (if it identifies as a terminal) to the safe platform-specific reader.
  • Observability & Safety:
    • Metrics: Built-in provider interface (IncHookExecuted, ObserveHookDuration) to track shutdown health.
    • Stall Detection: Automatically detects and logs warnings if a shutdown hook is stalled (runs > 5s).

Usage

Signal Context
package main

import (
    "context"
    "fmt"
    "github.com/aretw0/lifecycle"
)

func main() {
    // captures SIGINT/SIGTERM
    ctx := lifecycle.NewSignalContext(context.Background())
    defer ctx.Cancel() 

    // Register cleanup hooks (LIFO execution)
    ctx.OnShutdown(func() {
        fmt.Println("Cleanup: Database closed")
    })
    ctx.OnShutdown(func() {
        fmt.Println("Cleanup: HTTP server stopped")
    })

    <-ctx.Done()
    
    // Check why we stopped
    if sig := ctx.Signal(); sig != nil {
        fmt.Printf("Stopped by signal: %v\n", sig)
    }

    // IMPORTANT: Wait for hooks to finish!
    ctx.Wait()
}
Interruptible I/O
package main

import (
    "context"
    "fmt"
    "github.com/aretw0/lifecycle"
)

func main() {
    ctx := context.Background() // or SignalContext
    
    // Smart Open (handles Windows CONIN$)
    reader, _ := lifecycle.OpenTerminal()
    
    // Wrap to respect context cancellation
    r := lifecycle.NewInterruptibleReader(reader, ctx.Done())

    buf := make([]byte, 1024)
    n, err := r.Read(buf)
    if lifecycle.IsInterrupted(err) {
        fmt.Println("Read cancelled!")
        return
    }
    fmt.Printf("Read: %s\n", buf[:n])
}

Caveats

Interruptible I/O & Data Loss

The InterruptibleReader uses a "Peek & Abandon" strategy to allow unblocking a read.

  • Risk: If data arrives from the OS exactly when the context is cancelled, that data is discarded to prioritize the cancellation.
  • Impact: This is acceptable for interactive CLIs (user hits Ctrl+C, we discard the "y" they just typed), but NOT suitable for critical binary streams where every byte matters.

Documentation

Documentation

Overview

Package lifecycle provides a centralized library for managing application lifecycles and interactive I/O.

Dual Signal Context

Standard Go `signal.NotifyContext` cancels on the first signal. `lifecycle` distinguishes between:

  • SIGINT (Ctrl+C): "Soft" interrupt. Captures the signal but keeps the Context active. Allows the application to decide whether to pause, confirm exit, or ignore.
  • SIGTERM: "Hard" stop. Cancels the Context immediately, triggering graceful shutdown.

Interruptible I/O

On many systems (especially Windows), reading from `os.Stdin` blocks the goroutine indefinitely, preventing clean cancellation. Furthermore, on Windows, receiving a signal can close the standard input handle, causing an unexpected EOF. `lifecycle` provides `OpenTerminal` (using `CONIN$`) and `NewInterruptibleReader` to ensure I/O operations respect `context.Context` cancellation and signals are handled gracefully without premature termination.

Shutdown Timeouts

Graceful shutdown often involves waiting for background goroutines to finish (e.g., closing database connections, flushing logs). To prevent the application from hanging indefinitely if a cleanup operation stalls, `lifecycle` provides `BlockWithTimeout`. This ensures the process exits deterministically even if some components are stuck.

Usage

ctx := lifecycle.NewSignalContext(context.Background())
defer ctx.Cancel()

// Safe terminal reading
term, _ := lifecycle.OpenTerminal()
reader := lifecycle.NewInterruptibleReader(term, ctx.Done())

See examples/demo for a full interactive application.

Index

Examples

Constants

This section is empty.

Variables

View Source
var Version string

Functions

func BlockWithTimeout added in v1.1.0

func BlockWithTimeout(done <-chan struct{}, timeout time.Duration) error

BlockWithTimeout blocks until the done channel is closed or the timeout expires. Alias for pkg/runtime.BlockWithTimeout.

Example

ExampleBlockWithTimeout demonstrates how to enforce a deadline on shutdown cleanup.

package main

import (
	"fmt"
	"time"

	"github.com/aretw0/lifecycle"
)

func main() {
	done := make(chan struct{})

	// Simulate a cleanup task
	go func() {
		defer close(done)
		// Simulate fast cleanup
		time.Sleep(10 * time.Millisecond)
	}()

	// Wait for cleanup, but give up after 1 second
	err := lifecycle.BlockWithTimeout(done, 1*time.Second)
	if err != nil {
		fmt.Println("Cleanup timed out!")
	} else {
		fmt.Println("Cleanup finished successfully")
	}

}
Output:

Cleanup finished successfully

func IsInterrupted

func IsInterrupted(err error) bool

IsInterrupted checks if an error indicates an interruption (Context Canceled, EOF, etc.). Alias for pkg/termio.IsInterrupted.

func Mermaid added in v1.2.0

func Mermaid(s State) string

Mermaid returns a Mermaid state diagram string representing the lifecycle configuration. Alias for pkg/signal.Mermaid.

func NewInterruptibleReader

func NewInterruptibleReader(base io.Reader, cancel <-chan struct{}) *termio.InterruptibleReader

NewInterruptibleReader returns a reader that checks the cancel channel before/after blocking reads. Alias for pkg/termio.NewInterruptibleReader.

func NewLogMetricsProvider added in v1.1.0

func NewLogMetricsProvider() metrics.Provider

NewLogMetricsProvider returns a metrics provider that logs to the current logger. Useful for development and local verification. Alias for pkg/metrics.LogProvider.

func NewSignalContext

func NewSignalContext(parent context.Context, opts ...signal.Option) *signal.Context

NewSignalContext creates a context that cancels on SIGTERM/SIGINT. On the first signal, context is cancelled. On the second, it force exits. Behavior can be customized via functional options. Alias for pkg/signal.NewContext.

Example

ExampleNewSignalContext demonstrates how to use the Dual Signal context. Note: This example is illustrative; in a real run, it waits for SIGINT/SIGTERM.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/aretw0/lifecycle"
)

func main() {
	// Create a context that listens for signals.
	ctx := lifecycle.NewSignalContext(context.Background())

	// For checking output deterministically in this example, we cancel manually
	// after a short delay, allowing "work" to happen first.
	go func() {
		time.Sleep(50 * time.Millisecond)
		ctx.Cancel()
	}()

	// Simulate work
	select {
	case <-ctx.Done():
		fmt.Println("Context cancelled too early")
	case <-time.After(10 * time.Millisecond):
		fmt.Println("Doing work...")
	}

}
Output:

Doing work...

func OpenTerminal

func OpenTerminal() (io.ReadCloser, error)

OpenTerminal checks for text input capability and returns a Reader. On Windows, it tries to open CONIN$. Alias for pkg/termio.Open.

Example

ExampleOpenTerminal demonstrates how to open the terminal safely.

package main

import (
	"fmt"

	"github.com/aretw0/lifecycle"
)

func main() {
	// OpenTerminal handles OS-specific logic (like CONIN$ on Windows)
	reader, err := lifecycle.OpenTerminal()
	if err != nil {
		fmt.Printf("Error opening terminal: %v\n", err)
		return
	}
	defer reader.Close()

	fmt.Println("Terminal opened successfully")

	// Wrap with InterruptibleReader to respect context cancellation
	// r := lifecycle.NewInterruptibleReader(reader, ctx.Done())

}
Output:

Terminal opened successfully

func SetLogger added in v1.1.0

func SetLogger(l *slog.Logger)

SetLogger overrides the global logger used by the library. Alias for pkg/log.SetLogger.

func SetMetricsProvider added in v1.1.0

func SetMetricsProvider(p metrics.Provider)

SetMetricsProvider overrides the global metrics provider. This allowing bridging library metrics to Prometheus, OTEL, etc. Alias for pkg/metrics.SetProvider.

func SetStrictMode added in v1.1.0

func SetStrictMode(strict bool)

SetStrictMode sets whether to block on unsupported platforms for process hygiene. Alias for pkg/proc.StrictMode.

func StartProcess added in v1.1.0

func StartProcess(cmd *exec.Cmd) error

StartProcess starts the specified command with process hygiene (auto-kill on parent exit). Alias for pkg/proc.Start.

func UpgradeTerminal added in v0.1.1

func UpgradeTerminal(r io.Reader) (io.Reader, error)

UpgradeTerminal checks if the provided reader is a terminal and returns a safe reader (e.g. CONIN$ on Windows). If not a terminal, returns the original reader.

func WithForceExit added in v1.1.0

func WithForceExit(threshold int) signal.Option

WithForceExit configures the threshold of signals required to trigger an immediate os.Exit(1). Set to 0 to disable forced exit. Alias for pkg/signal.WithForceExit.

func WithHookTimeout added in v1.2.0

func WithHookTimeout(d time.Duration) signal.Option

WithHookTimeout configures the duration after which a running hook produces a warning log. Alias for pkg/signal.WithHookTimeout.

func WithInterrupt added in v1.1.0

func WithInterrupt(cancel bool) signal.Option

WithInterrupt configures whether SIGINT (Ctrl+C) should cancel the context. Alias for pkg/signal.WithInterrupt.

Types

type State added in v1.2.0

type State = signal.State

State represents the configuration state of the SignalContext. Alias for pkg/signal.State.

Directories

Path Synopsis
examples
demo command
hooks command
introspection command
observability command
zombie command
pkg
log
Package log provides a lightweight, structured logging interface for the lifecycle library.
Package log provides a lightweight, structured logging interface for the lifecycle library.
metrics
Package metrics provides a decoupled interface for collecting library metrics.
Package metrics provides a decoupled interface for collecting library metrics.
proc
Package proc provides primitives for managing process lifecycle and hygiene.
Package proc provides primitives for managing process lifecycle and hygiene.
runtime
Package runtime provides utilities for deterministic process management.
Package runtime provides utilities for deterministic process management.
signal
Package signal provides a stateful signal context with introspection and LIFO hooks.
Package signal provides a stateful signal context with introspection and LIFO hooks.
termio
Package termio provides interruptible I/O primitives and terminal handling.
Package termio provides interruptible I/O primitives and terminal handling.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL