Files

855 lines
28 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bus
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// SourceAdapter is the interface daemon.go uses to talk to the cloud Source
// (in practice internal/event/source.DingtalkSource). Kept abstract so the
// bus daemon can be tested without spinning up the real Stream SDK; the
// integration test substitutes a fake.
type SourceAdapter interface {
// Start opens the cloud connection and blocks until ctx is cancelled
// or a fatal error occurs. emit is called for each incoming event.
Start(ctx context.Context, emit dwsevent.EmitFn) error
}
// Config bundles everything Run needs to start a daemon. All paths and
// identifiers come from busctl/Source so the bus itself stays oblivious
// to ConfigDir / edition rules.
type Config struct {
// WorkDir is the bus working directory:
// <ConfigDir>/events/<edition>/<source_kind>/<identity_hash>/
// The caller MUST mkdir this with pkg/config.DirPerm before calling Run.
WorkDir string
// IPCEndpoint is the Unix socket path or Windows pipe name. Caller
// computes this from WorkDir (Unix) or edition/clientIDHash (Windows).
IPCEndpoint string
// ClientID is the human-readable identifier written into bus.meta and
// status output. NOT used in any path.
ClientID string
// SourceKind/IdentityHash/SourceID are diagnostic identity fields used by
// list/status. Empty SourceKind is interpreted as app_stream for backward
// compatibility.
SourceKind dwsevent.SourceKind
IdentityHash string
SourceID string
// Edition is written into bus.meta. Comes from edition.Get().Name with
// "open" fallback applied by the caller.
Edition string
// SDKVersion is recorded in bus.meta for diagnostics.
SDKVersion string
// Source is the cloud adapter. Required.
Source SourceAdapter
// CredentialBroker enables additive runtime-token handoff over the
// owner-only local IPC transport. Nil preserves the original protocol and
// does not advertise runtime-token support.
CredentialBroker *runtimecred.Broker
// IdleTimeout: bus self-exits after this long with zero consumers.
// Zero disables (bus runs until SIGTERM).
IdleTimeout time.Duration
// ConsumerBuffer overrides per-consumer sendCh capacity. Zero uses
// DefaultSendBuffer.
ConsumerBuffer int
// DedupCapacity overrides event_id LRU size. Zero uses dedup.DefaultCapacity.
DedupCapacity int
// DropWarnPercent is the per-event-type drop-rate threshold (whole
// percentage points) that triggers a slog WARN in bus.log. Zero or
// out-of-range values fall back to DefaultDropWarnPercent. Overridable
// via env DWS_EVENT_DROP_WARN_PCT (read by the cobra layer).
DropWarnPercent int
// ReadyPipe receives a single byte ('R' on success, 'E' on failure)
// once the bus has either come up or failed startup, so the parent
// process forked by busctl/spawn can stop polling and either dial or
// surface the error. nil disables (foreground mode).
ReadyPipe *os.File
// Logger sink. Nil → slog.Default.
Logger *slog.Logger
}
var (
daemonMkdirAll = os.MkdirAll
daemonAcquire = Acquire
daemonWriteMeta = WriteMeta
daemonListen = transport.Listen
daemonShutdownTimeout = 2 * time.Second
daemonByeDrainTimeout = 100 * time.Millisecond
daemonCredentialHandshakeTimeout = 10 * time.Second
)
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
// 1. Acquire bus.lock (single-instance enforcement)
// 2. Write bus.meta
// 3. Listen IPC (so consumers can connect before SDK starts pushing)
// 4. Signal readiness via ReadyPipe
// 5. Start the Source (cloud SDK); concurrent with consumer accept loop
// 6. Wait on ctx for shutdown signal
// 7. Graceful: broadcast Bye → close listener → close source → release lock
//
// Run blocks until ctx is cancelled, the Source returns an error, or a fatal
// startup error occurs.
func Run(ctx context.Context, cfg Config) error {
if cfg.Source == nil {
return errors.New("bus: Source is required")
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
log := cfg.Logger.With("component", "bus", "client_id", cfg.ClientID, "edition", cfg.Edition)
// 1. Acquire bus.lock
if err := daemonMkdirAll(cfg.WorkDir, config.DirPerm); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: mkdir workdir: %w", err))
}
lockPath := filepath.Join(cfg.WorkDir, LockFileName)
lock, err := daemonAcquire(lockPath)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: acquire lock: %w", err))
}
defer lock.Close()
// 2. Write bus.meta
meta := Meta{
ClientID: cfg.ClientID,
Edition: cfg.Edition,
SourceKind: cfg.SourceKind,
IdentityHash: cfg.IdentityHash,
SourceID: cfg.SourceID,
StartedAt: time.Now().UTC(),
SDKVersion: cfg.SDKVersion,
BusPID: os.Getpid(),
}
if err := daemonWriteMeta(cfg.WorkDir, meta); err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err))
}
// 3. IPC listen
listener, err := daemonListen(cfg.IPCEndpoint)
if err != nil {
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: ipc listen: %w", err))
}
defer listener.Close()
hub := NewHub(cfg.ConsumerBuffer)
dd := dedup.NewWithCapacity(cfg.DedupCapacity)
d := &daemon{
cfg: cfg,
log: log,
lock: lock,
listener: listener,
hub: hub,
dedup: dd,
started: time.Now().UTC(),
idleStop: make(chan struct{}),
stopReq: make(chan string, 1),
}
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
// scenario where the parent thinks bus is dead but it's actually mid-
// startup). Source.Start hasn't yet pulled events from the cloud, but
// any consumer that connects gets queued for the first events.
signalReady(cfg.ReadyPipe)
// runCtx is a child of the caller's ctx that ALL background goroutines
// (acceptLoop / idleWatch / dropWarnWatcher / source.Start) listen on.
// On idle-timeout shutdown the parent ctx is never cancelled, so we
// cancel runCtx ourselves before waiting for the goroutines — otherwise
// dropWarnWatcher (which only exits on ctx.Done) hangs forever.
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
// 5. Start accept loop and Source concurrently. runCtx cancellation
// propagates to both.
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
d.acceptLoop(runCtx)
}()
idleDone := make(chan struct{})
go func() {
defer close(idleDone)
d.idleWatch(runCtx)
}()
// Drop-rate WARN: per-event-type back-pressure monitoring. Runs in
// the background until runCtx cancellation; emits one WARN per scan
// when a type's drop rate first crosses the threshold (hysteresis
// suppresses repeats unless the rate jumps further).
dropWarnDone := make(chan struct{})
go func() {
defer close(dropWarnDone)
dropWarnWatcher(runCtx, hub.Counters(), log, cfg.DropWarnPercent)
}()
srcErr := make(chan error, 1)
go func() {
// emit is called from inside the SDK callback goroutine. It MUST
// NOT block (plan invariant #1) — dedup + Hub.Deliver are both
// non-blocking by construction.
emit := func(raw *dwsevent.RawEvent) {
if raw == nil {
return
}
if dd.Seen(raw.DedupKey()) {
return // duplicate redelivery from the cloud
}
hub.Deliver(raw)
}
srcErr <- cfg.Source.Start(runCtx, emit)
}()
// 6. Wait for shutdown trigger.
var exitErr error
shutdownReason := "shutdown"
select {
case <-ctx.Done():
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
case err := <-srcErr:
log.Error("bus: source exited", "err", err)
exitErr = err
shutdownReason = sourceShutdownReason(err)
if shutdownReason == transport.ByeReasonRuntimeTokenRejected {
// A runtime token can fail immediately after Broker.Update. Serialize
// terminal publication with that handshake so the initiating consumer
// is registered (or receives a terminal HelloAck) before shutdown.
d.credentialHandoffMu.Lock()
d.setTerminalReason(shutdownReason)
d.credentialHandoffMu.Unlock()
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
case reason := <-d.stopReq:
shutdownReason = reason
log.Info("bus: shutdown requested via IPC", "reason", reason)
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then stop accepting connections before draining
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown(acceptDone, shutdownReason)
<-idleDone
<-dropWarnDone
return exitErr
}
// daemon is the in-memory state of one bus run. Lifetime equals one Run() call.
type daemon struct {
cfg Config
log *slog.Logger
lock *Lock
listener transport.Listener
hub *Hub
dedup *dedup.LRU
started time.Time
consumerWG sync.WaitGroup // tracks live connection handler goroutines
conns sync.Map // map[net.Conn]struct{} for forced shutdown close
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
stopReq chan string
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
terminalReason string
}
// closeOnceConn makes every connection close path idempotent. A live consumer
// can be closed by its writer, the connection handler, or daemon shutdown.
type closeOnceConn struct {
net.Conn
once sync.Once
err error
}
func (c *closeOnceConn) Close() error {
c.once.Do(func() {
c.err = c.Conn.Close()
})
return c.err
}
func ensureCloseOnce(conn net.Conn) net.Conn {
if _, ok := conn.(*closeOnceConn); ok {
return conn
}
return &closeOnceConn{Conn: conn}
}
// acceptLoop drives the IPC accept goroutine. Each accepted connection is
// passed to handleConnection in its own goroutine; the accept loop returns
// when the listener Close()s (typically during shutdown).
func (d *daemon) acceptLoop(ctx context.Context) {
for {
conn, err := d.listener.Accept()
if err != nil {
if d.shuttingDown.Load() {
return
}
// Transient errors: log + continue. Non-transient (listener
// closed) shows up as net.ErrClosed → also a clean exit.
if errors.Is(err, net.ErrClosed) {
return
}
d.log.Warn("bus: accept error", "err", err)
continue
}
conn = ensureCloseOnce(conn)
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
d.conns.Store(conn, struct{}{})
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
d.handleConnection(ctx, conn)
}()
}
}
// handleConnection processes one IPC connection's full lifecycle: read
// Hello → optional runtime credential negotiation → register with Hub → spawn
// writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
conn = ensureCloseOnce(conn)
defer func() {
d.conns.Delete(conn)
_ = conn.Close()
}()
r := transport.NewReader(conn)
w := transport.NewWriter(conn)
// Expect Hello first.
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
d.log.Warn("bus: hello read failed", "err", err)
return
}
if hello.Type != transport.FrameTypeHello {
d.log.Warn("bus: first frame not hello", "type", hello.Type)
return
}
// Ad-hoc tooling (status/list/stop) — short-lived RPC, no Hub register.
if hello.Role == transport.HelloRoleStatus {
d.handleStatusRPC(w, r)
return
}
if hello.Role == transport.HelloRoleStop {
// Signal shutdown by cancelling our parent ctx via shutdown().
// For now, return after acking — daemon.shutdown is wired through
// daemon's exit path (the busctl/stop command sends SIGTERM in
// addition to this RPC for v1).
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "stop_request"})
go d.triggerShutdown("stop_request")
return
}
if hello.Role == transport.HelloRoleConsumerStop {
d.handleConsumerStopRPC(w, r)
return
}
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
ack := d.helloAck()
handoffLocked := false
runtimeGeneration := uint64(0)
defer func() {
if handoffLocked {
d.credentialHandoffMu.Unlock()
}
}()
// Runtime credentials use a two-phase additive handshake. The first ack
// proves capability before the client sends any secret. Only a successful
// CAS and credential ack permit Hub registration.
if hello.CredentialMode != "" {
if hello.CredentialMode != transport.CredentialModeRuntimeToken || d.cfg.CredentialBroker == nil {
ack.Capabilities = nil
ack.CredentialGeneration = 0
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: incompatible helloack write failed", "err", err)
}
return
}
d.credentialHandoffMu.Lock()
handoffLocked = true
// Terminal state may have been published while this Hello waited for a
// concurrent credential handoff. Rebuild the ack while holding the gate.
ack = d.helloAck()
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: runtime helloack write failed", "err", err)
return
}
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
return
}
var update transport.CredentialUpdate
_ = conn.SetReadDeadline(time.Now().Add(daemonCredentialHandshakeTimeout))
if err := r.ReadJSON(&update); err != nil {
// Do not include the decoder error: malformed JSON may contain
// fragments of the credential.
d.log.Warn("bus: malformed runtime credential update")
return
}
_ = conn.SetReadDeadline(time.Time{})
if update.Type != transport.FrameTypeCredentialUpdate {
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorInvalid,
Error: "unexpected credential update frame",
})
return
}
// Validate registration before applying the credential or sending an
// accepted ack. Hub.Register performs the same deterministic compile
// before mutating the Hub; this preflight keeps invalid filters from
// producing a ready marker after credential negotiation.
if _, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID); err != nil {
update.Token = ""
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorRegistration,
Error: "consumer registration validation failed",
})
d.log.Warn("bus: runtime consumer registration validation failed")
return
}
generation, updateErr := d.cfg.CredentialBroker.Update(update.ExpectedGeneration, update.Token)
runtimeGeneration = generation
update.Token = ""
credentialAck := transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: updateErr == nil,
CredentialGeneration: generation,
}
if updateErr != nil {
credentialAck.ErrorCode, credentialAck.Error = classifyCredentialUpdateError(updateErr)
}
if err := w.WriteJSON(credentialAck); err != nil {
d.log.Warn("bus: credential update ack write failed", "err", err)
return
}
if updateErr != nil {
d.log.Warn("bus: runtime credential update rejected", "error_code", credentialAck.ErrorCode)
return
}
}
// Regular consumer registration. Local clients retain the original
// register-before-HelloAck ordering; runtime clients were already acked by
// the additive handshake above.
c, err := d.hub.Register(hello)
if err != nil {
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
return
}
if handoffLocked {
// The runtime broker deliberately keeps the seed pending until the
// initiating consumer is registered. This prevents ticket acquisition
// (and an immediate 401) from racing ahead of the only connection that
// can observe the typed terminal reason.
if _, activateErr := d.cfg.CredentialBroker.Activate(runtimeGeneration); activateErr != nil {
d.log.Error("bus: runtime credential activation failed")
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "runtime_credential_activation_failed"})
d.hub.Unregister(c.ID)
return
}
}
// A local/legacy consumer can arrive after terminal publication but after
// StopAll took its snapshot. Refuse it synchronously so it cannot observe a
// clean EOF for a runtime-token rejection.
if terminalReason := d.getTerminalReason(); terminalReason != "" {
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: terminalReason})
d.hub.Unregister(c.ID)
return
}
if handoffLocked {
d.credentialHandoffMu.Unlock()
handoffLocked = false
}
if hello.CredentialMode == "" {
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
d.hub.Unregister(c.ID)
return
}
}
// Writer goroutine pulls from SendCh and writes to the wire.
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
for {
// Give an already-queued targeted stop priority over buffered
// events. The second select still handles a stop that arrives
// between this check and the blocking wait.
select {
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
default:
}
select {
case frame, ok := <-c.SendCh:
if !ok {
return
}
// A stop may have arrived while both channels were ready and the
// scheduler selected the buffered event. Re-check before starting a
// potentially blocking event write so terminal reasons stay prompt.
select {
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
default:
}
if err := w.WriteJSON(frame); err != nil {
return
}
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
}
}
}()
// Reader loop: wait for Bye or EOF. Both trigger Unregister.
for {
raw, err := r.Read()
if err != nil {
if !errors.Is(err, io.EOF) {
d.log.Debug("bus: consumer read error", "pid", c.PID, "err", err)
}
break
}
typ, err := transport.PeekType(raw)
if err != nil {
continue
}
if typ == transport.FrameTypeBye {
break
}
// Heartbeat / future client→bus frames: ignored for v1.
}
// Order matters: Unregister first (closes SendCh), then wait for the
// writer goroutine to drain. The reverse order would deadlock because
// the writer loops on `range SendCh` until close, but only the Hub
// can close that channel via Unregister.
d.hub.Unregister(c.ID)
<-writerDone
_ = ctx // for future use (writer ctx-cancel propagation)
}
func (d *daemon) helloAck() transport.HelloAck {
ack := transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: int(d.cfg.IdleTimeout / time.Second),
}
if d.cfg.CredentialBroker != nil {
ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
ack.CredentialGeneration = d.cfg.CredentialBroker.Generation()
}
ack.TerminalReason = d.getTerminalReason()
return ack
}
func (d *daemon) setTerminalReason(reason string) {
if d == nil || reason != transport.ByeReasonRuntimeTokenRejected {
return
}
d.terminalMu.Lock()
d.terminalReason = reason
d.terminalMu.Unlock()
}
func (d *daemon) getTerminalReason() string {
if d == nil {
return ""
}
d.terminalMu.RLock()
defer d.terminalMu.RUnlock()
return d.terminalReason
}
func classifyCredentialUpdateError(err error) (string, string) {
var conflict *runtimecred.GenerationConflictError
if errors.As(err, &conflict) {
return transport.CredentialErrorGenerationConflict, conflict.Error()
}
if errors.Is(err, runtimecred.ErrEmptyToken) || errors.Is(err, runtimecred.ErrTokenTooLarge) {
return transport.CredentialErrorInvalid, err.Error()
}
return transport.CredentialErrorInternal, "runtime credential update failed"
}
func (d *daemon) handleConsumerStopRPC(w *transport.Writer, r *transport.Reader) {
var req transport.ConsumerStopReq
if err := r.ReadJSON(&req); err != nil {
d.log.Debug("bus: malformed consumer stop request", "err", err)
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Error: "malformed consumer stop request",
})
return
}
if req.Type != transport.FrameTypeConsumerStopReq {
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Error: fmt.Sprintf("unexpected consumer stop request type %q", req.Type),
})
return
}
stopped := d.hub.StopConsumers(req.SubscribeIDs, transport.ByeReasonSubscriptionStopped)
stoppedSet := make(map[string]struct{}, len(stopped))
for _, id := range stopped {
stoppedSet[id] = struct{}{}
}
notFoundSet := make(map[string]struct{})
for _, id := range req.SubscribeIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := stoppedSet[id]; !ok {
notFoundSet[id] = struct{}{}
}
}
notFound := make([]string, 0, len(notFoundSet))
for id := range notFoundSet {
notFound = append(notFound, id)
}
sort.Strings(notFound)
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Stopped: stopped,
NotFound: notFound,
})
}
// handleStatusRPC services a single status_req and returns. The connection
// is closed by the caller's defer.
func (d *daemon) handleStatusRPC(w *transport.Writer, r *transport.Reader) {
var req transport.StatusReq
if err := r.ReadJSON(&req); err != nil {
return
}
resp := transport.StatusResp{
Type: transport.FrameTypeStatusResp,
Bus: transport.StatusBus{
PID: os.Getpid(),
UptimeSecs: int64(time.Since(d.started).Seconds()),
IdleTimeoutSec: int(d.cfg.IdleTimeout / time.Second),
ClientID: d.cfg.ClientID,
Edition: d.cfg.Edition,
SourceKind: d.cfg.SourceKind,
IdentityHash: d.cfg.IdentityHash,
SourceID: d.cfg.SourceID,
},
SourceState: transport.StatusSource{
State: "connected", // v1: source state plumbed in P3+
Source: "inferred",
},
Consumers: d.hub.Snapshot(),
PerEventTypeCounters: d.hub.Counters().Snapshot(),
}
_ = w.WriteJSON(resp)
}
// idleWatch fires d.idleStop when IdleTimeout passes with zero registered
// consumers. Disabled when IdleTimeout <= 0 (returns immediately; idleStop
// is then never closed and Run's select branch on it is effectively dead).
//
// Pre-condition: d.idleStop has already been allocated by Run so the parent
// select never races on a nil channel (which would block forever).
func (d *daemon) idleWatch(ctx context.Context) {
if d.cfg.IdleTimeout <= 0 {
return
}
tick := time.NewTicker(d.cfg.IdleTimeout / 4)
defer tick.Stop()
emptySince := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-tick.C:
if d.hub.Len() == 0 {
if emptySince.IsZero() {
emptySince = time.Now()
} else if time.Since(emptySince) >= d.cfg.IdleTimeout {
close(d.idleStop)
return
}
} else {
emptySince = time.Time{}
}
}
}
}
// triggerShutdown is called from RPC handlers that want to end the bus. The
// buffered request wakes Run's lifecycle select, which then cancels the cloud
// source and performs the same graceful shutdown used for signals and idle
// expiry. It is intentionally non-blocking so repeated stop requests cannot
// strand connection handlers.
func (d *daemon) triggerShutdown(reason string) {
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
select {
case d.stopReq <- reason:
default:
}
}
// shutdown performs the graceful tear-down sequence:
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}, reasons ...string) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
return
}
reason := normalizedShutdownReason(reasons...)
if reason == transport.ByeReasonRuntimeTokenRejected {
d.hub.StopAll(reason)
} else {
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
}
_ = d.listener.Close()
<-acceptDone
// Let local consumers drain the final Bye before force-closing their
// connections. This short grace period is what makes typed shutdown
// reasons (notably runtime_token_rejected) observable instead of racing
// with EOF. Consumers close their side immediately after reading Bye.
doneCh := make(chan struct{})
go func() {
d.consumerWG.Wait()
close(doneCh)
}()
select {
case <-doneCh:
return
case <-time.After(daemonByeDrainTimeout):
}
// A wedged/old consumer may not close after Bye. Force-close remaining
// connections so the daemon still has a bounded shutdown.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
select {
case <-doneCh:
case <-time.After(daemonShutdownTimeout):
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
}
}
func sourceShutdownReason(err error) string {
if errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
func normalizedShutdownReason(reasons ...string) string {
if len(reasons) > 0 && reasons[0] == transport.ByeReasonRuntimeTokenRejected {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
func signalReady(p *os.File) {
if p == nil {
return
}
_, _ = p.Write([]byte{'R'})
_ = p.Close()
}
// failReady writes 'E' to the ready pipe (if provided) and returns err.
// Used by the startup-failure paths so the parent can distinguish "still
// starting up" from "failed to start".
func failReady(p *os.File, err error) error {
if p != nil {
_, _ = p.Write([]byte{'E'})
_ = p.Close()
}
return err
}