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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"]
module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/features", "pluginkit/graphwire"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand Down Expand Up @@ -99,7 +99,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire", "postgresblob"]
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/features", "pluginkit/graphwire", "postgresblob"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -121,7 +121,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire", "postgresblob"]
module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/features", "pluginkit/graphwire", "postgresblob"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand Down
7 changes: 7 additions & 0 deletions pluginkit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- `NewHook` collects the plugins that implement an interface into a `Hook`. `Hook.Fire` calls an action on each in registration order and `Filter` passes a value through each, every call inside its own recover.
- One firing shares `HookSettings.Budget` as a context deadline. The plugins after a slow one are still called, and an error that matches the ended context counts as `ContextEnded`, not as that plugin's fault.
- After a firing runs over its budget, the next `TimedFirings` firings time each plugin, without the time of `Report`, and a timed firing whose plugins together run over is charged to the slowest. `Stats` returns every counter, and `HookSettings.Report` receives each error and panic as a `HookFailure` and must not panic.
- `HookStats.UnchargedOverruns` counts the timed firings that ran over while their plugins together kept the budget, and `HookStats.TimedReportTime` the time `Report` took in timed firings.

## 0.9.0 - 2026-11-05

- `wire.Manifest` holds one `plugin.json` as data, reading `version`, `license`, `did`, `requires`, `tested`, `engines`, `uninstall`, `hooks`, `defines`, `jobs` and `settings`, with every other key kept raw in `Extra`.
Expand Down
36 changes: 36 additions & 0 deletions pluginkit/features/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
version: "2"

linters:
default: standard
enable:
- depguard
- lll
- misspell
- revive
- unconvert
- unparam
settings:
lll:
line-length: 120
revive:
rules:
- name: blank-imports
disabled: true
- name: exported
depguard:
rules:
dependency-purity:
list-mode: strict
allow:
- $gostd
- github.com/cucumber/godog
- github.com/gopherium/framework/pluginkit

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/gopherium/framework
39 changes: 39 additions & 0 deletions pluginkit/features/features_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: Apache-2.0

package features_test

import (
"os"
"testing"

"github.com/cucumber/godog"
)

// wipTag selects the scenarios no code makes pass yet.
const wipTag = "@wip"

// runFeature runs one feature file as its own suite, refusing undefined steps.
func runFeature(t *testing.T, path string, initialize func(*godog.ScenarioContext)) {
t.Helper()
tags := "~" + wipTag
if os.Getenv("PLUGINKIT_BDD_WIP") != "" {
tags = ""
}
suite := godog.TestSuite{
ScenarioInitializer: initialize,
Options: &godog.Options{
Format: "pretty",
Paths: []string{path},
Tags: tags,
Strict: true,
TestingT: t,
},
}
if suite.Run() != 0 {
t.Error("the feature did not pass")
}
}

func TestHooks(t *testing.T) {
runFeature(t, "hooks.feature", initializeHooks(t))
}
20 changes: 20 additions & 0 deletions pluginkit/features/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module github.com/gopherium/framework/pluginkit/features

go 1.27.3

require (
github.com/cucumber/godog v0.16.0
github.com/gopherium/framework/pluginkit v0.0.0
)

require (
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/spf13/pflag v1.0.10 // indirect
)

replace github.com/gopherium/framework/pluginkit => ../
28 changes: 28 additions & 0 deletions pluginkit/features/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
169 changes: 169 additions & 0 deletions pluginkit/features/hooks.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
Feature: Calling the plugins that implement a hook
A hook is a named moment where an application calls every plugin that
implements one interface, in the order the plugins were registered. An
action tells each plugin that something happened, and a filter passes a
value through each plugin. One firing of a hook shares one time budget
among all its plugins. A plugin that fails, panics or runs slow never
stops the plugins after it. The counters name the plugin that failed or
panicked, and the slowest one once the firings after an overrun are
timed. The report is the application's own function that receives each
error and panic of a plugin.

Scenario: Every plugin that implements the hook is called in order
Given the plugins "seo-basics" and "analytics" implement the hook "public.head-tags"
And the plugin "theme" is registered without implementing it
And the plugin "feed" implements the hook "public.head-tags"
When the hook fires
Then "seo-basics", "analytics" and "feed" are called in that order

Scenario: A failing plugin leaves the plugins after it running
Given the plugins "analytics", "feed" and "seo-basics" implement the hook "content.changed"
And "feed" fails with "index unavailable"
When the hook fires
Then "analytics", "feed" and "seo-basics" are called in that order
And the report holds "pluginkit: feed content.changed: index unavailable"
And "feed" is counted 1 error

Scenario: A panicking plugin leaves the plugins after it running
Given the plugins "analytics", "feed" and "seo-basics" implement the hook "content.changed"
And "feed" panics with "boom"
When the hook fires
Then "analytics", "feed" and "seo-basics" are called in that order
And the report holds "pluginkit: feed content.changed panicked: boom" with the stack of the panic
And "feed" is counted 1 panic

