zip.Mount is a proxy, not a mount — the name now says which of the two it is

`Mount` meant two unrelated things across the fleet at once:

  zip/remote.go    func Mount(prefix, addr string, decl ...Declaration) (*App, error)
  112 subsystems   func Mount(app cloud.Router, deps cloud.Deps) error

The second is the fleet's registration contract, stated in hanzoai/cloud's
package doc and checked by cloud.MountFunc. The first is a leftover: Mount was
one of five composition verbs (Listen/Mount/Add/Graft/Use), the other four were
dissolved into Use, and this one survived by being the only one that pointed at
another process. A reader seeing `Mount` in a diff could not tell which was
meant without counting arguments.

Renamed to Proxy, which is what it BUILDS. Every route it registers runs the
one handler this file calls `proxy`, whose whole body is forward() to the
address given; "proxy" is the term of art for a local stand-in whose handlers
hand the request to someone else, so it needs no gloss. It is also a noun,
which a function returning a value should be — composition is Use's job now,
and the doc has said so since the four verbs were dissolved.

NOT Remote: [Remote] already exists here and is the CALLER's side, a client
executing commands against a service elsewhere. This is the SERVER's side, a
definition standing in for that service inside this program's routing table.
Two sides of one wire are two things.

Also renamed the tests that named it, and mount_test.go -> adapt_net_http_test.go
with it: those pin AdaptNetHTTP, a THIRD sense of "mount", and TestMount_*
named none of the three.

