Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pluginkit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to
- `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.
- `RegisterEach` and `Register` take only plugins with an `ID() string` method, and fail at `StepRegister` a plugin whose `ID` is not its entry's id, or a `Register` that answers no plugin, a breaking change.

## 0.12.0 - 2026-11-07

Expand Down
2 changes: 1 addition & 1 deletion pluginkit/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func NewBoot(ctx context.Context, store PluginStore) (*Boot, error) {
}

// 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],
func Register[D any, P interface{ ID() string }](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 {
Expand Down
8 changes: 8 additions & 0 deletions pluginkit/features/boot.feature
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ 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"

Scenario: A plugin that calls itself by another id is paused under its entry's id
Given the compiled plugins "events" and "feed"
And the Register of "feed" answers a plugin that calls itself "news"
When the application boots its plugins
Then "events" started
And the store records "feed" as paused with "pluginkit: feed register: the plugin calls itself "news""
And the store holds no record of "news"

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"
Expand Down
21 changes: 19 additions & 2 deletions pluginkit/features/registration.feature
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ Feature: Registering compiled plugins one by one
every backend plugin's id and Register function, in wiring order, in
place of one function that registers them all. The application keeps
the plugins it wants on, builds each one's dependencies from its id,
and registers them one by one. A plugin whose Register fails or panics
is reported by its id, and the plugins after it still register.
and registers them one by one. A plugin whose Register fails or panics,
or answers a plugin whose ID is not the entry's id, is reported by the
entry's id, and the plugins after it still register.

Scenario: The registration list names each backend plugin in wiring order
Given the plugin "events" has a backend
Expand Down Expand Up @@ -58,6 +59,22 @@ Feature: Registering compiled plugins one by one
And "feed" failed at the step "register" with: pluginkit: feed register: the title is too long
And the failure of "feed" is not marked as a panic

Scenario: A plugin that calls itself by another id fails alone
Given the compiled plugins "events", "feed" and "gallery"
And the Register of "feed" answers a plugin that calls itself "news"
When the application registers each plugin
Then "events" and "gallery" are registered, in that order
And "feed" failed at the step "register" with: pluginkit: feed register: the plugin calls itself "news"
And the failure of "feed" is not marked as a panic

Scenario: A plugin whose Register answers no plugin fails alone
Given the compiled plugins "events", "feed" and "gallery"
And the Register of "feed" answers no plugin
When the application registers each plugin
Then "events" and "gallery" are registered, in that order
And "feed" failed at the step "register" with: pluginkit: feed register: Register answered no plugin
And the failure of "feed" is not marked as a panic

Scenario: An error that holds the text of a panic is not marked as a panic
Given the compiled plugins "events" and "feed"
And the Register of "feed" answers the error "pluginkit: feed register panicked: boom"
Expand Down
22 changes: 21 additions & 1 deletion pluginkit/features/steps_boot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ type bootWorld struct {
seeds map[string]pluginkit.PluginRecord
registerFail map[string]string
registerBoom map[string]string
calledAs map[string]string
migrateFails map[string]string
startFails map[string]string
recordFails map[string]string
Expand Down Expand Up @@ -197,6 +198,9 @@ func (w *bootWorld) registerOf(id string) func(string) (*bootPlugin, error) {
if err := w.failure(w.registerFail, id); err != nil {
return nil, err
}
if name, ok := w.calledAs[id]; ok {
return &bootPlugin{id: name, world: w}, nil
}
return &bootPlugin{id: id, world: w}, nil
}
}
Expand Down Expand Up @@ -287,6 +291,18 @@ func (w *bootWorld) neverStarted(id string) error {
return nil
}

// noRecordOf checks that the store holds no record of the plugin id.
func (w *bootWorld) noRecordOf(id string) error {
records, err := w.store.inner.Records(w.t.Context())
if err != nil {
return err
}
if held, ok := records[id]; ok {
return fmt.Errorf("the store holds %+v, want no record of %s", held, 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())
Expand Down Expand Up @@ -341,9 +357,11 @@ 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 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 Register of "([^"]*)" answers a plugin that calls itself "([^"]*)"$`,
func(id, name string) { set(w.calledAs, id, name) })
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 "([^"]*)"$`,
Expand All @@ -362,6 +380,7 @@ func thenBoot(sc *godog.ScenarioContext, w *bootWorld) {
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 store holds no record of "([^"]*)"$`, w.noRecordOf)
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)
Expand All @@ -374,6 +393,7 @@ func initializeBoot(t *testing.T) func(*godog.ScenarioContext) {
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{}, endsOn: map[string]string{},
calledAs: map[string]string{},
}
givenBoot(sc, w)
thenBoot(sc, w)
Expand Down
27 changes: 26 additions & 1 deletion pluginkit/features/steps_registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ type laneDeps struct{ id string }
// lanePlugin is a plugin a scenario registered.
type lanePlugin struct{ id string }