Scenario: The plugins after a slow one are still called
Given the plugins "analytics", "feed" and "seo-basics" implement the hook "public.head-tags"
And the hook has a budget of 2 ms
And "analytics" takes 5 ms
When the hook fires
Then "analytics", "feed" and "seo-basics" are called in that order
And "feed" and "seo-basics" are called after the deadline passed
And the hook is counted 1 overrun

Scenario: An error caused by the ended deadline is not the plugin's fault
Given the plugins "analytics" and "search" implement the hook "content.changed"
And the hook has a budget of 2 ms
And "analytics" takes 5 ms
And "search" fails with the error of its ended context
When the hook fires
Then "search" is counted 0 errors and 1 call whose context ended
And the report marks the failure of "search" as caused by the ended context

Scenario: An error of its own after the deadline still counts against the plugin
Given the plugins "analytics" and "search" implement the hook "content.changed"
And the hook has a budget of 2 ms
And "analytics" takes 5 ms
And "search" fails with "index unavailable"
When the hook fires
Then "search" is counted 1 error and 0 calls whose context ended

Scenario: A plugin that hides the context's error behind its own text is counted an error
Given the plugins "analytics" and "search" implement the hook "content.changed"
And the hook has a budget of 2 ms
And "analytics" takes 5 ms
And "search" fails with the text of its ended context but not the error itself
When the hook fires
Then "search" is counted 1 error and 0 calls whose context ended

Scenario: After the deadline, an error that panics when the hook unwraps it counts as a panic of its plugin
Given the plugins "analytics", "search" and "feed" implement the hook "content.changed"
And the hook has a budget of 2 ms
And "analytics" takes 5 ms
And "search" fails with an error that panics when it is unwrapped
When the hook fires
Then "analytics", "search" and "feed" are called in that order
And "search" is counted 1 panic

Scenario: A panic in the report reaches the code that fired the hook
Given the plugins "analytics" and "feed" implement the hook "content.changed"
And "analytics" fails with "index unavailable"
And the report panics with "report broken"
When the hook fires
Then the firing panics with "report broken"
And "feed" is not called

Scenario: An untimed overrun charges no plugin
Given the plugins "analytics", "reviews" and "feed" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 20 firings after an overrun
And "analytics" takes 3 ms
When the hook fires
Then the hook is counted 1 overrun
And no plugin is charged an overrun
And the hook is counted 0 timed overruns that no plugin is charged for

Scenario: A timed overrun is charged to the slowest plugin
Given the plugins "reviews", "analytics" and "feed" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 20 firings after an overrun
And the hook has run over its budget once
When the hook fires while the plugins take
| plugin | ms |
| reviews | 0.5 |
| analytics | 1.2 |
| feed | 0.9 |
Then "analytics" is charged 1 overrun
And "reviews" and "feed" are charged no overrun

Scenario: The time the report takes is not charged to the failing plugin
Given the plugins "reviews", "analytics" and "feed" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 20 firings after an overrun
And the report takes 1.5 ms
And "reviews" fails with "rating unavailable"
And the hook has run over its budget once
When the hook fires while the plugins take
| plugin | ms |
| reviews | 0.1 |
| analytics | 1.2 |
| feed | 0.9 |
Then "analytics" is charged 1 overrun
And "reviews" and "feed" are charged no overrun
And the hook is counted 0 timed overruns that no plugin is charged for
And the report is counted 1.5 ms in the timed firings

Scenario: A firing that only the report runs over its budget charges no plugin
Given the plugins "reviews" and "analytics" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 20 firings after an overrun
And the report takes 3 ms
And "reviews" fails with "rating unavailable"
And the hook has run over its budget once
When the hook fires while the plugins take
| plugin | ms |
| reviews | 0.1 |
| analytics | 0.5 |
Then the hook is counted 2 overruns
And no plugin is charged an overrun
And the hook is counted 1 timed overrun that no plugin is charged for
And the report is counted 3 ms in the timed firings

Scenario: Each plugin is timed only for the firings after an overrun
Given the plugins "analytics" and "feed" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 3 firings after an overrun
And the hook has run over its budget once
When the hook fires 5 times within its budget
Then "analytics" and "feed" are each timed in 3 firings
And no plugin is charged an overrun

Scenario: An overrun among the timed firings starts the timing again in full
Given the plugins "analytics" and "feed" implement the hook "public.head-tags"
And the hook has a budget of 2 ms and times each plugin for 4 firings after an overrun
And the hook has run over its budget once
And the hook fires 2 times within its budget
And the hook has run over its budget once
When the hook fires 5 times within its budget
Then "analytics" and "feed" are each timed in 7 firings

Scenario: A filter passes the value through every plugin
Given the plugins "brand" and "suffix" implement the filter hook "page.title"
And "brand" puts "Example: " before the title
And "suffix" puts " | Home" after the title
When the hook filters the title "Hello"
Then the title becomes "Example: Hello | Home"

Scenario: A filter keeps the incoming value when a plugin fails or panics
Given the plugins "brand", "broken", "loud" and "suffix" implement the filter hook "page.title"
And "brand" puts "Example: " before the title
And "broken" fails with "no title"
And "loud" panics with "boom"
And "suffix" puts " | Home" after the title
When the hook filters the title "Hello"
Then the title becomes "Example: Hello | Home"
Loading
Loading