diff --git a/docs/adr/0007-register-sees-the-current-guest-host-services.md b/docs/adr/0007-register-sees-the-current-guest-host-services.md new file mode 100644 index 0000000..04ce732 --- /dev/null +++ b/docs/adr/0007-register-sees-the-current-guest-host-services.md @@ -0,0 +1,36 @@ +# Register sees the current guest host services + +Go guest plugins can declare capability-backed runtime surfaces from their +`Register` callback. Bridge providers are the first such surface: the provider +stores its invokable value in the guest-local bridge stub while the stub tells +the CMS host that the named service exists. + +`Serve` previously assigned the package-level guest runtime only after +`newGuest` returned. Because `newGuest` runs `Register` synchronously, +`HostServices()` returned an empty `CoreServices` value during registration. +Plugins that correctly nil-checked the bridge silently skipped registration. +The CMS could therefore mark a Wasm plugin loaded while consumers failed with +`bridge: no service registered` during a later load hook. + +`newGuest` now makes the guest under construction current for the synchronous +registration pass and restores the previous runtime before returning. `Serve` +then installs the completed guest as before. This keeps construction isolated +for native tests while making the documented `HostServices()` escape hatch +truthful during `Register` on every pooled Wasm instance. + +Alternatives rejected were moving bridge registration into `Load`, which runs +on only one pooled instance, and adding bridge names only to the manifest, +which would advertise host availability without installing the guest-local +`BridgeInvokable` value needed by `HOOK_BRIDGE_CALL`. + +Consequences: + +- Bridge services registered from `Register` exist on every pooled guest + instance and remain callable after CMS startup and hot swap. +- A regression test exercises the observable `Serve` plus + `HOOK_BRIDGE_CALL` contract instead of relying on initialization internals. +- No ABI or protobuf change is required; consumers need a plugin SDK release + containing the corrected Go guest runtime. + +Keywords: wasmguest.Serve, newGuest, HostServices, PluginRegistration.Register, +plugin bridge, RegisterService, BridgeInvokable, HOOK_BRIDGE_CALL, wiki content diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index 73300bc..6f41fef 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -100,7 +100,15 @@ func newGuest(reg plugin.PluginRegistration) *guest { // Pool whose calls fail cleanly, matching the capability stubs. g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport)) g.rag, _ = g.services.RAGService.(*caps.RAGStub) + // Register callbacks may use HostServices for capability-backed surfaces + // that are not passed as Register parameters, such as bridge services. + // Publish this partially initialized guest only for the synchronous + // registration pass, then restore the previous runtime. Serve installs g + // permanently after newGuest returns. + previous := current + current = g g.registerErr = runRegister(reg, g.templates, g.blocks) + current = previous return g } diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go index 4a99bde..c2c6fcb 100644 --- a/plugin/wasmguest/dispatch_test.go +++ b/plugin/wasmguest/dispatch_test.go @@ -130,6 +130,43 @@ func TestServeInstallsRuntime(t *testing.T) { } } +func TestServeExposesHostServicesDuringRegister(t *testing.T) { + previous := current + t.Cleanup(func() { current = previous }) + + Serve(plugin.PluginRegistration{ + Name: "bridge-provider", + Version: "0.1.0", + Register: func(_ templates.TemplateRegistry, _ blocks.BlockRegistry) error { + bridge := HostServices().Bridge + if bridge == nil { + return fmt.Errorf("HostServices bridge is unavailable during Register") + } + bridge.RegisterService("bridge-provider", "content", bridgeInvokableFunc( + func(_ context.Context, _ string, _ []byte) ([]byte, error) { + return []byte("registered"), nil + }, + )) + return nil + }, + }) + + resp := invokeHook(t, current, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{ + ServiceName: "content", + Method: "apply", + }) + if resp.GetError() != nil { + t.Fatalf("bridge call returned error: %v", resp.GetError()) + } + bridgeResp := &abiv1.BridgeCallResponse{} + if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil { + t.Fatalf("unmarshal BridgeCallResponse: %v", err) + } + if got := string(bridgeResp.GetPayload()); got != "registered" { + t.Fatalf("bridge payload = %q, want registered", got) + } +} + func TestBridgeCallProvidesAuthenticatedCallerContext(t *testing.T) { g := newGuest(fixtureRegistration()) g.services.Bridge.RegisterService("fixture", "content", bridgeInvokableFunc(