// ID returns the id the plugin calls itself.
func (p *lanePlugin) ID() string {
return p.id
}

// registrationFailure is one failure of a registration outcome, its error as text.
type registrationFailure struct {
ID string
Expand All @@ -58,7 +63,9 @@ type registrationWorld struct {
compiled []string
leftOut map[string]bool
answers map[string]func() error
calledAs map[string]string
depsPanic map[string]string
noPlugin map[string]bool
built []string
received map[string]laneDeps
registered []string
Expand Down Expand Up @@ -254,6 +261,16 @@ func (w *registrationWorld) registerFails(id, text string) {
w.answers[id] = func() error { return errors.New(text) }
}

// registerCallsItself makes the Register of id answer a plugin whose ID is name.
func (w *registrationWorld) registerCallsItself(id, name string) {
w.calledAs[id] = name
}

// registerAnswersNothing makes the Register of id answer no plugin and no error.
func (w *registrationWorld) registerAnswersNothing(id string) {
w.noPlugin[id] = true
}

// depsPanics makes building the dependencies of id panic with value.
func (w *registrationWorld) depsPanics(id, value string) {
w.depsPanic[id] = value
Expand All @@ -280,6 +297,12 @@ func (w *registrationWorld) registerOf(id string) func(laneDeps) (*lanePlugin, e
return nil, err
}
}
if w.noPlugin[id] {
return nil, nil
}
if name, ok := w.calledAs[id]; ok {
return &lanePlugin{id: name}, nil
}
return &lanePlugin{id: id}, nil
}
}
Expand Down Expand Up @@ -414,6 +437,8 @@ func givenRegistration(sc *godog.ScenarioContext, w *registrationWorld) {
sc.Step(`^the application leaves "([^"]*)" out$`, w.leaveOut)
sc.Step(`^the Register of "([^"]*)" panics with "([^"]*)"$`, w.registerPanics)
sc.Step(`^the Register of "([^"]*)" answers the error "([^"]*)"$`, w.registerFails)
sc.Step(`^the Register of "([^"]*)" answers a plugin that calls itself "([^"]*)"$`, w.registerCallsItself)
sc.Step(`^the Register of "([^"]*)" answers no plugin$`, w.registerAnswersNothing)
sc.Step(`^building the dependencies of "([^"]*)" panics with "([^"]*)"$`, w.depsPanics)
givenBuild(sc, w)
}
Expand Down Expand Up @@ -449,7 +474,7 @@ func initializeRegistration(t *testing.T) func(*godog.ScenarioContext) {
w := &registrationWorld{
t: t, manifests: map[string]map[string]any{}, cfg: registrationConfig, leftOut: map[string]bool{},
answers: map[string]func() error{}, depsPanic: map[string]string{}, received: map[string]laneDeps{},
sources: map[string]string{},
sources: map[string]string{}, calledAs: map[string]string{}, noPlugin: map[string]bool{},
}
givenRegistration(sc, w)
whenRegistration(sc, w)
Expand Down
42 changes: 37 additions & 5 deletions pluginkit/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

package pluginkit

import "context"
import (
"context"
"errors"
"fmt"
"reflect"
)

// errNoPlugin is the failure of a Register that answered neither a plugin nor an error.
var errNoPlugin = errors.New("Register answered no plugin")

// Registration is one compiled plugin's manifest id and the function that registers it from its dependencies.
type Registration[D, P any] struct {
Expand All @@ -16,8 +24,10 @@ type RegisterOutcome[P any] struct {
Failed []Failure
}

// RegisterEach calls each entry's Register with depsFor of its id, each inside its own recover, past any failure.
func RegisterEach[D, P any](entries []Registration[D, P], depsFor func(id string) D) RegisterOutcome[P] {
// RegisterEach calls each entry's Register with depsFor of its id inside a recover, past any failure or mismatched ID.
func RegisterEach[D any, P interface{ ID() string }](
entries []Registration[D, P], depsFor func(id string) D,
) RegisterOutcome[P] {
var outcome RegisterOutcome[P]
for _, entry := range entries {
plugin, err := register(entry, depsFor(entry.ID))
Expand All @@ -31,11 +41,33 @@ func RegisterEach[D, P any](entries []Registration[D, P], depsFor func(id string
}

// register calls entry's Register with deps inside a recover, naming the plugin in any failure.
func register[D, P any](entry Registration[D, P], deps D) (plugin P, err error) {
func register[D any, P interface{ ID() string }](entry Registration[D, P], deps D) (plugin P, err error) {
err = guarded(context.Background(), entry.ID+" "+string(StepRegister), func(context.Context) error {
var registerErr error
plugin, registerErr = entry.Register(deps)
return registerErr
if registerErr != nil {
return registerErr
}
if isNil(plugin) {
return errNoPlugin
}
if id := plugin.ID(); id != entry.ID {
return fmt.Errorf("the plugin calls itself %q", id)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return nil
})
return plugin, err
}

// isNil reports whether plugin holds nothing: a nil interface, pointer, map, slice, channel or function.
func isNil(plugin any) bool {
value := reflect.ValueOf(plugin)
switch value.Kind() {
case reflect.Invalid:
return true
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func:
return value.IsNil()
default:
return false
}
}
96 changes: 95 additions & 1 deletion pluginkit/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package pluginkit_test

import (
"context"
"errors"
"testing"

Expand All @@ -18,6 +19,11 @@ type listedPlugin struct {
deps listedDeps
}

// ID returns the id the plugin calls itself.
func (p *listedPlugin) ID() string {
return p.id
}

// entry is one registration a test lists, its Register noting its call in calls and answering through answer.
func entry(id string, calls *[]string, answer func() error) pluginkit.Registration[listedDeps, *listedPlugin] {
register := func(deps listedDeps) (*listedPlugin, error) {
Expand Down Expand Up @@ -63,7 +69,7 @@ func registeredIDs(outcome pluginkit.RegisterOutcome[*listedPlugin]) []string {
}

// failures returns the failures of outcome with their errors as text.
func failures(outcome pluginkit.RegisterOutcome[*listedPlugin]) []failureWant {
func failures[P any](outcome pluginkit.RegisterOutcome[P]) []failureWant {
got := []failureWant{}
for _, failure := range outcome.Failed {
got = append(got, failureWant{failure.ID, failure.Step, failure.Panicked, failure.Err.Error()})
Expand Down Expand Up @@ -140,6 +146,94 @@ func TestRegisterEachGoesOnPastAFailingRegisterAndNamesIt(t *testing.T) {
}
}

func TestRegisterEachFailsAPluginWhoseIDIsNotItsEntrys(t *testing.T) {
t.Parallel()

tests := map[string]struct {
plugin *listedPlugin
panicked bool
text string
}{
"another id": {&listedPlugin{id: "news"}, false, `pluginkit: feed register: the plugin calls itself "news"`},
"no id": {&listedPlugin{}, false, `pluginkit: feed register: the plugin calls itself ""`},
"no plugin": {nil, false, "pluginkit: feed register: Register answered no plugin"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()

var calls []string
entries := []pluginkit.Registration[listedDeps, *listedPlugin]{
entry("events", &calls, succeeds),
{ID: "feed", Register: func(listedDeps) (*listedPlugin, error) { return tc.plugin, nil }},
}

outcome := pluginkit.RegisterEach(entries, depsOf(&calls))

wantEqual(t, "registered", registeredIDs(outcome), []string{"events"})
wantEqual(t, "failed", failures(outcome), []failureWant{
{"feed", pluginkit.StepRegister, tc.panicked, tc.text},
})
})
}
}

// steadyPlugin is a plugin whose ID reads nothing behind its pointer.
type steadyPlugin struct{}

// ID returns the id of the entry that registers it.
func (*steadyPlugin) ID() string {
return "feed"
}

// valuePlugin is a plugin held by value.
type valuePlugin struct{}

// ID returns the id of the entry that registers it.
func (valuePlugin) ID() string {
return "feed"
}

// Start starts nothing.
func (valuePlugin) Start(context.Context) error {
return nil
}

// Stop stops nothing.
func (valuePlugin) Stop(context.Context) error {
return nil
}

func TestRegisterEachFailsARegisterThatAnswersANilPlugin(t *testing.T) {
t.Parallel()

var calls []string
want := []failureWant{{"feed", pluginkit.StepRegister, false, "pluginkit: feed register: Register answered no plugin"}}

pointer := pluginkit.RegisterEach([]pluginkit.Registration[listedDeps, *steadyPlugin]{
{ID: "feed", Register: func(listedDeps) (*steadyPlugin, error) { return nil, nil }},
}, depsOf(&calls))
held := pluginkit.RegisterEach([]pluginkit.Registration[listedDeps, pluginkit.Plugin]{
{ID: "feed", Register: func(listedDeps) (pluginkit.Plugin, error) { return nil, nil }},
}, depsOf(&calls))

wantEqual(t, "a nil pointer whose ID reads nothing", failures(pointer), want)
wantEqual(t, "a nil interface", failures(held), want)
}

func TestRegisterEachRegistersAPluginHeldByValue(t *testing.T) {
t.Parallel()

var calls []string

outcome := pluginkit.RegisterEach([]pluginkit.Registration[listedDeps, valuePlugin]{
{ID: "feed", Register: func(listedDeps) (valuePlugin, error) { return valuePlugin{}, nil }},
}, depsOf(&calls))

wantEqual(t, "registered", outcome.Registered, []valuePlugin{{}})
wantEqual(t, "failed", failures(outcome), []failureWant{})
}

func TestRegisterEachWrapsTheErrorARegisterAnswered(t *testing.T) {
t.Parallel()

Expand Down
Loading