Regression set empty: TestServePathAllocsAreChainDepthInvariant fails
identically on the parent commit (an allocation bound, untouched here).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-04 19:30:42 -07:00
co-authored by Hanzo Dev
parent 7ceef82acd
commit 090bac90f1
16 changed files with 84 additions and 60 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import (
//
// app.Group("/legacy").All("/*", zip.AdaptNetHTTP(httpHandler))
//
// Group is what carries the prefix; Mount does not do this. Mount delegates a
// Group is what carries the prefix; Proxy does not do this. Proxy delegates a
// prefix to a REMOTE address over a transport, so it takes a string, not a
// handler. A local handler is composed, not delegated.
//
+6 -6
View File
@@ -51,9 +51,9 @@ func echoHandler() http.Handler {
})
}
// TestMount_NewForm_Serves proves the recommended form —
// TestAdaptNetHTTP_NewForm_Serves proves the recommended form —
// app.All(prefix+"/*", zip.AdaptNetHTTP(h)) — serves the mounted subtree.
func TestMount_NewForm_Serves(t *testing.T) {
func TestAdaptNetHTTP_NewForm_Serves(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.All("/api/*", zip.AdaptNetHTTP(echoHandler()))
@@ -66,12 +66,12 @@ func TestMount_NewForm_Serves(t *testing.T) {
}
}
// TestMount_StaticBeatsMount is the load-bearing proof: a static route
// TestAdaptNetHTTP_StaticRouteWins is the load-bearing proof: a static route
// registered AFTER the wildcard mount still wins for its exact path, while
// every other subpath falls through to the mount. This only holds because
// the mount is an ordinary route participating in specificity precedence —
// registration order is inverted on purpose.
func TestMount_StaticBeatsMount(t *testing.T) {
func TestAdaptNetHTTP_StaticRouteWins(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
// Wildcard mount FIRST, static route SECOND. Specificity, not order,
@@ -91,9 +91,9 @@ func TestMount_StaticBeatsMount(t *testing.T) {
}
}
// TestMount_BodyAndPathIntact proves the adapted handler receives the request
// TestAdaptNetHTTP_BodyAndPathIntact proves the adapted handler receives the request
// body and the full subpath + query unchanged through the wildcard mount.
func TestMount_BodyAndPathIntact(t *testing.T) {
func TestAdaptNetHTTP_BodyAndPathIntact(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.All("/api/*", zip.AdaptNetHTTP(echoHandler()))
+2 -2
View File
@@ -187,7 +187,7 @@ type Conn struct {
}
// Dial returns a Conn to the app at addr. The address scheme selects the
// transport exactly as it does for [App.Listen] and [Mount] — one registry,
// transport exactly as it does for [App.Listen] and [Proxy] — one registry,
// one vocabulary — so a bare path is ZAP over a unix socket and a bare host:port
// is ZAP over tcp:
//
@@ -286,7 +286,7 @@ func Call[In, Out any](ctx context.Context, c *Conn, op string, in *In) (*Out, e
forwardIdentity(ctx, req)
if err := c.client.Do(req, resp); err != nil {
// The far end, not this hop, is what failed — same reading Mount gives a
// The far end, not this hop, is what failed — same reading Proxy gives a
// broken upstream.
return nil, Errorf(502, "zip: call %s at %s: %v", op, c.addr, err)
}
+6 -6
View File
@@ -8,7 +8,7 @@ import (
)
// Two definitions cannot own one address. That rule used to live in a private
// prefix-claim ledger (App.claim/release) that only Mount and Load consulted;
// prefix-claim ledger (App.claim/release) that only Proxy and Load consulted;
// it now lives where every OTHER address collision is already decided — the
// walk, at build. One mechanism, so there is no second answer to disagree with
// the first, and the report is strictly better: every collision at once, with
@@ -71,19 +71,19 @@ func TestARefusedCompositionIsRolledBack(t *testing.T) {
}
}
// Mount and Load are the same kind of thing now — definition constructors — so
// Proxy and Load are the same kind of thing now — definition constructors — so
// they collide with each other exactly as two Loads do. There is no separate
// register for either.
func TestMountAndLoadCollideThroughOneMechanism(t *testing.T) {
remote, err := zip.Mount("/v1/y", "/run/zip/y.sock")
func TestProxyAndLoadCollideThroughOneMechanism(t *testing.T) {
remote, err := zip.Proxy("/v1/y", "/run/zip/y.sock")
if err != nil {
t.Fatalf("Mount: %v", err)
t.Fatalf("Proxy: %v", err)
}
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(remote, plugin(t, "z", "/v1/y"))
if err := app.Build(); err == nil {
t.Fatal("a Mount and a Load claiming /v1/y were both accepted")
t.Fatal("a Proxy and a Load claiming /v1/y were both accepted")
} else if !strings.Contains(err.Error(), "/v1/y") {
t.Errorf("refusal does not name the address: %v", err)
}
+1 -1
View File
@@ -436,7 +436,7 @@ func LocalInvoke(ctx context.Context, c Command, path map[string]string, body []
// Remote executes a command against a running zip service, and reads that
// service's registry off the document it derives from it. Base is an address in
// the same form Mount takes — the scheme selects the transport, so a command
// the same form Proxy takes — the scheme selects the transport, so a command
// runs over ZAP, HTTP or anything else RegisterTransport'd without the CLI
// knowing which.
type Remote struct {
+1 -1
View File
@@ -32,7 +32,7 @@ import (
// REFERENCE to a definition. At build time one interpreter walks one program;
// the child's entries are read in place, exactly as if they had been written
// there. There is no boundary at run time to cross, so there is nothing for a
// request to hop through: those words are reserved for [Mount], the one
// request to hop through: those words are reserved for [Proxy], the one
// operation where another runtime genuinely exists.
//
// The same definition may be referenced from any number of places. Each
+1 -1
View File
@@ -5,7 +5,7 @@ import "github.com/zap-proto/zip"
// must unwraps a definition constructor's (*App, error) so it can be composed
// inline: app.Use(must(zip.Load(...))).
//
// zip.Load and zip.Mount return an error because BUILDING a definition can fail
// zip.Load and zip.Proxy return an error because BUILDING a definition can fail
// — a binary that will not start, a scheme with no transport. Use cannot fail,
// because appending a reference to a program is not a fallible act. The error
// belongs at construction, and composition stays one verb.
+1 -1
View File
@@ -443,7 +443,7 @@ func TestFreeze_MutateAfterBuildPanics_IncludeAfterBuildSucceeds(t *testing.T) {
child.Get("/sneak", func(c *Ctx) error { return nil })
}()
// Mount-after-seal: allowed. A second host includes the same sealed
// Proxy-after-seal: allowed. A second host includes the same sealed
// definition, and gets its own occurrence of it.
second := quiet("second")
second.Group("/v2").Use(child)
+1 -1
View File
@@ -83,7 +83,7 @@ type Route struct {
Pattern string `json:"pattern"`
// Op is the operation id this pattern answers under, empty when the route is
// untyped. It is what makes a remote [Mount] able to contribute OPS and
// untyped. It is what makes a remote [Proxy] able to contribute OPS and
// not merely addresses: the mounting app is handed the declaration inline and
// reads the ids out of it, instead of asking the remote what it serves.
Op string `json:"op,omitempty"`
+1 -1
View File
@@ -133,7 +133,7 @@ func (h *Host) Include(cs ...Component) error {
// is referenced by the GROUP, so dropping it means dropping the group.
//
// Routing-level only. Go's plugin package has no Close and never unloads a .so,
// so to reclaim memory run the subsystem out of process behind [Mount].
// so to reclaim memory run the subsystem out of process behind [Proxy].
func (h *Host) Drop(defs ...*App) error {
site := here(1)
return h.app.transact(site, func() {
+31 -7
View File
@@ -13,7 +13,7 @@ import (
// A remote service is a LEAF, not a verb.
//
// [Mount] used to be one of five composition verbs and the only one that
// [Proxy] used to be one of five composition verbs and the only one that
// pointed at another process. Under one program it stops being special: it
// appends an App like any other inclusion, whose routes proxy and whose ops
// forward. Every projection then reads it through the same walk — a mounted
@@ -35,13 +35,13 @@ import (
// depend on what the network said at the moment it was asked.
//
// The spec is a build input; the server is not asked what it serves. Without a
// declaration a mount contributes its two proxy addresses and no ops, which is
// declaration a proxy contributes its two proxy addresses and no ops, which is
// exactly what it contributed before and says honestly that nothing is known
// about the shape behind it.
// Mount returns another service, running elsewhere, as a DEFINITION the host
// Proxy returns another service, running elsewhere, as a DEFINITION the host
// composes with Use like any other:
//
// ledger, err := zip.Mount("/v1/ledger", "ledger.hanzo.svc:9653", decl)
// ledger, err := zip.Proxy("/v1/ledger", "ledger.hanzo.svc:9653", decl)
// if err != nil { return err }
// app.Use(ledger)
//
@@ -51,6 +51,30 @@ import (
// projection because it is in the registry, not because five projections each
// learned what mounting meant.
//
// # Why the name changed
//
// This was Mount, and Mount already meant something else to everyone reading it:
// 118 of the fleet's ~140 subsystems export
// `func Mount(app cloud.Router, deps cloud.Deps) error`, the function by which an
// app registers ITSELF. One name, two unrelated shapes — a caller who knows one
// learns nothing about the other, and a reader seeing `Mount` in a diff cannot
// tell which is meant without checking the arity.
//
// The verb was also wrong on its own terms, which is what makes the rename a
// correction and not a coat of paint. The doc above already says composition
// went to Use; a function that returns a value is a NOUN.
//
// Proxy is what it builds. Every route it registers runs the handler this file
// calls `proxy`, and that handler's whole body is `forward(...)` to the address
// given. The term is the term of art for a local stand-in whose handlers hand
// the request to someone else, so it needs no gloss.
//
// Not Remote, which was the first choice and is wrong: [Remote] already exists
// here, and correctly — it is the CALLER's side, a client that executes commands
// against a service running elsewhere. This is the SERVER's side: a definition
// that stands in for that service inside this program's own routing table. Two
// sides of one wire are two things, and the compiler said so.
//
// The address scheme selects the transport exactly as [App.Listen]'s does, so a
// bare address is ZAP and one registry serves both directions.
//
@@ -58,7 +82,7 @@ import (
// contributes its two proxy addresses and no ops, which honestly says nothing
// is known about the shape behind it. With it, the remote's ops join the
// document, the tool list, the commands and the call plane.
func Mount(prefix, addr string, decl ...Declaration) (*App, error) {
func Proxy(prefix, addr string, decl ...Declaration) (*App, error) {
var d Declaration
if len(decl) > 0 {
d = decl[0]
@@ -85,7 +109,7 @@ func remoteApp(parent *App, prefix, addr string, d Declaration) (*App, error) {
}
if len(d.Routes) == 0 {
// Nothing declared: the prefix and everything under it, which is what a
// bare Mount has always registered.
// bare Proxy has always registered.
p := trimPrefix(prefix)
r.All(p, proxy)
r.All(p+"/*", proxy)
@@ -100,7 +124,7 @@ func remoteApp(parent *App, prefix, addr string, d Declaration) (*App, error) {
// program poisoned. Validate at the boundary the input crosses, so a bad
// declaration is an error like every other bad declaration.
if !knownMethod(rt.Method) {
return nil, Errorf(400, "zip: Mount(%s): %q is not an HTTP method zip can route (route %q)",
return nil, Errorf(400, "zip: Proxy(%s): %q is not an HTTP method zip can route (route %q)",
prefix, rt.Method, rt.Pattern)
}
if rt.Op == "" {
+6 -6
View File
@@ -48,7 +48,7 @@ func remoteService(t *testing.T) (string, Declaration) {
return sock, svc.Declaration()
}
// TestMount_DeclarationIsAnInputNotAFetch is the rule the whole design hangs
// TestProxy_DeclarationIsAnInputNotAFetch is the rule the whole design hangs
// on: building a program does no I/O. The address here goes nowhere, and the
// registry, the document and the tool list are still complete — because the
// declaration was handed in.
@@ -56,10 +56,10 @@ func remoteService(t *testing.T) (string, Declaration) {
// If the walk dialled instead, this test could not exist: Registry() would be
// fallible and slow, and an OpenAPI document would depend on some other process
// being up at boot.
func TestMount_DeclarationIsAnInputNotAFetch(t *testing.T) {
func TestProxy_DeclarationIsAnInputNotAFetch(t *testing.T) {
host := quiet("cloud")
dead := "/nonexistent/nothing-listens-here.sock"
host.Use(mustApp(Mount("/v1/ledger", dead, Declaration{
host.Use(mustApp(Proxy("/v1/ledger", dead, Declaration{
Name: "ledger",
Routes: []Route{
{Method: "GET", Pattern: "/v1/ledger/entries/:id", Op: "getEntry"},
@@ -89,9 +89,9 @@ func TestMount_DeclarationIsAnInputNotAFetch(t *testing.T) {
// And nothing was dialled: an unreachable address built a complete program.
}
// TestMount_ServesAndCallsTheRemote: the leaf's routes proxy and its ops
// TestProxy_ServesAndCallsTheRemote: the leaf's routes proxy and its ops
// forward, over the same transport, to a service that really is somewhere else.
func TestMount_ServesAndCallsTheRemote(t *testing.T) {
func TestProxy_ServesAndCallsTheRemote(t *testing.T) {
sock, decl := remoteService(t)
if len(decl.Routes) != 2 {
t.Fatalf("the service declared %d routes, want 2: %+v", len(decl.Routes), decl.Routes)
@@ -107,7 +107,7 @@ func TestMount_ServesAndCallsTheRemote(t *testing.T) {
}
host := quiet("cloud")
host.Use(mustApp(Mount("/v1/ledger", sock, decl)))
host.Use(mustApp(Proxy("/v1/ledger", sock, decl)))
if err := host.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
@@ -11,12 +11,12 @@ import (
"github.com/zap-proto/zip"
)
// TestMount_OverUnixSocket proves the plumbing is read off the address shape
// TestProxy_OverUnixSocket proves the plumbing is read off the address shape
// rather than off a second scheme: the SAME "zap" transport serves and dials a
// unix socket when the address is a path. This is the colocated plugin case —
// no port to allocate, filesystem permissions as the ACL — carrying byte-identical
// ZAP frames to the tcp case.
func TestMount_OverUnixSocket(t *testing.T) {
func TestProxy_OverUnixSocket(t *testing.T) {
sock := filepath.Join(t.TempDir(), "billing.sock")
plugin := zip.New(zip.Config{AppName: "billing", DisableStartupMessage: true})
@@ -28,7 +28,7 @@ func TestMount_OverUnixSocket(t *testing.T) {
waitSocket(t, sock)
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
core.Use(must(zip.Mount("/v1/billing", sock)))
core.Use(must(zip.Proxy("/v1/billing", sock)))
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
if status != 200 || !strings.Contains(body, `"servedBy":"over-uds"`) {
@@ -49,17 +49,17 @@ func waitSocket(t *testing.T, path string) {
t.Fatalf("unix socket %s never became reachable", path)
}
// These tests pin the plugin mechanism: Listen serves here, Mount delegates
// These tests pin the plugin mechanism: Listen serves here, Proxy delegates
// there, and both read the transport off the address scheme through one
// registry. The property that matters is that the mounting app holds no
// reference whatsoever to the mounted app's code — that is what lets a service
// ship, rebuild, and redeploy as its own binary without relinking its host.
// TestMount_OverZAP is the end-to-end proof. The "plugin" is a real, separate
// TestProxy_OverZAP is the end-to-end proof. The "plugin" is a real, separate
// zip.App with its own routes, listening on the real ZAP transport. The "core"
// mounts it by address alone — a bare address, so DefaultScheme (ZAP) — and
// serves its routes as if they were local. Nothing is shared but the address.
func TestMount_OverZAP(t *testing.T) {
func TestProxy_OverZAP(t *testing.T) {
plugin := zip.New(zip.Config{AppName: "billing", DisableStartupMessage: true})
plugin.Get("/v1/billing/invoices", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"servedBy": "billing-plugin"})
@@ -74,7 +74,7 @@ func TestMount_OverZAP(t *testing.T) {
waitReachable(t, pluginAddr)
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
core.Use(must(zip.Mount("/v1/billing", pluginAddr)))
core.Use(must(zip.Proxy("/v1/billing", pluginAddr)))
// GET travels core -> ZAP -> plugin and the plugin's body comes back.
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
@@ -96,11 +96,11 @@ func TestMount_OverZAP(t *testing.T) {
}
}
// TestMount_StaticBeatsMount pins the same precedence property the local mount
// TestProxy_StaticBeatsRemoteProxy pins the same precedence property the local mount
// path pins: a mount is an ORDINARY wildcard route, so a more specific route
// registered afterwards still wins. A mount that short-circuited the router
// would silently shadow the host's own routes.
func TestMount_StaticBeatsRemoteMount(t *testing.T) {
func TestProxy_StaticBeatsRemoteProxy(t *testing.T) {
plugin := zip.New(zip.Config{AppName: "plug", DisableStartupMessage: true})
plugin.Get("/v1/billing/*", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"servedBy": "plugin"})
@@ -112,7 +112,7 @@ func TestMount_StaticBeatsRemoteMount(t *testing.T) {
waitReachable(t, pluginAddr)
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
core.Use(must(zip.Mount("/v1/billing", pluginAddr)))
core.Use(must(zip.Proxy("/v1/billing", pluginAddr)))
// Registered AFTER the mount, and still wins for its exact path.
core.Get("/v1/billing/health", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"servedBy": "core"})
@@ -128,27 +128,27 @@ func TestMount_StaticBeatsRemoteMount(t *testing.T) {
}
}
// TestMount_UnknownScheme proves Mount refuses an unregistered scheme loudly
// TestProxy_UnknownScheme proves Proxy refuses an unregistered scheme loudly
// rather than silently falling back to a default wire.
func TestMount_UnknownScheme(t *testing.T) {
_, err := zip.Mount("/v1/x", "carrier-pigeon://somewhere:1")
func TestProxy_UnknownScheme(t *testing.T) {
_, err := zip.Proxy("/v1/x", "carrier-pigeon://somewhere:1")
if err == nil {
t.Fatal("Mount with an unregistered scheme returned nil, want an error")
t.Fatal("Proxy with an unregistered scheme returned nil, want an error")
}
if !strings.Contains(err.Error(), "carrier-pigeon") {
t.Fatalf("error = %v, want it to name the unknown scheme", err)
}
}
// TestMount_DialOnlyAndServeOnly proves a transport may implement one
// direction: Listen reports a dial-only scheme and Mount reports a serve-only
// TestProxy_DialOnlyAndServeOnly proves a transport may implement one
// direction: Listen reports a dial-only scheme and Proxy reports a serve-only
// one, each naming which half was missing.
func TestMount_ServeOnlyTransportCannotDial(t *testing.T) {
func TestProxy_ServeOnlyTransportCannotDial(t *testing.T) {
zip.RegisterTransport("serveonly", zip.Transport{
Serve: func(addr string, h fasthttp.RequestHandler) zip.Server { return nil },
})
_, err := zip.Mount("/v1/x", "serveonly://host:1")
_, err := zip.Proxy("/v1/x", "serveonly://host:1")
if err == nil || !strings.Contains(err.Error(), "cannot dial") {
t.Fatalf("Mount on a serve-only transport: err=%v, want a 'cannot dial' error", err)
t.Fatalf("Proxy on a serve-only transport: err=%v, want a 'cannot dial' error", err)
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ func WithStripPrefix(prefix string) StaticOption {
return func(c *staticConfig) { c.stripPrefix = prefix }
}
// Static returns a leaf Handler that serves files from fsys. Mount it on a
// Static returns a leaf Handler that serves files from fsys. Register it on a
// wildcard route; the "*" capture selects the file:
//
// app.Get("/assets/*", zip.Static(assets)) // embed.FS
+5 -5
View File
@@ -47,7 +47,7 @@ type Client interface {
// Transport is one address scheme in both directions: Serve terminates bytes
// arriving here, Dial originates bytes going there. They are the same concept
// — a wire — so they live in one value rather than two registries that drift.
// A scheme may leave either half nil; Listen and Mount each say which they
// A scheme may leave either half nil; Listen and Proxy each say which they
// needed.
type Transport struct {
Serve func(addr string, handler fasthttp.RequestHandler) Server
@@ -76,7 +76,7 @@ var (
},
// Dial-only: terminating TLS is the ingress's job, so nothing in the
// fleet serves https directly — but reaching something that does (an
// api.* host, a third party) is an ordinary Mount, and a CLI talking to
// api.* host, a third party) is an ordinary Proxy, and a CLI talking to
// a published API is the same call. One registry entry, both directions
// covered by the half that exists.
"https": {
@@ -97,8 +97,8 @@ func withPort(addr, port string) string {
}
// RegisterTransport adds (or replaces) a transport keyed by address scheme, so
// any future protocol slots into both Listen and Mount with ZERO change to
// either API. Call before Listen or Mount.
// any future protocol slots into both Listen and Proxy with ZERO change to
// either API. Call before Listen or Proxy.
//
// zip.RegisterTransport("quic", zip.Transport{
// Serve: func(addr string, h fasthttp.RequestHandler) zip.Server {
@@ -239,7 +239,7 @@ func (a *App) listenOn(addrs []string) error {
}
// mount registers prefix onto a dialled address. The prefix is already CLAIMED
// by the caller — [Mount] claims its one, [App.load] claims a plugin's whole set
// by the caller — [Proxy] claims its one, [App.load] claims a plugin's whole set
// all-or-nothing before spawning anything — so this does not claim again and
// there is exactly one claim per prefix however the composition came in.
func (a *App) mount(prefix, addr string) error {
+1 -1
View File
@@ -30,7 +30,7 @@ import (
// ops; nothing dedupes middleware, which is also why [Handler] being a
// non-comparable func type costs nothing.
// - PURE. No global state, no projection-specific logic, no I/O. In
// particular a remote [Mount] contributes its declaration INLINE and is
// particular a remote [Proxy] contributes its declaration INLINE and is
// never asked over the network what it serves: a walk that did I/O would
// make the document fallible, slow, untestable, and a function of whether
// some other process happened to be up at boot.