* dws event * fix consume fail * test: add stream ticket injection probe * feat: add portal ticket stream mode * user event * fix: allow portal ticket normal without app secret * event * user event * eventType filter * refactor(event): 优化IPC端点路径处理和改进相关测试 - 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑 - 新增event包实现Unix socket路径长度限制及长路径fallback机制 - 添加endpoint_test.go覆盖路径短长及唯一性的单元测试 - 修改busctl模块使用统一的IPC端点获取方法,避免重复实现 - transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误 - 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度 - 去除多个文件中无用的runtime导入,简化代码依赖 * opt * event skill * default value * install script event * fix: remove subscribe id event fanout filter * fix(personal): 修正指定发送人消息描述错误 * more im event * filter subId * fix: align personal event schema with stream payload * fix: avoid duplicate app helper name * feat: simplify personal event schemas * feat: simplify event schema output * docs: refine dingtalk event skill references * feat: align personal event consume flags * fix event stop and status visibility * hide app event public entrypoints * hide incomplete personal sender event * remove external event reference comments * chore(event): prepare official release * fix(event): harden personal stream lifecycle --------- Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com> Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
526 lines
17 KiB
Go
526 lines
17 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"
|
|
"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/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
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 := os.MkdirAll(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 := Acquire(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 := WriteMeta(cfg.WorkDir, meta); err != nil {
|
|
return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err))
|
|
}
|
|
|
|
// 3. IPC listen
|
|
listener, err := transport.Listen(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{}),
|
|
}
|
|
|
|
// 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
|
|
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
|
|
case <-d.idleStop:
|
|
log.Info("bus: idle timeout reached, shutting down")
|
|
}
|
|
|
|
// 7. Graceful shutdown — cancel runCtx first so all background
|
|
// goroutines wake up, then close listener / drain consumers.
|
|
cancelRun()
|
|
d.shutdown()
|
|
<-acceptDone
|
|
<-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{}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
d.consumerWG.Add(1)
|
|
go func() {
|
|
defer d.consumerWG.Done()
|
|
d.handleConnection(ctx, conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// handleConnection processes one IPC connection's full lifecycle: read
|
|
// Hello → 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) {
|
|
d.conns.Store(conn, struct{}{})
|
|
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
|
|
}
|
|
|
|
// Regular consumer registration
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
idleSecs := int(d.cfg.IdleTimeout / time.Second)
|
|
if err := w.WriteJSON(transport.HelloAck{
|
|
Type: transport.FrameTypeHelloAck,
|
|
BusPID: os.Getpid(),
|
|
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
|
|
StateSource: "inferred",
|
|
IdleTimeoutSecs: idleSecs,
|
|
}); err != nil {
|
|
d.log.Warn("bus: helloack write failed", "err", err)
|
|
return
|
|
}
|
|
|
|
// Writer goroutine pulls from SendCh and writes to the wire.
|
|
writerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(writerDone)
|
|
for frame := range c.SendCh {
|
|
if err := w.WriteJSON(frame); err != nil {
|
|
// Wire error: peer dead. Returning here will let the
|
|
// reader goroutine notice EOF and Unregister.
|
|
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)
|
|
}
|
|
|
|
// 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.
|
|
// It works by closing the listener (which unblocks Run's select via the
|
|
// source error path, indirectly). For v1 a full ctx-cancellation hook is
|
|
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
|
|
// shutdown path.
|
|
func (d *daemon) triggerShutdown(reason string) {
|
|
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
|
|
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
|
|
// Best-effort: a future version wires a context.CancelFunc here.
|
|
}
|
|
|
|
// 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 all per-connection goroutines to drain
|
|
// 5. lock + meta cleanup via Run's defers
|
|
func (d *daemon) shutdown() {
|
|
d.shutdownMu.Lock()
|
|
defer d.shutdownMu.Unlock()
|
|
if !d.shuttingDown.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
|
_ = d.listener.Close()
|
|
// Force-close all open IPC connections so any reader goroutine blocked
|
|
// on Read() returns with a network error and exits cleanly. Without
|
|
// this the consumerWG never drains and Run hangs forever.
|
|
d.conns.Range(func(k, _ any) bool {
|
|
if c, ok := k.(net.Conn); ok {
|
|
_ = c.Close()
|
|
}
|
|
return true
|
|
})
|
|
// Give consumers a brief moment to drain final frames before we tear
|
|
// down their channels.
|
|
doneCh := make(chan struct{})
|
|
go func() {
|
|
d.consumerWG.Wait()
|
|
close(doneCh)
|
|
}()
|
|
select {
|
|
case <-doneCh:
|
|
case <-time.After(2 * time.Second):
|
|
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|