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:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
Reference in New Issue
Block a user