Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,154 @@
|
||||
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartAndStopReverseOrder(t *testing.T) {
|
||||
var events []string
|
||||
runner, err := New(
|
||||
Service{
|
||||
Name: "first",
|
||||
Start: func(context.Context) error { events = append(events, "start first"); return nil },
|
||||
Stop: func(context.Context) error { events = append(events, "stop first"); return nil },
|
||||
},
|
||||
Service{
|
||||
Name: "second",
|
||||
Start: func(context.Context) error { events = append(events, "start second"); return nil },
|
||||
Stop: func(context.Context) error { events = append(events, "stop second"); return nil },
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := runner.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if runner.State() != StateRunning {
|
||||
t.Fatalf("state after start = %s, want %s", runner.State(), StateRunning)
|
||||
}
|
||||
if err := runner.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
want := strings.Join([]string{"start first", "start second", "stop second", "stop first"}, ",")
|
||||
if got := strings.Join(events, ","); got != want {
|
||||
t.Fatalf("events = %q, want %q", got, want)
|
||||
}
|
||||
if runner.State() != StateStopped {
|
||||
t.Fatalf("state after stop = %s, want %s", runner.State(), StateStopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupFailureStopsStartedServices(t *testing.T) {
|
||||
startFailure := errors.New("start failed")
|
||||
stopFailure := errors.New("stop failed")
|
||||
var stopped bool
|
||||
runner, err := New(
|
||||
Service{
|
||||
Name: "ready",
|
||||
Start: func(context.Context) error { return nil },
|
||||
Stop: func(context.Context) error {
|
||||
stopped = true
|
||||
return stopFailure
|
||||
},
|
||||
},
|
||||
Service{
|
||||
Name: "broken",
|
||||
Start: func(context.Context) error { return startFailure },
|
||||
Stop: func(context.Context) error { return nil },
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
err = runner.Start(context.Background())
|
||||
if !errors.Is(err, startFailure) || !errors.Is(err, stopFailure) {
|
||||
t.Fatalf("Start error = %v, want both start and stop failures", err)
|
||||
}
|
||||
if !stopped {
|
||||
t.Fatal("started service was not stopped")
|
||||
}
|
||||
if runner.State() != StateStopped {
|
||||
t.Fatalf("state after failed start = %s, want %s", runner.State(), StateStopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledStartDoesNotCallService(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var called bool
|
||||
runner, err := New(Service{
|
||||
Name: "service",
|
||||
Start: func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
Stop: func(context.Context) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
err = runner.Start(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Start error = %v, want context.Canceled", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("service start was called after cancellation")
|
||||
}
|
||||
if runner.State() != StateStopped {
|
||||
t.Fatalf("state after canceled start = %s, want %s", runner.State(), StateStopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopIsIdempotent(t *testing.T) {
|
||||
runner, err := New(Service{
|
||||
Name: "service",
|
||||
Start: func(context.Context) error { return nil },
|
||||
Stop: func(context.Context) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := runner.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := runner.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("first Stop: %v", err)
|
||||
}
|
||||
if err := runner.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("second Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopLogsCompletedServiceStages(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
runner, err := NewWithLogger(logger, Service{
|
||||
Name: "service",
|
||||
Start: func(context.Context) error { return nil },
|
||||
Stop: func(context.Context) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLogger: %v", err)
|
||||
}
|
||||
if err := runner.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := runner.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
output := logs.String()
|
||||
if !strings.Contains(output, "lifecycle service stopping") ||
|
||||
!strings.Contains(output, "lifecycle service stopped") ||
|
||||
!strings.Contains(output, "service") {
|
||||
t.Fatalf("stop logs = %q, want stopping and stopped records for service", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidService(t *testing.T) {
|
||||
if _, err := New(Service{Name: "invalid"}); err == nil {
|
||||
t.Fatal("New accepted a service without functions")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user