From 53dbb897ed635f06a5685fefa6ef93754f96f520 Mon Sep 17 00:00:00 2001 From: Ovsep Avakian Date: Wed, 29 Jul 2026 21:42:13 +0300 Subject: [PATCH 1/3] Add mesh-level plugins with a Profiler and Autowire A mesh plugin bundles initialization for a whole mesh, which makes it the home for cross-cutting concerns that belong to no single component. Since a mesh is constructed empty and filled by AddComponents afterwards, a plugin reaches components by registering an OnComponentAdded hook and instrumenting each one as it arrives -- so it can observe every activation without any component knowing it exists. Storage, the duplicate-name check and the sorted init order now live in internal/plugin.Registry[T], shared by both levels. Component plugins used to initialize in map order; since plugins register hooks and hooks fire in registration order, that order was observable and varied between runs of the same binary. Both levels now sort by name. Two plugins ship in plugin/: - Profiler times runs, cycles and each component's activations. A Go CPU profile of a mesh is dominated by the scheduler and barely distinguishes one component from another; timing activations directly names the slow one. Components activate concurrently and queue on the profiler's mutex, so the start is stamped inside the lock and the end before it, keeping that queueing out of the measured window. - Autowire pipes ports by naming convention, in both directions on every arrival, so AddComponents order does not matter. Pipes are not deduplicated and a port flushes once per pipe, so it skips a pair that is already wired -- two conventions agreeing on one pair would otherwise deliver every signal twice. Docs: .agent/docs/hooks.md covers both levels and the shared registry; docs/wiki/502.-Plugins.md is the user-facing page, linked from Home and 501. Co-Authored-By: Claude Opus 5 (1M context) --- .agent/docs/hooks.md | 58 ++++- component/component.go | 9 +- component/plugin.go | 36 +-- component/plugin_test.go | 43 ++++ docs/wiki/501.-Hooks.md | 2 + docs/wiki/502.-Plugins.md | 237 ++++++++++++++++++++ docs/wiki/Home.md | 2 + fmesh.go | 9 + internal/plugin/registry.go | 64 ++++++ internal/plugin/registry_test.go | 81 +++++++ plugin.go | 56 +++++ plugin/autowire.go | 142 ++++++++++++ plugin/plugin_test.go | 364 +++++++++++++++++++++++++++++++ plugin/profiler.go | 245 +++++++++++++++++++++ plugin_test.go | 122 +++++++++++ 15 files changed, 1443 insertions(+), 27 deletions(-) create mode 100644 docs/wiki/502.-Plugins.md create mode 100644 internal/plugin/registry.go create mode 100644 internal/plugin/registry_test.go create mode 100644 plugin.go create mode 100644 plugin/autowire.go create mode 100644 plugin/plugin_test.go create mode 100644 plugin/profiler.go create mode 100644 plugin_test.go diff --git a/.agent/docs/hooks.md b/.agent/docs/hooks.md index aac38ec..24cf8b2 100644 --- a/.agent/docs/hooks.md +++ b/.agent/docs/hooks.md @@ -1,11 +1,13 @@ # Hooks & plugins — extension points -Source: `hooks.go`, `component/hooks.go`, `port/hooks.go`, `internal/hook/hook_group.go`, `component/plugin.go`. +Source: `hooks.go`, `component/hooks.go`, `port/hooks.go`, `internal/hook/hook_group.go`, +`plugin.go`, `component/plugin.go`, `internal/plugin/registry.go`, `plugin/`. ## The hook primitive -`hook.Group[T]` (package `internal/hook` — not part of the public API) — the **one approved -generic type** in the codebase. An ordered +`hook.Group[T]` (package `internal/hook` — not part of the public API). Generics are confined to +`internal/` containers — `hook.Group[T]` and `plugin.Registry[T]` are **the only two approved +generic types** in the codebase; nothing public is generic. An ordered slice of `func(T) error`; `Trigger(arg)` runs all in insertion order, **fail-fast** on first error. Registration is chainable and happens through `SetupHooks(func(*Hooks))` closures (or the `WithHooks` constructor option on components) — the `Hooks` structs' fields are unexported, so @@ -43,8 +45,48 @@ closures are the only registration path. ## Plugins -`component.Plugin` interface: `GetName() string` + `Init(*Component) error`. Registered via the -`component.WithPlugins(...)` constructor option; `Init` runs during `component.New` (after all -other options), duplicate names are a construction error. Query with `c.PluginRegistered(name)`. -A plugin is just an initialization bundle — typically registers hooks/ports on the component. -Order inside `New`: options → plugin `Init`s → `OnCreation` hooks. +Two levels, same shape: `GetName() string` + `Init(T) error`, registered via a `WithPlugins(...)` +constructor option, duplicate names are a construction error, queried with `PluginRegistered(name)`. +A plugin is just an initialization bundle — typically registers hooks. + +| Level | Interface | Registration | `Init` receives | Query | +|---|---|---|---|---| +| Component | `component.Plugin` | `component.WithPlugins(...)` | `*Component` | `c.PluginRegistered(name)` | +| Mesh | `fmesh.Plugin` | `fmesh.WithPlugins(...)` | `*FMesh` | `fm.PluginRegistered(name)` | + +Storage, the duplicate-name check and the init order are shared: both levels hold an +`internal/plugin.Registry[T]` and call its `InitAll`. Only the public interfaces and the +`WithPlugins` options (which must be typed as each level's `Option`) live at the level itself — so +the two cannot drift, and a third level would not be a third copy. + +- **Both levels `Init` in sorted name order**, not map order — plugins register hooks, hooks fire in + registration order, so init order is observable and must not vary between runs of the same binary. +- **Component**: `Init` runs during `component.New` after all other options. It may modify the + component's ports and metadata as well as its hooks. + Order inside `New`: options → plugin `Init`s → `OnCreation` hooks. +- **Mesh**: `Init` runs during `fmesh.New` after the options *and* after `runtimeInfo` is built, so + a plugin sees a fully configured mesh. + +**A mesh plugin cannot walk components in `Init`** — a mesh is constructed empty and filled by +`AddComponents` afterwards, so there is nothing to walk yet. To instrument components, register an +`OnComponentAdded` hook and attach component hooks to each one as it arrives. That is how a mesh +plugin observes every activation without any component knowing it exists, and it is the intended +pattern for profiling, tracing, and assertion plugins. + +Two consequences of the arrival-hook pattern, both learned the hard way in `plugin/`: + +- A component is only ever looked at **on arrival**, so ports added afterwards + (`AddInputs`/`AddOutputs`) are invisible to a plugin that wires or inspects ports. +- Instrumentation hooks fire from the **concurrent activation goroutines**. Guard shared state, and + take clock readings *outside* the plugin's own lock — timing from inside the critical section + charges each component for the contention caused by all the others. + +## Bundled plugins (`plugin/`) + +Package `plugin` ships mesh-level plugins built on exactly the pattern above. It imports `fmesh`, so +`fmesh` can never import it. + +| Plugin | What it does | +|---|---| +| `plugin.NewProfiler()` | Times whole runs, single cycles, and each component's activations. `Runs()`, `Cycles()`, `Components()`, `TopN(n)`, `Report()`, `Reset()`. One instance belongs to one mesh. | +| `plugin.Prefixed(prefix)` / `Broadcast(name)` / `BroadcastAs(out, in)` / `&Autowire{Name: ...}` | Pipes ports by naming convention, in both directions on every arrival, so `AddComponents` order does not matter. Each convention is a separate plugin instance with its own `PluginName`. | diff --git a/component/component.go b/component/component.go index cb142be..ad79e49 100644 --- a/component/component.go +++ b/component/component.go @@ -5,6 +5,7 @@ import ( "fmt" "log" + "github.com/hovsep/fmesh/internal/plugin" "github.com/hovsep/fmesh/meta" "github.com/hovsep/fmesh/port" ) @@ -23,7 +24,7 @@ type Component struct { state State parentMesh ParentMesh hooks *Hooks - plugins plugins + plugins *plugin.Registry[*Component] } // New creates a new component with the given name and options. @@ -47,10 +48,8 @@ func New(name string, opts ...Option) (*Component, error) { } } - for pluginName, plugin := range c.plugins { - if err := plugin.Init(c); err != nil { - return nil, fmt.Errorf("component %q plugin %s initialization failed: %w", name, pluginName, err) - } + if err := c.initPlugins(); err != nil { + return nil, err } if err := c.hooks.onCreation.Trigger(c); err != nil { diff --git a/component/plugin.go b/component/plugin.go index 974ad3e..17b3123 100644 --- a/component/plugin.go +++ b/component/plugin.go @@ -1,6 +1,10 @@ package component -import "fmt" +import ( + "fmt" + + "github.com/hovsep/fmesh/internal/plugin" +) // Plugin defines the component plugin interface. type Plugin interface { @@ -8,22 +12,13 @@ type Plugin interface { Init(*Component) error } -// plugins defines a container of component plugins. -type plugins map[string]Plugin - -// newPlugins is a constructor for plugins. -func newPlugins() plugins { - return make(plugins) -} - // WithPlugins is a component constructor option that adds plugins. func WithPlugins(plugins ...Plugin) Option { return func(c *Component) error { - for _, plugin := range plugins { - if _, exists := c.plugins[plugin.GetName()]; exists { - return fmt.Errorf("plugin %s already registered", plugin.GetName()) + for _, p := range plugins { + if err := c.plugins.Add(p); err != nil { + return err } - c.plugins[plugin.GetName()] = plugin } return nil } @@ -31,5 +26,18 @@ func WithPlugins(plugins ...Plugin) Option { // PluginRegistered returns true if the plugin is registered. func (c *Component) PluginRegistered(name string) bool { - return c.plugins[name] != nil + return c.plugins.Has(name) +} + +// initPlugins runs every registered plugin's Init, in name order. +func (c *Component) initPlugins() error { + if err := c.plugins.InitAll(c); err != nil { + return fmt.Errorf("component %q %w", c.name, err) + } + return nil +} + +// newPlugins is a constructor for the component plugin registry. +func newPlugins() *plugin.Registry[*Component] { + return plugin.NewRegistry[*Component]() } diff --git a/component/plugin_test.go b/component/plugin_test.go index ed46b3a..de91791 100644 --- a/component/plugin_test.go +++ b/component/plugin_test.go @@ -1,6 +1,7 @@ package component import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -46,6 +47,8 @@ func TestComponent_Plugin(t *testing.T) { }), 0.0001) assert.True(t, c.Labels().ValueIs("plugin/price/version", "v1.2.4")) assert.True(t, c.Scalars().ValueIs("plugin/price/threshold", 105.54)) + assert.True(t, c.PluginRegistered("PricePlugin")) + assert.False(t, c.PluginRegistered("nope")) }) t.Run("plugins can be registered only once", func(t *testing.T) { @@ -66,8 +69,48 @@ func TestComponent_Plugin(t *testing.T) { require.ErrorContains(t, err, "plugin PricePlugin already registered") assert.Nil(t, c) }) + + t.Run("plugins initialize in name order", func(t *testing.T) { + // Same reasoning as at mesh level: plugins register hooks, hooks fire in + // registration order, so init order is observable behavior. + var order []string + record := func(name string) recordingPlugin { + return recordingPlugin{name: name, seen: &order} + } + + c, err := New("dummy", WithPlugins(record("zulu"), record("alpha"), record("mike"))) + + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, []string{"alpha", "mike", "zulu"}, order) + }) + + t.Run("a failing plugin fails construction", func(t *testing.T) { + c, err := New("dummy", WithPlugins(brokenPlugin{})) + + require.Error(t, err) + require.ErrorContains(t, err, `component "dummy" plugin brokenPlugin initialization failed`) + assert.Nil(t, c) + }) +} + +type recordingPlugin struct { + name string + seen *[]string } +func (p recordingPlugin) GetName() string { return p.name } + +func (p recordingPlugin) Init(*Component) error { + *p.seen = append(*p.seen, p.name) + return nil +} + +type brokenPlugin struct{} + +func (brokenPlugin) GetName() string { return "brokenPlugin" } +func (brokenPlugin) Init(*Component) error { return errors.New("no") } + type PricePlugin struct { } diff --git a/docs/wiki/501.-Hooks.md b/docs/wiki/501.-Hooks.md index 7c5a742..67364a2 100644 --- a/docs/wiki/501.-Hooks.md +++ b/docs/wiki/501.-Hooks.md @@ -425,3 +425,5 @@ Hooks make F-Mesh more extensible and observable without compromising the simpli For reading `ActivationResult`s after a run, see [Inspecting a run](https://github.com/hovsep/fmesh/wiki/402.-Inspecting-a-run). +To package a set of hooks so it can be attached to any component or mesh — including the `OnComponentAdded` trick that lets one object instrument every component in a mesh — see [502. Plugins](https://github.com/hovsep/fmesh/wiki/502.-Plugins). + diff --git a/docs/wiki/502.-Plugins.md b/docs/wiki/502.-Plugins.md new file mode 100644 index 0000000..3bd6bd0 --- /dev/null +++ b/docs/wiki/502.-Plugins.md @@ -0,0 +1,237 @@ +## Overview + +A plugin is a named bundle of initialization. You pass it to a constructor, its `Init` runs once, and whatever it set up — hooks, ports, metadata, pipes — is simply part of the thing you constructed. + +That is all a plugin is. Its value is that it makes a concern *portable*: written once, it can be attached to any component or any mesh without editing either. + +Plugins come at two levels, with the same shape: + +| Level | Interface | Attach with | `Init` receives | Query | +|-------|-----------|-------------|-----------------|-------| +| Component | `component.Plugin` | `component.WithPlugins(...)` | `*component.Component` | `c.PluginRegistered(name)` | +| Mesh | `fmesh.Plugin` | `fmesh.WithPlugins(...)` | `*fmesh.FMesh` | `fm.PluginRegistered(name)` | + +Both interfaces are two methods: + +```go +GetName() string +Init(T) error +``` + +Rules that hold at both levels: + +- **Names are unique.** Attaching two plugins with the same name is a construction error. +- **A failing `Init` fails construction.** `New` returns `nil` and the error; you never get a half-initialized object. +- **`Init` runs in sorted name order.** Plugins mostly register hooks, and hooks fire in registration order — so init order is visible in behavior, and sorting keeps it identical between runs. + +--- + +## Component plugins + +`Init` runs during `component.New`, after every other option has been applied. The component is fully formed at that point, so a plugin can add ports, metadata, state and hooks: + +```go +type PricePlugin struct{} + +func (PricePlugin) GetName() string { return "price" } + +func (PricePlugin) Init(c *component.Component) error { + // Extend the component's interface + if err := c.AddInputs("price_in"); err != nil { + return err + } + if err := c.AddOutputs("price_out"); err != nil { + return err + } + + // Extend its behavior + c.SetupHooks(func(hooks *component.Hooks) { + hooks.OnCreation(func(this *component.Component) error { + this.State().Set("base_price", 77.5) + return nil + }) + hooks.OnActivation(func(this *component.Component) error { + return this.OutputByName("price_out").PutPayloads(999.0) + }) + }) + + // Extend its metadata + c.AddLabel("plugin/price/version", "v1.2.4") + return nil +} +``` + +```go +c, err := component.New("priced", + component.WithInputs("i1"), + component.WithOutputs("o1"), + component.WithActivationFunc(myLogic), + component.WithPlugins(PricePlugin{}), +) +``` + +Order inside `component.New`: **options → plugin `Init`s → `OnCreation` hooks.** A plugin registering an `OnCreation` hook still has it run, because `OnCreation` fires after all plugins are initialized. + +--- + +## Mesh plugins + +A mesh plugin bundles initialization for a whole mesh, which makes it the right home for anything cross-cutting — measuring, tracing, exporting, asserting, wiring. None of those belong to a single component, and all of them would otherwise have to be repeated in every one. + +`Init` runs during `fmesh.New`, after the options, so the plugin sees a fully configured mesh. + +### The one thing to know: you cannot walk components in `Init` + +A mesh is constructed **empty** and filled by `AddComponents` afterwards. At `Init` time there is nothing to walk: + +```go +func (p *MyPlugin) Init(fm *fmesh.FMesh) error { + fm.Components().ForEach(...) // always empty — the mesh has no components yet + return nil +} +``` + +Instead, register an `OnComponentAdded` hook and instrument each component **as it arrives**: + +```go +type ActivationCounter struct { + count atomic.Int64 +} + +func (p *ActivationCounter) GetName() string { return "activation-counter" } + +func (p *ActivationCounter) Init(fm *fmesh.FMesh) error { + fm.SetupHooks(func(hooks *fmesh.Hooks) { + hooks.OnComponentAdded(func(ctx *fmesh.ComponentAddedContext) error { + ctx.Component.SetupHooks(func(ch *component.Hooks) { + ch.AfterActivation(func(*component.ActivationContext) error { + p.count.Add(1) + return nil + }) + }) + return nil + }) + }) + return nil +} + +func (p *ActivationCounter) Count() int64 { return p.count.Load() } +``` + +```go +counter := &ActivationCounter{} +fm, err := fmesh.New("mesh", fmesh.WithPlugins(counter)) +// ... AddComponents, Run ... +fmt.Println(counter.Count()) +``` + +That indirection is the whole trick: the plugin observes every activation in the mesh, and no component knows it exists. + +### Two consequences of instrumenting on arrival + +- **Components are inspected only when added.** Ports created later with `AddInputs`/`AddOutputs` are invisible to the plugin. Declare ports in `component.New`. +- **Component hooks run concurrently.** Components in a cycle activate in parallel, so a plugin holding shared state must guard it — and if it is timing anything, take the clock readings *outside* its own lock, or each component gets charged for the contention caused by the others. + +--- + +## Bundled plugins + +The `plugin` package ships ready-made mesh plugins. + +```go +import "github.com/hovsep/fmesh/plugin" +``` + +### Profiler — where the time goes + +`Profiler` times whole runs, single cycles, and every component's activations. The per-component numbers are the reason it exists: a Go CPU profile of a mesh is dominated by the scheduler and barely distinguishes one component from another, because every component's work is the same handful of runtime calls. Timing activations directly names the slow one. + +```go +prof := plugin.NewProfiler() + +fm, err := fmesh.New("mesh", fmesh.WithPlugins(prof)) +// ... AddComponents, Run ... + +fmt.Println(prof.Report()) +``` + +``` +runs: 1, total 2.41ms, avg 2.41ms +cycles: 7, total 2.28ms, avg 325µs + +component count total avg min max +tokenizer 7 1.42ms 202µs 180µs 310µs +counter 7 612µs 87µs 71µs 140µs +``` + +| Method | Returns | +|--------|---------| +| `Runs()` | `Stat` for whole runs | +| `Cycles()` | `Stat` for individual cycles | +| `Components()` | `[]ComponentStat`, slowest total first | +| `TopN(n)` | the `n` busiest components, by activation count | +| `Report()` | the table above, as a string | +| `Reset()` | discards everything measured so far | + +`Stat` carries `Count`, `Total`, `Min`, `Max` and an `Avg()` method. Stats accumulate across runs, which is what you want when comparing a mesh against itself; call `Reset()` between runs that should not be pooled. One `Profiler` belongs to one mesh. + +`TopN` and `Components()` answer different questions. `Components()` sorts by total time — *what is slow*. `TopN` sorts by activation count — *what is hot*. A component that activates every cycle and does almost nothing can matter more than one that is individually slow but rarely runs. + +### Autowire — pipes by naming convention + +A mesh of any size accumulates long stretches of wiring that say nothing a reader could not have guessed: everything that keeps time gets the clock, everything that wants the weather gets the weather. Written out, that is a list to maintain, and forgetting it fails silently — the component sits there, looking perfectly connected, waiting on inputs that never arrive. + +`Autowire` makes the list derivable. Give it a rule mapping an output port to the input port name that should receive it, and it wires every match: + +```go +fm, err := fmesh.New("mesh", + fmesh.WithPlugins(plugin.Broadcast("time")), +) + +// No PipeTo calls: every component with an input named "time" +// is wired to every output named "time". +err = fm.AddComponents(clock, heart, lung) +``` + +Three ready-made conventions plus the general form: + +| Constructor | Rule | +|-------------|------| +| `plugin.Broadcast("time")` | every output named `time` → every input named `time` | +| `plugin.BroadcastAs("tick", "time")` | every output named `tick` → every input named `time` | +| `plugin.Prefixed("env_")` | an output → the input named `_`, e.g. `env_sun_uvi` | +| `&plugin.Autowire{Name: func(source *component.Component, output *port.Port) string {...}}` | your own rule; return `""` to decline | + +Wiring happens on every arrival, **in both directions**: a new component is offered every output already in the mesh, and every output it brings is offered to the components already there. So the order of `AddComponents` does not matter — which is the property that makes a convention safe to lean on. + +A mesh often wants more than one convention at once — a clock reaching everything that keeps time, *and* a set of environmental factors reaching whatever asked for them by name. Those are separate rules, so they are separate plugins: + +```go +fm, err := fmesh.New("habitat", fmesh.WithPlugins( + plugin.BroadcastAs("tick", "time"), + plugin.Prefixed("env_"), +)) +``` + +Each carries its own `PluginName`, so they coexist. Two conventions that happen to agree on the same output/input pair wire it once, not twice. + +Two limits worth knowing: + +- A component is never wired to **itself**. A convention is not how you express a loopback — use `c.LoopbackPipe(out, in)`. +- Ports must exist **before** `AddComponents`, since arrival is the only moment a component is looked at. + +--- + +## Choosing a level + +| You want to... | Use | +|----------------|-----| +| Give one kind of component extra ports, state or behavior | Component plugin | +| Observe or wire *every* component, without touching any of them | Mesh plugin + `OnComponentAdded` | +| React to a lifecycle event in one specific mesh, once | Just a hook — see [501. Hooks](https://github.com/hovsep/fmesh/wiki/501.-Hooks) | + +A plugin is worth the wrapper when the setup is reusable or belongs to a concern rather than to a component. For a one-off, `SetupHooks` directly is simpler and says more. + +--- + +For complete API documentation, see [FMesh](https://pkg.go.dev/github.com/hovsep/fmesh#Plugin), [Component](https://pkg.go.dev/github.com/hovsep/fmesh/component#Plugin) and the [plugin package](https://pkg.go.dev/github.com/hovsep/fmesh/plugin). diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 19b4467..f08a978 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -30,6 +30,7 @@ F-Mesh follows semantic versioning. See [releases page](https://github.com/hovse | Running the mesh | [401. Scheduling rules](https://github.com/hovsep/fmesh/wiki/401.-Scheduling-rules) | | Inspecting a run | [402. Inspecting a run](https://github.com/hovsep/fmesh/wiki/402.-Inspecting-a-run) | | Hooks | [501. Hooks](https://github.com/hovsep/fmesh/wiki/501.-Hooks) | +| Plugins | [502. Plugins](https://github.com/hovsep/fmesh/wiki/502.-Plugins) | | Configuration & tips | [601. Tips & tricks](https://github.com/hovsep/fmesh/wiki/601.-Tips-&-tricks) | | Patterns & recipes | [602. Patterns & recipes](https://github.com/hovsep/fmesh/wiki/602.-Patterns-and-recipes) | | Export / visualization | [701. Export](https://github.com/hovsep/fmesh/wiki/701.-Export) | @@ -41,6 +42,7 @@ F-Mesh follows semantic versioning. See [releases page](https://github.com/hovse * [Port](https://pkg.go.dev/github.com/hovsep/fmesh/port) * [Signal](https://pkg.go.dev/github.com/hovsep/fmesh/signal) * [Meta (labels & scalars)](https://pkg.go.dev/github.com/hovsep/fmesh/meta) +* [Plugin (bundled mesh plugins)](https://pkg.go.dev/github.com/hovsep/fmesh/plugin) The `cycle` package surfaces in the run-time report — see [402. Inspecting a run](https://github.com/hovsep/fmesh/wiki/402.-Inspecting-a-run); hooks are covered in [501. Hooks](https://github.com/hovsep/fmesh/wiki/501.-Hooks). diff --git a/fmesh.go b/fmesh.go index dfa9a31..b76f975 100644 --- a/fmesh.go +++ b/fmesh.go @@ -8,6 +8,7 @@ import ( "github.com/hovsep/fmesh/component" "github.com/hovsep/fmesh/cycle" + "github.com/hovsep/fmesh/internal/plugin" "github.com/hovsep/fmesh/meta" ) @@ -25,6 +26,7 @@ type FMesh struct { logger *log.Logger config Config hooks *Hooks + plugins *plugin.Registry[*FMesh] } // New creates a new F-Mesh with the default configuration and applies any provided options. @@ -38,6 +40,7 @@ func New(name string, opts ...Option) (*FMesh, error) { logger: newDefaultLogger(name), config: newDefaultConfig(), hooks: newHooks(), + plugins: newPlugins(), } for _, opt := range opts { if err := opt(fm); err != nil { @@ -47,6 +50,12 @@ func New(name string, opts ...Option) (*FMesh, error) { // Built after the options so the runtime info picks up the configured // history limit (Run rebuilds it the same way). fm.runtimeInfo = newRuntimeInfo(fm.config.CyclesHistoryLimit) + + // Last, so a plugin sees the fully configured mesh and can rely on anything + // the options set up. + if err := fm.initPlugins(); err != nil { + return nil, err + } return fm, nil } diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..5145a43 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,64 @@ +// Package plugin provides the storage and initialization order shared by the +// mesh- and component-level plugin systems. +// +// Not part of the public API: the two public Plugin interfaces +// (fmesh.Plugin, component.Plugin) and their WithPlugins options stay where +// users expect them. Only the parts that were identical at both levels — the +// by-name container, the duplicate check, and the init order — live here, so a +// third level cannot drift from the first two. +package plugin + +import ( + "fmt" + "maps" + "slices" +) + +// Plugin is the shape every plugin has: a name, and an Init that receives the +// thing being constructed. +type Plugin[T any] interface { + GetName() string + Init(T) error +} + +// Registry is a by-name collection of plugins. +type Registry[T any] struct { + plugins map[string]Plugin[T] +} + +// NewRegistry creates an empty registry. +func NewRegistry[T any]() *Registry[T] { + return &Registry[T]{plugins: make(map[string]Plugin[T])} +} + +// Add registers plugins, failing on the first duplicate name. +func (r *Registry[T]) Add(plugins ...Plugin[T]) error { + for _, p := range plugins { + name := p.GetName() + if _, exists := r.plugins[name]; exists { + return fmt.Errorf("plugin %s already registered", name) + } + r.plugins[name] = p + } + return nil +} + +// Has returns true if a plugin with that name is registered. +func (r *Registry[T]) Has(name string) bool { + return r.plugins[name] != nil +} + +// InitAll runs every plugin's Init against the target, in name order. +// +// The order is sorted rather than whatever the map yields because plugins +// register hooks, and hooks fire in registration order. Ranging a map would make +// the order in which two plugins observe the same event differ between runs of +// the same program. +func (r *Registry[T]) InitAll(target T) error { + for _, name := range slices.Sorted(maps.Keys(r.plugins)) { + if err := r.plugins[name].Init(target); err != nil { + return fmt.Errorf("plugin %s initialization failed: %w", name, err) + } + } + return nil +} diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go new file mode 100644 index 0000000..9a137d7 --- /dev/null +++ b/internal/plugin/registry_test.go @@ -0,0 +1,81 @@ +package plugin + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// target is a stand-in for whatever a plugin initializes (*FMesh, *Component). +type target struct { + seen []string +} + +type recorder struct { + name string + err error +} + +func (r recorder) GetName() string { return r.name } + +func (r recorder) Init(t *target) error { + t.seen = append(t.seen, r.name) + return r.err +} + +func TestRegistry(t *testing.T) { + t.Run("empty registry initializes nothing", func(t *testing.T) { + tgt := &target{} + r := NewRegistry[*target]() + + require.NoError(t, r.InitAll(tgt)) + assert.Empty(t, tgt.seen) + assert.False(t, r.Has("anything")) + }) + + t.Run("plugins initialize in name order", func(t *testing.T) { + // Plugins register hooks and hooks fire in registration order, so this + // order is observable behavior. Ranging the map would make it vary + // between runs of the same binary. + tgt := &target{} + r := NewRegistry[*target]() + require.NoError(t, r.Add(recorder{name: "zulu"}, recorder{name: "alpha"}, recorder{name: "mike"})) + + require.NoError(t, r.InitAll(tgt)) + assert.Equal(t, []string{"alpha", "mike", "zulu"}, tgt.seen) + }) + + t.Run("a registered plugin is queryable by name", func(t *testing.T) { + r := NewRegistry[*target]() + require.NoError(t, r.Add(recorder{name: "alpha"})) + + assert.True(t, r.Has("alpha")) + assert.False(t, r.Has("Alpha"), "names are compared exactly") + }) + + t.Run("a duplicate name is rejected", func(t *testing.T) { + r := NewRegistry[*target]() + require.NoError(t, r.Add(recorder{name: "alpha"})) + + err := r.Add(recorder{name: "alpha"}) + require.Error(t, err) + assert.ErrorContains(t, err, "plugin alpha already registered") + }) + + t.Run("a failing plugin stops initialization", func(t *testing.T) { + tgt := &target{} + r := NewRegistry[*target]() + require.NoError(t, r.Add( + recorder{name: "alpha"}, + recorder{name: "mike", err: errors.New("no")}, + recorder{name: "zulu"}, + )) + + err := r.InitAll(tgt) + require.Error(t, err) + require.ErrorContains(t, err, "plugin mike initialization failed: no") + assert.Equal(t, []string{"alpha", "mike"}, tgt.seen, "zulu never ran") + }) +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..e046d13 --- /dev/null +++ b/plugin.go @@ -0,0 +1,56 @@ +package fmesh + +import ( + "fmt" + + "github.com/hovsep/fmesh/internal/plugin" +) + +// Plugin defines the mesh plugin interface. +// +// The component-level counterpart (component.Plugin) bundles initialization for +// one component. This one bundles it for a whole mesh, which makes it the +// natural home for anything cross-cutting: measuring, tracing, exporting, +// asserting. None of those belong to any single component, and all of them would +// otherwise have to be wired into every one by hand. +// +// A plugin that wants to reach the components cannot simply walk them in Init: +// a mesh is constructed empty and filled by AddComponents afterwards, so at Init +// time there is nothing to walk. It registers an OnComponentAdded hook instead +// and instruments each component as it arrives. That indirection is the whole +// trick, and it is why a mesh plugin can observe every activation in a mesh +// without a single component knowing it exists. +type Plugin interface { + GetName() string + Init(*FMesh) error +} + +// WithPlugins is a mesh constructor option that adds plugins. +func WithPlugins(plugins ...Plugin) Option { + return func(fm *FMesh) error { + for _, p := range plugins { + if err := fm.plugins.Add(p); err != nil { + return err + } + } + return nil + } +} + +// PluginRegistered returns true if the plugin is registered. +func (fm *FMesh) PluginRegistered(name string) bool { + return fm.plugins.Has(name) +} + +// initPlugins runs every registered plugin's Init, in name order. +func (fm *FMesh) initPlugins() error { + if err := fm.plugins.InitAll(fm); err != nil { + return fmt.Errorf("fmesh %q %w", fm.name, err) + } + return nil +} + +// newPlugins is a constructor for the mesh plugin registry. +func newPlugins() *plugin.Registry[*FMesh] { + return plugin.NewRegistry[*FMesh]() +} diff --git a/plugin/autowire.go b/plugin/autowire.go new file mode 100644 index 0000000..754a794 --- /dev/null +++ b/plugin/autowire.go @@ -0,0 +1,142 @@ +package plugin + +import ( + "errors" + "fmt" + + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" +) + +// Autowire pipes components together by naming convention instead of by hand. +// +// A mesh of any size accumulates long stretches of wiring that say nothing an +// attentive reader could not have guessed: every component that keeps time gets +// the clock, every component that wants the weather gets the weather. Written +// out, that is a list which has to be maintained, and the failure mode is +// silent -- add a component, forget the list, and it sits there activating +// forever on inputs that never arrive, looking perfectly connected. +// +// The convention makes the list derivable. An input port named by Name(output) +// is wired to that output automatically, whatever it is and whenever it appears. +// +// Wiring happens as components are added, and in both directions: a new +// component is offered every output already in the mesh, and every output it +// brings is offered to the components already there. Order of AddComponents +// therefore does not matter, which is the property that makes it safe to lean on. +// +// What does matter is that a component carries its ports when it is added: the +// only moment a component is looked at is its arrival, so ports created later +// with AddInputs/AddOutputs are never wired, and -- as with any missing pipe -- +// nothing says so. Declare ports in component.New. +// +// A mesh commonly wants more than one convention at once -- a clock that reaches +// everything keeping time, and a set of environmental factors that reach +// whatever asked for them by name. Those are separate rules, so they are +// separate plugins, which is why each carries its own PluginName. +type Autowire struct { + // Name maps a source component and one of its output ports to the input + // port name that should receive it. Returning "" declines to wire. + Name func(source *component.Component, output *port.Port) string + + // PluginName distinguishes one convention from another on the same mesh. + // Defaults to "autowire". + PluginName string +} + +// Prefixed wires an output to any input named "_". +// +// It is the shape most meshes converge on: "habitat_gas_environmental_gas" is +// the gas factor's environmental_gas output, and reads as one. +func Prefixed(prefix string) *Autowire { + return &Autowire{ + PluginName: "autowire:prefixed:" + prefix, + Name: func(source *component.Component, output *port.Port) string { + return fmt.Sprintf("%s%s_%s", prefix, source.Name(), output.Name()) + }, + } +} + +// Broadcast wires every output named portName to every input of the same name. +func Broadcast(portName string) *Autowire { + return BroadcastAs(portName, portName) +} + +// BroadcastAs wires every output named outputName to every input named +// inputName. +// +// This is the clock case, where the two differ: a component emitting "tick" +// feeds everything that declared an input called "time", including the +// components added after it. +func BroadcastAs(outputName, inputName string) *Autowire { + return &Autowire{ + PluginName: "autowire:broadcast:" + outputName + "->" + inputName, + Name: func(_ *component.Component, output *port.Port) string { + if output.Name() != outputName { + return "" + } + return inputName + }, + } +} + +// GetName implements fmesh.Plugin. +func (a *Autowire) GetName() string { + if a.PluginName == "" { + return "autowire" + } + return a.PluginName +} + +// Init implements fmesh.Plugin. +func (a *Autowire) Init(fm *fmesh.FMesh) error { + if a.Name == nil { + return errors.New("autowire: Name must be set") + } + + fm.SetupHooks(func(hooks *fmesh.Hooks) { + hooks.OnComponentAdded(func(ctx *fmesh.ComponentAddedContext) error { + arrived := ctx.Component + + return ctx.FMesh.Components().ForEach(func(existing *component.Component) error { + if existing == arrived { + // Wiring a component to itself would be a loopback, which is + // never what a convention meant to express. + return nil + } + if err := a.connect(existing, arrived); err != nil { + return err + } + return a.connect(arrived, existing) + }) + }) + }) + return nil +} + +// connect pipes every output of source to the matching input of destination. +func (a *Autowire) connect(source, destination *component.Component) error { + return source.Outputs().ForEach(func(out *port.Port) error { + name := a.Name(source, out) + if name == "" { + return nil + } + in := destination.InputByName(name) + if in == nil { + return nil + } + if isPipedTo(out, in) { + // Pipes are not deduplicated, and a port flushes once per pipe, so a + // second identical pipe would deliver every signal twice. Two + // conventions on the same mesh can easily agree on one pair. + return nil + } + return out.PipeTo(in) + }) +} + +// isPipedTo reports whether out already pipes to in. +func isPipedTo(out, in *port.Port) bool { + return out.Pipes().Find(func(p *port.Port) bool { return p == in }) != nil +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go new file mode 100644 index 0000000..9e21077 --- /dev/null +++ b/plugin/plugin_test.go @@ -0,0 +1,364 @@ +package plugin + +import ( + "errors" + "testing" + "time" + + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/port" + "github.com/hovsep/fmesh/signal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfiler(t *testing.T) { + p := NewProfiler() + + fm, err := fmesh.New("m", fmesh.WithPlugins(p)) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("producer", + component.WithInputs("i1"), + component.WithOutputs("o1"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("o1").PutPayloads(1) + })), + mustComponent("consumer", + component.WithInputs("i1"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + )) + + require.NoError(t, fm.Components().ByName("producer").OutputByName("o1"). + PipeTo(fm.Components().ByName("consumer").InputByName("i1"))) + require.NoError(t, fm.Components().ByName("producer").InputByName("i1"). + PutSignals(signal.New("go"))) + + _, err = fm.Run() + require.NoError(t, err) + + assert.Equal(t, 1, p.Runs().Count) + assert.Positive(t, p.Cycles().Count, "the run took at least one cycle") + + stats := p.Components() + require.Len(t, stats, 2, "both components timed, without either knowing") + + byName := map[string]ComponentStat{} + for _, s := range stats { + byName[s.Component] = s + } + assert.Equal(t, 1, byName["producer"].Count) + assert.Equal(t, 1, byName["consumer"].Count) + assert.GreaterOrEqual(t, byName["producer"].Max, byName["producer"].Min) + + assert.Contains(t, p.Report(), "producer") + + t.Run("TopN ranks by how often, not how slow", func(t *testing.T) { + top := p.TopN(1) + require.Len(t, top, 1) + assert.Equal(t, 1, top[0].Count) + }) + + t.Run("TopN clamps to what was measured", func(t *testing.T) { + assert.Len(t, p.TopN(100), 2, "asking for more than exists returns everything") + assert.Empty(t, p.TopN(0)) + assert.Empty(t, p.TopN(-1), "a negative n returns nothing rather than panicking") + }) + + t.Run("an empty stat has no average", func(t *testing.T) { + assert.Zero(t, Stat{}.Avg()) + }) + + t.Run("Reset discards everything measured", func(t *testing.T) { + p.Reset() + + assert.Zero(t, p.Runs().Count) + assert.Zero(t, p.Cycles().Count) + assert.Empty(t, p.Components()) + }) +} + +func TestProfiler_NothingMeasured(t *testing.T) { + p := NewProfiler() + + assert.Zero(t, p.Runs().Count) + assert.Zero(t, p.Cycles().Count) + assert.Empty(t, p.Components()) + assert.Empty(t, p.TopN(3)) + assert.Contains(t, p.Report(), "runs: 0", "the header renders without any data behind it") +} + +func TestProfiler_RanksByActivationCount(t *testing.T) { + // TestProfiler times two components that each activate exactly once, so it + // never sees TopN actually rank anything. + p := NewProfiler() + fm, err := fmesh.New("m", fmesh.WithPlugins(p)) + require.NoError(t, err) + + looper := mustComponent("looper", + component.WithInputs("i1"), + component.WithOutputs("o1"), + component.WithActivationFunc(func(this *component.Component) error { + left, _ := this.State().GetOrDefault("left", 3).(int) + if left <= 1 { + return nil + } + this.State().Set("left", left-1) + return this.OutputByName("o1").PutPayloads(left - 1) + })) + require.NoError(t, looper.LoopbackPipe("o1", "i1")) + + oneShot := mustComponent("one-shot", + component.WithInputs("i1"), + component.WithActivationFunc(func(*component.Component) error { return nil })) + + require.NoError(t, fm.AddComponents(looper, oneShot)) + require.NoError(t, looper.InputByName("i1").PutSignals(signal.New("go"))) + require.NoError(t, oneShot.InputByName("i1").PutSignals(signal.New("go"))) + + _, err = fm.Run() + require.NoError(t, err) + + top := p.TopN(2) + require.Len(t, top, 2) + assert.Equal(t, "looper", top[0].Component, "the busiest component comes first") + assert.Greater(t, top[0].Count, top[1].Count) +} + +func TestProfiler_TiesBreakOnName(t *testing.T) { + // Both sorts fall back to the component name so a report cannot reorder + // itself between runs. Real durations never tie, so the stats are planted + // directly -- there is no other way to reach the tiebreak deterministically. + p := NewProfiler() + p.components = map[string]Stat{ + "charlie": {Count: 2, Total: time.Second}, + "alpha": {Count: 2, Total: time.Second}, + "bravo": {Count: 9, Total: time.Millisecond}, + } + + byTotal := make([]string, 0, 3) + for _, s := range p.Components() { + byTotal = append(byTotal, s.Component) + } + assert.Equal(t, []string{"alpha", "charlie", "bravo"}, byTotal, + "slowest total first, equal totals in name order") + + byCount := make([]string, 0, 3) + for _, s := range p.TopN(3) { + byCount = append(byCount, s.Component) + } + assert.Equal(t, []string{"bravo", "alpha", "charlie"}, byCount, + "busiest first, equal counts in name order") +} + +func TestProfiler_ActivationThatNeverBegan(t *testing.T) { + // AfterActivation is a finally block: a component whose BeforeActivation hook + // fails still reaches it, with no start time recorded. Registering the failing + // hook before the plugin arrives puts it ahead of the profiler's own hook in + // the group, so the profiler never sees the start. + p := NewProfiler() + + fm, err := fmesh.New("m", + fmesh.WithPlugins(p), + fmesh.WithErrorHandlingStrategy(fmesh.IgnoreAll)) + require.NoError(t, err) + + c := mustComponent("doomed", + component.WithInputs("i1"), + component.WithHooks(func(hooks *component.Hooks) { + hooks.BeforeActivation(func(*component.Component) error { + return errors.New("refused") + }) + }), + component.WithActivationFunc(func(*component.Component) error { return nil })) + require.NoError(t, fm.AddComponents(c)) + require.NoError(t, c.InputByName("i1").PutSignals(signal.New("go"))) + + _, err = fm.Run() + require.NoError(t, err) + + assert.Empty(t, p.Components(), "an activation that never began is not timed") +} + +func TestAutowire(t *testing.T) { + t.Run("wires by convention regardless of the order components arrive", func(t *testing.T) { + // The consumer is added first, so it can only be wired by the second + // component's arrival -- which is the half that a one-directional + // implementation silently gets wrong. + fm, err := fmesh.New("m", fmesh.WithPlugins(Prefixed("env_"))) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("body", + component.WithInputs("env_sun_uvi"), + component.WithActivationFunc(recordArrival("env_sun_uvi"))), + )) + require.NoError(t, fm.AddComponents( + mustComponent("sun", + component.WithInputs("i1"), + component.WithOutputs("uvi"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("uvi").PutPayloads(7) + })), + )) + + require.NoError(t, fm.Components().ByName("sun").InputByName("i1"). + PutSignals(signal.New("go"))) + _, err = fm.Run() + require.NoError(t, err) + + assert.Equal(t, 7, fm.Components().ByName("body").State().Get("arrived"), + "the sun reached the body with no wiring written") + }) + + t.Run("Broadcast feeds every same-named input", func(t *testing.T) { + fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("clock", + component.WithInputs("i1"), + component.WithOutputs("time"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("time").PutPayloads(1) + })), + mustComponent("heart", + component.WithInputs("time"), + component.WithActivationFunc(recordArrival("time"))), + mustComponent("lung", + component.WithInputs("time"), + component.WithActivationFunc(recordArrival("time"))), + )) + + require.NoError(t, fm.Components().ByName("clock").InputByName("i1"). + PutSignals(signal.New("go"))) + _, err = fm.Run() + require.NoError(t, err) + + assert.Equal(t, 1, fm.Components().ByName("heart").State().Get("arrived")) + assert.Equal(t, 1, fm.Components().ByName("lung").State().Get("arrived")) + }) + + t.Run("declining to name a port wires nothing", func(t *testing.T) { + fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("clock", + component.WithOutputs("not_time"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + mustComponent("heart", + component.WithInputs("time"), + component.WithActivationFunc(recordArrival("time"))), + )) + + assert.Empty(t, fm.Components().ByName("clock").OutputByName("not_time").Pipes().Len(), + "a declined port is left unwired") + }) + + t.Run("a plugin with no naming rule fails construction", func(t *testing.T) { + fm, err := fmesh.New("m", fmesh.WithPlugins(&Autowire{})) + + require.Error(t, err) + require.ErrorContains(t, err, "Name must be set") + assert.Nil(t, fm) + }) + + t.Run("naming an input the destination does not have wires nothing", func(t *testing.T) { + // Distinct from declining: the rule does name a port, there is just no + // such input on this component. + fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("clock", + component.WithOutputs("time"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + mustComponent("deaf", + component.WithInputs("not_time"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + )) + + assert.Zero(t, fm.Components().ByName("clock").OutputByName("time").Pipes().Len()) + }) + + t.Run("two conventions agreeing on a pair wire it once", func(t *testing.T) { + // Pipes are not deduplicated and a port flushes once per pipe, so a + // second identical pipe would deliver every signal twice. + everythingIsTime := &Autowire{ + PluginName: "autowire:everything-is-time", + Name: func(*component.Component, *port.Port) string { return "time" }, + } + + fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"), everythingIsTime)) + require.NoError(t, err) + + require.NoError(t, fm.AddComponents( + mustComponent("clock", + component.WithInputs("i1"), + component.WithOutputs("time"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("time").PutPayloads(1) + })), + mustComponent("heart", + component.WithInputs("time"), + component.WithActivationFunc(func(this *component.Component) error { + this.State().Set("received", this.InputByName("time").Signals().Len()) + return nil + })), + )) + + assert.Equal(t, 1, fm.Components().ByName("clock").OutputByName("time").Pipes().Len()) + + require.NoError(t, fm.Components().ByName("clock").InputByName("i1"). + PutSignals(signal.New("go"))) + _, err = fm.Run() + require.NoError(t, err) + + assert.Equal(t, 1, fm.Components().ByName("heart").State().Get("received"), + "the tick arrived once, not once per convention") + }) + + t.Run("a failing pipe fails AddComponents", func(t *testing.T) { + fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + require.NoError(t, err) + + heart := mustComponent("heart", + component.WithInputs("time"), + component.WithActivationFunc(func(*component.Component) error { return nil })) + heart.InputByName("time").SetupHooks(func(hooks *port.Hooks) { + hooks.OnInboundPipe(func(*port.InboundPipeContext) error { + return errors.New("refused") + }) + }) + + err = fm.AddComponents( + mustComponent("clock", + component.WithOutputs("time"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + heart, + ) + + require.Error(t, err) + assert.ErrorContains(t, err, "refused") + }) +} + +// recordArrival notes what turned up on a port, since the mesh drains ports once +// a cycle is done and nothing is left to inspect after Run returns. +func recordArrival(portName string) component.ActivationFunc { + return func(this *component.Component) error { + this.State().Set("arrived", this.InputByName(portName).Signals().FirstPayloadOrNil()) + return nil + } +} + +func mustComponent(name string, opts ...component.Option) *component.Component { + c, err := component.New(name, opts...) + if err != nil { + panic(err) + } + return c +} diff --git a/plugin/profiler.go b/plugin/profiler.go new file mode 100644 index 0000000..a81ddd3 --- /dev/null +++ b/plugin/profiler.go @@ -0,0 +1,245 @@ +// Package plugin provides mesh-level plugins that ship with F-Mesh. +// +// Each is a plain fmesh.Plugin: an initialization bundle that registers hooks +// and then gets out of the way. What they have in common is that they reach +// every component through the OnComponentAdded hook, so nothing in a mesh has to +// be written differently to be measured (Profiler) or wired (Autowire) -- both +// are things you add to a mesh rather than things components opt into. +package plugin + +import ( + "cmp" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" +) + +// Stat is how long something took, over however many times it happened. +type Stat struct { + Count int + Total time.Duration + Min time.Duration + Max time.Duration +} + +// Avg is the mean duration, or zero if it never happened. +func (s Stat) Avg() time.Duration { + if s.Count == 0 { + return 0 + } + return s.Total / time.Duration(s.Count) +} + +// with returns the stat updated to include one more observation. +func (s Stat) with(d time.Duration) Stat { + if s.Count == 0 || d < s.Min { + s.Min = d + } + if d > s.Max { + s.Max = d + } + s.Count++ + s.Total += d + return s +} + +// Profiler measures where a mesh spends its time: whole runs, single cycles, and +// each component's activations. +// +// The component numbers are the interesting ones, and they are the reason this +// is a plugin rather than something you reach for a CPU profile to answer. A Go +// profile of a mesh is dominated by the scheduler and tells you almost nothing +// about which component is slow, because every component's work is the same +// handful of runtime calls. Timing activations directly names the culprit. +// +// A Profiler holds the timings of one mesh: it tracks a single in-flight run and +// cycle, so sharing an instance between two meshes interleaves their numbers. +// Stats accumulate across runs of that mesh until Reset. +type Profiler struct { + mu sync.Mutex + run Stat + cycle Stat + components map[string]Stat + + runStarted time.Time + cycleStarted time.Time + started map[string]time.Time +} + +// NewProfiler returns a profiler plugin. +func NewProfiler() *Profiler { + return &Profiler{ + components: make(map[string]Stat), + started: make(map[string]time.Time), + } +} + +// GetName implements fmesh.Plugin. +func (p *Profiler) GetName() string { return "profiler" } + +// Init implements fmesh.Plugin. +func (p *Profiler) Init(fm *fmesh.FMesh) error { + fm.SetupHooks(func(hooks *fmesh.Hooks) { + hooks.BeforeRun(func(*fmesh.FMesh) error { + p.mu.Lock() + defer p.mu.Unlock() + p.runStarted = time.Now() + return nil + }) + hooks.AfterRun(func(*fmesh.FMesh) error { + p.mu.Lock() + defer p.mu.Unlock() + p.run = p.run.with(time.Since(p.runStarted)) + return nil + }) + hooks.BeforeCycle(func(*fmesh.CycleContext) error { + p.mu.Lock() + defer p.mu.Unlock() + p.cycleStarted = time.Now() + return nil + }) + hooks.AfterCycle(func(*fmesh.CycleContext) error { + p.mu.Lock() + defer p.mu.Unlock() + p.cycle = p.cycle.with(time.Since(p.cycleStarted)) + return nil + }) + hooks.OnComponentAdded(func(ctx *fmesh.ComponentAddedContext) error { + p.instrument(ctx.Component) + return nil + }) + }) + return nil +} + +// instrument times one component's activations. +// +// Components activate concurrently, so the start times live in a map under the +// same lock as the stats rather than in a field. Every component in a cycle +// therefore queues on this one mutex, and the goal is to keep that queueing +// outside the measured window: the start is stamped as late as possible (inside +// the lock, just before the activation returns to the scheduler) and the end as +// early as possible (before the lock, the instant the activation finished). +// +// Stamping the start *before* its lock instead looks symmetrical and is much +// worse -- it moves the BeforeActivation contention into the window, where it +// dominates. Measured on a 500-component cycle doing ~2us of work each: ~18us +// reported this way, ~180us with the start stamped early. +func (p *Profiler) instrument(c *component.Component) { + c.SetupHooks(func(hooks *component.Hooks) { + hooks.BeforeActivation(func(this *component.Component) error { + p.mu.Lock() + defer p.mu.Unlock() + p.started[this.Name()] = time.Now() + return nil + }) + hooks.AfterActivation(func(ctx *component.ActivationContext) error { + endedAt := time.Now() + + p.mu.Lock() + defer p.mu.Unlock() + + name := ctx.Component.Name() + startedAt, ok := p.started[name] + if !ok { + // AfterActivation is a finally block and can fire for an + // activation that never began. + return nil + } + delete(p.started, name) + + p.components[name] = p.components[name].with(endedAt.Sub(startedAt)) + return nil + }) + }) +} + +// Runs reports the whole-run timings. +func (p *Profiler) Runs() Stat { + p.mu.Lock() + defer p.mu.Unlock() + return p.run +} + +// Cycles reports the per-cycle timings. +func (p *Profiler) Cycles() Stat { + p.mu.Lock() + defer p.mu.Unlock() + return p.cycle +} + +// Components reports each component's activation timings, slowest total first. +func (p *Profiler) Components() []ComponentStat { + p.mu.Lock() + defer p.mu.Unlock() + + stats := make([]ComponentStat, 0, len(p.components)) + for name, stat := range p.components { + stats = append(stats, ComponentStat{Component: name, Stat: stat}) + } + slices.SortFunc(stats, func(a, b ComponentStat) int { + if c := cmp.Compare(b.Total, a.Total); c != 0 { + return c + } + return cmp.Compare(a.Component, b.Component) + }) + return stats +} + +// ComponentStat is one component's activation timings. +type ComponentStat struct { + Component string + Stat +} + +// TopN returns the n components that activated most often, busiest first. +// An n of zero or less returns nothing. +// +// "Hottest" and "slowest" are different questions and this answers the first: +// a component that activates on every cycle and does almost nothing can matter +// more than one that is individually slow but rarely runs. +func (p *Profiler) TopN(n int) []ComponentStat { + stats := p.Components() + slices.SortFunc(stats, func(a, b ComponentStat) int { + if c := cmp.Compare(b.Count, a.Count); c != 0 { + return c + } + return cmp.Compare(a.Component, b.Component) + }) + return stats[:max(0, min(n, len(stats)))] +} + +// Reset discards everything measured so far. +// +// Stats accumulate across runs, which is what you want when comparing a mesh +// against itself; call this between runs that should not be pooled. +func (p *Profiler) Reset() { + p.mu.Lock() + defer p.mu.Unlock() + + p.run, p.cycle = Stat{}, Stat{} + p.components = make(map[string]Stat) + p.started = make(map[string]time.Time) +} + +// Report renders the profile as a table, slowest component first. +func (p *Profiler) Report() string { + var b strings.Builder + + runs, cycles := p.Runs(), p.Cycles() + fmt.Fprintf(&b, "runs: %d, total %v, avg %v\n", runs.Count, runs.Total, runs.Avg()) + fmt.Fprintf(&b, "cycles: %d, total %v, avg %v\n", cycles.Count, cycles.Total, cycles.Avg()) + fmt.Fprintf(&b, "\n%-32s %8s %12s %12s %12s %12s\n", + "component", "count", "total", "avg", "min", "max") + + for _, s := range p.Components() { + fmt.Fprintf(&b, "%-32s %8d %12v %12v %12v %12v\n", + s.Component, s.Count, s.Total, s.Avg(), s.Min, s.Max) + } + return b.String() +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..e3a7ae7 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,122 @@ +package fmesh + +import ( + "errors" + "testing" + + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/signal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFMesh_Plugin(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + counter := &activationCounter{} + + fm, err := New("fm1", WithPlugins(counter)) + require.NoError(t, err) + require.NotNil(t, fm) + assert.True(t, fm.PluginRegistered("activationCounter")) + assert.True(t, fm.Labels().ValueIs("plugin/counter/version", "v1")) + + // The plugin instruments whatever arrives, not what was there when it was + // initialized -- a mesh is empty at construction time. + require.NoError(t, fm.AddComponents( + mustNewComponent("c1", + component.WithInputs("i1"), + component.WithOutputs("o1"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("o1").PutPayloads(1) + })), + mustNewComponent("c2", + component.WithInputs("i1"), + component.WithActivationFunc(func(*component.Component) error { return nil })), + )) + assert.Equal(t, 2, counter.instrumented, "both components instrumented on arrival") + + mustPipeTo(fm.Components().ByName("c1").OutputByName("o1"), + fm.Components().ByName("c2").InputByName("i1")) + mustPutSignals(fm.Components().ByName("c1").InputByName("i1"), signal.New("go")) + + _, err = fm.Run() + require.NoError(t, err) + assert.Equal(t, 2, counter.activations, "every activation observed without any component knowing") + }) + + t.Run("plugins can be registered only once", func(t *testing.T) { + fm, err := New("fm1", WithPlugins(&activationCounter{}, &activationCounter{})) + + require.Error(t, err) + require.ErrorContains(t, err, "plugin activationCounter already registered") + assert.Nil(t, fm) + }) + + t.Run("a failing plugin fails construction", func(t *testing.T) { + fm, err := New("fm1", WithPlugins(brokenPlugin{})) + + require.Error(t, err) + require.ErrorContains(t, err, `fmesh "fm1" plugin brokenPlugin initialization failed`) + assert.Nil(t, fm) + }) + + t.Run("plugins initialize in name order", func(t *testing.T) { + // Hooks fire in registration order, so plugin init order is observable + // behavior rather than an implementation detail. Ranging the map would + // make it vary between runs of the same binary. + var order []string + record := func(name string) *recordingPlugin { + return &recordingPlugin{name: name, seen: &order} + } + + fm, err := New("fm1", WithPlugins(record("zulu"), record("alpha"), record("mike"))) + require.NoError(t, err) + require.NotNil(t, fm) + + assert.Equal(t, []string{"alpha", "mike", "zulu"}, order) + }) +} + +// activationCounter is a mesh plugin that observes every component activation +// without any component being told about it. +type activationCounter struct { + instrumented int + activations int +} + +func (p *activationCounter) GetName() string { return "activationCounter" } + +func (p *activationCounter) Init(fm *FMesh) error { + fm.AddLabel("plugin/counter/version", "v1") + + fm.SetupHooks(func(hooks *Hooks) { + hooks.OnComponentAdded(func(ctx *ComponentAddedContext) error { + p.instrumented++ + ctx.Component.SetupHooks(func(ch *component.Hooks) { + ch.AfterActivation(func(*component.ActivationContext) error { + p.activations++ + return nil + }) + }) + return nil + }) + }) + return nil +} + +type brokenPlugin struct{} + +func (brokenPlugin) GetName() string { return "brokenPlugin" } +func (brokenPlugin) Init(*FMesh) error { return errors.New("no") } + +type recordingPlugin struct { + name string + seen *[]string +} + +func (p *recordingPlugin) GetName() string { return p.name } + +func (p *recordingPlugin) Init(*FMesh) error { + *p.seen = append(*p.seen, p.name) + return nil +} From 429632686b7b75f8fa6987fa93930a6c04f43d67 Mon Sep 17 00:00:00 2001 From: Ovsep Avakian Date: Wed, 29 Jul 2026 21:44:32 +0300 Subject: [PATCH 2/3] Add an integration test wiring a mesh entirely by convention Four components and five connections, with no PipeTo between components: two Autowire conventions derive the whole graph as components arrive, and the Profiler measures the result without any component opting in. The body is added before everything it listens to, so most of its wiring can only happen on the later arrivals -- the property that makes AddComponents order irrelevant. The clock's loopback is the one hand-written pipe, because a loopback is not something a naming convention can express. Co-Authored-By: Claude Opus 5 (1M context) --- integration_tests/plugins/fmesh_test.go | 161 ++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 integration_tests/plugins/fmesh_test.go diff --git a/integration_tests/plugins/fmesh_test.go b/integration_tests/plugins/fmesh_test.go new file mode 100644 index 0000000..ddecf1e --- /dev/null +++ b/integration_tests/plugins/fmesh_test.go @@ -0,0 +1,161 @@ +// Package plugins exercises the bundled mesh plugins together on one mesh. +package plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/internal/testutil" + "github.com/hovsep/fmesh/plugin" + "github.com/hovsep/fmesh/signal" +) + +// A small habitat: a clock drives everything that keeps time, two environmental +// factors publish readings, and a body consumes all three. +// +// The point of the test is what is absent. Four components, five connections, +// and not one PipeTo between components -- two naming conventions derive the +// whole graph as the components arrive, and a profiler measures the result. +// Adding a fifth component that declares an input named "time" would wire it to +// the clock with no edit anywhere. +func TestPlugins_ConventionWiredMesh(t *testing.T) { + profiler := plugin.NewProfiler() + + fm := testutil.MustFMesh("habitat", fmesh.WithPlugins( + profiler, + // Everything that declared an input called "time" hears the clock. + plugin.BroadcastAs("tick", "time"), + // Everything that asked for a factor by name gets it: + // sun's "uvi" output reaches the input named "env_sun_uvi". + plugin.Prefixed("env_"), + )) + + assert.True(t, fm.PluginRegistered("profiler")) + assert.True(t, fm.PluginRegistered("autowire:broadcast:tick->time")) + assert.True(t, fm.PluginRegistered("autowire:prefixed:env_")) + + // The clock is the only component that wires anything by hand, and only to + // itself: a loopback is not something a naming convention can express. + clock := testutil.MustComponent("clock", + component.WithInputs("i1"), + component.WithOutputs("tick", "beat"), + component.WithActivationFunc(func(this *component.Component) error { + left, _ := this.State().GetOrDefault("left", 3).(int) + this.State().Set("left", left-1) + + if err := this.OutputByName("tick").PutPayloads(left); err != nil { + return err + } + if left-1 > 0 { + return this.OutputByName("beat").PutPayloads(left - 1) + } + return nil + })) + require.NoError(t, clock.LoopbackPipe("beat", "i1")) + + sun := testutil.MustComponent("sun", + component.WithInputs("time"), + component.WithOutputs("uvi"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("uvi").PutPayloads(7) + })) + + air := testutil.MustComponent("air", + component.WithInputs("time"), + component.WithOutputs("gas"), + component.WithActivationFunc(func(this *component.Component) error { + return this.OutputByName("gas").PutPayloads(21) + })) + + body := testutil.MustComponent("body", + component.WithInputs("time", "env_sun_uvi", "env_air_gas"), + component.WithActivationFunc(func(this *component.Component) error { + this.State().Upsert("uvi", func(old any) any { + prev, _ := old.(int) + return prev + sumInts(this, "env_sun_uvi") + }) + this.State().Upsert("gas", func(old any) any { + prev, _ := old.(int) + return prev + sumInts(this, "env_air_gas") + }) + this.State().Upsert("ticks", func(old any) any { + prev, _ := old.(int) + return prev + this.InputByName("time").Signals().Len() + }) + return nil + })) + + // The body is added before anything it listens to, so most of its wiring can + // only happen on the later arrivals. Order is not supposed to matter. + require.NoError(t, fm.AddComponents(body)) + require.NoError(t, fm.AddComponents(sun, air, clock)) + + t.Run("the conventions derived the graph", func(t *testing.T) { + assert.Equal(t, 3, clock.OutputByName("tick").Pipes().Len(), + "the tick reaches sun, air and body -- but not the clock itself") + assert.Equal(t, 1, sun.OutputByName("uvi").Pipes().Len()) + assert.Equal(t, 1, air.OutputByName("gas").Pipes().Len()) + assert.Equal(t, 1, clock.OutputByName("beat").Pipes().Len(), + "only the hand-written loopback") + }) + + testutil.MustPutSignals(clock.InputByName("i1"), signal.New("start")) + _, err := fm.Run() + require.NoError(t, err) + + t.Run("signals flowed over the derived pipes", func(t *testing.T) { + assert.Equal(t, 3, body.State().Get("ticks"), "three ticks reached the body") + // The sun and air publish on every tick they hear, so all three readings + // arrive -- each one cycle behind the tick that produced it, since the + // clock reaches the body and the factors in the same cycle. + assert.Equal(t, 3*7, body.State().Get("uvi")) + assert.Equal(t, 3*21, body.State().Get("gas")) + }) + + t.Run("the profiler measured every component", func(t *testing.T) { + assert.Equal(t, 1, profiler.Runs().Count) + assert.Positive(t, profiler.Cycles().Count) + + measured := make(map[string]int) + for _, s := range profiler.Components() { + measured[s.Component] = s.Count + assert.Positive(t, s.Total, "%s was timed", s.Component) + assert.LessOrEqual(t, s.Min, s.Max) + } + assert.Equal(t, map[string]int{"clock": 3, "sun": 3, "air": 3, "body": 4}, measured, + "no component opted in to being measured") + + require.NotEmpty(t, profiler.TopN(1)) + assert.Equal(t, "body", profiler.TopN(1)[0].Component, + "the body activates once more than the rest: a final pass on the last readings") + + assert.Contains(t, profiler.Report(), "clock") + }) + + t.Run("a second run pools into the same profile", func(t *testing.T) { + clock.State().Delete("left") + testutil.MustPutSignals(clock.InputByName("i1"), signal.New("start")) + _, err := fm.Run() + require.NoError(t, err) + + assert.Equal(t, 2, profiler.Runs().Count) + + profiler.Reset() + assert.Zero(t, profiler.Runs().Count) + assert.Empty(t, profiler.Components()) + }) +} + +// sumInts adds up the int payloads waiting on a port. +func sumInts(c *component.Component, portName string) int { + sum, _ := c.InputByName(portName).Signals().ReducePayloads(0, func(acc, payload any) any { + a, _ := acc.(int) + v, _ := payload.(int) + return a + v + }).(int) + return sum +} From 0e4abe43b37545c13588f7c2f4e88a8e610451c8 Mon Sep 17 00:00:00 2001 From: Ovsep Avakian Date: Wed, 29 Jul 2026 21:49:00 +0300 Subject: [PATCH 3/3] Name the Autowire constructors after what they do plugin.Prefixed("env_") says nothing about autowiring; at a call site inside WithPlugins it could be anything. Broadcast and BroadcastAs had the same problem, so all three are renamed together rather than leaving an inconsistent family: AutowirePrefixed, AutowireBroadcast, AutowireBroadcastAs. The PluginName strings ("autowire:prefixed:env_") already said it and are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .agent/docs/hooks.md | 2 +- docs/wiki/502.-Plugins.md | 12 ++++++------ integration_tests/plugins/fmesh_test.go | 4 ++-- plugin/autowire.go | 15 ++++++++------- plugin/plugin_test.go | 14 +++++++------- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.agent/docs/hooks.md b/.agent/docs/hooks.md index 24cf8b2..c7c39cf 100644 --- a/.agent/docs/hooks.md +++ b/.agent/docs/hooks.md @@ -89,4 +89,4 @@ Package `plugin` ships mesh-level plugins built on exactly the pattern above. It | Plugin | What it does | |---|---| | `plugin.NewProfiler()` | Times whole runs, single cycles, and each component's activations. `Runs()`, `Cycles()`, `Components()`, `TopN(n)`, `Report()`, `Reset()`. One instance belongs to one mesh. | -| `plugin.Prefixed(prefix)` / `Broadcast(name)` / `BroadcastAs(out, in)` / `&Autowire{Name: ...}` | Pipes ports by naming convention, in both directions on every arrival, so `AddComponents` order does not matter. Each convention is a separate plugin instance with its own `PluginName`. | +| `plugin.AutowirePrefixed(prefix)` / `AutowireBroadcast(name)` / `AutowireBroadcastAs(out, in)` / `&Autowire{Name: ...}` | Pipes ports by naming convention, in both directions on every arrival, so `AddComponents` order does not matter. Each convention is a separate plugin instance with its own `PluginName`. | diff --git a/docs/wiki/502.-Plugins.md b/docs/wiki/502.-Plugins.md index 3bd6bd0..b74b021 100644 --- a/docs/wiki/502.-Plugins.md +++ b/docs/wiki/502.-Plugins.md @@ -185,7 +185,7 @@ A mesh of any size accumulates long stretches of wiring that say nothing a reade ```go fm, err := fmesh.New("mesh", - fmesh.WithPlugins(plugin.Broadcast("time")), + fmesh.WithPlugins(plugin.AutowireBroadcast("time")), ) // No PipeTo calls: every component with an input named "time" @@ -197,9 +197,9 @@ Three ready-made conventions plus the general form: | Constructor | Rule | |-------------|------| -| `plugin.Broadcast("time")` | every output named `time` → every input named `time` | -| `plugin.BroadcastAs("tick", "time")` | every output named `tick` → every input named `time` | -| `plugin.Prefixed("env_")` | an output → the input named `_`, e.g. `env_sun_uvi` | +| `plugin.AutowireBroadcast("time")` | every output named `time` → every input named `time` | +| `plugin.AutowireBroadcastAs("tick", "time")` | every output named `tick` → every input named `time` | +| `plugin.AutowirePrefixed("env_")` | an output → the input named `_`, e.g. `env_sun_uvi` | | `&plugin.Autowire{Name: func(source *component.Component, output *port.Port) string {...}}` | your own rule; return `""` to decline | Wiring happens on every arrival, **in both directions**: a new component is offered every output already in the mesh, and every output it brings is offered to the components already there. So the order of `AddComponents` does not matter — which is the property that makes a convention safe to lean on. @@ -208,8 +208,8 @@ A mesh often wants more than one convention at once — a clock reaching everyth ```go fm, err := fmesh.New("habitat", fmesh.WithPlugins( - plugin.BroadcastAs("tick", "time"), - plugin.Prefixed("env_"), + plugin.AutowireBroadcastAs("tick", "time"), + plugin.AutowirePrefixed("env_"), )) ``` diff --git a/integration_tests/plugins/fmesh_test.go b/integration_tests/plugins/fmesh_test.go index ddecf1e..c5de00d 100644 --- a/integration_tests/plugins/fmesh_test.go +++ b/integration_tests/plugins/fmesh_test.go @@ -28,10 +28,10 @@ func TestPlugins_ConventionWiredMesh(t *testing.T) { fm := testutil.MustFMesh("habitat", fmesh.WithPlugins( profiler, // Everything that declared an input called "time" hears the clock. - plugin.BroadcastAs("tick", "time"), + plugin.AutowireBroadcastAs("tick", "time"), // Everything that asked for a factor by name gets it: // sun's "uvi" output reaches the input named "env_sun_uvi". - plugin.Prefixed("env_"), + plugin.AutowirePrefixed("env_"), )) assert.True(t, fm.PluginRegistered("profiler")) diff --git a/plugin/autowire.go b/plugin/autowire.go index 754a794..f102fed 100644 --- a/plugin/autowire.go +++ b/plugin/autowire.go @@ -45,11 +45,11 @@ type Autowire struct { PluginName string } -// Prefixed wires an output to any input named "_". +// AutowirePrefixed wires an output to any input named "_". // // It is the shape most meshes converge on: "habitat_gas_environmental_gas" is // the gas factor's environmental_gas output, and reads as one. -func Prefixed(prefix string) *Autowire { +func AutowirePrefixed(prefix string) *Autowire { return &Autowire{ PluginName: "autowire:prefixed:" + prefix, Name: func(source *component.Component, output *port.Port) string { @@ -58,18 +58,19 @@ func Prefixed(prefix string) *Autowire { } } -// Broadcast wires every output named portName to every input of the same name. -func Broadcast(portName string) *Autowire { - return BroadcastAs(portName, portName) +// AutowireBroadcast wires every output named portName to every input of the +// same name. +func AutowireBroadcast(portName string) *Autowire { + return AutowireBroadcastAs(portName, portName) } -// BroadcastAs wires every output named outputName to every input named +// AutowireBroadcastAs wires every output named outputName to every input named // inputName. // // This is the clock case, where the two differ: a component emitting "tick" // feeds everything that declared an input called "time", including the // components added after it. -func BroadcastAs(outputName, inputName string) *Autowire { +func AutowireBroadcastAs(outputName, inputName string) *Autowire { return &Autowire{ PluginName: "autowire:broadcast:" + outputName + "->" + inputName, Name: func(_ *component.Component, output *port.Port) string { diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 9e21077..97e9781 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -187,7 +187,7 @@ func TestAutowire(t *testing.T) { // The consumer is added first, so it can only be wired by the second // component's arrival -- which is the half that a one-directional // implementation silently gets wrong. - fm, err := fmesh.New("m", fmesh.WithPlugins(Prefixed("env_"))) + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowirePrefixed("env_"))) require.NoError(t, err) require.NoError(t, fm.AddComponents( @@ -213,8 +213,8 @@ func TestAutowire(t *testing.T) { "the sun reached the body with no wiring written") }) - t.Run("Broadcast feeds every same-named input", func(t *testing.T) { - fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + t.Run("AutowireBroadcast feeds every same-named input", func(t *testing.T) { + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowireBroadcast("time"))) require.NoError(t, err) require.NoError(t, fm.AddComponents( @@ -242,7 +242,7 @@ func TestAutowire(t *testing.T) { }) t.Run("declining to name a port wires nothing", func(t *testing.T) { - fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowireBroadcast("time"))) require.NoError(t, err) require.NoError(t, fm.AddComponents( @@ -269,7 +269,7 @@ func TestAutowire(t *testing.T) { t.Run("naming an input the destination does not have wires nothing", func(t *testing.T) { // Distinct from declining: the rule does name a port, there is just no // such input on this component. - fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowireBroadcast("time"))) require.NoError(t, err) require.NoError(t, fm.AddComponents( @@ -292,7 +292,7 @@ func TestAutowire(t *testing.T) { Name: func(*component.Component, *port.Port) string { return "time" }, } - fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"), everythingIsTime)) + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowireBroadcast("time"), everythingIsTime)) require.NoError(t, err) require.NoError(t, fm.AddComponents( @@ -322,7 +322,7 @@ func TestAutowire(t *testing.T) { }) t.Run("a failing pipe fails AddComponents", func(t *testing.T) { - fm, err := fmesh.New("m", fmesh.WithPlugins(Broadcast("time"))) + fm, err := fmesh.New("m", fmesh.WithPlugins(AutowireBroadcast("time"))) require.NoError(t, err) heart := mustComponent("heart",