Files
zip/generation.go
zeekayandHanzo Dev 1e1218791f fix: a lazy build's error is read under its lock, and Detach carries a caller past its request
mustBuild read the draft's error off the App while liveOrBuild wrote it under
buildMu, so a goroutine reaching the router raced a concurrent build;
liveOrBuild now returns the error it read under the lock. Detach is the context
for work that outlives a handler: the server reuses a request once its handler
returns, so a goroutine holding the handler's context read the next request's
headers. Detach states the caller while the request is still its own, on a
context that no longer reaches the request and is not cancelled with it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-03 14:53:18 -07:00

315 lines
11 KiB
Go

package zip
import (
"fmt"
"github.com/valyala/fasthttp"
fiber "github.com/zap-proto/fiber/v3"
)
// A program is not mutable. It is VERSIONED.
//
// The runtime requirement is real: plugins load lazily, routing updates on
// demand, subsystems get dropped and reloaded. The wrong conclusion is that the
// program must therefore be editable while it serves. A generation is a sealed,
// immutable program together with everything projected from it, and the live
// system is one atomic pointer to the current one.
//
// # Build then swap, never mutate
//
// A composition change constructs generation N+1 from the current entry list
// plus or minus the changed references, runs the whole walk and every
// validation over it, and swaps ONLY on success. A plugin whose patterns
// collide with the live set fails the build, with breadcrumbs, and the old
// generation keeps serving. Load and reload are transactional: a bad plugin
// cannot take down routing. That is the property that makes dynamic composition
// safe, and it is unreachable if composition edits a live structure in place.
//
// # Requests are generation-pinned
//
// A request loads the pointer once, on arrival, and completes on that
// generation whatever happens afterwards. In-flight requests never observe a
// half-applied change, because there is no such state to observe: the new
// generation is complete before the pointer moves. Reads are lock-free and
// there is no lock on the served path in any phase.
//
// # What freezes
//
// A definition freezes when it first appears in a BUILT generation. Mutating a
// frozen App panics, and the panic means exactly one thing: go through a
// generation. This aligns with Go's plugin mechanics rather than fighting them
// — a reloaded .so yields a NEW *App from a new plugin.Open, so versions are
// distinct definitions with distinct pointers, which is precisely what the
// walk's pointer-identity rule wants.
type generation struct {
// n counts generations from 0, so a diagnostic can say which one answered.
n uint64
// occ is the walk this generation was built from, RETAINED so the reducers
// serving it — the OpenAPI endpoint, the MCP list, llms.txt — reuse it
// rather than rewalking per request.
occ []occurrence
// routes is how many routes this generation's router held the moment it was
// built. A later rebuild compares it against the router's CURRENT count to
// catch a caller that registered on the value [App.Fiber] handed out — see
// checkForeignRoutes.
routes int
// The projections, computed once, read without a lock forever after.
router *fiber.App
serve fasthttp.RequestHandler
ops []*registeredOp
hosts []*App
ctl []route
}
// Generation reports which generation is live, and whether one is.
func (a *App) Generation() (uint64, bool) {
g := a.live.Load()
if g == nil {
return 0, false
}
return g.n, true
}
// build computes the next generation from the current program WITHOUT
// installing it. Nothing it does is observable, so a failed build costs the
// live system nothing.
func (a *App) build() (*generation, error) {
// Pipeline, in order, or nothing publishes: walk once, then BOTH validation
// stages complete with trails, then the reducers.
occ, err := walk(a)
if err != nil {
return nil, err
}
if err := structural(occ); err != nil {
return nil, err
}
if err := derived(occ); err != nil {
return nil, err
}
// Which rule governs which definition. After both validation stages because
// a composition that will not serve has nothing to settle, and before any
// projection because an op must know its rule before it can be reached.
if err := adopt(occ); err != nil {
return nil, err
}
var n uint64
if prev := a.live.Load(); prev != nil {
n = prev.n + 1
}
g := &generation{n: n, occ: occ, ctl: a.ctl}
g.router = a.materialise(occ, g.ctl)
g.serve = g.router.Handler()
g.ops = composeOps(occ)
g.hosts = computeHosts(a, occ)
// Adopt: every plugin in this composition learns which app is the ROOT of it.
// This is the only place that knows, and a plugin needs it to ask the two
// questions its own definition cannot answer — how many processes are running
// across the whole host, and what the host's ceiling is.
for _, h := range g.hosts {
h.plugMu.Lock()
for _, pl := range h.plugins {
pl.owner.Store(a)
}
h.plugMu.Unlock()
}
g.routes = len(g.router.GetRoutes(false))
return g, nil
}
// install makes g live and freezes every definition it reaches. Freezing is
// what earns the lock-free read: a definition that cannot change cannot
// invalidate a projection taken from it.
// checkForeignRoutes refuses to discard work a caller did on the router.
//
// [App.Fiber] hands out the CURRENT generation's router, and a generation is a
// projection: the next build materialises a fresh one and the old is dropped. So
//
// app.Fiber().Use(cors.New(...))
//
// registered CORS on a value that the next registration silently threw away — no
// error, no panic, no CORS headers, on a policy whose entire job is to be there.
// A middleware seam lost silently is the exact failure this design exists to
// prevent, and it was arriving through the one door left open.
//
// A doc line is weak medicine for that, so this is a mechanism: the generation
// records how many routes its router had when it was built, and a rebuild that
// finds MORE refuses rather than discarding them. It cannot see a Use that
// matched nothing, but it catches every registration, which is the shape the
// footgun actually takes.
func (a *App) checkForeignRoutes(site callsite) {
prev := a.live.Load()
if prev == nil || prev.router == nil {
return
}
if now := len(prev.router.GetRoutes(false)); now != prev.routes {
panic(fmt.Sprintf("zip: %s: %d route(s) were registered directly on the value App.Fiber() "+
"returned, and rebuilding the program would discard them.\n\tFiber() hands out the "+
"CURRENT generation's router; a generation is a PROJECTION, so the next build "+
"materialises a fresh one.\n\tRegister on the App instead — app.Use(mw) for "+
"middleware, app.Get(path, h) for a route — so it survives every generation.",
site, now-prev.routes))
}
}
func (a *App) install(g *generation) {
site := here(1)
for _, child := range g.hosts {
child.freeze(site)
}
a.freeze(site)
a.live.Store(g)
// The MCP door serves pre-rendered bytes — that is what makes tools/list free
// — so a new generation has to re-render them or an agent keeps reading the
// previous composition's tool set.
if a.mcpList.Load() != nil {
a.renderTools()
}
}
func (a *App) freeze(site callsite) {
if a.frozen.CompareAndSwap(false, true) {
a.freezeSite = site
}
}
// Frozen reports whether this definition has appeared in a built generation and
// can therefore no longer be edited in place.
func (a *App) Frozen() bool { return a.frozen.Load() }
// transact is the ONE path a composition change takes once something is live:
// edit, build, and install — or put the entry list back exactly as it was and
// return why. There is no partial outcome, because the edit is only ever
// observed through a generation and a generation is only ever installed whole.
func (a *App) transact(site callsite, edit func()) error {
a.buildMu.Lock()
defer a.buildMu.Unlock()
before := a.entries
adopted := len(a.hooks)
// DEFERRED, because a build can PANIC as well as fail: fiber refuses an
// unknown HTTP method by panicking, and a Declaration is a build input from
// another team. A straight-line rollback unwinds past itself and leaves the
// edit in a.entries, so the next composition — a valid one — panics on the
// poison. "The old generation keeps serving" has to hold for a panic too.
ok := false
defer func() {
if ok {
return
}
a.entries = before
// compose() adopts a child's teardown BEFORE the build can refuse it; a
// host that refused a plugin must not own that plugin's shutdown.
a.hookMu.Lock()
if len(a.hooks) > adopted {
a.hooks = a.hooks[:adopted]
}
a.hookMu.Unlock()
version.Add(1)
}()
a.entries = append(a.entries[:0:0], a.entries...) // copy: rollback must be total
edit()
a.checkForeignRoutes(site)
g, err := a.build()
if err != nil {
return fmt.Errorf("zip: %s: composition refused, generation %d still serving: %w",
site, a.liveN(), err)
}
a.install(g)
ok = true
return nil
}
func (a *App) liveN() uint64 {
n, _ := a.Generation()
return n
}
// compose appends components without the seal check, for use inside a
// transaction where the entry list is a private copy.
func (a *App) compose(site callsite, cs []Component) {
for _, c := range cs {
switch v := c.(type) {
case nil:
case Handler:
if v == nil {
continue
}
a.entries = append(a.entries, entry{n: v, site: site})
case *App:
if v == nil {
panic(fmt.Sprintf("zip: %s: Include(nil *App)", site))
}
a.entries = append(a.entries, entry{n: v, site: site})
a.OnShutdown(v.ShutdownWithContext)
default:
panic(fmt.Sprintf("zip: %s: %T is not a Component", site, c))
}
}
version.Add(1)
}
// includeRoutes is Include for routes rather than Components — the door a
// plugin mounted at run time comes through ([App.load]). Same transaction, same
// all-or-nothing.
func (a *App) includeRoutes(site callsite, rs ...route) error {
if a.live.Load() == nil {
// Nothing is serving yet, so this is ordinary composition.
for _, r := range rs {
a.addRoute(site, r)
}
return nil
}
return a.transact(site, func() {
for _, r := range rs {
a.entries = append(a.entries, entry{n: r, site: site})
}
version.Add(1)
})
}
// pinned is the handler Listen serves: ONE pointer load per request, then that
// request belongs to that generation until it finishes.
func (a *App) pinned() fasthttp.RequestHandler {
return func(rc *fasthttp.RequestCtx) {
a.live.Load().serve(rc)
}
}
// liveOrBuild returns the live generation, building an uninstalled one if
// nothing is serving yet.
//
// Pre-build inspection recomputes per call and deliberately does not install:
// a codegen step, a test or a doc generator that looks at a program must not
// freeze it and turn the next legitimate Use into a panic about a generation
// nobody asked for.
//
// The error is the draft's, returned with it under the same lock. A live
// generation has none: installing it is what proved the program composes.
func (a *App) liveOrBuild() (*generation, error) {
if g := a.live.Load(); g != nil {
return g, nil
}
a.buildMu.Lock()
defer a.buildMu.Unlock()
if g := a.live.Load(); g != nil {
return g, nil
}
if a.draft != nil && a.draftAt == version.Load() {
return a.draft, a.draftErr
}
at := version.Load() // BEFORE the build: an append during it must invalidate
g, err := a.build()
if g == nil {
g = &generation{router: fiber.New(a.fiberConfig(nil))}
}
// The error is REMEMBERED, not swallowed. An invalid program used to render
// as an empty one — Registry() 0 ops, Declaration() 0 routes, and
// `app declare` writing {"routes":[]} and exiting 0, which ships an empty
// API document for a broken build. Accessors still answer (they cannot
// return an error), but anything that PUBLISHES asks first: see project().
a.draft, a.draftAt, a.draftErr = g, at, err
return g, err
}