Files
zeekayandHanzo Dev 0692608056 declaration projects the program; Fiber() escapes are refused; Listen is Serve+Wait
Three things a consumer migration surfaced.

HEAD shadows. Declaration filtered ALL HEAD and OPTIONS, which also deleted an
EXPLICITLY declared HEAD — a real door a host would then answer 405 on, which is
the analytics-outage class this type exists to prevent. The doctrine was right
and the mechanism was wrong: a declaration now projects the PROGRAM, not the
materialised router, so shadows never appear (nothing wrote them) and declared
HEAD does. App.All is the one entry that is genuinely many doors, so it expands.
Consumers enumerating routes should read Declaration, not Fiber().GetRoutes —
fiber's own method cannot apply a doctrine zip owns.

Fiber() escapes. Fiber() hands out the CURRENT generation's router, and a
generation is a projection, so app.Fiber().Use(cors.New(...)) was silently
discarded by the next build — no error, no CORS headers. A doc line is weak
medicine for a lost CORS policy, so the generation records its route count and a
rebuild that finds more refuses, naming the count and pointing at app.Use.

Listen. Serve is the one serving mechanism and Listen is now literally Serve
followed by Wait — sugar, not a second path. The no-handle shape is deliberate
and documented: a fixed program wants the terminal form, anything that loads at
run time wants the handle.

And a bug of my own, caught by TestLoad_EmbeddedBinary: the first cut of Serve
left h.errc nil, so Wait returned immediately and Listen stopped serving at all.
The test I wrote for Listen passed against that, so it has been strengthened to
dial the socket and assert Listen has NOT returned — verified to fail without
the fix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 14:20:25 -07:00

116 lines
4.3 KiB
Go

// Command testplugin is a minimal zip plugin used by the reload tests. It
// serves one route reporting the version stamped in at link time, so a test can
// prove WHICH build answered — the only honest way to show a reload swapped
// processes rather than just restarting the same one.
package main
import (
"context"
"encoding/json"
"log"
"os"
"github.com/zap-proto/zip"
)
// version is set with -ldflags "-X main.version=…" so two builds of identical
// source are distinguishable on the wire.
var version = "unset"
// Version reports which build is answering.
type Version struct {
Version string `json:"version"`
}
// Crashing acknowledges a crash request before the process dies.
type Crashing struct {
Crashing string `json:"crashing"`
}
// Nothing is an operation that takes no input.
type Nothing struct{}
// tenant is a per-caller tool source: the half of a door that cannot be
// projected, because the answer depends on WHO is asking. A host that declares
// this plugin Open asks it per caller, and this is what answers.
type tenant struct{}
func (tenant) Tools(ctx context.Context) []map[string]any {
org := orgOf(ctx)
if org == "" {
return nil
}
return []map[string]any{{
"name": org + "_own", "description": "a tool only " + org + " has",
"inputSchema": map[string]any{"type": "object"},
}}
}
func (tenant) Call(ctx context.Context, name string, _ json.RawMessage) (any, error) {
if org := orgOf(ctx); org != "" && name == org+"_own" {
return map[string]any{"ran": name, "org": org}, nil
}
return nil, zip.Errorf(404, "unknown tool: %s", name)
}
// orgKey carries the caller the door is serving, parked by the middleware below
// the way a host's identity boundary does.
type orgKey struct{}
func orgOf(ctx context.Context) string { org, _ := ctx.Value(orgKey{}).(string); return org }
func main() {
app := zip.New(zip.Config{AppName: "testplugin", DisableStartupMessage: true,
MCP: zip.MCPConfig{Source: tenant{}}})
app.Use(zip.H(func(c *zip.Ctx) error {
c.SetContext(context.WithValue(c.Context(), orgKey{}, c.Org()))
return c.Continue()
}))
// Typed, like any other route worth having: a plugin is an ordinary zip
// app, so its ops project into the host's document, tools and call plane
// exactly as a linked-in service's do.
zip.Get(app, "/v1/demo/version", func(context.Context, *Nothing) (*Version, error) {
return &Version{Version: version}, nil
}, zip.WithSummary("Reports which build of the plugin is answering."))
// Crashes the process the way a real bug does — a panic on a goroutine,
// which no handler recover() can catch. Used to prove the host survives a
// plugin dying and brings it back.
zip.Get(app, "/v1/demo/crash", func(context.Context, *Nothing) (*Crashing, error) {
go func() { panic("testplugin: deliberate crash") }()
return &Crashing{Crashing: "true"}, nil
}, zip.WithSummary("Crashes the plugin process, to prove the host survives it."))
// Untyped on purpose, and the one route here that has to be. It echoes
// whatever path reached it, so a host that mounts this plugin at more than
// one prefix can tell "the second prefix is not mounted" (the HOST 404s)
// from "the plugin has no such route" (the PLUGIN 404s) — which are
// otherwise identical from the outside. A wildcard answers paths that do not
// exist, so there is no operation for it to be: an op is one named endpoint
// with one schema, and this is deliberately neither.
app.All("/*", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"echo": c.Path(), "version": version})
})
// `testplugin tools` prints this plugin's MCP catalogue instead of serving —
// the build-time step a host's Plugin.Tools is captured from, and the same
// thing hanzoai/cloud's `<app> describe` does. Projecting it is the whole of
// what a plugin does to be on a host's composed MCP door.
if len(os.Args) > 1 && os.Args[1] == "tools" {
b, err := json.Marshal(app.MCPTools())
if err != nil {
os.Exit(1)
}
_, _ = os.Stdout.Write(b)
return
}
// Addr is the whole plugin side of the contract: serve where the host said.
// Report rather than swallow: a fixture that discards this error exits
// silently and the host reports only "exited before listening".
if err := app.Listen(zip.Addr(":9999")); err != nil {
log.Fatalf("testplugin: %v", err)
}
}