From 225018b1f8df9980f5d8c57f914ba71cc54a251f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 19:39:40 +0100 Subject: [PATCH 1/8] test(pluginkit): pin how a boot keeps each plugin's state and pauses a failing one --- pluginkit/features/boot.feature | 138 ++++++++++++++++++++++++++++ pluginkit/features/features_test.go | 4 + 2 files changed, 142 insertions(+) create mode 100644 pluginkit/features/boot.feature diff --git a/pluginkit/features/boot.feature b/pluginkit/features/boot.feature new file mode 100644 index 0000000..9a408da --- /dev/null +++ b/pluginkit/features/boot.feature @@ -0,0 +1,138 @@ +Feature: Booting plugins and pausing the one that fails + An application keeps the state of each compiled plugin between boots: + not installed, inactive, active or paused. Inactive means turned off on + purpose. Paused means the boot stopped the plugin after it failed, as + WordPress's recovery mode does. Each boot reads the records once, + registers the active and paused plugins, lets the application run its + own steps, then starts them. A plugin that fails is paused with its + error, and the others still start. A paused plugin is tried again at + every boot. + + @wip + Scenario: The first boot records each compiled plugin as active + Given the compiled plugins "events" and "feed" + And the store holds no record + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "events" as active with no error + And the store records "feed" as active with no error + + @wip + Scenario: A boot where nothing changes writes nothing to the store + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store was not written + + @wip + Scenario: A plugin whose Register fails is paused and the others start + Given the compiled plugins "events", "feed" and "gallery" + And the Register of "feed" fails with "the title is too long" + When the application boots its plugins + Then "events" and "gallery" started, in that order + And the store records "feed" as paused with "pluginkit: feed register: the title is too long" + + @wip + Scenario: A plugin whose Register panics is paused + Given the compiled plugins "events" and "feed" + And the Register of "feed" panics with "boom" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed register panicked: boom" + + @wip + Scenario: A plugin whose migration fails is paused and never starts + Given the compiled plugins "events" and "feed" + And the migration of "feed" fails with "schema exploded" + When the application boots its plugins + Then "events" started + And "feed" never started + And the store records "feed" as paused with "pluginkit: feed migrate: schema exploded" + + @wip + Scenario: A plugin whose Start fails is paused + Given the compiled plugins "events" and "feed" + And the Start of "feed" fails with "no route table" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed start: no route table" + + @wip + Scenario: A plugin whose migration record fails still starts and stays active + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the migration record of "feed" fails with "ledger locked" + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "feed" as active with no error + + @wip + Scenario Outline: A plugin the store keeps is never registered + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as + When the application boots its plugins + Then "events" started + And the Register of "feed" never ran + And the store records "feed" as with no error + + Examples: + | state | + | inactive | + | not installed | + + @wip + Scenario: A paused plugin that now starts is active again with no error + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as paused with "a setting is missing" + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "feed" as active with no error + + @wip + Scenario: A paused plugin that fails again stays paused with its new error + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as paused with "a setting is missing" + And the Start of "feed" fails with "no route table" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed start: no route table" + + @wip + Scenario: The application pauses a plugin for a reason of its own + Given the compiled plugins "events" and "forum" + And the store records "events" and "forum" as active + And the application finds that "forum" declares a role it cannot accept + When the application boots its plugins + Then "events" started + And "forum" never started + And the store records "forum" as paused with "forum declares a role it cannot accept" + + @wip + Scenario: A boot whose context ends pauses nothing + Given the compiled plugins "events", "feed" and "gallery" + And the store records "events", "feed" and "gallery" as active + And the context of the boot ends while "feed" starts + When the application boots its plugins + Then the boot reports that its context ended + And the store was not written + + @wip + Scenario: A store that cannot read its records stops the boot + Given the compiled plugins "events" and "feed" + And the store fails to read with "connection dropped" + When the application boots its plugins + Then the boot fails with: pluginkit: read the plugin records: connection dropped + And the Register of "events" never ran + + @wip + Scenario: A store that cannot write a pause stops the boot + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the Register of "feed" fails with "the title is too long" + And the store fails to write with "connection dropped" + When the application boots its plugins + Then the boot fails with: pluginkit: pause feed: connection dropped diff --git a/pluginkit/features/features_test.go b/pluginkit/features/features_test.go index 15a8a55..45b123c 100644 --- a/pluginkit/features/features_test.go +++ b/pluginkit/features/features_test.go @@ -45,3 +45,7 @@ func TestScreens(t *testing.T) { func TestRegistration(t *testing.T) { runFeature(t, "registration.feature", initializeRegistration(t)) } + +func TestBoot(t *testing.T) { + runFeature(t, "boot.feature", func(*godog.ScenarioContext) {}) +} From ff8de902f3f299c0bbbb2fb77d86487fa015b333 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:10:17 +0100 Subject: [PATCH 2/8] feat(pluginkit): define the plugin store and the contract every store keeps --- pluginkit/pluginstore.go | 37 +++++ pluginkit/pluginstoretest/check_test.go | 72 ++++++++++ pluginkit/pluginstoretest/memory_test.go | 41 ++++++ pluginkit/pluginstoretest/pluginstoretest.go | 136 +++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 pluginkit/pluginstore.go create mode 100644 pluginkit/pluginstoretest/check_test.go create mode 100644 pluginkit/pluginstoretest/memory_test.go create mode 100644 pluginkit/pluginstoretest/pluginstoretest.go diff --git a/pluginkit/pluginstore.go b/pluginkit/pluginstore.go new file mode 100644 index 0000000..17d500d --- /dev/null +++ b/pluginkit/pluginstore.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import "context" + +// PluginState is where one compiled plugin stands for the application. +type PluginState string + +const ( + // PluginNotInstalled is a compiled plugin the application has not installed, which a boot never registers. + PluginNotInstalled PluginState = "not installed" + // PluginInactive is a plugin turned off on purpose, which a boot never registers. + PluginInactive PluginState = "inactive" + // PluginActive is a plugin a boot registers and starts. + PluginActive PluginState = "active" + // PluginPaused is a plugin a boot stopped after it failed, which every boot tries again. + PluginPaused PluginState = "paused" +) + +// PluginRecord is what an application keeps about one plugin between boots. +type PluginRecord struct { + ID string + State PluginState + // LastError is the text of the failure that paused the plugin, and empty otherwise. + LastError string +} + +// PluginStore is an application's record of each plugin's state. +type PluginStore interface { + // Records returns every record, keyed by plugin id, in one read. + Records(ctx context.Context) (map[string]PluginRecord, error) + // Activate marks the plugin id active and clears its error, adding its record when it has none. + Activate(ctx context.Context, id string) error + // Pause marks the plugin id paused with the text of its failure, adding its record when it has none. + Pause(ctx context.Context, id, failure string) error +} diff --git a/pluginkit/pluginstoretest/check_test.go b/pluginkit/pluginstoretest/check_test.go new file mode 100644 index 0000000..d1a1d4c --- /dev/null +++ b/pluginkit/pluginstoretest/check_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginstoretest + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +// recorder is a [testing.TB] that keeps each failure it is told about and goes on. +type recorder struct { + testing.TB + failures []string +} + +// Helper marks nothing. +func (r *recorder) Helper() {} + +// Errorf keeps the failure. +func (r *recorder) Errorf(format string, args ...any) { + r.failures = append(r.failures, fmt.Sprintf(format, args...)) +} + +// Fatalf keeps the failure and returns, where a real test would stop. +func (r *recorder) Fatalf(format string, args ...any) { + r.failures = append(r.failures, fmt.Sprintf(format, args...)) +} + +// unreadable is a store that cannot read its records. +type unreadable struct { + pluginkit.PluginStore +} + +// Records answers the store's failure. +func (unreadable) Records(context.Context) (map[string]pluginkit.PluginRecord, error) { + return nil, errors.New("connection dropped") +} + +func TestActReportsAWriteTheStoreRefused(t *testing.T) { + r := &recorder{} + + act(r, errors.New("connection dropped")) + + if len(r.failures) != 1 || r.failures[0] != "the store refused a write: connection dropped" { + t.Errorf("failures = %q, want the refused write", r.failures) + } +} + +func TestWantReportsAStoreThatCannotRead(t *testing.T) { + r := &recorder{} + + want(r, unreadable{}, nil) + + if len(r.failures) != 1 || r.failures[0] != "the store could not read its records: connection dropped" { + t.Errorf("failures = %q, want the failed read", r.failures) + } +} + +func TestWantReportsRecordsThatDiffer(t *testing.T) { + r := &recorder{} + store := NewMemory(pluginkit.PluginRecord{ID: "events", State: pluginkit.PluginActive}) + + want(r, store, map[string]pluginkit.PluginRecord{"events": {ID: "events", State: pluginkit.PluginPaused}}) + + if len(r.failures) != 1 { + t.Errorf("failures = %q, want one naming the records that differ", r.failures) + } +} diff --git a/pluginkit/pluginstoretest/memory_test.go b/pluginkit/pluginstoretest/memory_test.go new file mode 100644 index 0000000..bf6516a --- /dev/null +++ b/pluginkit/pluginstoretest/memory_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginstoretest_test + +import ( + "maps" + "testing" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +func TestMemoryKeepsThePluginStoreContract(t *testing.T) { + pluginstoretest.Run(t, func(*testing.T) pluginkit.PluginStore { return pluginstoretest.NewMemory() }) +} + +func TestNewMemoryHoldsTheRecordsItIsGiven(t *testing.T) { + seeded := []pluginkit.PluginRecord{ + {ID: "events", State: pluginkit.PluginInactive}, + {ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}, + } + + got, err := pluginstoretest.NewMemory(seeded...).Records(t.Context()) + + want := map[string]pluginkit.PluginRecord{"events": seeded[0], "feed": seeded[1]} + if err != nil || !maps.Equal(got, want) { + t.Errorf("Records() = %+v, %v, want %+v", got, err, want) + } +} + +func TestMemoryRecordsAreACopyOfWhatItHolds(t *testing.T) { + store := pluginstoretest.NewMemory(pluginkit.PluginRecord{ID: "events", State: pluginkit.PluginActive}) + first, _ := store.Records(t.Context()) + + delete(first, "events") + again, _ := store.Records(t.Context()) + + if len(again) != 1 { + t.Errorf("Records() after a change to the last answer = %+v, want events still held", again) + } +} diff --git a/pluginkit/pluginstoretest/pluginstoretest.go b/pluginkit/pluginstoretest/pluginstoretest.go new file mode 100644 index 0000000..612d21d --- /dev/null +++ b/pluginkit/pluginstoretest/pluginstoretest.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package pluginstoretest checks that a [pluginkit.PluginStore] keeps the contract a boot relies on. +package pluginstoretest + +import ( + "context" + "maps" + "sync" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +// Run runs every case of the contract, each on a new empty store from newStore. +func Run(t *testing.T, newStore func(t *testing.T) pluginkit.PluginStore) { + t.Helper() + for name, check := range cases() { + t.Run(name, func(t *testing.T) { + check(t, newStore(t)) + }) + } +} + +// cases are the checks of the contract, by name. +func cases() map[string]func(t *testing.T, store pluginkit.PluginStore) { + return map[string]func(t *testing.T, store pluginkit.PluginStore){ + "an empty store holds no record": func(t *testing.T, store pluginkit.PluginStore) { + want(t, store, nil) + }, + "activating a plugin it never saw records it active": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "pausing a plugin it never saw records it paused": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "a setting is missing")}) + }, + "pausing an active plugin keeps its failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "a setting is missing")}) + }, + "pausing a paused plugin keeps the newer failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + act(t, store.Pause(t.Context(), "events", "no route table")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "no route table")}) + }, + "activating a paused plugin clears its failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "activating an active plugin keeps it active": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "one read answers every plugin by its id": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Pause(t.Context(), "feed", "no route table")) + want(t, store, map[string]pluginkit.PluginRecord{ + "events": active("events"), "feed": paused("feed", "no route table"), + }) + }, + } +} + +// active returns the record of an active plugin id. +func active(id string) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: pluginkit.PluginActive} +} + +// paused returns the record of the plugin id paused for failure. +func paused(id, failure string) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: pluginkit.PluginPaused, LastError: failure} +} + +// act stops t when the store refused a write. +func act(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Fatalf("the store refused a write: %v", err) + } +} + +// want checks that store holds exactly records. +func want(t testing.TB, store pluginkit.PluginStore, records map[string]pluginkit.PluginRecord) { + t.Helper() + got, err := store.Records(context.Background()) + if err != nil { + t.Fatalf("the store could not read its records: %v", err) + return + } + if !maps.Equal(got, records) { + t.Errorf("records = %+v, want %+v", got, records) + } +} + +// Memory is a [pluginkit.PluginStore] kept in memory, safe for concurrent use. +type Memory struct { + mu sync.Mutex + records map[string]pluginkit.PluginRecord +} + +// NewMemory returns a store in memory holding records. +func NewMemory(records ...pluginkit.PluginRecord) *Memory { + held := make(map[string]pluginkit.PluginRecord, len(records)) + for _, record := range records { + held[record.ID] = record + } + return &Memory{records: held} +} + +// Records returns a copy of every record held. +func (m *Memory) Records(context.Context) (map[string]pluginkit.PluginRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + return maps.Clone(m.records), nil +} + +// Activate marks the plugin id active and clears its error. +func (m *Memory) Activate(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records[id] = active(id) + return nil +} + +// Pause marks the plugin id paused with the text of its failure. +func (m *Memory) Pause(_ context.Context, id, failure string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records[id] = paused(id, failure) + return nil +} From bd5da3388a064b8da82722316ac03e61384102f5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:10:17 +0100 Subject: [PATCH 3/8] feat(pluginkit): carry a boot from the plugin records to the start and pause a failing plugin --- pluginkit/boot.go | 106 ++++++++++++ pluginkit/boot_test.go | 366 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 pluginkit/boot.go create mode 100644 pluginkit/boot_test.go diff --git a/pluginkit/boot.go b/pluginkit/boot.go new file mode 100644 index 0000000..7f227ac --- /dev/null +++ b/pluginkit/boot.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import ( + "context" + "fmt" +) + +// Boot is one start of the application's plugins, from their records to their start. +type Boot struct { + store PluginStore + records map[string]PluginRecord +} + +// NewBoot reads every record of store once. +func NewBoot(ctx context.Context, store PluginStore) (*Boot, error) { + var records map[string]PluginRecord + err := guarded(ctx, "read the plugin records", func(ctx context.Context) error { + var readErr error + records, readErr = store.Records(ctx) + return readErr + }) + if err != nil { + return nil, err + } + return &Boot{store: store, records: records}, nil +} + +// Register records each new entry active, registers the active and paused ones, and pauses each that fails. +func Register[D, P any](ctx context.Context, boot *Boot, entries []Registration[D, P], + depsFor func(id string) D) (RegisterOutcome[P], error) { + wanted, err := admitted(ctx, boot, entries) + if err != nil { + return RegisterOutcome[P]{}, err + } + outcome := RegisterEach(wanted, depsFor) + return outcome, boot.pauseEach(ctx, outcome.Failed) +} + +// admitted returns the entries boot registers, in order, recording each new one active. +func admitted[D, P any](ctx context.Context, boot *Boot, entries []Registration[D, P]) ([]Registration[D, P], error) { + var wanted []Registration[D, P] + for _, entry := range entries { + registers, err := boot.admit(ctx, entry.ID) + if err != nil { + return nil, err + } + if registers { + wanted = append(wanted, entry) + } + } + return wanted, nil +} + +// pauseEach pauses each failed plugin but one whose migration record failed, stopping at a write that fails. +func (b *Boot) pauseEach(ctx context.Context, failures []Failure) error { + for _, failure := range failures { + if failure.Step == StepRecord { + continue + } + if err := b.Pause(ctx, failure.ID, failure.Err); err != nil { + return err + } + } + return nil +} + +// admit records the plugin id active when boot has never seen it, and reports whether a boot registers it. +func (b *Boot) admit(ctx context.Context, id string) (bool, error) { + if record, known := b.records[id]; known { + return record.State == PluginActive || record.State == PluginPaused, nil + } + return true, b.activate(ctx, id) +} + +// Pause pauses the plugin id for failure, a step that failed or a reason of the application's own. +func (b *Boot) Pause(ctx context.Context, id string, failure error) error { + return guarded(ctx, "pause "+id, func(ctx context.Context) error { + return b.store.Pause(ctx, id, failure.Error()) + }) +} + +// activate marks the plugin id active and clears its error. +func (b *Boot) activate(ctx context.Context, id string) error { + return guarded(ctx, "activate "+id, func(ctx context.Context) error { return b.store.Activate(ctx, id) }) +} + +// Started pauses each plugin that failed to migrate or start, and activates each paused plugin that started. +func (b *Boot) Started(ctx context.Context, outcome StartOutcome) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("pluginkit: the boot's context ended: %w", err) + } + if err := b.pauseEach(ctx, outcome.Failed); err != nil { + return err + } + for _, started := range outcome.Started { + if b.records[started.ID].State != PluginPaused { + continue + } + if err := b.activate(ctx, started.ID); err != nil { + return err + } + } + return nil +} diff --git a/pluginkit/boot_test.go b/pluginkit/boot_test.go new file mode 100644 index 0000000..5ed5338 --- /dev/null +++ b/pluginkit/boot_test.go @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "context" + "errors" + "testing" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +// countingStore is a plugin store in memory that notes each read and write, and fails them when told. +type countingStore struct { + inner pluginkit.PluginStore + reads int + writes []string + readErr error + readPanic any + writeErr error +} + +// Records notes the read and answers the records, or the failure it was told. +func (s *countingStore) Records(ctx context.Context) (map[string]pluginkit.PluginRecord, error) { + s.reads++ + if s.readPanic != nil { + panic(s.readPanic) + } + if s.readErr != nil { + return nil, s.readErr + } + return s.inner.Records(ctx) +} + +// Activate notes the write and activates id, or answers the failure it was told. +func (s *countingStore) Activate(ctx context.Context, id string) error { + s.writes = append(s.writes, "activate "+id) + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Activate(ctx, id) +} + +// Pause notes the write and pauses id, or answers the failure it was told. +func (s *countingStore) Pause(ctx context.Context, id, failure string) error { + s.writes = append(s.writes, "pause "+id+": "+failure) + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Pause(ctx, id, failure) +} + +// assertErrorFree stops t when err is set. +func assertErrorFree(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("error = %v, want nil", err) + } +} + +// assertErrorText checks that err has exactly the text want. +func assertErrorText(t *testing.T, err error, want string) { + t.Helper() + if err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} + +// storeOf returns a counting store holding records. +func storeOf(records ...pluginkit.PluginRecord) *countingStore { + return &countingStore{inner: pluginstoretest.NewMemory(records...)} +} + +// record returns the record of id in state, with no error. +func record(id string, state pluginkit.PluginState) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: state} +} + +// newBoot reads store into a new boot, stopping t when it cannot. +func newBoot(t *testing.T, store pluginkit.PluginStore) *pluginkit.Boot { + t.Helper() + boot, err := pluginkit.NewBoot(t.Context(), store) + if err != nil { + t.Fatalf("NewBoot() error = %v, want nil", err) + } + return boot +} + +// held returns every record store holds. +func held(t *testing.T, store *countingStore) map[string]pluginkit.PluginRecord { + t.Helper() + records, err := store.inner.Records(t.Context()) + if err != nil { + t.Fatalf("Records() error = %v, want nil", err) + } + return records +} + +// registerAll registers entries on boot, stopping t when the boot fails. +func registerAll(t *testing.T, boot *pluginkit.Boot, + entries ...pluginkit.Registration[listedDeps, *listedPlugin]) pluginkit.RegisterOutcome[*listedPlugin] { + t.Helper() + var calls []string + outcome, err := pluginkit.Register(t.Context(), boot, entries, depsOf(&calls)) + if err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + return outcome +} + +func TestNewBootReadsTheRecordsOnceForAWholeBoot(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive)) + boot := newBoot(t, store) + registerAll(t, boot, entry("events", &calls, succeeds)) + + err := boot.Started(t.Context(), pluginkit.StartOutcome{Started: []pluginkit.StartedPlugin{{ID: "events"}}}) + + assertErrorFree(t, err) + wantEqual(t, "reads", store.reads, 1) +} + +func TestNewBootNamesAStoreThatCannotRead(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + want string + }{ + "an error": {&countingStore{readErr: errors.New("connection dropped")}, + "pluginkit: read the plugin records: connection dropped"}, + "a panic": {&countingStore{readPanic: "boom"}, "pluginkit: read the plugin records panicked: boom"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + boot, err := pluginkit.NewBoot(t.Context(), tc.store) + + assertErrorText(t, err, tc.want) + if boot != nil { + t.Errorf("NewBoot() = %v, want no boot", boot) + } + }) + } +} + +func TestRegisterRecordsAPluginItHasNeverSeenAsActive(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf() + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "feed"}) + wantEqual(t, "writes", store.writes, []string{"activate events", "activate feed"}) + wantEqual(t, "records", held(t, store), map[string]pluginkit.PluginRecord{ + "events": record("events", pluginkit.PluginActive), "feed": record("feed", pluginkit.PluginActive), + }) +} + +func TestRegisterLeavesOutInactiveAndNotInstalledPlugins(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginInactive), + record("gallery", pluginkit.PluginNotInstalled)) + + outcome := registerAll(t, newBoot(t, store), + entry("events", &calls, succeeds), entry("feed", &calls, succeeds), entry("gallery", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events"}) + wantEqual(t, "calls", calls, []string{"register events"}) + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestRegisterTriesAPausedPluginAgainWithoutWriting(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), + pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}) + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "feed"}) + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestRegisterPausesEachPluginThatFailsToRegister(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginActive), + record("gallery", pluginkit.PluginActive)) + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), + entry("feed", &calls, answers(errors.New("the title is too long"))), entry("gallery", &calls, panicsWith("boom"))) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events"}) + wantEqual(t, "writes", store.writes, []string{ + "pause feed: pluginkit: feed register: the title is too long", + "pause gallery: pluginkit: gallery register panicked: boom", + }) +} + +func TestRegisterNamesAStoreThatCannotWrite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + answer func() error + want string + calling []string + }{ + "recording a new plugin": {storeOf(), succeeds, "pluginkit: activate feed: connection dropped", nil}, + "pausing a failed plugin": {storeOf(record("feed", pluginkit.PluginActive)), + answers(errors.New("the title is too long")), "pluginkit: pause feed: connection dropped", + []string{"deps feed", "register feed"}}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var calls []string + boot := newBoot(t, tc.store) + tc.store.writeErr = errors.New("connection dropped") + + _, err := pluginkit.Register(t.Context(), boot, + []pluginkit.Registration[listedDeps, *listedPlugin]{entry("feed", &calls, tc.answer)}, depsOf(&calls)) + + assertErrorText(t, err, tc.want) + wantEqual(t, "calls", calls, tc.calling) + }) + } +} + +func TestBootPausePausesAPluginForTheApplicationsReason(t *testing.T) { + t.Parallel() + + store := storeOf(record("forum", pluginkit.PluginActive)) + + err := newBoot(t, store).Pause(t.Context(), "forum", errors.New("forum declares a role it cannot accept")) + + assertErrorFree(t, err) + wantEqual(t, "records", held(t, store), map[string]pluginkit.PluginRecord{"forum": { + ID: "forum", State: pluginkit.PluginPaused, LastError: "forum declares a role it cannot accept", + }}) +} + +func TestStartedPausesEachPluginThatFailedToMigrateOrStart(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive), record("gallery", pluginkit.PluginActive)) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepMigrate, Err: errors.New("pluginkit: feed migrate: schema exploded")}, + {ID: "gallery", Step: pluginkit.StepStart, Err: errors.New("pluginkit: gallery start: no route table")}, + }}) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{ + "pause feed: pluginkit: feed migrate: schema exploded", + "pause gallery: pluginkit: gallery start: no route table", + }) +} + +func TestStartedLeavesAFailedMigrationRecordAlone(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive), record("gallery", pluginkit.PluginActive)) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "feed"}}, + Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepRecord, Err: errors.New("pluginkit: feed record: ledger locked")}, + {ID: "gallery", Step: pluginkit.StepStart, Err: errors.New("pluginkit: gallery start: no route table")}, + }, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{"pause gallery: pluginkit: gallery start: no route table"}) +} + +func TestStartedActivatesAPausedPluginThatStarted(t *testing.T) { + t.Parallel() + + store := storeOf(record("events", pluginkit.PluginActive), + pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "events"}, {ID: "feed"}}, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{"activate feed"}) + wantEqual(t, "feed", held(t, store)["feed"], record("feed", pluginkit.PluginActive)) +} + +func TestStartedWritesNothingWhenTheContextEnded(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := boot.Started(ctx, pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepMigrate, Err: ctx.Err()}, + }}) + + assertErrorText(t, err, "pluginkit: the boot's context ended: context canceled") + if !errors.Is(err, context.Canceled) { + t.Errorf("Started() error = %v, want it to wrap the context's error", err) + } + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestStartedNamesAStoreThatCannotWrite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + outcome pluginkit.StartOutcome + want string + }{ + "a pause": {storeOf(record("feed", pluginkit.PluginActive)), pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepStart, Err: errors.New("pluginkit: feed start: no route table")}, + }}, "pluginkit: pause feed: connection dropped"}, + "an activation": {storeOf(pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused}), + pluginkit.StartOutcome{Started: []pluginkit.StartedPlugin{{ID: "feed"}}}, + "pluginkit: activate feed: connection dropped"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + boot := newBoot(t, tc.store) + tc.store.writeErr = errors.New("connection dropped") + + err := boot.Started(t.Context(), tc.outcome) + + assertErrorText(t, err, tc.want) + }) + } +} + +func TestABootWhereNothingChangesWritesNothing(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + registerAll(t, boot, entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + err := boot.Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "events"}, {ID: "feed"}}, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string(nil)) +} From 4e5213b8f1b9a18939398ad4c349f72bdb6d189d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:10:17 +0100 Subject: [PATCH 4/8] docs(pluginkit): list the plugin records changes under Unreleased --- pluginkit/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 783e20d..4f66526 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -3,6 +3,13 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to 0.5.1 were tagged `vX.Y.Z` in `github.com/gopherium/pluginkit`. +## Unreleased + +- `PluginStore`, with `Records`, `Activate` and `Pause`, is an application's record of each plugin's state: not installed, inactive, active or paused, with the text of the failure that paused it. +- `NewBoot`, `Register`, `Boot.Pause` and `Boot.Started` carry one boot from the records to the start: a plugin seen for the first time is recorded active, inactive and not installed plugins are never registered, a plugin that fails to register, migrate or start is paused, and a paused plugin that starts again is active with no error. +- A boot whose context ends pauses nothing, and a store read or write that fails is returned to the application. +- `pluginstoretest.Run` checks any `PluginStore` against the contract, and `pluginstoretest.NewMemory` is a store in memory that keeps it. + ## 0.12.0 - 2026-11-07 - `RegisterEach` calls each `Registration` with the dependencies `depsFor` builds for its id, each inside its own recover, goes on past an error or a panic, and answers a `RegisterOutcome` naming each failure at the new `StepRegister`. From d9262b49be29ebe32a5cad797a478e40a647d253 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:10:18 +0100 Subject: [PATCH 5/8] test(pluginkit): run the boot scenarios --- pluginkit/features/boot.feature | 15 +- pluginkit/features/features_test.go | 2 +- pluginkit/features/steps_boot_test.go | 380 ++++++++++++++++++++++++++ 3 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 pluginkit/features/steps_boot_test.go diff --git a/pluginkit/features/boot.feature b/pluginkit/features/boot.feature index 9a408da..a8e49b1 100644 --- a/pluginkit/features/boot.feature +++ b/pluginkit/features/boot.feature @@ -8,7 +8,6 @@ Feature: Booting plugins and pausing the one that fails error, and the others still start. A paused plugin is tried again at every boot. - @wip Scenario: The first boot records each compiled plugin as active Given the compiled plugins "events" and "feed" And the store holds no record @@ -17,7 +16,6 @@ Feature: Booting plugins and pausing the one that fails And the store records "events" as active with no error And the store records "feed" as active with no error - @wip Scenario: A boot where nothing changes writes nothing to the store Given the compiled plugins "events" and "feed" And the store records "events" and "feed" as active @@ -25,7 +23,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" and "feed" started, in that order And the store was not written - @wip Scenario: A plugin whose Register fails is paused and the others start Given the compiled plugins "events", "feed" and "gallery" And the Register of "feed" fails with "the title is too long" @@ -33,7 +30,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" and "gallery" started, in that order And the store records "feed" as paused with "pluginkit: feed register: the title is too long" - @wip Scenario: A plugin whose Register panics is paused Given the compiled plugins "events" and "feed" And the Register of "feed" panics with "boom" @@ -41,7 +37,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" started And the store records "feed" as paused with "pluginkit: feed register panicked: boom" - @wip Scenario: A plugin whose migration fails is paused and never starts Given the compiled plugins "events" and "feed" And the migration of "feed" fails with "schema exploded" @@ -50,7 +45,6 @@ Feature: Booting plugins and pausing the one that fails And "feed" never started And the store records "feed" as paused with "pluginkit: feed migrate: schema exploded" - @wip Scenario: A plugin whose Start fails is paused Given the compiled plugins "events" and "feed" And the Start of "feed" fails with "no route table" @@ -58,7 +52,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" started And the store records "feed" as paused with "pluginkit: feed start: no route table" - @wip Scenario: A plugin whose migration record fails still starts and stays active Given the compiled plugins "events" and "feed" And the store records "events" and "feed" as active @@ -66,8 +59,8 @@ Feature: Booting plugins and pausing the one that fails When the application boots its plugins Then "events" and "feed" started, in that order And the store records "feed" as active with no error + And the store was not written - @wip Scenario Outline: A plugin the store keeps is never registered Given the compiled plugins "events" and "feed" And the store records "events" as active @@ -82,7 +75,6 @@ Feature: Booting plugins and pausing the one that fails | inactive | | not installed | - @wip Scenario: A paused plugin that now starts is active again with no error Given the compiled plugins "events" and "feed" And the store records "events" as active @@ -91,7 +83,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" and "feed" started, in that order And the store records "feed" as active with no error - @wip Scenario: A paused plugin that fails again stays paused with its new error Given the compiled plugins "events" and "feed" And the store records "events" as active @@ -101,7 +92,6 @@ Feature: Booting plugins and pausing the one that fails Then "events" started And the store records "feed" as paused with "pluginkit: feed start: no route table" - @wip Scenario: The application pauses a plugin for a reason of its own Given the compiled plugins "events" and "forum" And the store records "events" and "forum" as active @@ -111,7 +101,6 @@ Feature: Booting plugins and pausing the one that fails And "forum" never started And the store records "forum" as paused with "forum declares a role it cannot accept" - @wip Scenario: A boot whose context ends pauses nothing Given the compiled plugins "events", "feed" and "gallery" And the store records "events", "feed" and "gallery" as active @@ -120,7 +109,6 @@ Feature: Booting plugins and pausing the one that fails Then the boot reports that its context ended And the store was not written - @wip Scenario: A store that cannot read its records stops the boot Given the compiled plugins "events" and "feed" And the store fails to read with "connection dropped" @@ -128,7 +116,6 @@ Feature: Booting plugins and pausing the one that fails Then the boot fails with: pluginkit: read the plugin records: connection dropped And the Register of "events" never ran - @wip Scenario: A store that cannot write a pause stops the boot Given the compiled plugins "events" and "feed" And the store records "events" and "feed" as active diff --git a/pluginkit/features/features_test.go b/pluginkit/features/features_test.go index 45b123c..705cf88 100644 --- a/pluginkit/features/features_test.go +++ b/pluginkit/features/features_test.go @@ -47,5 +47,5 @@ func TestRegistration(t *testing.T) { } func TestBoot(t *testing.T) { - runFeature(t, "boot.feature", func(*godog.ScenarioContext) {}) + runFeature(t, "boot.feature", initializeBoot(t)) } diff --git a/pluginkit/features/steps_boot_test.go b/pluginkit/features/steps_boot_test.go new file mode 100644 index 0000000..9c7dcf8 --- /dev/null +++ b/pluginkit/features/steps_boot_test.go @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: Apache-2.0 + +package features_test + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + + "github.com/cucumber/godog" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +// bootPlugin is a compiled plugin of a boot scenario, failing where the scenario says. +type bootPlugin struct { + id string + world *bootWorld +} + +// ID returns the plugin's id. +func (p *bootPlugin) ID() string { + return p.id +} + +// Migrate applies the plugin's schema, or fails as the scenario says. +func (p *bootPlugin) Migrate(context.Context) error { + return p.world.failure(p.world.migrateFails, p.id) +} + +// MigrationVersion reports the one version every scenario plugin ships. +func (p *bootPlugin) MigrationVersion() (string, error) { + return "1", nil +} + +// Start notes the start, ends the boot's context or fails as the scenario says. +func (p *bootPlugin) Start(context.Context) error { + if p.world.endsOn == p.id { + p.world.cancel() + } + return p.world.failure(p.world.startFails, p.id) +} + +// Stop releases nothing. +func (p *bootPlugin) Stop(context.Context) error { + return nil +} + +// bootStore is the scenario's plugin store, noting each write and failing as the scenario says. +type bootStore struct { + inner pluginkit.PluginStore + readErr error + writeErr error + writes int +} + +// Records answers the records, or the failure the scenario set. +func (s *bootStore) Records(ctx context.Context) (map[string]pluginkit.PluginRecord, error) { + if s.readErr != nil { + return nil, s.readErr + } + return s.inner.Records(ctx) +} + +// Activate notes the write and activates id, or answers the failure the scenario set. +func (s *bootStore) Activate(ctx context.Context, id string) error { + s.writes++ + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Activate(ctx, id) +} + +// Pause notes the write and pauses id, or answers the failure the scenario set. +func (s *bootStore) Pause(ctx context.Context, id, failure string) error { + s.writes++ + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Pause(ctx, id, failure) +} + +// bootMigrations is the scenario's migration store, failing the record of the plugins the scenario names. +type bootMigrations struct { + world *bootWorld +} + +// Versions answers that no plugin has recorded a version yet. +func (m bootMigrations) Versions(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} + +// Record keeps the version, or fails as the scenario says. +func (m bootMigrations) Record(_ context.Context, id, _ string) error { + return m.world.failure(m.world.recordFails, id) +} + +// bootWorld is what one boot scenario builds and finds. +type bootWorld struct { + t *testing.T + compiled []string + seeds map[string]pluginkit.PluginRecord + registerFail map[string]string + registerBoom map[string]string + migrateFails map[string]string + startFails map[string]string + recordFails map[string]string + rejected map[string]bool + endsOn string + readErr error + writeErr error + cancel context.CancelFunc + store *bootStore + booted bool + registered []string + started []string + err error +} + +// failure returns the error a scenario set for id in failures, or nil. +func (w *bootWorld) failure(failures map[string]string, id string) error { + if text, ok := failures[id]; ok { + return errors.New(text) + } + return nil +} + +// compile lists the plugins of list as compiled. +func (w *bootWorld) compile(list string) { + w.compiled = names(list) +} + +// noRecord leaves the store empty. +func (w *bootWorld) noRecord() {} + +// seed records each plugin of list in state before the boot. +func (w *bootWorld) seed(list, state string) { + for _, id := range names(list) { + w.seeds[id] = pluginkit.PluginRecord{ID: id, State: pluginkit.PluginState(state)} + } +} + +// pausedWith seeds the plugin id as paused with text before the boot, and checks that record after it. +func (w *bootWorld) pausedWith(id, text string) error { + want := pluginkit.PluginRecord{ID: id, State: pluginkit.PluginPaused, LastError: text} + if !w.booted { + w.seeds[id] = want + return nil + } + return w.holds(want) +} + +// set stores text under id in failures. +func set(failures map[string]string, id, text string) { + failures[id] = text +} + +// rejectRole makes the application pause the plugin id for a role it cannot accept. +func (w *bootWorld) rejectRole(id string) { + w.rejected[id] = true +} + +// endWhile ends the boot's context while the plugin id starts. +func (w *bootWorld) endWhile(id string) { + w.endsOn = id +} + +// readFails makes the store fail every read with text. +func (w *bootWorld) readFails(text string) { + w.readErr = errors.New(text) +} + +// writeFails makes the store fail every write with text. +func (w *bootWorld) writeFails(text string) { + w.writeErr = errors.New(text) +} + +// entries returns the registration of each compiled plugin. +func (w *bootWorld) entries() []pluginkit.Registration[string, *bootPlugin] { + entries := make([]pluginkit.Registration[string, *bootPlugin], 0, len(w.compiled)) + for _, id := range w.compiled { + entries = append(entries, pluginkit.Registration[string, *bootPlugin]{ID: id, Register: w.registerOf(id)}) + } + return entries +} + +// registerOf returns the Register of the plugin id, noting the call and failing as the scenario says. +func (w *bootWorld) registerOf(id string) func(string) (*bootPlugin, error) { + return func(string) (*bootPlugin, error) { + w.registered = append(w.registered, id) + if value, ok := w.registerBoom[id]; ok { + panic(value) + } + if err := w.failure(w.registerFail, id); err != nil { + return nil, err + } + return &bootPlugin{id: id, world: w}, nil + } +} + +// accept pauses each registered plugin the application rejects, and answers the ones it keeps. +func (w *bootWorld) accept(ctx context.Context, boot *pluginkit.Boot, + registered []*bootPlugin) ([]pluginkit.Plugin, error) { + var kept []pluginkit.Plugin + for _, plugin := range registered { + if !w.rejected[plugin.id] { + kept = append(kept, plugin) + continue + } + if err := boot.Pause(ctx, plugin.id, fmt.Errorf("%s declares a role it cannot accept", plugin.id)); err != nil { + return nil, err + } + } + return kept, nil +} + +// bootPlugins boots the compiled plugins as an application does, noting what started and how the boot ended. +func (w *bootWorld) bootPlugins() error { + var seeds []pluginkit.PluginRecord + for _, record := range w.seeds { + seeds = append(seeds, record) + } + w.store = &bootStore{inner: pluginstoretest.NewMemory(seeds...), readErr: w.readErr, writeErr: w.writeErr} + w.booted = true + ctx, cancel := context.WithCancel(w.t.Context()) + defer cancel() + w.cancel = cancel + w.err = w.run(ctx) + return nil +} + +// run registers, accepts and starts the plugins, answering the error that stopped the boot. +func (w *bootWorld) run(ctx context.Context) error { + boot, err := pluginkit.NewBoot(ctx, w.store) + if err != nil { + return err + } + outcome, err := pluginkit.Register(ctx, boot, w.entries(), func(id string) string { return id }) + if err != nil { + return err + } + plugins, err := w.accept(ctx, boot, outcome.Registered) + if err != nil { + return err + } + host, err := pluginkit.NewHostWithMigrationStore(bootMigrations{world: w}, nil, plugins...) + if err != nil { + return err + } + started, err := host.StartEach(ctx) + if err != nil { + return err + } + for _, plugin := range started.Started { + w.started = append(w.started, plugin.ID) + } + return boot.Started(ctx, started) +} + +// settled answers the error that stopped the boot, if one did. +func (w *bootWorld) settled() error { + if w.err != nil { + return fmt.Errorf("the boot failed: %w", w.err) + } + return nil +} + +// startedInOrder checks that exactly the plugins of list started, in order. +func (w *bootWorld) startedInOrder(list string) error { + if err := w.settled(); err != nil { + return err + } + if want := names(list); !slices.Equal(w.started, want) { + return fmt.Errorf("started %q, want %q", w.started, want) + } + return nil +} + +// neverStarted checks that the plugin id did not start. +func (w *bootWorld) neverStarted(id string) error { + if slices.Contains(w.started, id) { + return fmt.Errorf("%s started", id) + } + return nil +} + +// holds checks that the store holds want. +func (w *bootWorld) holds(want pluginkit.PluginRecord) error { + records, err := w.store.inner.Records(w.t.Context()) + if err != nil { + return err + } + if got := records[want.ID]; got != want { + return fmt.Errorf("the store holds %+v, want %+v", got, want) + } + return nil +} + +// recordsClean checks that the store holds the plugin id in state with no error. +func (w *bootWorld) recordsClean(id, state string) error { + return w.holds(pluginkit.PluginRecord{ID: id, State: pluginkit.PluginState(state)}) +} + +// notWritten checks that the boot wrote nothing to the store. +func (w *bootWorld) notWritten() error { + if w.store.writes > 0 { + return fmt.Errorf("the store was written %d times, want none", w.store.writes) + } + return nil +} + +// neverRegistered checks that the Register of the plugin id was not called. +func (w *bootWorld) neverRegistered(id string) error { + if slices.Contains(w.registered, id) { + return fmt.Errorf("the Register of %s ran", id) + } + return nil +} + +// contextEnded checks that the boot reported its ended context. +func (w *bootWorld) contextEnded() error { + if !errors.Is(w.err, context.Canceled) { + return fmt.Errorf("the boot ended with %v, want its context's end", w.err) + } + return nil +} + +// failedWith checks that the boot stopped with the text. +func (w *bootWorld) failedWith(text string) error { + if w.err == nil || w.err.Error() != text { + return fmt.Errorf("the boot ended with %v, want %q", w.err, text) + } + return nil +} + +// givenBoot registers the steps that set the plugins, the records and the failures. +func givenBoot(sc *godog.ScenarioContext, w *bootWorld) { + sc.Step(`^the compiled plugins (.+)$`, w.compile) + sc.Step(`^the store holds no record$`, w.noRecord) + sc.Step(`^the store records (.+) as (active|inactive|not installed)$`, w.seed) + sc.Step(`^the store records "([^"]*)" as paused with "([^"]*)"$`, w.pausedWith) + sc.Step(`^the Register of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.registerFail, id, text) }) + sc.Step(`^the Register of "([^"]*)" panics with "([^"]*)"$`, func(id, text string) { set(w.registerBoom, id, text) }) + sc.Step(`^the migration of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.migrateFails, id, text) }) + sc.Step(`^the Start of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.startFails, id, text) }) + sc.Step(`^the migration record of "([^"]*)" fails with "([^"]*)"$`, + func(id, text string) { set(w.recordFails, id, text) }) + sc.Step(`^the application finds that "([^"]*)" declares a role it cannot accept$`, w.rejectRole) + sc.Step(`^the context of the boot ends while "([^"]*)" starts$`, w.endWhile) + sc.Step(`^the store fails to read with "([^"]*)"$`, w.readFails) + sc.Step(`^the store fails to write with "([^"]*)"$`, w.writeFails) +} + +// thenBoot registers the step that boots and the steps that check what the boot did. +func thenBoot(sc *godog.ScenarioContext, w *bootWorld) { + sc.Step(`^the application boots its plugins$`, w.bootPlugins) + sc.Step(`^((?:"[^"]*"(?:, | and )?)+) started(?:, in that order)?$`, w.startedInOrder) + sc.Step(`^"([^"]*)" never started$`, w.neverStarted) + sc.Step(`^the store records "([^"]*)" as (active|inactive|not installed) with no error$`, w.recordsClean) + sc.Step(`^the store was not written$`, w.notWritten) + sc.Step(`^the Register of "([^"]*)" never ran$`, w.neverRegistered) + sc.Step(`^the boot reports that its context ended$`, w.contextEnded) + sc.Step(`^the boot fails with: (.+)$`, w.failedWith) +} + +// initializeBoot gives each boot scenario a world of its own and its steps. +func initializeBoot(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { + w := &bootWorld{ + t: t, seeds: map[string]pluginkit.PluginRecord{}, registerFail: map[string]string{}, + registerBoom: map[string]string{}, migrateFails: map[string]string{}, startFails: map[string]string{}, + recordFails: map[string]string{}, rejected: map[string]bool{}, + } + givenBoot(sc, w) + thenBoot(sc, w) + } +} From ab2b509c248be5aff1ae41466211f4b22226707e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:47:28 +0100 Subject: [PATCH 6/8] fix(pluginkit): pause nothing when the context ends while plugins register --- pluginkit/boot.go | 8 ++++---- pluginkit/boot_test.go | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pluginkit/boot.go b/pluginkit/boot.go index 7f227ac..abe971d 100644 --- a/pluginkit/boot.go +++ b/pluginkit/boot.go @@ -53,8 +53,11 @@ func admitted[D, P any](ctx context.Context, boot *Boot, entries []Registration[ return wanted, nil } -// pauseEach pauses each failed plugin but one whose migration record failed, stopping at a write that fails. +// pauseEach pauses each failed plugin but one whose migration record failed, and none once ctx ended. func (b *Boot) pauseEach(ctx context.Context, failures []Failure) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("pluginkit: the boot's context ended: %w", err) + } for _, failure := range failures { if failure.Step == StepRecord { continue @@ -88,9 +91,6 @@ func (b *Boot) activate(ctx context.Context, id string) error { // Started pauses each plugin that failed to migrate or start, and activates each paused plugin that started. func (b *Boot) Started(ctx context.Context, outcome StartOutcome) error { - if err := ctx.Err(); err != nil { - return fmt.Errorf("pluginkit: the boot's context ended: %w", err) - } if err := b.pauseEach(ctx, outcome.Failed); err != nil { return err } diff --git a/pluginkit/boot_test.go b/pluginkit/boot_test.go index 5ed5338..fe703c3 100644 --- a/pluginkit/boot_test.go +++ b/pluginkit/boot_test.go @@ -239,6 +239,27 @@ func TestRegisterNamesAStoreThatCannotWrite(t *testing.T) { } } +func TestRegisterPausesNothingWhenTheContextEnded(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + outcome, err := pluginkit.Register(ctx, boot, []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("feed", &calls, answers(errors.New("the boot is shutting down"))), + }, depsOf(&calls)) + + assertErrorText(t, err, "pluginkit: the boot's context ended: context canceled") + if !errors.Is(err, context.Canceled) { + t.Errorf("Register() error = %v, want it to wrap the context's error", err) + } + wantEqual(t, "failed", len(outcome.Failed), 1) + wantEqual(t, "writes", store.writes, []string(nil)) +} + func TestBootPausePausesAPluginForTheApplicationsReason(t *testing.T) { t.Parallel() From 794b23135bd9aca4dfdddf17d52782bdac29e20b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:47:29 +0100 Subject: [PATCH 7/8] test(pluginkit): check that a store keeps every other record when it activates a plugin --- pluginkit/pluginstoretest/pluginstoretest.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pluginkit/pluginstoretest/pluginstoretest.go b/pluginkit/pluginstoretest/pluginstoretest.go index 612d21d..f9f1379 100644 --- a/pluginkit/pluginstoretest/pluginstoretest.go +++ b/pluginkit/pluginstoretest/pluginstoretest.go @@ -56,6 +56,13 @@ func cases() map[string]func(t *testing.T, store pluginkit.PluginStore) { act(t, store.Activate(t.Context(), "events")) want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) }, + "activating a plugin keeps every other record": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "feed", "no route table")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{ + "events": active("events"), "feed": paused("feed", "no route table"), + }) + }, "one read answers every plugin by its id": func(t *testing.T, store pluginkit.PluginStore) { act(t, store.Activate(t.Context(), "events")) act(t, store.Pause(t.Context(), "feed", "no route table")) From 002822a2309485f9791e2bdbc6eda2a641e8c762 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 7 Nov 2026 22:47:30 +0100 Subject: [PATCH 8/8] test(pluginkit): pin a context that ends while plugins register and note every Start call --- pluginkit/features/boot.feature | 9 +++++++++ pluginkit/features/steps_boot_test.go | 27 ++++++++++++++------------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/pluginkit/features/boot.feature b/pluginkit/features/boot.feature index a8e49b1..f27a9e7 100644 --- a/pluginkit/features/boot.feature +++ b/pluginkit/features/boot.feature @@ -109,6 +109,15 @@ Feature: Booting plugins and pausing the one that fails Then the boot reports that its context ended And the store was not written + Scenario: A boot whose context ends while a plugin registers pauses nothing + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the context of the boot ends while "feed" registers + And the Register of "feed" fails with "the boot is shutting down" + When the application boots its plugins + Then the boot reports that its context ended + And the store was not written + Scenario: A store that cannot read its records stops the boot Given the compiled plugins "events" and "feed" And the store fails to read with "connection dropped" diff --git a/pluginkit/features/steps_boot_test.go b/pluginkit/features/steps_boot_test.go index 9c7dcf8..96c7400 100644 --- a/pluginkit/features/steps_boot_test.go +++ b/pluginkit/features/steps_boot_test.go @@ -36,9 +36,10 @@ func (p *bootPlugin) MigrationVersion() (string, error) { return "1", nil } -// Start notes the start, ends the boot's context or fails as the scenario says. +// Start notes the call, ends the boot's context or fails as the scenario says. func (p *bootPlugin) Start(context.Context) error { - if p.world.endsOn == p.id { + p.world.startCalls = append(p.world.startCalls, p.id) + if p.world.endsOn["starts"] == p.id { p.world.cancel() } return p.world.failure(p.world.startFails, p.id) @@ -109,13 +110,14 @@ type bootWorld struct { startFails map[string]string recordFails map[string]string rejected map[string]bool - endsOn string + endsOn map[string]string readErr error writeErr error cancel context.CancelFunc store *bootStore booted bool registered []string + startCalls []string started []string err error } @@ -163,11 +165,6 @@ func (w *bootWorld) rejectRole(id string) { w.rejected[id] = true } -// endWhile ends the boot's context while the plugin id starts. -func (w *bootWorld) endWhile(id string) { - w.endsOn = id -} - // readFails makes the store fail every read with text. func (w *bootWorld) readFails(text string) { w.readErr = errors.New(text) @@ -191,6 +188,9 @@ func (w *bootWorld) entries() []pluginkit.Registration[string, *bootPlugin] { func (w *bootWorld) registerOf(id string) func(string) (*bootPlugin, error) { return func(string) (*bootPlugin, error) { w.registered = append(w.registered, id) + if w.endsOn["registers"] == id { + w.cancel() + } if value, ok := w.registerBoom[id]; ok { panic(value) } @@ -279,10 +279,10 @@ func (w *bootWorld) startedInOrder(list string) error { return nil } -// neverStarted checks that the plugin id did not start. +// neverStarted checks that the Start of the plugin id was not called. func (w *bootWorld) neverStarted(id string) error { - if slices.Contains(w.started, id) { - return fmt.Errorf("%s started", id) + if slices.Contains(w.startCalls, id) { + return fmt.Errorf("the Start of %s ran", id) } return nil } @@ -349,7 +349,8 @@ func givenBoot(sc *godog.ScenarioContext, w *bootWorld) { sc.Step(`^the migration record of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.recordFails, id, text) }) sc.Step(`^the application finds that "([^"]*)" declares a role it cannot accept$`, w.rejectRole) - sc.Step(`^the context of the boot ends while "([^"]*)" starts$`, w.endWhile) + sc.Step(`^the context of the boot ends while "([^"]*)" (starts|registers)$`, + func(id, step string) { set(w.endsOn, step, id) }) sc.Step(`^the store fails to read with "([^"]*)"$`, w.readFails) sc.Step(`^the store fails to write with "([^"]*)"$`, w.writeFails) } @@ -372,7 +373,7 @@ func initializeBoot(t *testing.T) func(*godog.ScenarioContext) { w := &bootWorld{ t: t, seeds: map[string]pluginkit.PluginRecord{}, registerFail: map[string]string{}, registerBoom: map[string]string{}, migrateFails: map[string]string{}, startFails: map[string]string{}, - recordFails: map[string]string{}, rejected: map[string]bool{}, + recordFails: map[string]string{}, rejected: map[string]bool{}, endsOn: map[string]string{}, } givenBoot(sc, w) thenBoot(sc, w)