serve: a plugin child listens where the host told it to

zip.Load starts a plugin with a private unix socket in ZIP_ADDR and blocks
in waitListening until that socket accepts. Serve ignored it and bound
cfg's :9653/:8080/:9090 instead, so the socket never appeared: the host
timed out after 10s, killed the child, and every request to the mounted
prefix answered 503 — not at boot, where a test would see it, but on the
first request to a lazily-loaded prefix (apps.where sets Lazy). That made
all ~115 generated cmd/<app> binaries unusable as plugins; only the
hand-written cmd/o11y worked, because it calls zip.Addr itself.

listenOn is now the one place that decides where this process serves, and
it honours the contract for every entrypoint that shares Serve. The ops
port is deliberately left unbound in that mode: liveness for the fleet
belongs to the host, and N children sharing one cfg would otherwise fight
over one :9090.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-07-27 18:16:37 -07:00
parent f69f4be63a
commit 48103214ab
2 changed files with 88 additions and 13 deletions
+39 -13
View File
@@ -448,14 +448,14 @@ func Serve(specs []MountSpec, enable []string) error {
ReadHeaderTimeout: 5 * time.Second,
}
addrs, ops := listenOn(cfg)
listenErr := make(chan error, 1)
go func() {
deps.Logger.Info("health listening", "addr", cfg.HealthListenAddr)
if err := healthSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
listenErr <- fmt.Errorf("health listen: %w", err)
}
}()
go func() {
if ops == "" {
// Plugin child (see listenOn): the ops port belongs to the host, which
// answers liveness for the whole fleet while its children are still cold.
deps.Logger.Info("listening as plugin", "addr", addrs[0], "enabled", cfg.Enable, "brand", cfg.Brand)
} else {
deps.Logger.Info("listening",
"http", cfg.ListenAddr,
"zap", cfg.ZAPListenAddr,
@@ -463,12 +463,14 @@ func Serve(specs []MountSpec, enable []string) error {
"brand", cfg.Brand,
"domain", cfg.Domain,
)
// ONE app, TWO transports: ZAP is the primary machine transport
// (PLAINTEXT TCP over :9653 — parity with prior HTTP; needs mesh mTLS), plain HTTP the edge/browser extra. Both serve the
// identical route surface, so /v1/* answers over either. Serve returns
// the first listener error.
listenErr <- app.Listen(cfg.ZAPListenAddr, "http://"+cfg.ListenAddr)
}()
go func() {
deps.Logger.Info("health listening", "addr", ops)
if err := healthSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
listenErr <- fmt.Errorf("health listen: %w", err)
}
}()
}
go func() { listenErr <- app.Listen(addrs...) }()
select {
case <-ctx.Done():
@@ -516,6 +518,30 @@ func Serve(specs []MountSpec, enable []string) error {
return app.ShutdownWithContext(shutdownCtx)
}
// listenOn is the ONE decision about where this process serves: the addresses
// for the app, and the ops port for the liveness/metrics contract — empty when
// this process must not bind one.
//
// A host that composed this binary as a plugin started it with a private unix
// socket in ZIP_ADDR and is blocked in zip's waitListening until that socket
// accepts; binding cfg's fixed ports instead means the host never sees the
// child come up, kills it as failed, and the mounted prefix 502s on its first
// request. Every plugin in a fleet is handed the same cfg, so they would also
// fight over one :8080/:9653/:9090 and all but the first would die on "address
// already in use". zip.Addr is the whole plugin side of that contract and this
// is the one place cloud honours it, which is what makes every generated
// cmd/<app> binary a valid plugin without a line of its own.
func listenOn(cfg *Config) (addrs []string, ops string) {
if sock := zip.Addr(""); sock != "" {
return []string{sock}, ""
}
// ONE app, TWO transports: ZAP is the primary machine transport (PLAINTEXT
// TCP over :9653 — parity with prior HTTP; needs mesh mTLS), plain HTTP the
// edge/browser extra. Both serve the identical route surface, so /v1/*
// answers over either.
return []string{cfg.ZAPListenAddr, "http://" + cfg.ListenAddr}, cfg.HealthListenAddr
}
// healthMux is the liveness/readiness + metrics contract on the ops port
// (HIP-0113). /healthz, /readyz, /health return 200 once the process is up
// (readiness can grow a real dependency check later); /metrics exposes a
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package cloud
import (
"reflect"
"testing"
"github.com/zap-proto/zip"
)
// ZIP_ADDR is the entire plugin contract: the host created that socket, told
// the child about it, and blocks until it accepts. A child that serves cfg's
// ports instead is never seen to come up — and because apps.Wire() loads
// plugins lazily, the host does not fail at boot but 502s the first real
// request to the prefix. RED before Serve honoured zip.Addr.
func TestListenOn_PluginServesTheSocketItWasGiven(t *testing.T) {
sock := t.TempDir() + "/wallets.sock"
t.Setenv(zip.AddrEnv, sock)
addrs, ops := listenOn(testListenCfg())
if want := []string{sock}; !reflect.DeepEqual(addrs, want) {
t.Fatalf("addrs = %q, want %q — the host waits on that socket and nothing else", addrs, want)
}
// Second-order bug: one ops port, N children. Binding it here means every
// plugin after the first dies on "address already in use", and the host's
// own probes answer from whichever child won the race.
if ops != "" {
t.Fatalf("plugin bound the host's ops port %q", ops)
}
}
// The common path. A regression here breaks production, so it is pinned
// separately from the plugin case.
func TestListenOn_StandaloneBindsTheConfiguredPorts(t *testing.T) {
t.Setenv(zip.AddrEnv, "") // zip.Addr treats empty as unset — started directly
addrs, ops := listenOn(testListenCfg())
if want := []string{":9653", "http://:8080"}; !reflect.DeepEqual(addrs, want) {
t.Fatalf("addrs = %q, want %q", addrs, want)
}
if ops != ":9090" {
t.Fatalf("ops = %q, want :9090", ops)
}
}
func testListenCfg() *Config {
return &Config{ListenAddr: ":8080", ZAPListenAddr: ":9653", HealthListenAddr: ":9090"}
}