// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package lifecycle import ( "context" "errors" "fmt" "log/slog" ) type State string const ( StateNew State = "New" StateStarting State = "Starting" StateRunning State = "Running" StateStopping State = "Stopping" StateStopped State = "Stopped" StateFailed State = "Failed" ) type Service struct { Name string Start func(context.Context) error Stop func(context.Context) error } type Runner struct { state State services []Service started []int logger *slog.Logger } func New(services ...Service) (*Runner, error) { return NewWithLogger(nil, services...) } func NewWithLogger(logger *slog.Logger, services ...Service) (*Runner, error) { names := make(map[string]struct{}, len(services)) for index, service := range services { switch { case service.Name == "": return nil, fmt.Errorf("service %d has no name", index) case service.Start == nil: return nil, fmt.Errorf("service %q has no start function", service.Name) case service.Stop == nil: return nil, fmt.Errorf("service %q has no stop function", service.Name) } if _, exists := names[service.Name]; exists { return nil, fmt.Errorf("duplicate service name %q", service.Name) } names[service.Name] = struct{}{} } return &Runner{ state: StateNew, services: append([]Service(nil), services...), logger: logger, }, nil } func (runner *Runner) State() State { return runner.state } func (runner *Runner) Start(ctx context.Context) error { if ctx == nil { return errors.New("lifecycle start requires a non-nil context") } switch runner.state { case StateNew: case StateRunning, StateStarting, StateStopping, StateStopped, StateFailed: return fmt.Errorf("cannot start from state %s", runner.state) } runner.state = StateStarting for index, service := range runner.services { if err := ctx.Err(); err != nil { return runner.failStartup(fmt.Errorf("start %q canceled: %w", service.Name, err)) } if err := service.Start(ctx); err != nil { return runner.failStartup(fmt.Errorf("start %q: %w", service.Name, err)) } runner.started = append(runner.started, index) } runner.state = StateRunning return nil } func (runner *Runner) Stop(ctx context.Context) error { if ctx == nil { return errors.New("lifecycle stop requires a non-nil context") } switch runner.state { case StateNew: runner.state = StateStopped return nil case StateRunning: case StateStopped: return nil case StateStarting, StateStopping, StateFailed: return fmt.Errorf("cannot stop from state %s", runner.state) } runner.state = StateStopping stopError := runner.stopStartedServices(ctx) runner.state = StateStopped return stopError } func (runner *Runner) failStartup(startError error) error { runner.state = StateFailed runner.state = StateStopping stopError := runner.stopStartedServices(context.Background()) runner.state = StateStopped return errors.Join(startError, stopError) } func (runner *Runner) stopStartedServices(ctx context.Context) error { var stopErrors []error for index := len(runner.started) - 1; index >= 0; index-- { serviceIndex := runner.started[index] service := runner.services[serviceIndex] if runner.logger != nil { runner.logger.Debug("lifecycle service stopping", "service", service.Name) } if err := service.Stop(ctx); err != nil { stopErrors = append(stopErrors, fmt.Errorf("stop %q: %w", service.Name, err)) if runner.logger != nil { runner.logger.Warn("lifecycle service stop failed", "service", service.Name, "error", err) } } else if runner.logger != nil { runner.logger.Debug("lifecycle service stopped", "service", service.Name) } } runner.started = nil return errors.Join(stopErrors...) }