From daac1622519da710b0cd4b5cd2a8fa15d9a06cff Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 06:40:48 -0700 Subject: [PATCH 1/7] plugins: migrate workers to vNext Why: - Workers must expose bounded managed execution through the explicit provider and contract model What: - replace global registration with an explicit provider catalog - declare strict authorities, config schema, typed service contract, and lifecycle behavior - update tests, manifest, and documentation for reviewed dependency metadata Proof: - GOWORK=off go test ./... - GOWORK=off go vet ./... - wago plugin catalog --check Docs: - README.md updated for the vNext API --- README.md | 427 +++++++----------------------------- go.mod | 2 +- go.sum | 4 +- manifest_test.go | 100 +++++++++ register/catalog.go | 12 + register/register.go | 6 - wago.json | 26 ++- wago.providers.json | 68 ++++++ workers.go | 508 +++++++++++++++++++++++++++++++++++-------- workers_test.go | 242 +++++++++++++++------ 10 files changed, 876 insertions(+), 519 deletions(-) create mode 100644 manifest_test.go create mode 100644 register/catalog.go delete mode 100644 register/register.go create mode 100644 wago.providers.json diff --git a/README.md b/README.md index d37ec15..c6a9abb 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,140 @@
-

workers

-

Bounded, composable worker primitives for the Wago WebAssembly runtime.

+

workers

+

Bounded, composable WebAssembly worker primitives for Wago.

- CI - Coverage - Go >= 1.24 - Wago >= 0.1.0 + CI + Go >= 1.24

