mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
newZapTransport handed luxfi/zap a Port and no Address, and a zap node given only a Port listens on ":<port>". So BASE_LISTEN_P2P=127.0.0.1:9999 bound 9999 on every interface and the host was dropped without a word. Pass the address itself, with Port as the port it binds. Now that the address binds verbatim, validate() refuses one with no port rather than letting it fail at Start. The parser used to fall back to 9999 on a bad value, and its comment named a check validate() never made. Co-Authored-By: Claude Opus 5 <[email protected]>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
// Copyright (c) 2025, Hanzo Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
//
|
|
// ZAP-backed transport for the base/network Quasar plane.
|
|
//
|
|
// This is the production Transport. It wires base/network Envelopes onto a
|
|
// `github.com/luxfi/zap` Node: one TCP listener on BASE_LISTEN_P2P (default
|
|
// :9999), explicit peer dial to every entry in BASE_PEERS (no mDNS in K8s),
|
|
// and a single registered handler for envelope delivery.
|
|
//
|
|
// The envelope payload is the output of `Frame.encode()` — the shardID is
|
|
// already embedded in the frame, so the wire carries the frame bytes and
|
|
// nothing else. Decode is the inverse. One Envelope in, one ZAP message
|
|
// out (no fan-out inside the transport; the Quasar shard engine dedupes).
|
|
//
|
|
// Scale 1 → N:
|
|
// - N=1 (singleton): cfg.Peers is just self. Broadcast is a no-op (no peers
|
|
// connected); WAL hook still installs. Zero cross-pod traffic, but the
|
|
// path is identical — no "standalone vs network" branch for callers.
|
|
// - N≥2: every peer dials every other peer on Start. Quasar writer-pin
|
|
// sorts NodeID, lowest wins the write lock; followers relay through
|
|
// the router; replication factor caps quorum at ⌈k/2⌉+1 ≤ len(Peers)+1.
|
|
|
|
package network
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// zapEnvelopeMsgType is the ZAP handler key for envelope delivery. ZAP
|
|
// dispatches on `msg.Flags() >> 8` — the high 8 bits of the 16-bit flags
|
|
// field — so the effective space is uint8. 0xBE = "Base Envelope".
|
|
const zapEnvelopeMsgType uint8 = 0xBE
|
|
|
|
// zapServiceType is the mDNS service identifier. mDNS is disabled on the
|
|
// production Node (K8s pods rely on BASE_PEERS, not link-local), but the
|
|
// type string is still required by luxfi/zap's NodeConfig.
|
|
const zapServiceType = "_hanzo-base._tcp"
|
|
|
|
// dialRetryInterval is how often Start's background reconnector tries to
|
|
// dial peers that weren't reachable at Start time. K8s pod DNS may resolve
|
|
// slowly during a StatefulSet bring-up, so we keep reattempting rather
|
|
// than failing the whole Start path.
|
|
const dialRetryInterval = 3 * time.Second
|
|
|
|
// zapTransport implements Transport using luxfi/zap's Node as the peer
|
|
// plane. Constructed once per base/network.Node and owned for the lifetime
|
|
// of the process.
|
|
type zapTransport struct {
|
|
node *zap.Node
|
|
cfg Config
|
|
self string
|
|
peers []string
|
|
|
|
logger luxlog.Logger
|
|
|
|
recv atomic.Value // func(Envelope) — set once by Start, read by handler
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// reconnectOnce ensures only one reconnect loop runs even if Start is
|
|
// called and then Stop and then Start again on the same transport
|
|
// instance (defensive — base/network today constructs + starts once).
|
|
reconnectOnce sync.Once
|
|
}
|
|
|
|
// newZapTransport builds a zapTransport from a validated Config. It does
|
|
// NOT bind the listener — Start does. Construction is cheap so the base
|
|
// node can embed the transport before the base app has finished booting.
|
|
func newZapTransport(cfg Config) *zapTransport {
|
|
// validate() has refused an address with no port, so this is the port
|
|
// Address binds.
|
|
port, _ := listenPort(cfg.ListenP2P)
|
|
|
|
logger := luxlog.New("component", "base-network", "transport", "zap", "nodeID", cfg.NodeID)
|
|
|
|
// luxfi/zap uses mDNS by default for peer discovery; K8s pods don't
|
|
// get link-local multicast so we disable it and rely on the explicit
|
|
// BASE_PEERS list below.
|
|
//
|
|
// Address is what binds, host included. Given Port alone the node
|
|
// listens on ":<port>", every interface, whatever host BASE_LISTEN_P2P
|
|
// names. Port is then only what mDNS would advertise.
|
|
node := zap.NewNode(zap.NodeConfig{
|
|
NodeID: cfg.NodeID,
|
|
ServiceType: zapServiceType,
|
|
Address: cfg.ListenP2P,
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
|
|
return &zapTransport{
|
|
node: node,
|
|
cfg: cfg,
|
|
self: cfg.NodeID,
|
|
peers: append([]string(nil), cfg.Peers...),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Start binds the TCP listener, installs the envelope handler, and dials
|
|
// every peer. Dial failures are logged and retried in the background; they
|
|
// do not fail Start. Returns an error only when the listener itself can't
|
|
// bind — that's a fatal misconfiguration (port in use, permission denied).
|
|
func (z *zapTransport) Start(ctx context.Context, recv func(Envelope)) error {
|
|
if recv == nil {
|
|
return errors.New("zap-transport: recv callback is required")
|
|
}
|
|
z.recv.Store(recv)
|
|
|
|
z.node.Handle(uint16(zapEnvelopeMsgType), z.handle)
|
|
|
|
if err := z.node.Start(); err != nil {
|
|
return fmt.Errorf("zap-transport: start node: %w", err)
|
|
}
|
|
|
|
z.ctx, z.cancel = context.WithCancel(ctx)
|
|
|
|
// Initial peer dial — best-effort, then hand off to the reconnect loop.
|
|
z.dialAllOnce()
|
|
z.reconnectOnce.Do(func() {
|
|
go z.reconnectLoop()
|
|
})
|
|
|
|
z.logger.Info("zap-transport started",
|
|
"listen", z.cfg.ListenP2P,
|
|
"peers", len(z.peers),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// Publish fans out the envelope to every currently-connected peer. Peers
|
|
// that aren't yet connected are skipped silently — the reconnect loop will
|
|
// catch them up, and Quasar's DAG sync replays missed frames on reconnect.
|
|
func (z *zapTransport) Publish(env Envelope) error {
|
|
payload := env.Frame.encode()
|
|
msg, err := buildZapEnvelopeMessage(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("zap-transport: build message: %w", err)
|
|
}
|
|
|
|
if z.ctx == nil {
|
|
return errors.New("zap-transport: not started")
|
|
}
|
|
|
|
results := z.node.Broadcast(z.ctx, msg)
|
|
if len(results) == 0 {
|
|
// Singleton topology or all peers disconnected — neither is an
|
|
// error condition; local WAL already applied before Publish runs.
|
|
return nil
|
|
}
|
|
// Record per-peer failures at debug; transient errors are normal and
|
|
// the reconnect loop handles recovery.
|
|
for peer, perr := range results {
|
|
if perr != nil {
|
|
z.logger.Debug("publish peer error", "peer", peer, "err", perr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop cancels the reconnect loop and tears down the ZAP node. Safe to
|
|
// call multiple times.
|
|
func (z *zapTransport) Stop(ctx context.Context) error {
|
|
if z.cancel != nil {
|
|
z.cancel()
|
|
}
|
|
z.node.Stop()
|
|
return nil
|
|
}
|
|
|
|
// handle is registered with zap.Node. It decodes the envelope bytes and
|
|
// delivers the frame to the shard engine via the recv callback.
|
|
func (z *zapTransport) handle(_ context.Context, from string, msg *zap.Message) (*zap.Message, error) {
|
|
payload := extractZapEnvelopePayload(msg)
|
|
if len(payload) == 0 {
|
|
return nil, errors.New("zap-transport: empty envelope payload")
|
|
}
|
|
frame, err := decodeFrame(payload)
|
|
if err != nil {
|
|
z.logger.Debug("decode failed", "peer", from, "err", err)
|
|
return nil, nil // don't propagate decode errors to the peer
|
|
}
|
|
if err := frame.Valid(); err != nil {
|
|
z.logger.Debug("invalid frame dropped", "peer", from, "err", err)
|
|
return nil, nil
|
|
}
|
|
if cb, ok := z.recv.Load().(func(Envelope)); ok && cb != nil {
|
|
cb(Envelope{ShardID: frame.ShardID, Frame: frame})
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// isSelfPeer returns true when the BASE_PEERS entry refers to this pod.
|
|
//
|
|
// Operator-emitted BASE_PEERS carries per-ordinal DNS names such as
|
|
// "<svc>-0.<svc>-network.<ns>.svc.cluster.local:9999" while
|
|
// BASE_NODE_ID is the bare hostname ("<svc>-0"). Plain equality misses
|
|
// the match and the transport dials itself — luxfi/zap's handshake then
|
|
// detects duplicate NodeID and closes, producing a 3s reconnect loop.
|
|
//
|
|
// Match rule: strip optional ":port" from the peer, take the first DNS
|
|
// label, compare to self. Also accept exact equality so test transports
|
|
// that use raw NodeID strings still work.
|
|
func (z *zapTransport) isSelfPeer(peer string) bool {
|
|
if peer == "" || peer == z.self {
|
|
return true
|
|
}
|
|
host := peer
|
|
if i := strings.LastIndex(host, ":"); i >= 0 {
|
|
host = host[:i]
|
|
}
|
|
if dot := strings.Index(host, "."); dot >= 0 {
|
|
host = host[:dot]
|
|
}
|
|
return host == z.self
|
|
}
|
|
|
|
// dialAllOnce attempts to ConnectDirect to every peer except self. Errors
|
|
// are logged at debug; the reconnect loop will retry.
|
|
func (z *zapTransport) dialAllOnce() {
|
|
for _, p := range z.peers {
|
|
if z.isSelfPeer(p) {
|
|
continue
|
|
}
|
|
if err := z.node.ConnectDirect(p); err != nil {
|
|
z.logger.Debug("initial peer dial failed (will retry)", "peer", p, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// reconnectLoop reattempts ConnectDirect for peers that aren't in the
|
|
// current connection set. Called once per transport lifetime. Exits when
|
|
// ctx is cancelled (Stop).
|
|
func (z *zapTransport) reconnectLoop() {
|
|
ticker := time.NewTicker(dialRetryInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-z.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
connected := make(map[string]struct{})
|
|
for _, id := range z.node.Peers() {
|
|
connected[id] = struct{}{}
|
|
}
|
|
for _, p := range z.peers {
|
|
if z.isSelfPeer(p) {
|
|
continue
|
|
}
|
|
if _, ok := connected[p]; ok {
|
|
continue
|
|
}
|
|
if err := z.node.ConnectDirect(p); err != nil {
|
|
z.logger.Debug("reconnect dial failed", "peer", p, "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// listenPort is the port in a host:port listen address. 0 is a port, the one
|
|
// the OS picks, so ":0" is well formed; a missing or named port is not.
|
|
func listenPort(listen string) (int, error) {
|
|
_, p, err := net.SplitHostPort(listen)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
port, err := strconv.ParseUint(p, 10, 16)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("port %q is not a number from 0 to 65535", p)
|
|
}
|
|
return int(port), nil
|
|
}
|
|
|
|
// buildZapEnvelopeMessage wraps the raw frame bytes in a ZAP message with
|
|
// Flags = msgType << 8 so the receiving Node dispatches to the envelope
|
|
// handler. The body is a single ZAP object with one `bytes` field at
|
|
// offset 0; this is the smallest valid ZAP message that carries a byte
|
|
// slice without borrowing a full struct schema.
|
|
func buildZapEnvelopeMessage(payload []byte) (*zap.Message, error) {
|
|
b := zap.NewBuilder(len(payload) + 64)
|
|
obj := b.StartObject(8) // one bytes field = 4 bytes offset + 4 bytes length
|
|
obj.SetBytes(0, payload)
|
|
obj.FinishAsRoot()
|
|
raw := b.FinishWithFlags(uint16(zapEnvelopeMsgType) << 8)
|
|
msg, err := zap.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse self-built message: %w", err)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// extractZapEnvelopePayload pulls the bytes field at offset 0 out of the
|
|
// message root object. Returns nil if the message is malformed.
|
|
func extractZapEnvelopePayload(msg *zap.Message) []byte {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
root := msg.Root()
|
|
if root.IsNull() {
|
|
return nil
|
|
}
|
|
return root.Bytes(0)
|
|
}
|
|
|
|
// Compile-time assertion: zapTransport satisfies Transport.
|
|
var _ Transport = (*zapTransport)(nil)
|
|
|
|
// Unused but imported — keep binary.LittleEndian reachable for future
|
|
// frame-header extensions without re-adding the import.
|
|
var _ = binary.LittleEndian
|