-
-Table of Contents +Workers turns a WebAssembly table entry into a bounded, host-supervised worker. +Each worker is a managed fork of its caller, runs on one goroutine, and owns a +fixed-capacity, byte-bounded mailbox. -- [Overview](#overview) -- [Installation](#installation) -- [Concepts](#concepts) -- [Usage](#usage) -- [API](#api) - - [Spawning a worker](#spawning-a-worker) - - [Sending messages](#sending-messages) - - [Receiving inside a worker](#receiving-inside-a-worker) - - [Linking and termination](#linking-and-termination) - - [Worker options and limits](#worker-options-and-limits) - - [Errors](#errors) -- [Examples](#examples) -- [Testing](#testing) -- [Architecture](#architecture) -- [Contributing](#contributing) -- [License](#license) -- [Contact](#contact) +The package deliberately stops at primitives: spawn, send, receive, link, kill, +and observe. Policies such as supervision trees, restarts, and guest-visible +mailbox ABIs belong in a plugin built on top. -
+> Workers is experimental (`v0.1.0`). Its API may change before the first stable +> release. -## Overview - -`workers` is an optional [Wago](https://github.com/wago-org/wago) plugin that turns a -WebAssembly table entry into a **bounded, host-supervised worker**. Each worker is a -forked instance of the caller, driven on its own goroutine, fed by a fixed-size, -byte-bounded mailbox. The host observes every message and every exit. - -Workers does *not* define PIDs, guest-visible mailboxes, monitors, supervision trees, restart -policy, or a guest ABI. Those are policies you build on top of the primitives here: `Spawn`, `Send`, -`DispatchNext`, `Link`, and `Kill`, using the host callbacks `OnMessage` and `OnExit`. - -What you get out of the box: - -- **Bounded by construction**: every worker has a capped queue depth, a max payload - size, and a max total queued bytes. `Send` copies the payload and never blocks. -- **Host-supervised**: `OnMessage` sees every delivered message; `OnExit` sees every - return, failure, or kill, with the cause. -- **Lifecycle-safe**: a linked worker is torn down cooperatively when its creating - instance closes, and every worker is drained when the runtime shuts down. - -> **Stability:** experimental (`v0.0.0`). The API may change without notice. - -## Installation - -If you have the [`wago`](https://github.com/wago-org/wago) CLI installed: - -```sh -wago pkg install github.com/wago-org/workers -``` - -or use [`go get`](https://pkg.go.dev/cmd/go#hdr-Get_packages_and_dependencies): +## Install ```sh -go get github.com/wago-org/workers +wago add github.com/wago-org/workers ``` -The plugin requires two privileged capabilities - `instance.manage` (to fork and manage -worker instances) and `instance.lifecycle` (to observe parent shutdown). Select the -plugin in `wago.json`: +The install review shows two exact required authorities: -```json -{ - "$schema": "https://wago.sh/v0/schema.json", - "plugins": { - "wago-org/workers": "^0.0.0" - } -} -``` - -Exact versions and reviewed authority belong in `wago-lock.json`: - -```json -{ - "plugins": { - "wago-org/workers": { - "version": "0.0.0", - "requiredCapabilities": [ - "instance.manage", - "instance.lifecycle" - ], - "capabilities": { - "instance.manage": { "maxInstances": 64 }, - "instance.lifecycle": true - } - } - } -} -``` - -When you register the plugin programmatically, grant the same capabilities with -[`wago.WithPluginGrants`](https://pkg.go.dev/github.com/wago-org/wago#WithPluginGrants) -(see [Usage](#usage)). Grants are enforced in strict mode; omitting the option registers -the plugin without capability checks. +- `instance.manage`, with positive instance and memory ceilings, to fork and own + workers. +- `instance.close.observe`, to stop linked workers when their exact creator + closes. -## Concepts +You may narrow the published `instance.manage` limits. The plugin also enforces +its own worker-count and mailbox-memory ceilings. -| Term | Meaning | -| --- | --- | -| **Plugin** | `workers.New()` - the `wago.Extension` you register with a runtime. It provides one **service**. | -| **Service** (`*workers.Workers`) | The host-side handle you use to spawn, send, link, and kill workers, and to register `OnMessage` / `OnExit` observers. Obtain it with `plugin.Service()` or look it up through `workers.ServiceKey`. | -| **Worker** | A forked child instance running one table entry with the exact Wasm signature `() -> ()`, on its own goroutine. Identified by a `WorkerID`. | -| **Mailbox** | A fixed-capacity, byte-bounded FIFO queue owned by each worker. `Send` enqueues; the worker drains it cooperatively via `DispatchNext`. | -| **Creator** | The instance that spawned the worker. `Link` ties a worker's lifetime to its exact creator. | +## Use from another plugin -A worker's life: **spawn → run its table entry → receive messages cooperatively → -exit (return, fail, or kill) → `OnExit` fires → resources freed.** - -## Usage - -Register the plugin, grant its capabilities, and attach host observers: +Workers provides the typed contract +`github.com/wago-org/workers/service@1`. Declare it in the consumer's immutable +definition: ```go -package main - -import ( - "log" - - "github.com/wago-org/wago" - "github.com/wago-org/workers" -) - -func main() { - workerPlugin := workers.New() - - rt := wago.NewRuntime() - if err := rt.Use(workerPlugin, wago.WithPluginGrants( - wago.PluginManagedInstances, // "instance.manage" - wago.PluginInstanceHooks, // "instance.lifecycle" - )); err != nil { - log.Fatal(err) +var WorkersContract = workers.Contract + +func Definition() wago.PluginDefinition { + return wago.PluginDefinition{ + // ...ID, version, and provenance... + Requires: []wago.PluginRequirement{{ + ID: workers.PluginID, Version: "^0.1.0", + }}, + Consumes: []wago.ContractRequirement{{ + ID: WorkersContract.ID(), Major: WorkersContract.Major(), + Mode: wago.ContractRequired, + }}, } - defer rt.Close() - - service := workerPlugin.Service() - - // Observe every message delivered to any worker. - service.OnMessage(func(ctx *workers.MessageContext) error { - // Decode ctx.Tag / ctx.Payload, or write into ctx.Caller.Memory(). - // Returning an error stops that worker with WorkerFailed. - return nil - }) - - // Observe every worker exit: return, failure, or kill. - service.OnExit(func(ctx *workers.WorkerExitContext) { - // Build monitoring, restarts, signalling, or logging here. - log.Printf("worker %d exited: kind=%d err=%v", ctx.WorkerID, ctx.Kind, ctx.Err) - }) - - // ... compile and instantiate guest modules that spawn/drive workers. } ``` -`Service()` returns the same handle that a downstream plugin can retrieve through the -exported `workers.ServiceKey`, so composing plugins share one worker registry. Inside your -plugin's `Register`, take a typed reference and resolve it once the runtime is wired up: +Require the contract during registration and use it only through its callback: ```go -ref, err := plugin.Require(reg, workers.ServiceKey) +workersRef, err := wagoplugin.Require(reg, workers.Contract) if err != nil { return err } -// later, when handling a call: -svc, err := ref.Get() -``` - -## API - -All host-side operations that cross into a guest - `Spawn`, `DispatchNext`, `Current`, -`Link` - must be called from **inside a synchronous host import**, where `caller` is the -active `wago.HostModule` for that call. `Send` and `Kill` take a `WorkerID` and may be -called from anywhere. - -### Spawning a worker -Spawn forks the calling instance and runs the table entry at `tableIndex`. The entry must -have the exact Wasm signature `() -> ()`; otherwise `Spawn` returns a validation error and -the child is discarded. - -```go -id, err := service.Spawn(caller, tableIndex, workers.WorkerOptions{ - QueueCapacity: 64, - MaxPayloadBytes: 64 << 10, // 64 KiB - MaxQueueBytes: 1 << 20, // 1 MiB +err = workersRef.With(func(service workers.Service) error { + id, err := service.Spawn(caller, tableIndex, workers.WorkerOptions{}) + if err != nil { + return err + } + return service.Send(id, 42, payload) }) -if err != nil { - return err -} ``` -Zero-valued options fall back to the package defaults (see -[Worker options and limits](#worker-options-and-limits)). +Wago records the exact provider binding in `wago-lock.json`. A new matching +provider cannot silently enter the graph, and the reference fails closed after +the consumer stops. The package requirement lets the resolver install Workers +transitively; the contract requirement records the exact reviewed call target. -### Sending messages +## Observe safely -`Send` copies the payload and returns immediately - it never blocks the caller. It fails -if the worker is unknown, stopping, or its mailbox is at capacity (by count or by bytes), -or if the payload exceeds the worker's `MaxPayloadBytes`. +Observers return an opaque subscription token: ```go -if err := service.Send(id, 42 /* tag */, []byte("hello")); err != nil { - // e.g. errors.Is(err, workers.ErrWorkerQueueFull) - return err -} -``` - -Because the payload is copied, the caller may reuse or mutate its buffer as soon as `Send` -returns. - -### Receiving inside a worker - -A worker drains one message per `DispatchNext`, which you expose to the guest through a -plugin-defined host import. It delivers the next queued message to every registered -`OnMessage` observer, blocking cooperatively until a message arrives or the worker stops. - -```go -// Inside the host import the guest calls to receive: -if err := service.DispatchNext(caller); err != nil { - return pluginErrno(err) // map to a guest-visible errno -} -``` - -A worker can learn its own ID with `Current`: - -```go -id, err := service.Current(caller) -``` - -### Linking and termination - -`Link` ties a worker's lifetime to its **exact creator**: when the creating instance -closes, the runtime's `BeforeClose` hook cooperatively stops every linked worker and waits -for them to drain. A worker cannot link to itself, and only the creator may link it. - -```go -if err := service.Link(caller, childID); err != nil { +err = workersRef.With(func(service workers.Service) error { + messages, err = service.ObserveMessages(func(ctx *workers.MessageContext) error { + return handleMessage(ctx.WorkerID, ctx.Tag, ctx.Payload) + }) return err -} -``` - -`Kill` requests cooperative termination of any worker by ID. The worker's in-flight -`DispatchNext` unblocks, its queue is cleared, and it exits with `WorkerKilled`. - -```go -_ = service.Kill(id) -``` - -Every exit - whether the table entry returned, an `OnMessage` observer returned an error, -or the worker was killed - surfaces through `OnExit`: - -```go -service.OnExit(func(ctx *workers.WorkerExitContext) { - switch ctx.Kind { - case workers.WorkerReturned: // clean return from the table entry - case workers.WorkerFailed: // trap or observer error; see ctx.Err - case workers.WorkerKilled: // Kill, parent close, or runtime shutdown - } }) ``` -### Worker options and limits - -`WorkerOptions` bounds a single worker. A zero field takes the package default. Values -above the hard maximum (or a `MaxQueueBytes` smaller than `MaxPayloadBytes`) are rejected -with `ErrInvalidWorkerOptions`. +Pass subscriptions to `service.Unsubscribe` through `workersRef.With` in the +consuming plugin's `Stop` callback. Unsubscribe removes the observer and waits +for callbacks already in flight, so the consumer can then release its state +safely. It must not be called from inside its own callback. -| Field | Default | Maximum | Meaning | -| --- | --- | --- | --- | -| `QueueCapacity` | `64` | `65536` | Max number of queued messages. | -| `MaxPayloadBytes` | `64 KiB` | `16 MiB` | Max size of a single `Send` payload. | -| `MaxQueueBytes` | `1 MiB` | `64 MiB` | Max total bytes queued at once (must be ≥ `MaxPayloadBytes`). | +## Worker operations -The corresponding exported constants are `DefaultWorkerQueueCapacity`, -`DefaultWorkerMaxPayloadBytes`, `DefaultWorkerMaxQueueBytes`, `MaxWorkerQueueCapacity`, -`MaxWorkerPayloadBytes`, and `MaxWorkerQueueBytes`. +`Spawn`, `Current`, `DispatchNext`, and `Link` must run inside a synchronous host +call and receive that call's `wago.HostModule`. This prevents one guest from +impersonating another. -`WorkerOptions` bounds one worker; `WorkerLimits` bounds the **whole service**, so a -guest cannot exhaust the host by spawning workers without end. It always applies — even -when the host grants `instance.manage` with no `maxInstances` budget — and complements -that core budget rather than replacing it. Pass it at construction: +- `Spawn` forks the caller and runs a `() -> ()` table entry. +- `Send` copies a tagged payload into a bounded mailbox and never blocks. +- `DispatchNext` waits cooperatively and delivers one queued message. Its + context lets the consuming plugin cancel the wait during `Stop`. +- `Current` returns the worker ID for the current managed caller. +- `Link` ties a worker to its exact creator. +- `Kill` requests cooperative termination by worker ID. -```go -workerPlugin := workers.New(workers.WithLimits(workers.WorkerLimits{ - MaxLiveWorkers: 128, // default 64 - MaxQueueBytes: 128 << 20, // default 64 MiB, summed across live workers -})) -``` +Every worker has `QueueCapacity`, `MaxPayloadBytes`, and `MaxQueueBytes` bounds. +Zero fields take safe package defaults. -| Field | Default | Meaning | -| --- | --- | --- | -| `MaxLiveWorkers` | `64` | Max workers live at once. `Spawn` returns `ErrWorkerQuotaExceeded` past it. | -| `MaxQueueBytes` | `64 MiB` | Max total per-worker `MaxQueueBytes` reservation summed across live workers. | - -### Errors - -All errors are comparable sentinels - match them with `errors.Is`. - -| Error | When | -| --- | --- | -| `ErrWorkersInactive` | Service is nil or not registered. | -| `ErrInvalidWorkerOptions` | Options exceed a hard limit or are inconsistent. | -| `ErrInvalidWorkerCaller` | Operation needs the current plugin host caller and none is active. | -| `ErrWorkerImportLifetime` | The worker would inherit a borrowed import it cannot safely keep. | -| `ErrWorkerNotFound` | No worker with that ID (or not owned by the caller). | -| `ErrWorkerStopping` | The worker is already shutting down. | -| `ErrWorkerQueueFull` | Mailbox at capacity by count or bytes. | -| `ErrWorkerDispatchActive` | `DispatchNext` is already running for that worker. | -| `ErrPayloadTooLarge` | Payload exceeds the worker's `MaxPayloadBytes`. | -| `ErrWorkerIDExhausted` | The `WorkerID` space is exhausted. | -| `ErrInvalidWorkerLink` | Link target is not the caller's direct child, or is self. | -| `ErrWorkerKilled` | Exit cause: `Kill` was requested. | -| `ErrWorkerParentClosed` | Exit cause: the linked creator instance closed. | -| `ErrWorkerRuntimeClosed` | Exit cause: the runtime shut down. | -| `ErrWorkerQuotaExceeded` | Spawn would exceed the service-wide `WorkerLimits`. | - -## Examples - -The end-to-end integration test is the canonical, runnable example: it defines a guest -module that imports `spawn`/`next`, spawns a worker, and verifies the host copies a -message and observes a clean exit. See -[`workers_test.go`](./workers_test.go) - in particular `TestPluginSpawnsCopiesMessageAndStops`. - -A generated Wago host does not import this package directly; it blank-imports the -[`register`](./register) subpackage, which activates the plugin's init-time registration: - -```go -import _ "github.com/wago-org/workers/register" -``` - -## Testing +## Configure ```sh -go test ./... +wago plugin config github.com/wago-org/workers \ + '{"maxLiveWorkers":128,"maxQueueBytes":134217728}' ``` -The suite uses `github.com/wago-org/wago/testutil/wasmtest` to hand-build guest modules, -so no external toolchain is required. Run with the race detector while iterating on the -concurrency paths: +Configuration is strict: unknown fields, zero explicit limits, out-of-range +values, and trailing JSON are rejected before activation. + +## Test ```sh +go test ./... go test -race ./... ``` -## Architecture - -- **`workers.go`** - the whole plugin: the `Plugin` extension, the `Workers` service, the - per-worker mailbox and goroutine, and the lifecycle wiring (`BeforeClose` teardown, - runtime-close drain). -- **`register/`** - a blank-import shim that activates init-time registration for - Wago-generated hosts. -- **`wago.json`** - the package manifest declaring plugin dependencies and - version constraints; reviewed capabilities live in `wago-lock.json`. - -Design notes: - -- Each worker runs on one goroutine (`worker.run`) that invokes the table entry and, on - return, resolves the exit kind, closes the child instance, unregisters it, and fans out - to `OnExit` observers (each guarded against panics). -- The mailbox is a fixed-size ring buffer guarded by a mutex; `Send` enqueues and signals, - `DispatchNext` dequeues and delivers. Back-pressure is explicit: a full queue rejects - rather than blocks. -- `Spawn` forks through the managed-instance API and validates the target table entry - before the worker is made visible, so a bad spawn leaves no partial state. - -## Contributing - -Contributions are welcome! Please: - -- Run `go test -race ./...` and `go vet ./...` before opening a pull request. -- Keep the plugin's boundaries intact - no PIDs, guest mailboxes, supervision, or guest - ABI belong in this package; those are policies for layers built on top. -- Follow standard Go formatting (`gofmt`) and conventional commit messages. +The suite covers quotas, host-call identity, worker teardown, copied payloads, +typed contract revocation, and observer unsubscribe races. ## License -This project is distributed under the [Apache License 2.0](./LICENSE). Work on this -project is done out of passion - if you want to support it financially, you can donate -through [GitHub Sponsors](https://github.com/sponsors/JairusSW). - -## Contact - -Please file issues at [GitHub Issues](https://github.com/wago-org/workers/issues). To chat, -join the [Wago Discord](https://wago.sh/discord). - -- **GitHub:** [https://github.com/wago-org/](https://github.com/wago-org/) -- **Website:** [https://wago.sh/](https://wago.sh/) -- **Discord:** [https://wago.sh/discord](https://wago.sh/discord) +Apache-2.0. See [LICENSE](./LICENSE). diff --git a/go.mod b/go.mod index 247fc55..4539c8a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260711053856-7d89b98c854d +require github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8 diff --git a/go.sum b/go.sum index ff18b21..aebaed8 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260711053856-7d89b98c854d h1:fzJn6UlsGwiqwyTN+Si6oFoETUhUoJ/Rphhx94M5Pqc= -github.com/wago-org/wago v0.0.0-20260711053856-7d89b98c854d/go.mod h1:d44S+59u6VHeqEaIZBKTeB8viyswTP97O9IKA6Gyzc4= +github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8 h1:VHkyQAIQxbcHYp4OffVuGZzUxyXYxBkgGYHTDxTJ/So= +github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= diff --git a/manifest_test.go b/manifest_test.go new file mode 100644 index 0000000..2a1720f --- /dev/null +++ b/manifest_test.go @@ -0,0 +1,100 @@ +package workers_test + +import ( + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/wago-org/wago" + "github.com/wago-org/workers" + workersregister "github.com/wago-org/workers/register" +) + +type manifestAuthor struct { + Name string `json:"name"` +} + +type manifestPackage struct { + Module string `json:"module"` + Version string `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + Stability wago.Stability `json:"stability"` + License string `json:"license"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + Authors []manifestAuthor `json:"authors"` + Engines map[string]string `json:"engines"` + Platforms []string `json:"platforms"` +} + +func TestManifestMatchesCatalogMetadata(t *testing.T) { + raw, err := os.ReadFile("wago.json") + if err != nil { + t.Fatal(err) + } + var manifest struct { + Schema string `json:"$schema"` + Package manifestPackage `json:"package"` + Plugins map[string]string `json:"plugins"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + if manifest.Schema != "https://wago.sh/v1/schema.json" { + t.Fatalf("manifest schema = %q", manifest.Schema) + } + providers := workersregister.Providers() + if len(providers) != 1 { + t.Fatalf("catalog providers = %d, want 1", len(providers)) + } + definition := workers.Definition() + if !reflect.DeepEqual(providers[0].Definition, definition) { + t.Fatalf("catalog definition drifted\ncatalog=%#v\ncanonical=%#v", providers[0].Definition, definition) + } + assertManifestMetadata(t, manifest.Package, definition) + if len(manifest.Plugins) != 0 { + t.Fatalf("leaf manifest dependencies = %v", manifest.Plugins) + } + assertProviderCatalogCurrent(t, "github.com/wago-org/workers/register", providers) +} + +func assertProviderCatalogCurrent(t *testing.T, importPath string, providers []wago.PluginProvider) { + t.Helper() + want, err := wago.EncodeProviderCatalog(importPath, providers) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(wago.ProviderCatalogFile) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s is stale; run wago plugin catalog", wago.ProviderCatalogFile) + } + if _, err := wago.DecodeProviderCatalog(got); err != nil { + t.Fatalf("%s: %v", wago.ProviderCatalogFile, err) + } +} + +func assertManifestMetadata(t *testing.T, manifest manifestPackage, definition wago.PluginDefinition) { + t.Helper() + authors := make([]string, len(manifest.Authors)) + for i := range manifest.Authors { + authors[i] = manifest.Authors[i].Name + } + if manifest.Module != definition.ID || + manifest.Version != definition.Version || + manifest.Name != definition.Name || + manifest.Description != definition.Description || + manifest.Stability != definition.Stability || + manifest.License != definition.Provenance.License || + manifest.Homepage != definition.Provenance.Homepage || + manifest.Repository != definition.Provenance.Repository || + !reflect.DeepEqual(authors, definition.Provenance.Authors) || + !reflect.DeepEqual(manifest.Engines, definition.Compatibility.Engines) || + !reflect.DeepEqual(manifest.Platforms, definition.Compatibility.Platforms) { + t.Fatalf("manifest metadata drifted\nmanifest=%#v\ndefinition=%#v", manifest, definition) + } +} diff --git a/register/catalog.go b/register/catalog.go new file mode 100644 index 0000000..d314dd5 --- /dev/null +++ b/register/catalog.go @@ -0,0 +1,12 @@ +// Package register exposes Workers' explicit provider catalog to generated Wago +// runtimes. Importing this package has no registration side effects. +package register + +import ( + "github.com/wago-org/wago" + "github.com/wago-org/workers" +) + +func Providers() []wago.PluginProvider { + return []wago.PluginProvider{workers.Provider()} +} diff --git a/register/register.go b/register/register.go deleted file mode 100644 index 62ef0c9..0000000 --- a/register/register.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package register activates the workers plugin's init-time registration for -// Wago-generated hosts. Applications normally import github.com/wago-org/workers -// directly; generated hosts blank-import this package. -package register - -import _ "github.com/wago-org/workers" diff --git a/wago.json b/wago.json index d689173..a596a72 100644 --- a/wago.json +++ b/wago.json @@ -1,13 +1,17 @@ { - "$schema": "https://wago.sh/v0/schema.json", - "module": "github.com/wago-org/workers", - "version": "0.0.0", - "name": "Wago Workers", - "short": "workers", - "description": "Bounded, composable WebAssembly worker primitives for Wago plugins.", - "license": "Apache-2.0", - "repository": "https://github.com/wago-org/workers", - "homepage": "https://github.com/wago-org/workers#readme", - "category": "concurrency", - "tags": ["workers", "concurrency", "webassembly"] + "$schema": "https://wago.sh/v1/schema.json", + "package": { + "module": "github.com/wago-org/workers", + "version": "0.1.0", + "name": "Workers", + "description": "Bounded, composable WebAssembly worker primitives for Wago plugins.", + "stability": "experimental", + "license": "Apache-2.0", + "homepage": "https://github.com/wago-org/workers#readme", + "repository": "https://github.com/wago-org/workers", + "category": "concurrency", + "tags": ["workers", "concurrency", "webassembly", "wasm"], + "authors": [{"name": "Wago contributors"}], + "engines": {"wago": ">=0.1.0"} + } } diff --git a/wago.providers.json b/wago.providers.json new file mode 100644 index 0000000..fb87993 --- /dev/null +++ b/wago.providers.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://wago.sh/v1/providers.schema.json", + "providers": [ + { + "importPath": "github.com/wago-org/workers/register", + "definition": { + "id": "github.com/wago-org/workers", + "name": "Workers", + "version": "0.1.0", + "description": "Bounded, composable WebAssembly worker primitives for Wago plugins.", + "stability": "experimental", + "compatibility": { + "engines": { + "wago": "\u003e=0.1.0" + } + }, + "provenance": { + "homepage": "https://github.com/wago-org/workers#readme", + "repository": "https://github.com/wago-org/workers", + "license": "Apache-2.0", + "authors": [ + "Wago contributors" + ] + }, + "authorities": [ + { + "name": "instance.close.observe", + "mode": "required", + "reason": "stop linked workers when their exact creator closes", + "scope": {} + }, + { + "name": "instance.manage", + "mode": "required", + "reason": "fork and own bounded worker instances", + "scope": { + "maxInstances": 1024, + "maxMemoryBytes": 4294967296 + } + } + ], + "configSchema": { + "additionalProperties": false, + "properties": { + "maxLiveWorkers": { + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "maxQueueBytes": { + "maximum": 68719476736, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "provides": [ + { + "id": "github.com/wago-org/workers/service", + "major": 1 + } + ] + }, + "definitionDigest": "sha256:efe2db34dbba3b2782a5ec3fbc741945bb98c2d5ddb20d127b0fa31de4fb3425" + } + ] +} diff --git a/workers.go b/workers.go index 29ef47f..84f6874 100644 --- a/workers.go +++ b/workers.go @@ -2,16 +2,20 @@ package workers import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" + "sort" "sync" "github.com/wago-org/wago" - "github.com/wago-org/wago/plugin" + wagoplugin "github.com/wago-org/wago/plugin" ) -const PluginName = "workers" +const PluginID = "github.com/wago-org/workers" type WorkerID uint64 @@ -26,8 +30,8 @@ const ( // DefaultMaxLiveWorkers bounds the number of simultaneously live workers for // one service when WorkerLimits.MaxLiveWorkers is left zero. Each worker owns a // managed instance, a goroutine, and a foreign stack, so an unbounded count is a - // denial-of-service vector; a default keeps Spawn bounded even when the host - // grants instance.manage without a maxInstances budget. + // denial-of-service vector; a package-level default remains useful even when + // the reviewed instance.manage grant permits a larger ceiling. DefaultMaxLiveWorkers uint32 = 64 // DefaultMaxServiceQueueBytes bounds the total queued-payload reservation across // all live workers when WorkerLimits.MaxQueueBytes is left zero. @@ -107,71 +111,189 @@ type WorkerExitContext struct { Err error } -var ServiceKey = plugin.NewServiceKey[*Workers]("wago.workers/v1") - -type Plugin struct { - service *Workers - limits WorkerLimits +// Service is Workers' typed cross-plugin contract. Call it only inside the +// callback of a wagoplugin.Ref; Wago holds the provider alive for that callback. +type Service interface { + Spawn(wago.HostModule, uint32, WorkerOptions) (WorkerID, error) + Send(WorkerID, uint64, []byte) error + Current(wago.HostModule) (WorkerID, error) + DispatchNext(context.Context, wago.HostModule) error + Link(wago.HostModule, WorkerID) error + Kill(WorkerID) error + ObserveMessages(func(*MessageContext) error) (Subscription, error) + ObserveExits(func(*WorkerExitContext)) (Subscription, error) + Unsubscribe(Subscription) error +} + +// Contract is the major-versioned Workers composition seam. +var Contract = wagoplugin.NewContract[Service](PluginID+"/service", 1) + +type pluginConfig struct { + MaxLiveWorkers *uint32 `json:"maxLiveWorkers,omitempty"` + MaxQueueBytes *uint64 `json:"maxQueueBytes,omitempty"` +} + +var configSchema = json.RawMessage(`{ + "type": "object", + "additionalProperties": false, + "properties": { + "maxLiveWorkers": {"type": "integer", "minimum": 1, "maximum": 65536}, + "maxQueueBytes": {"type": "integer", "minimum": 1, "maximum": 68719476736} + } +}`) + +func decodePluginConfig(raw json.RawMessage) (pluginConfig, WorkerLimits, error) { + if len(raw) == 0 { + raw = json.RawMessage(`{}`) + } + if err := validateConfigObject(raw); err != nil { + return pluginConfig{}, WorkerLimits{}, fmt.Errorf("workers: config: %w", err) + } + var cfg pluginConfig + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return pluginConfig{}, WorkerLimits{}, fmt.Errorf("workers: config: %w", err) + } + if err := dec.Decode(new(any)); err != io.EOF { + return pluginConfig{}, WorkerLimits{}, fmt.Errorf("workers: config has a trailing JSON value") + } + limits := WorkerLimits{MaxLiveWorkers: DefaultMaxLiveWorkers, MaxQueueBytes: DefaultMaxServiceQueueBytes} + if cfg.MaxLiveWorkers != nil { + if *cfg.MaxLiveWorkers == 0 || *cfg.MaxLiveWorkers > 65536 { + return pluginConfig{}, WorkerLimits{}, fmt.Errorf("workers: maxLiveWorkers must be in [1, 65536]") + } + limits.MaxLiveWorkers = *cfg.MaxLiveWorkers + } + if cfg.MaxQueueBytes != nil { + if *cfg.MaxQueueBytes == 0 || *cfg.MaxQueueBytes > 64<<30 { + return pluginConfig{}, WorkerLimits{}, fmt.Errorf("workers: maxQueueBytes must be in [1, 68719476736]") + } + limits.MaxQueueBytes = *cfg.MaxQueueBytes + } + return cfg, limits, nil } -// Option configures the workers Plugin at construction. -type Option func(*Plugin) - -// WithLimits sets the aggregate resource limits for the worker service. Zero -// fields fall back to the package defaults (see WorkerLimits). -func WithLimits(l WorkerLimits) Option { return func(p *Plugin) { p.limits = l } } - -// New creates the workers plugin. Pass WithLimits to override the default -// aggregate resource caps. -func New(opts ...Option) *Plugin { - p := &Plugin{} - for _, opt := range opts { - opt(p) +func validateConfigObject(raw json.RawMessage) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + token, err := dec.Token() + if err != nil { + return err + } + if token != json.Delim('{') { + return fmt.Errorf("must be a JSON object") } - return p + seen := map[string]struct{}{} + for dec.More() { + keyToken, err := dec.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("object key is not a string") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("duplicate field %q", key) + } + seen[key] = struct{}{} + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return err + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("field %q must not be null", key) + } + } + if _, err := dec.Token(); err != nil { + return err + } + if err := dec.Decode(new(any)); err != io.EOF { + if err == nil { + return fmt.Errorf("has a trailing JSON value") + } + return err + } + return nil } -func (*Plugin) Info() wago.ExtensionInfo { - return wago.ExtensionInfo{ - ID: "wago.workers", Name: "Workers", Version: "0.0.0", - Description: "Bounded, extension-scoped WebAssembly worker primitives", - Stability: wago.Experimental, Repository: "https://github.com/wago-org/workers", - License: "Apache-2.0", Tags: []string{"workers", "concurrency", "plugin-foundation"}, - RequiresCapabilities: []wago.PluginCapability{wago.PluginManagedInstances, wago.PluginInstanceHooks}, - Compat: wago.Compatibility{Engines: map[string]string{"wago": ">=0.1.0"}}, +// Definition returns fresh immutable metadata for Workers' explicit provider. +func Definition() wago.PluginDefinition { + return wago.PluginDefinition{ + ID: PluginID, + Name: "Workers", + Version: "0.1.0", + Description: "Bounded, composable WebAssembly worker primitives for Wago plugins.", + Stability: wago.Experimental, + Compatibility: wago.Compatibility{ + Engines: map[string]string{"wago": ">=0.1.0"}, + }, + Provenance: wago.PluginProvenance{ + Homepage: "https://github.com/wago-org/workers#readme", + Repository: "https://github.com/wago-org/workers", + License: "Apache-2.0", + Authors: []string{"Wago contributors"}, + }, + Authorities: []wago.AuthorityRequest{ + { + Name: wago.AuthorityInstanceManage, Mode: wago.AuthorityRequired, + Reason: "fork and own bounded worker instances", + Scope: wago.AuthorityScope{MaxInstances: 1024, MaxMemoryBytes: 4 << 30}, + }, + { + Name: wago.AuthorityInstanceCloseObserve, Mode: wago.AuthorityRequired, + Reason: "stop linked workers when their exact creator closes", + }, + }, + ConfigSchema: append(json.RawMessage(nil), configSchema...), + Provides: []wago.ContractSpec{Contract.Spec()}, + } +} + +// Provider is Workers' side-effect-free catalog entry. +func Provider() wago.PluginProvider { + return wago.PluginProvider{ + Definition: Definition(), + New: func() wago.Plugin { return new(plugin) }, + ValidateConfig: func(raw json.RawMessage) error { + _, _, err := decodePluginConfig(raw) + return err + }, } } -func (p *Plugin) Register(reg *wago.Registry) error { +type plugin struct{ service *Workers } + +func (p *plugin) Register(reg *wago.Registrar) error { + var cfg pluginConfig + if err := reg.Config(&cfg); err != nil { + return err + } + limits := WorkerLimits{MaxLiveWorkers: DefaultMaxLiveWorkers, MaxQueueBytes: DefaultMaxServiceQueueBytes} + if cfg.MaxLiveWorkers != nil { + limits.MaxLiveWorkers = *cfg.MaxLiveWorkers + } + if cfg.MaxQueueBytes != nil { + limits.MaxQueueBytes = *cfg.MaxQueueBytes + } manager, err := reg.ManagedInstances() if err != nil { return err } - lifecycle, err := reg.InstanceLifecycle() + closeObserver, err := reg.InstanceCloseObserver() if err != nil { return err } - p.service = newWorkers(manager, p.limits) - lifecycle.BeforeClose(func(ctx *wago.InstanceContext) { p.service.parentClosing(ctx.Instance) }) - return plugin.Provide(reg, ServiceKey, p.service) -} - -func (p *Plugin) Stop(context.Context) error { - if p == nil || p.service == nil { - return nil + p.service = newWorkers(manager, limits) + if err := closeObserver.Before(func(event wago.InstanceCloseEvent) { p.service.parentClosing(event.Instance) }); err != nil { + return err } - return p.service.close() -} - -func (p *Plugin) Service() *Workers { - if p == nil { - return nil + if err := wagoplugin.Provide(reg, Contract, Service(p.service)); err != nil { + return err } - return p.service + return reg.Lifecycle(wago.PluginLifecycle{Stop: func(context.Context) error { return p.service.close() }}) } -func init() { wago.RegisterExtension(PluginName, func() wago.Extension { return New() }) } - type Workers struct { mu sync.Mutex manager *wago.InstanceManager @@ -180,16 +302,18 @@ type Workers struct { queueBytes uint64 // total per-worker queue-byte reservation currently held next WorkerID workers map[WorkerID]*worker - byInstance map[*wago.Instance]*worker - messages []func(*MessageContext) error - exits []func(*WorkerExitContext) + byInstance map[wago.InstanceIdentity]*worker + nextObs uint64 + messages map[uint64]*messageObserver + exits map[uint64]*exitObserver closed bool exitPanics []error } func newWorkers(manager *wago.InstanceManager, limits WorkerLimits) *Workers { return &Workers{manager: manager, limits: normalizeLimits(limits), next: 1, - workers: map[WorkerID]*worker{}, byInstance: map[*wago.Instance]*worker{}} + workers: map[WorkerID]*worker{}, byInstance: map[wago.InstanceIdentity]*worker{}, + messages: map[uint64]*messageObserver{}, exits: map[uint64]*exitObserver{}} } // reserve claims one live-worker slot and queueBytes of the aggregate queue-byte @@ -217,19 +341,208 @@ func (w *Workers) release(queueBytes uint32) { w.mu.Unlock() } -func (w *Workers) OnMessage(fns ...func(*MessageContext) error) { +type subscriptionKind uint8 + +const ( + messageSubscription subscriptionKind = iota + 1 + exitSubscription +) + +// Subscription is an opaque observer token. It has no provider operations of +// its own; pass it back to Service.Unsubscribe inside a leased contract call. +type Subscription struct { + id uint64 + kind subscriptionKind +} + +type observerGate struct { + mu sync.Mutex + cond *sync.Cond + active bool + inFlight uint32 +} + +func newObserverGate() *observerGate { + g := &observerGate{active: true} + g.cond = sync.NewCond(&g.mu) + return g +} + +func (g *observerGate) begin() bool { + g.mu.Lock() + defer g.mu.Unlock() + if !g.active { + return false + } + g.inFlight++ + return true +} + +func (g *observerGate) end() { + g.mu.Lock() + g.inFlight-- + if g.inFlight == 0 { + g.cond.Broadcast() + } + g.mu.Unlock() +} + +func (g *observerGate) stop() { + g.mu.Lock() + g.active = false + for g.inFlight != 0 { + g.cond.Wait() + } + g.mu.Unlock() +} + +type messageObserver struct { + id uint64 + gate *observerGate + fn func(*MessageContext) error +} + +func (o *messageObserver) invoke(ctx *MessageContext) (err error) { + if !o.gate.begin() { + return nil + } + defer o.gate.end() + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("workers: message observer %d panicked: %v", o.id, recovered) + } + }() + return o.fn(ctx) +} + +type exitObserver struct { + id uint64 + gate *observerGate + fn func(*WorkerExitContext) +} + +func (o *exitObserver) invoke(ctx *WorkerExitContext) (panicErr error) { + if !o.gate.begin() { + return nil + } + defer o.gate.end() + defer func() { + if recovered := recover(); recovered != nil { + panicErr = fmt.Errorf("workers: exit observer %d panicked: %v", o.id, recovered) + } + }() + o.fn(ctx) + return nil +} + +func (w *Workers) nextObserverIDLocked() (uint64, error) { + w.nextObs++ + if w.nextObs == 0 { + return 0, fmt.Errorf("workers: observer ID space exhausted") + } + return w.nextObs, nil +} + +// ObserveMessages registers a message observer until it is unsubscribed. +func (w *Workers) ObserveMessages(fn func(*MessageContext) error) (Subscription, error) { + if w == nil || fn == nil { + return Subscription{}, fmt.Errorf("workers: invalid message observer") + } w.mu.Lock() - defer w.mu.Unlock() - if !w.closed { - w.messages = append(w.messages, fns...) + if w.closed { + w.mu.Unlock() + return Subscription{}, ErrWorkerRuntimeClosed + } + id, err := w.nextObserverIDLocked() + if err != nil { + w.mu.Unlock() + return Subscription{}, err } + observer := &messageObserver{id: id, gate: newObserverGate(), fn: fn} + w.messages[id] = observer + w.mu.Unlock() + return Subscription{id: id, kind: messageSubscription}, nil } -func (w *Workers) OnExit(fns ...func(*WorkerExitContext)) { + +// ObserveExits registers an exit observer until it is unsubscribed. +func (w *Workers) ObserveExits(fn func(*WorkerExitContext)) (Subscription, error) { + if w == nil || fn == nil { + return Subscription{}, fmt.Errorf("workers: invalid exit observer") + } w.mu.Lock() - defer w.mu.Unlock() - if !w.closed { - w.exits = append(w.exits, fns...) + if w.closed { + w.mu.Unlock() + return Subscription{}, ErrWorkerRuntimeClosed } + id, err := w.nextObserverIDLocked() + if err != nil { + w.mu.Unlock() + return Subscription{}, err + } + observer := &exitObserver{id: id, gate: newObserverGate(), fn: fn} + w.exits[id] = observer + w.mu.Unlock() + return Subscription{id: id, kind: exitSubscription}, nil +} + +// Unsubscribe removes one observer and waits for callbacks already in flight. +// It is idempotent. Do not call it from inside that observer's callback. +func (w *Workers) Unsubscribe(subscription Subscription) error { + if w == nil || subscription.id == 0 { + return fmt.Errorf("workers: invalid subscription") + } + var gate *observerGate + w.mu.Lock() + switch subscription.kind { + case messageSubscription: + if observer := w.messages[subscription.id]; observer != nil { + delete(w.messages, subscription.id) + gate = observer.gate + } + case exitSubscription: + if observer := w.exits[subscription.id]; observer != nil { + delete(w.exits, subscription.id) + gate = observer.gate + } + default: + w.mu.Unlock() + return fmt.Errorf("workers: invalid subscription") + } + w.mu.Unlock() + if gate != nil { + gate.stop() + } + return nil +} + +func (w *Workers) messageObservers() []*messageObserver { + w.mu.Lock() + ids := make([]uint64, 0, len(w.messages)) + for id := range w.messages { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + observers := make([]*messageObserver, 0, len(ids)) + for _, id := range ids { + observers = append(observers, w.messages[id]) + } + w.mu.Unlock() + return observers +} + +func (w *Workers) exitObservers() []*exitObserver { + w.mu.Lock() + ids := make([]uint64, 0, len(w.exits)) + for id := range w.exits { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + observers := make([]*exitObserver, 0, len(ids)) + for _, id := range ids { + observers = append(observers, w.exits[id]) + } + w.mu.Unlock() + return observers } func normalizeOptions(o WorkerOptions) (WorkerOptions, error) { @@ -252,7 +565,7 @@ func (w *Workers) Spawn(caller wago.HostModule, tableIndex uint32, opts WorkerOp if w == nil || w.manager == nil { return 0, ErrWorkersInactive } - parent, err := w.manager.Caller(caller) + parent, err := w.manager.CallerIdentity(caller) if err != nil { return 0, ErrInvalidWorkerCaller } @@ -299,10 +612,11 @@ func (w *Workers) Spawn(caller wago.HostModule, tableIndex uint32, opts WorkerOp } else { w.next++ } - wr := &worker{owner: w, id: id, creator: parent, instance: child, tableIndex: tableIndex, + childIdentity := child.Identity() + wr := &worker{owner: w, id: id, creator: parent, identity: childIdentity, instance: child, tableIndex: tableIndex, queue: make([]message, opts.QueueCapacity), maxPayload: opts.MaxPayloadBytes, maxQueueBytes: opts.MaxQueueBytes, - wake: make(chan struct{}, 1), done: make(chan struct{}), messages: append([]func(*MessageContext) error(nil), w.messages...), exits: append([]func(*WorkerExitContext){}, w.exits...)} - w.workers[id], w.byInstance[child.Instance()] = wr, wr + wake: make(chan struct{}, 1), done: make(chan struct{})} + w.workers[id], w.byInstance[childIdentity] = wr, wr w.mu.Unlock() go wr.run() return id, nil @@ -322,7 +636,7 @@ func (w *Workers) Current(caller wago.HostModule) (WorkerID, error) { return 0, ErrWorkerNotFound } w.mu.Lock() - wr := w.byInstance[owned.Instance()] + wr := w.byInstance[owned.Identity()] w.mu.Unlock() if wr == nil { return 0, ErrWorkerNotFound @@ -330,22 +644,25 @@ func (w *Workers) Current(caller wago.HostModule) (WorkerID, error) { return wr.id, nil } -func (w *Workers) DispatchNext(caller wago.HostModule) error { +func (w *Workers) DispatchNext(ctx context.Context, caller wago.HostModule) error { + if ctx == nil { + ctx = context.Background() + } owned, err := w.manager.ManagedCaller(caller) if err != nil { return ErrInvalidWorkerCaller } w.mu.Lock() - wr := w.byInstance[owned.Instance()] + wr := w.byInstance[owned.Identity()] w.mu.Unlock() if wr == nil { return ErrWorkerNotFound } - return wr.dispatch(caller) + return wr.dispatch(ctx, caller) } func (w *Workers) Link(caller wago.HostModule, childID WorkerID) error { - parent, err := w.manager.Caller(caller) + parent, err := w.manager.CallerIdentity(caller) if err != nil { return ErrInvalidWorkerCaller } @@ -353,7 +670,7 @@ func (w *Workers) Link(caller wago.HostModule, childID WorkerID) error { if err != nil { return err } - if wr.creator != parent || wr.instance.Instance() == parent { + if wr.creator != parent || wr.identity == parent { return ErrInvalidWorkerLink } wr.mu.Lock() @@ -398,7 +715,8 @@ type worker struct { mu sync.Mutex owner *Workers id WorkerID - creator *wago.Instance + creator wago.InstanceIdentity + identity wago.InstanceIdentity instance *wago.ManagedInstance tableIndex uint32 queue []message @@ -409,8 +727,6 @@ type worker struct { done chan struct{} stopping, dispatching, linked bool stopErr error - messages []func(*MessageContext) error - exits []func(*WorkerExitContext) } func (wr *worker) signal() { @@ -443,7 +759,7 @@ func (wr *worker) enqueue(tag uint64, payload []byte) error { return nil } -func (wr *worker) dispatch(caller wago.HostModule) error { +func (wr *worker) dispatch(ctx context.Context, caller wago.HostModule) error { wr.mu.Lock() if wr.dispatching { wr.mu.Unlock() @@ -472,8 +788,8 @@ func (wr *worker) dispatch(caller wago.HostModule) error { wr.queuedBytes -= uint32(len(msg.payload)) wr.mu.Unlock() ctx := &MessageContext{WorkerID: wr.id, Tag: msg.tag, Payload: msg.payload, Caller: caller} - for _, fn := range wr.messages { - if err := fn(ctx); err != nil { + for _, observer := range wr.owner.messageObservers() { + if err := observer.invoke(ctx); err != nil { wr.stop(err) return err } @@ -485,6 +801,8 @@ func (wr *worker) dispatch(caller wago.HostModule) error { case <-wr.wake: case <-expired: return ErrInvalidWorkerCaller + case <-ctx.Done(): + return ctx.Err() } } } @@ -515,24 +833,18 @@ func (wr *worker) run() { } else if err != nil { kind = WorkerFailed } - in := wr.instance.Instance() _ = wr.instance.Close() wr.owner.mu.Lock() delete(wr.owner.workers, wr.id) - delete(wr.owner.byInstance, in) + delete(wr.owner.byInstance, wr.identity) wr.owner.mu.Unlock() ctx := &WorkerExitContext{WorkerID: wr.id, Kind: kind, Err: err} - for i, fn := range wr.exits { - func() { - defer func() { - if v := recover(); v != nil { - wr.owner.mu.Lock() - wr.owner.exitPanics = append(wr.owner.exitPanics, fmt.Errorf("worker %d exit observer %d: %v", wr.id, i, v)) - wr.owner.mu.Unlock() - } - }() - fn(ctx) - }() + for _, observer := range wr.owner.exitObservers() { + if panicErr := observer.invoke(ctx); panicErr != nil { + wr.owner.mu.Lock() + wr.owner.exitPanics = append(wr.owner.exitPanics, fmt.Errorf("worker %d: %w", wr.id, panicErr)) + wr.owner.mu.Unlock() + } } // Release aggregate quota only after the instance is closed and every exit // observer has run, so a concurrent Spawn cannot exceed the ceiling while this @@ -541,7 +853,7 @@ func (wr *worker) run() { close(wr.done) } -func (w *Workers) parentClosing(parent *wago.Instance) { +func (w *Workers) parentClosing(parent wago.InstanceIdentity) { w.mu.Lock() var linked []*worker for _, wr := range w.workers { @@ -578,8 +890,20 @@ func (w *Workers) close() error { <-wr.done } w.mu.Lock() + observers := make([]*observerGate, 0, len(w.messages)+len(w.exits)) + for _, observer := range w.messages { + observers = append(observers, observer.gate) + } + for _, observer := range w.exits { + observers = append(observers, observer.gate) + } + w.messages = nil + w.exits = nil errs := append([]error(nil), w.exitPanics...) w.exitPanics = nil w.mu.Unlock() + for _, observer := range observers { + observer.stop() + } return errors.Join(errs...) } diff --git a/workers_test.go b/workers_test.go index cded273..c8a70d7 100644 --- a/workers_test.go +++ b/workers_test.go @@ -1,43 +1,128 @@ package workers import ( + "context" + "encoding/json" "errors" + "sort" + "sync/atomic" "testing" "time" "github.com/wago-org/wago" - "github.com/wago-org/wago/testutil/wasmtest" + wagoplugin "github.com/wago-org/wago/plugin" + "github.com/wago-org/wago/tests/wasmtest" ) +type registerFunc func(*wago.Registrar) error + +func (f registerFunc) Register(reg *wago.Registrar) error { return f(reg) } + +func pluginTestSet(t *testing.T, providers []wago.PluginProvider, config json.RawMessage) wago.PluginSet { + t.Helper() + set := wago.PluginSet{Providers: providers} + for _, provider := range providers { + digest, err := wago.DefinitionDigest(provider.Definition) + if err != nil { + t.Fatal(err) + } + selection := wago.PluginSelection{ + ID: provider.Definition.ID, DefinitionDigest: digest, Direct: true, + Dependencies: map[string]string{}, + } + for _, requirement := range provider.Definition.Requires { + selection.Dependencies[requirement.ID] = requirement.Version + } + if provider.Definition.ID == PluginID { + selection.Config = append(json.RawMessage(nil), config...) + } + for _, authority := range provider.Definition.Authorities { + selection.Grants = append(selection.Grants, wago.AuthorityGrant{Name: authority.Name, Scope: authority.Scope}) + } + for _, requirement := range provider.Definition.Consumes { + var owners []string + for _, candidate := range providers { + for _, provided := range candidate.Definition.Provides { + if provided.ID == requirement.ID && provided.Major == requirement.Major { + owners = append(owners, candidate.Definition.ID) + } + } + } + sort.Strings(owners) + if requirement.Mode != wago.ContractMany && len(owners) > 1 { + owners = owners[:1] + } + selection.Contracts = append(selection.Contracts, wago.ContractBinding{ID: requirement.ID, Major: requirement.Major, Providers: owners}) + } + set.Selections = append(set.Selections, selection) + } + return set +} + type integrationPlugin struct { - Plugin + service *wagoplugin.Ref[Service] id WorkerID exits chan WorkerExitContext messages chan MessageContext + message Subscription + exit Subscription } -func (p *integrationPlugin) Info() wago.ExtensionInfo { - info := p.Plugin.Info() - info.ID = "wago.workers.integration" - info.RequiresCapabilities = append(info.RequiresCapabilities, wago.PluginHostImports) - return info -} - -func (p *integrationPlugin) Register(reg *wago.Registry) error { - if err := p.Plugin.Register(reg); err != nil { - return err - } - p.Service().OnMessage(func(ctx *MessageContext) error { p.messages <- *ctx; return nil }) - p.Service().OnExit(func(ctx *WorkerExitContext) { p.exits <- *ctx }) - imports, err := reg.HostImports() - if err != nil { - return err +func integrationProvider(p *integrationPlugin) wago.PluginProvider { + definition := wago.PluginDefinition{ + ID: "example.com/workers-integration", Version: "1.0.0", + Provenance: wago.PluginProvenance{Repository: "https://example.com/workers-integration", License: "MIT"}, + Requires: []wago.PluginRequirement{{ID: PluginID, Version: "^0.1.0"}}, + Authorities: []wago.AuthorityRequest{{ + Name: wago.AuthorityHostImportDefine, Mode: wago.AuthorityRequired, + Reason: "exercise worker calls from a guest host import", + Scope: wago.AuthorityScope{Modules: []string{"test"}}, + }}, + Consumes: []wago.ContractRequirement{{ID: Contract.ID(), Major: Contract.Major(), Mode: wago.ContractRequired}}, } - imports.Module("test").Func("spawn", func(caller wago.HostModule, _, _ []uint64) { - p.id, _ = p.Service().Spawn(caller, 0, WorkerOptions{QueueCapacity: 1, MaxPayloadBytes: 16, MaxQueueBytes: 16}) - }) - imports.Module("test").Func("next", func(caller wago.HostModule, _, _ []uint64) { _ = p.Service().DispatchNext(caller) }) - return nil + return wago.PluginProvider{Definition: definition, New: func() wago.Plugin { + return registerFunc(func(reg *wago.Registrar) error { + var err error + p.service, err = wagoplugin.Require(reg, Contract) + if err != nil { + return err + } + imports, err := reg.HostImports() + if err != nil { + return err + } + module, err := imports.Module("test") + if err != nil { + return err + } + module.Func("spawn", func(caller wago.HostModule, _, _ []uint64) { + _ = p.service.With(func(service Service) error { + p.id, _ = service.Spawn(caller, 0, WorkerOptions{QueueCapacity: 1, MaxPayloadBytes: 16, MaxQueueBytes: 16}) + return nil + }) + }) + module.Func("next", func(caller wago.HostModule, _, _ []uint64) { + _ = p.service.With(func(service Service) error { return service.DispatchNext(context.Background(), caller) }) + }) + return reg.Lifecycle(wago.PluginLifecycle{ + Start: func(context.Context) error { + return p.service.With(func(service Service) error { + p.message, err = service.ObserveMessages(func(ctx *MessageContext) error { p.messages <- *ctx; return nil }) + if err != nil { + return err + } + p.exit, err = service.ObserveExits(func(ctx *WorkerExitContext) { p.exits <- *ctx }) + return err + }) + }, + Stop: func(context.Context) error { + return p.service.With(func(service Service) error { + return errors.Join(service.Unsubscribe(p.message), service.Unsubscribe(p.exit)) + }) + }, + }) + }) + }} } func integrationModule() []byte { @@ -61,53 +146,32 @@ func integrationModule() []byte { } func TestWorkerLimitsQuota(t *testing.T) { - // Defaults apply when a field is zero. if got := normalizeLimits(WorkerLimits{}); got.MaxLiveWorkers != DefaultMaxLiveWorkers || got.MaxQueueBytes != DefaultMaxServiceQueueBytes { t.Fatalf("normalizeLimits(zero) = %+v", got) } - if got := normalizeLimits(WorkerLimits{MaxLiveWorkers: 3}); got.MaxQueueBytes != DefaultMaxServiceQueueBytes || got.MaxLiveWorkers != 3 { - t.Fatalf("normalizeLimits partial = %+v", got) - } - - // MaxLiveWorkers ceiling: the third reservation is rejected, and a release frees a slot. - w := newWorkers(nil, WorkerLimits{MaxLiveWorkers: 2, MaxQueueBytes: 1 << 20}) - if err := w.reserve(100); err != nil { - t.Fatalf("reserve 1: %v", err) - } - if err := w.reserve(100); err != nil { - t.Fatalf("reserve 2: %v", err) - } - if err := w.reserve(100); !errors.Is(err, ErrWorkerQuotaExceeded) { - t.Fatalf("reserve 3 = %v, want ErrWorkerQuotaExceeded", err) - } - w.release(100) - if err := w.reserve(100); err != nil { - t.Fatalf("reserve after release: %v", err) - } - - // Aggregate queue-byte ceiling is enforced independently and cannot overflow. - w2 := newWorkers(nil, WorkerLimits{MaxLiveWorkers: 100, MaxQueueBytes: 1000}) - if err := w2.reserve(600); err != nil { - t.Fatalf("reserve 600: %v", err) + w := newWorkers(nil, WorkerLimits{MaxLiveWorkers: 2, MaxQueueBytes: 1000}) + if err := w.reserve(600); err != nil { + t.Fatal(err) } - if err := w2.reserve(600); !errors.Is(err, ErrWorkerQuotaExceeded) { - t.Fatalf("reserve 600 over budget = %v, want ErrWorkerQuotaExceeded", err) + if err := w.reserve(400); err != nil { + t.Fatal(err) } - if err := w2.reserve(400); err != nil { - t.Fatalf("reserve exact remaining 400: %v", err) + if err := w.reserve(1); !errors.Is(err, ErrWorkerQuotaExceeded) { + t.Fatalf("reserve above quota = %v", err) } - - // A closed service rejects reservations. - w2.closed = true - if err := w2.reserve(1); !errors.Is(err, ErrWorkerRuntimeClosed) { - t.Fatalf("reserve on closed = %v, want ErrWorkerRuntimeClosed", err) + w.release(600) + if err := w.reserve(1); err != nil { + t.Fatal(err) } } -func TestPluginSpawnsCopiesMessageAndStops(t *testing.T) { +func TestPluginContractSpawnsCopiesMessageAndStops(t *testing.T) { p := &integrationPlugin{exits: make(chan WorkerExitContext, 1), messages: make(chan MessageContext, 1)} rt := wago.NewRuntime() - if err := rt.Use(p); err != nil { + // Reverse the dependency order to prove Requires plus the reviewed contract + // binding, rather than fixture order, places Workers before its consumer. + set := pluginTestSet(t, []wago.PluginProvider{integrationProvider(p), Provider()}, json.RawMessage(`{"maxLiveWorkers":8,"maxQueueBytes":1024}`)) + if err := rt.LoadPlugins(context.Background(), set); err != nil { t.Fatal(err) } mod, err := rt.Compile(integrationModule()) @@ -126,7 +190,7 @@ func TestPluginSpawnsCopiesMessageAndStops(t *testing.T) { t.Fatal("spawn returned zero ID") } payload := []byte("abc") - if err := p.Service().Send(p.id, 42, payload); err != nil { + if err := p.service.With(func(service Service) error { return service.Send(p.id, 42, payload) }); err != nil { t.Fatal(err) } copy(payload, "zzz") @@ -146,10 +210,62 @@ func TestPluginSpawnsCopiesMessageAndStops(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("exit timeout") } - if err := p.Service().Send(p.id, 0, nil); !errors.Is(err, ErrWorkerNotFound) { - t.Fatalf("send after exit = %v", err) - } if err := rt.Close(); err != nil { t.Fatal(err) } + if err := p.service.With(func(Service) error { return nil }); !errors.Is(err, wago.ErrPermissionDenied) { + t.Fatalf("contract after close = %v", err) + } +} + +func TestSubscriptionCloseWaitsAndPreventsFutureCalls(t *testing.T) { + w := newWorkers(nil, WorkerLimits{}) + started, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + sub, err := w.ObserveMessages(func(*MessageContext) error { + calls.Add(1) + close(started) + <-release + return nil + }) + if err != nil { + t.Fatal(err) + } + observer := w.messageObservers()[0] + done := make(chan struct{}) + go func() { _ = observer.invoke(new(MessageContext)); close(done) }() + <-started + closed := make(chan struct{}) + go func() { _ = w.Unsubscribe(sub); close(closed) }() + select { + case <-closed: + t.Fatal("subscription closed before callback returned") + case <-time.After(20 * time.Millisecond): + } + close(release) + <-done + <-closed + if err := observer.invoke(new(MessageContext)); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("observer calls = %d, want 1", got) + } +} + +func TestPluginRejectsStrictInvalidConfig(t *testing.T) { + for _, config := range []json.RawMessage{ + json.RawMessage(`null`), + json.RawMessage(`[]`), + json.RawMessage(`{"maxLiveWorkers":null}`), + json.RawMessage(`{"maxLiveWorkers":1,"maxLiveWorkers":2}`), + json.RawMessage(`{"unknown":1}`), + json.RawMessage(`{"maxLiveWorkers":0}`), + json.RawMessage(`{"maxQueueBytes":0}`), + json.RawMessage(`{} {}`), + } { + if err := wago.ValidatePluginSet(pluginTestSet(t, []wago.PluginProvider{Provider()}, config)); err == nil { + t.Fatalf("accepted invalid config %s", config) + } + } } From 108356bdb71574d52f69e88a082cfc351001fa94 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 06:57:50 -0700 Subject: [PATCH 2/7] chore: pin repaired plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4539c8a..bd8db47 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8 +require github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3 diff --git a/go.sum b/go.sum index aebaed8..e1cb228 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8 h1:VHkyQAIQxbcHYp4OffVuGZzUxyXYxBkgGYHTDxTJ/So= -github.com/wago-org/wago v0.0.0-20260812133922-747a0520edb8/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3 h1:0G6XWikBuovKJQADEhAI4P+MTiUUQIvtGhRtsgNpHkY= +github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= From 4c4387e62aeee4eac2e8ca1760048089118e1684 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 07:04:03 -0700 Subject: [PATCH 3/7] chore: pin final plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index bd8db47..d9dcb50 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3 +require github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e diff --git a/go.sum b/go.sum index e1cb228..771c526 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3 h1:0G6XWikBuovKJQADEhAI4P+MTiUUQIvtGhRtsgNpHkY= -github.com/wago-org/wago v0.0.0-20260812135637-485943d049a3/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e h1:lBgpSR9MAVZvlF3RNNpKzwTF6C4ZtyeNqu9pfKzQwYE= +github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= From 025a9f1fdc6f89d35f44870362af4390f433fe73 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 07:11:04 -0700 Subject: [PATCH 4/7] chore: pin final tested plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d9dcb50..4971ca6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e +require github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea diff --git a/go.sum b/go.sum index 771c526..a77f51d 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e h1:lBgpSR9MAVZvlF3RNNpKzwTF6C4ZtyeNqu9pfKzQwYE= -github.com/wago-org/wago v0.0.0-20260812140322-f669c98a153e/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea h1:SU/eDjjh7SFPwc9gDT7qFqqVIA+N/YND3lFqoSDcqbg= +github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= From 1139bcb72dedec5f7425e3e640d367bc700eecb5 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 07:24:20 -0700 Subject: [PATCH 5/7] chore: pin cross-platform plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4971ca6..1aef54f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea +require github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12 diff --git a/go.sum b/go.sum index a77f51d..f55228a 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea h1:SU/eDjjh7SFPwc9gDT7qFqqVIA+N/YND3lFqoSDcqbg= -github.com/wago-org/wago v0.0.0-20260812141033-492d7b4905ea/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12 h1:Eugr8XolBXuXJGXbKW7vYJIWWRSnn2HepWXxo9CzMSs= +github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= From 2283ca9dde942b2d792d951ab35fff6d3cd7d105 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 07:40:15 -0700 Subject: [PATCH 6/7] chore: pin final plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1aef54f..0f89a40 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12 +require github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f diff --git a/go.sum b/go.sum index f55228a..62409d4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12 h1:Eugr8XolBXuXJGXbKW7vYJIWWRSnn2HepWXxo9CzMSs= -github.com/wago-org/wago v0.0.0-20260812142346-bdfc6aaafa12/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f h1:3Kb/21SHOb7PrODj0PXgYkwcmG7CGYOEGCGGQIffn1I= +github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= From 4a90ff25d5e426de4759bbb65612efac717ea018 Mon Sep 17 00:00:00 2001 From: JairusSW Date: Wed, 12 Aug 2026 07:46:03 -0700 Subject: [PATCH 7/7] chore: pin verified plugin runtime --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0f89a40..82d9338 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/wago-org/workers go 1.24.0 -require github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f +require github.com/wago-org/wago v0.0.0-20260812144524-1c58c9862d25 diff --git a/go.sum b/go.sum index 62409d4..24b3655 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f h1:3Kb/21SHOb7PrODj0PXgYkwcmG7CGYOEGCGGQIffn1I= -github.com/wago-org/wago v0.0.0-20260812143921-6f5623623b4f/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8= +github.com/wago-org/wago v0.0.0-20260812144524-1c58c9862d25 h1:gopNHvaUaDSzmW2Hfd32r8dOwwzYUHH3ph1L/IuhJUA= +github.com/wago-org/wago v0.0.0-20260812144524-1c58c9862d25/go.mod h1:6XmxI3S5qJ+YAzObyXjC6FmxHtbLhBBxrglzix42zl8=