chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import "strings"
|
||||
|
||||
// SanitizeAppID replaces ".." / path separators / NUL with "_" to guard filepath.Join; empty/dot-only collapses to "_".
|
||||
func SanitizeAppID(appID string) string {
|
||||
if appID == "" {
|
||||
return "_"
|
||||
}
|
||||
repl := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
"\x00", "_",
|
||||
"..", "_",
|
||||
)
|
||||
out := repl.Replace(appID)
|
||||
if out == "" || out == "." {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeAppID_RejectsPathTraversal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantClean string
|
||||
forbidChars string
|
||||
}{
|
||||
{"happy path", "cli_XXXXXXXXXXXXXXXX", "cli_XXXXXXXXXXXXXXXX", "/\\\x00"},
|
||||
{"empty", "", "_", ""},
|
||||
{"dot", ".", "_", ""},
|
||||
{"double-dot only", "..", "_", ".."},
|
||||
{"leading traversal", "../etc/passwd", "__etc_passwd", "/"},
|
||||
{"traversal inside", "cli_../../etc", "cli_____etc", "/"},
|
||||
{"backslash traversal", "..\\windows\\system32", "__windows_system32", "\\"},
|
||||
{"nul injection", "cli_\x00backdoor", "cli__backdoor", "\x00"},
|
||||
{"pure slashes", "///", "___", "/"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SanitizeAppID(tc.input)
|
||||
if got != tc.wantClean {
|
||||
t.Errorf("SanitizeAppID(%q) = %q, want %q", tc.input, got, tc.wantClean)
|
||||
}
|
||||
for _, c := range tc.forbidChars {
|
||||
if strings.ContainsRune(got, c) {
|
||||
t.Errorf("SanitizeAppID(%q) = %q contains forbidden rune %q", tc.input, got, c)
|
||||
}
|
||||
}
|
||||
joined := filepath.ToSlash(filepath.Join("/root/events", got, "bus.log"))
|
||||
if strings.Contains(joined, "..") {
|
||||
t.Errorf("joined path %q contains .. after sanitization", joined)
|
||||
}
|
||||
if !strings.HasPrefix(joined, "/root/events/") {
|
||||
t.Errorf("joined path %q escaped /root/events/ parent", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package bus implements the per-AppID event-bus daemon; lifecycle is driven by consumer presence (idle timeout) and explicit shutdown.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/source"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
)
|
||||
|
||||
const (
|
||||
idleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Bus is the central event bus daemon.
|
||||
type Bus struct {
|
||||
appID string
|
||||
appSecret string
|
||||
domain string
|
||||
transport transport.IPC
|
||||
hub *Hub
|
||||
dedup *event.DedupFilter
|
||||
listener net.Listener
|
||||
logger *log.Logger
|
||||
startTime time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[*Conn]struct{}
|
||||
idleTimer *time.Timer
|
||||
shutdownCh chan struct{}
|
||||
|
||||
// pidHandle pins the alive.lock fd to the bus lifetime; OS releases on exit.
|
||||
pidHandle *busdiscover.Handle
|
||||
}
|
||||
|
||||
func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger) *Bus {
|
||||
return &Bus{
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
domain: domain,
|
||||
transport: tr,
|
||||
hub: NewHub(),
|
||||
dedup: event.NewDedupFilter(),
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
conns: make(map[*Conn]struct{}),
|
||||
// Buffered so shutdown and source-exit paths never drop the signal.
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Run binds the IPC socket, starts event sources, and blocks in the accept loop until shutdown.
|
||||
func (b *Bus) Run(ctx context.Context) error {
|
||||
addr := b.transport.Address(b.appID)
|
||||
|
||||
// alive.lock before bind: closes the cleanup-TOCTOU race where two newly forked
|
||||
// buses each unlink and rebind the socket. Brief retry covers stop-then-restart.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(b.appID))
|
||||
pidHandle, pidErr := acquireAliveLock(eventsDir)
|
||||
if pidErr != nil {
|
||||
if errors.Is(pidErr, lockfile.ErrHeld) {
|
||||
b.logger.Printf("Another bus already holds %s/bus.alive.lock, exiting", eventsDir)
|
||||
return nil
|
||||
}
|
||||
b.logger.Printf("[bus] pid file write failed: %v (status discovery may miss this bus)", pidErr)
|
||||
} else {
|
||||
b.pidHandle = pidHandle
|
||||
}
|
||||
|
||||
ln, err := b.transport.Listen(addr)
|
||||
if err != nil {
|
||||
if probe, dialErr := b.transport.Dial(addr); dialErr == nil {
|
||||
probe.Close()
|
||||
b.logger.Printf("Another bus is already running for %s, exiting", b.appID)
|
||||
return nil
|
||||
}
|
||||
b.transport.Cleanup(addr)
|
||||
ln, err = b.transport.Listen(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bus listen: %w", err)
|
||||
}
|
||||
}
|
||||
b.listener = ln
|
||||
b.logger.Printf("Bus started for app=%s pid=%d addr=%s", b.appID, os.Getpid(), addr)
|
||||
|
||||
b.idleTimer = time.NewTimer(idleTimeout)
|
||||
|
||||
sourceCtx, sourceCancel := context.WithCancel(ctx)
|
||||
defer sourceCancel()
|
||||
b.startSources(sourceCtx)
|
||||
|
||||
acceptDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(acceptDone)
|
||||
b.acceptLoop(ctx)
|
||||
}()
|
||||
|
||||
// Re-check live conn count under lock: a stale idle tick can linger past a concurrent Stop+Reset.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.logger.Printf("Bus shutting down (context cancelled)")
|
||||
case <-b.idleTimer.C:
|
||||
b.mu.Lock()
|
||||
active := len(b.conns)
|
||||
if active > 0 {
|
||||
b.idleTimer.Reset(idleTimeout)
|
||||
b.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.logger.Printf("Bus shutting down (idle %v, no active connections)", idleTimeout)
|
||||
case <-b.shutdownCh:
|
||||
b.logger.Printf("Bus shutting down (shutdown command received)")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
b.listener.Close()
|
||||
// Don't delete the socket: Run() handles stale sockets on startup, and deletion races a new bus.
|
||||
shutdownConns(b)
|
||||
<-acceptDone
|
||||
b.logger.Printf("Bus exited cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdownConns snapshots b.conns under lock then releases before Close() — Close→onClose reacquires b.mu.
|
||||
func shutdownConns(b *Bus) {
|
||||
b.mu.Lock()
|
||||
conns := make([]*Conn, 0, len(b.conns))
|
||||
for c := range b.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// startSources launches registered sources (or a default FeishuSource); any source exit triggers full bus shutdown.
|
||||
func (b *Bus) startSources(ctx context.Context) {
|
||||
sources := source.All()
|
||||
if len(sources) == 0 {
|
||||
sources = []source.Source{&source.FeishuSource{
|
||||
AppID: b.appID,
|
||||
AppSecret: b.appSecret,
|
||||
Domain: b.domain,
|
||||
Logger: b.logger,
|
||||
}}
|
||||
}
|
||||
eventTypes := subscribedEventTypes()
|
||||
b.hub.SetLogger(b.logger)
|
||||
for _, src := range sources {
|
||||
go func(s source.Source) {
|
||||
b.logger.Printf("Starting source: %s", s.Name())
|
||||
err := s.Start(ctx, eventTypes, func(raw *event.RawEvent) {
|
||||
b.logger.Printf("Event received: type=%s id=%s", raw.EventType, raw.EventID)
|
||||
if b.dedup.IsDuplicate(raw.EventID) {
|
||||
b.logger.Printf("Event deduplicated: id=%s", raw.EventID)
|
||||
return
|
||||
}
|
||||
b.hub.Publish(raw)
|
||||
}, func(state, detail string) {
|
||||
b.hub.BroadcastSourceStatus(s.Name(), state, detail)
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.logger.Printf("Source %s exited with error: %v — shutting down bus", s.Name(), err)
|
||||
} else {
|
||||
b.logger.Printf("Source %s exited without error before shutdown — shutting down bus", s.Name())
|
||||
}
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}(src)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribedEventTypes returns the deduplicated union of EventTypes from every registered EventKey.
|
||||
func subscribedEventTypes() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var types []string
|
||||
for _, def := range event.ListAll() {
|
||||
if _, ok := seen[def.EventType]; ok {
|
||||
continue
|
||||
}
|
||||
seen[def.EventType] = struct{}{}
|
||||
types = append(types, def.EventType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// acceptLoop accepts IPC connections until the listener is closed.
|
||||
func (b *Bus) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
b.logger.Printf("Accept error: %v", err)
|
||||
return
|
||||
}
|
||||
go b.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn reads the first protocol message and dispatches; the bufio.Reader is handed to Conn so buffered bytes carry over.
|
||||
func (b *Bus) handleConn(conn net.Conn) {
|
||||
br := bufio.NewReader(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Hello:
|
||||
b.handleHello(conn, br, m)
|
||||
case *protocol.StatusQuery:
|
||||
b.handleStatusQuery(conn)
|
||||
case *protocol.Shutdown:
|
||||
b.handleShutdown(conn)
|
||||
default:
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
subID := hello.SubscriptionID
|
||||
if subID == "" {
|
||||
subID = hello.EventKey
|
||||
}
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake.
|
||||
exclusive := false
|
||||
if def, ok := event.Lookup(hello.EventKey); ok {
|
||||
exclusive = def.SingleConsumer
|
||||
}
|
||||
var firstForKey bool
|
||||
if exclusive {
|
||||
ok, reason := b.hub.TryRegisterExclusive(bc)
|
||||
if !ok {
|
||||
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
|
||||
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
|
||||
}
|
||||
bc.Close()
|
||||
return
|
||||
}
|
||||
firstForKey = true
|
||||
} else {
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey = b.hub.RegisterAndIsFirst(bc)
|
||||
}
|
||||
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.SubscriptionID())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
b.logger.Printf("Consumer disconnected: pid=%d key=%s (remaining=%d)", c.PID(), c.EventKey(), remaining)
|
||||
if remaining == 0 {
|
||||
// Stop+drain before Reset (Go docs) to avoid a stale fire in .C.
|
||||
if !b.idleTimer.Stop() {
|
||||
select {
|
||||
case <-b.idleTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.idleTimer.Reset(idleTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
b.conns[bc] = struct{}{}
|
||||
// Stop+drain under mu so a fire can't slip past a fresh registration.
|
||||
if !b.idleTimer.Stop() {
|
||||
select {
|
||||
case <-b.idleTimer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
ack := protocol.NewHelloAck("v1", firstForKey)
|
||||
// writeFrame shares writeMu with every other write; bc.Close on failure unwinds hub+bus registration via onClose.
|
||||
if err := bc.writeFrame(ack); err != nil {
|
||||
b.logger.Printf("WARN: hello_ack write to pid=%d key=%q failed: %v (rejecting connection)",
|
||||
hello.PID, hello.EventKey, err)
|
||||
bc.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Quote untrusted fields to prevent log forging via embedded newlines.
|
||||
b.logger.Printf("Consumer connected: pid=%d key=%q event_types=%q first=%v",
|
||||
hello.PID, hello.EventKey, hello.EventTypes, firstForKey)
|
||||
|
||||
bc.Start()
|
||||
}
|
||||
|
||||
// handleStatusQuery replies with status and closes.
|
||||
func (b *Bus) handleStatusQuery(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
resp := protocol.NewStatusResponse(
|
||||
os.Getpid(),
|
||||
int(time.Since(b.startTime).Seconds()),
|
||||
b.hub.ConnCount(),
|
||||
b.hub.Consumers(),
|
||||
)
|
||||
_ = protocol.EncodeWithDeadline(conn, resp, protocol.WriteTimeout)
|
||||
}
|
||||
|
||||
// handleShutdown signals Run() to exit.
|
||||
func (b *Bus) handleShutdown(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
b.logger.Printf("Received shutdown command")
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
aliveLockMaxWait = 2 * time.Second
|
||||
aliveLockPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// acquireAliveLock retries on ErrHeld so a stop-then-immediate-restart finds the lock free.
|
||||
func acquireAliveLock(eventsDir string) (*busdiscover.Handle, error) {
|
||||
deadline := time.Now().Add(aliveLockMaxWait)
|
||||
for {
|
||||
h, err := busdiscover.WritePIDFile(eventsDir, os.Getpid())
|
||||
if err == nil {
|
||||
return h, nil
|
||||
}
|
||||
if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(aliveLockPollInterval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reproduces Run × onClose re-entrant deadlock if b.mu is held across Close.
|
||||
func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
}
|
||||
|
||||
const N = 3
|
||||
pipes := make([]net.Conn, 0, N*2)
|
||||
t.Cleanup(func() {
|
||||
for _, p := range pipes {
|
||||
p.Close()
|
||||
}
|
||||
})
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
b.mu.Unlock()
|
||||
})
|
||||
|
||||
b.mu.Lock()
|
||||
b.conns[bc] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
shutdownConns(b)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("shutdownConns deadlocked: did not complete within 2s")
|
||||
}
|
||||
|
||||
if got := hub.ConnCount(); got != 0 {
|
||||
t.Errorf("expected 0 subscribers in hub after shutdown, got %d", got)
|
||||
}
|
||||
b.mu.Lock()
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Errorf("expected 0 conns in Bus after shutdown, got %d", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownCh must be buffered so a signal sent before Run's select loop is still delivered.
|
||||
func TestShutdownSignalNotDroppedBeforeRunSelects(t *testing.T) {
|
||||
b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0))
|
||||
|
||||
select {
|
||||
case b.shutdownCh <- struct{}{}:
|
||||
default:
|
||||
t.Fatal("handleShutdown's send took default branch — signal would be lost")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-b.shutdownCh:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("shutdown signal was not latched")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
sendChCap = 100
|
||||
writeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Conn represents a single consume client connection in the Bus.
|
||||
type Conn struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
sendCh chan interface{}
|
||||
sendMu sync.Mutex // serialises drop+push atomically
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
subID string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(scope string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
received atomic.Int64 // events fanned out to us (post-filter)
|
||||
seqCounter atomic.Uint64 // per-conn monotonic seq assigned by Hub.Publish
|
||||
dropped atomic.Int64 // events evicted via drop-oldest backpressure
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
return &Conn{
|
||||
conn: conn,
|
||||
reader: reader,
|
||||
sendCh: make(chan interface{}, sendChCap),
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
subID: subID,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID returns the subscription identity. Falls back to EventKey
|
||||
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
|
||||
func (c *Conn) SubscriptionID() string {
|
||||
if c.subID == "" {
|
||||
return c.eventKey
|
||||
}
|
||||
return c.subID
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
func (c *Conn) SetCheckLastForKey(fn func(string) bool) { c.checkLastForKey = fn }
|
||||
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (c *Conn) SetLogger(l *log.Logger) { c.logger = l }
|
||||
|
||||
func (c *Conn) EventKey() string { return c.eventKey }
|
||||
func (c *Conn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *Conn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *Conn) PID() int { return c.pid }
|
||||
func (c *Conn) IncrementReceived() { c.received.Add(1) }
|
||||
func (c *Conn) Received() int64 { return c.received.Load() }
|
||||
|
||||
// NextSeq returns the next monotonic seq for this conn (first call returns 1).
|
||||
func (c *Conn) NextSeq() uint64 { return c.seqCounter.Add(1) }
|
||||
|
||||
func (c *Conn) DroppedCount() int64 { return c.dropped.Load() }
|
||||
func (c *Conn) IncrementDropped() { c.dropped.Add(1) }
|
||||
|
||||
// Start launches the sender and reader goroutines; call exactly once.
|
||||
func (c *Conn) Start() {
|
||||
go c.SenderLoop()
|
||||
go c.ReaderLoop()
|
||||
}
|
||||
|
||||
// writeFrame is the sole write path, serialised via writeMu.
|
||||
func (c *Conn) writeFrame(msg interface{}) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
return protocol.Encode(c.conn, msg)
|
||||
}
|
||||
|
||||
// SenderLoop exits on closed (not sendCh close) so Hub.Publish can send without panic risk.
|
||||
func (c *Conn) SenderLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return
|
||||
case msg := <-c.sendCh:
|
||||
if err := c.writeFrame(msg); err != nil {
|
||||
if c.logger != nil {
|
||||
c.logger.Printf("WARN: write to pid=%d failed: %v", c.pid, err)
|
||||
}
|
||||
c.shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReaderLoop reads control messages (Bye, PreShutdownCheck) until EOF.
|
||||
func (c *Conn) ReaderLoop() {
|
||||
for {
|
||||
line, err := protocol.ReadFrame(c.reader)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
line = bytes.TrimRight(line, "\n")
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.handleControlMessage(msg)
|
||||
}
|
||||
c.shutdown()
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
// Use the connection's own authoritative subscription identity rather
|
||||
// than recomputing from the incoming message: a stale or mismatched
|
||||
// PreShutdownCheck must not ask about the wrong scope (which would
|
||||
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
|
||||
// already falls back to EventKey when its stored subID is empty.
|
||||
scope := c.SubscriptionID()
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(scope)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
c.logger.Printf("WARN: pre_shutdown_ack to pid=%d failed: %v", c.pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) shutdown() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.closed)
|
||||
c.conn.Close()
|
||||
// sendCh is NOT closed: would race with Hub.Publish holding SendCh() after RUnlock.
|
||||
if c.onClose != nil {
|
||||
c.onClose(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TrySend enqueues non-evictively under sendMu so it respects PushDropOldest's atomicity contract.
|
||||
func (c *Conn) TrySend(msg interface{}) bool {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PushDropOldest enqueues msg; on full channel evicts one oldest and retries, atomically under sendMu.
|
||||
// Returns (enqueued, dropped). A rare concurrent drain may make drop unnecessary — still succeeds with dropped=false.
|
||||
func (c *Conn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, dropped
|
||||
default:
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
|
||||
// Close is idempotent.
|
||||
func (c *Conn) Close() {
|
||||
c.shutdown()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
Type: protocol.MsgTypeEvent,
|
||||
EventType: "im.message.receive_v1",
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(client)
|
||||
client.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("expected to read a line: %v", scanner.Err())
|
||||
}
|
||||
line := scanner.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"event"`)) {
|
||||
t.Errorf("unexpected line: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
type serializingDetector struct {
|
||||
net.Conn
|
||||
inFlight atomic.Int32
|
||||
violated atomic.Bool
|
||||
}
|
||||
|
||||
func (s *serializingDetector) Write(b []byte) (int, error) {
|
||||
if s.inFlight.Add(1) > 1 {
|
||||
s.violated.Store(true)
|
||||
}
|
||||
time.Sleep(500 * time.Microsecond)
|
||||
defer s.inFlight.Add(-1)
|
||||
return s.Conn.Write(b)
|
||||
}
|
||||
|
||||
// Two goroutines writing frames (event + ack) must not overlap on the underlying net.Conn.
|
||||
func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
go bc.SenderLoop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const workers = 8
|
||||
const perWorker = 20
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
|
||||
bc.SendCh() <- &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "im.msg"}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
|
||||
bc.handleControlMessage(&protocol.PreShutdownCheck{EventKey: "im.msg"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
bc.Close()
|
||||
|
||||
if det.violated.Load() {
|
||||
t.Error("concurrent Write on net.Conn detected: SenderLoop and handleControlMessage " +
|
||||
"overlapped without serialisation (framing / deadline race)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
t.Fatalf("TrySend returned false at iteration %d; expected all sendChCap (%d) to fit", i, sendChCap)
|
||||
}
|
||||
}
|
||||
if bc.TrySend("overflow") {
|
||||
t.Fatal("TrySend on full channel returned true: TrySend must be non-evicting")
|
||||
}
|
||||
first := <-bc.SendCh()
|
||||
if first != 0 {
|
||||
t.Errorf("first drained item = %v, want 0", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
bc.ReaderLoop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
client.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
|
||||
if got := conn.SubscriptionID(); got != "mail.x:abc" {
|
||||
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
|
||||
if got := conn.SubscriptionID(); got != "mail.x" {
|
||||
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// HelloAck write failure must unregister the conn from hub and bus before returning.
|
||||
func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
client.Close()
|
||||
defer server.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.msg",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s: stuck on write or not handling the error path")
|
||||
}
|
||||
|
||||
if got := hub.ConnCount(); got != 0 {
|
||||
t.Errorf("hub.ConnCount after failed HelloAck = %d, want 0 (connection must be unregistered)", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.msg"); got != 0 {
|
||||
t.Errorf("hub.EventKeyCount(im.msg) after failed HelloAck = %d, want 0", got)
|
||||
}
|
||||
b.mu.Lock()
|
||||
remaining := len(b.conns)
|
||||
b.mu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
|
||||
// subscription_id registers under EventKey (today's behavior preserved).
|
||||
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Legacy client: no subscription_id field (empty string).
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.message",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
SubscriptionID: "", // legacy: empty, should fallback to EventKey
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under EventKey (not a qualified subscription ID).
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message:something"); got != 0 {
|
||||
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
|
||||
// non-empty subscription_id registers under that ID, not EventKey.
|
||||
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Modern client: subscription_id explicitly set.
|
||||
subscriptionID := "mail.message:alice@example.com"
|
||||
hello := &protocol.Hello{
|
||||
PID: 8888,
|
||||
EventKey: "mail.message",
|
||||
EventTypes: []string{"mail.message.receive_v1"},
|
||||
SubscriptionID: subscriptionID, // modern: per-resource subscription
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under the subscription_id, not bare EventKey.
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("mail.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount(subscriptionID); got != 1 {
|
||||
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
|
||||
}
|
||||
if got := hub.SubCount("mail.message"); got != 0 {
|
||||
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
|
||||
// the first consumer and rejects the second for the same SubscriptionID.
|
||||
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
|
||||
const key = "test.handlehello.exclusive"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SingleConsumer: true,
|
||||
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
|
||||
t.Helper()
|
||||
server, client := net.Pipe()
|
||||
t.Cleanup(func() { server.Close(); client.Close() })
|
||||
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
|
||||
go b.handleHello(server, bufio.NewReader(server), hello)
|
||||
line, err := protocol.ReadFrame(bufio.NewReader(client))
|
||||
if err != nil {
|
||||
t.Fatalf("read ack (pid %d): %v", pid, err)
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ack (pid %d): %v", pid, err)
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want *HelloAck", msg)
|
||||
}
|
||||
return ack
|
||||
}
|
||||
|
||||
ack1 := readAck(t, 100)
|
||||
if ack1.Rejected {
|
||||
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
|
||||
}
|
||||
|
||||
ack2 := readAck(t, 200)
|
||||
if !ack2.Rejected {
|
||||
t.Fatal("second consumer should be rejected")
|
||||
}
|
||||
if !strings.Contains(ack2.RejectReason, "already running") {
|
||||
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
|
||||
// in-progress cleanup of the same subscription before rejecting, so a stuck
|
||||
// cleanup can never wedge new consumers forever. Kept below the consumer's
|
||||
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
|
||||
// the consumer as a clean failed_precondition instead of a handshake timeout.
|
||||
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
|
||||
// "2s"); values at or above the 5s handshake deadline are not recommended.
|
||||
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
|
||||
|
||||
func resolveExclusiveCleanupWaitTimeout() time.Duration {
|
||||
const def = 3 * time.Second
|
||||
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
// SubscriptionID identifies the per-resource subscription for dedup purposes.
|
||||
// When no resource qualifier is needed it equals EventKey.
|
||||
SubscriptionID() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
IncrementReceived()
|
||||
Received() int64
|
||||
// PushDropOldest enqueues atomically with drop-oldest backpressure.
|
||||
PushDropOldest(msg interface{}) (enqueued, dropped bool)
|
||||
// TrySend is non-evictive but shares PushDropOldest's mutex.
|
||||
TrySend(msg interface{}) bool
|
||||
DroppedCount() int64
|
||||
IncrementDropped()
|
||||
// NextSeq returns a monotonic per-subscriber seq; tests may return 0.
|
||||
NextSeq() uint64
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
// subCounts is keyed by SubscriptionID (not EventKey) so that different
|
||||
// per-resource subscriptions sharing the same EventKey are deduped independently.
|
||||
subCounts map[string]int
|
||||
// cleanupInProgress[subscriptionID] holds a channel closed on release;
|
||||
// presence means a cleanup lock is held for that subscription.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
subCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, registered := h.subscribers[s]; !registered {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
sid := s.SubscriptionID()
|
||||
h.subCounts[sid]--
|
||||
isLast := h.subCounts[sid] == 0
|
||||
if isLast {
|
||||
delete(h.subCounts, sid)
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.subCounts[subscriptionID] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[subscriptionID] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[subscriptionID]
|
||||
delete(h.cleanupInProgress, subscriptionID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its SubscriptionID. If a cleanup is in progress for
|
||||
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other subscriptions aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
sid := s.SubscriptionID()
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.subCounts[sid] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
}
|
||||
|
||||
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
|
||||
// and any in-progress cleanup for that subscription finishes within
|
||||
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
|
||||
// duplicate consumer already holds the subscription, or the cleanup did not
|
||||
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
|
||||
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
|
||||
// on in-progress cleanup, but bounded.
|
||||
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
|
||||
sid := s.SubscriptionID()
|
||||
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-ch:
|
||||
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
case <-timer.C:
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
}
|
||||
if h.subCounts[sid] != 0 {
|
||||
pid := h.existingPIDForSubscriptionLocked(sid)
|
||||
h.mu.Unlock()
|
||||
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
|
||||
}
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
|
||||
// Caller must hold h.mu.
|
||||
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
|
||||
for sub := range h.subscribers {
|
||||
if sub.SubscriptionID() == sid {
|
||||
return sub.PID()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
|
||||
//
|
||||
// A fresh *protocol.Event is allocated per subscriber so each consumer sees
|
||||
// its own monotonically-increasing Seq (assigned via Conn.NextSeq) — sharing
|
||||
// a single msg struct across subscribers would alias Seq and defeat the
|
||||
// gap-detection at the consume side. The extra allocation per fan-out is
|
||||
// cheap compared to the socket write that follows.
|
||||
func (h *Hub) Publish(raw *event.RawEvent) {
|
||||
h.mu.RLock()
|
||||
matches := make([]Subscriber, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
for _, et := range s.EventTypes() {
|
||||
if et == raw.EventType {
|
||||
matches = append(matches, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Resolve source time once per Publish (not per subscriber) — same value
|
||||
// across the fan-out. Prefer the upstream header create_time
|
||||
// (raw.SourceTime) over the local arrival timestamp so consumers see
|
||||
// original publisher intent; fall back to Timestamp when SourceTime
|
||||
// wasn't populated (e.g. test-only sources, pre-4.4 RawEvent producers).
|
||||
sourceTime := raw.SourceTime
|
||||
if sourceTime == "" && !raw.Timestamp.IsZero() {
|
||||
sourceTime = fmt.Sprintf("%d", raw.Timestamp.UnixMilli())
|
||||
}
|
||||
|
||||
for _, s := range matches {
|
||||
msg := protocol.NewEvent(
|
||||
raw.EventType,
|
||||
raw.EventID,
|
||||
sourceTime,
|
||||
s.NextSeq(),
|
||||
raw.Payload,
|
||||
)
|
||||
|
||||
enqueued, dropped := s.PushDropOldest(msg)
|
||||
if dropped {
|
||||
s.IncrementDropped()
|
||||
if lg := h.logger.Load(); lg != nil {
|
||||
lg.Printf("WARN: backpressure on conn pid=%d event_key=%s dropped_total=%d",
|
||||
s.PID(), s.EventKey(), s.DroppedCount())
|
||||
}
|
||||
}
|
||||
if enqueued {
|
||||
s.IncrementReceived()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConnCount returns the current number of registered subscribers.
|
||||
func (h *Hub) ConnCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns total subscribers for the given EventKey, aggregating
|
||||
// across all SubscriptionIDs. For per-subscription counts use SubCount.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
count := 0
|
||||
for s := range h.subscribers {
|
||||
if s.EventKey() == eventKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SubCount returns the count of subscribers for the given SubscriptionID.
|
||||
func (h *Hub) SubCount(subscriptionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.subCounts[subscriptionID]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
// subscriber. Best-effort: channel full → drop silently (status isn't
|
||||
// worth applying back-pressure for). Routes through Subscriber.TrySend
|
||||
// so the send shares PushDropOldest's sendMu — without this a status
|
||||
// broadcast could slip into the tiny window between another
|
||||
// goroutine's drop and its retry push and break the atomicity contract.
|
||||
func (h *Hub) BroadcastSourceStatus(source, state, detail string) {
|
||||
msg := protocol.NewSourceStatus(source, state, detail)
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for s := range h.subscribers {
|
||||
s.TrySend(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Consumers returns info about all connected consumers.
|
||||
func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
SubscriptionID: s.SubscriptionID(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
|
||||
if got := c.DroppedCount(); got != 2 {
|
||||
t.Errorf("expected 2 drops, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
h.Publish(&event.RawEvent{EventType: "t"})
|
||||
}
|
||||
|
||||
for i := uint64(1); i <= 5; i++ {
|
||||
msg := <-c.SendCh()
|
||||
ev, ok := msg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("iter %d: expected *protocol.Event, got %T", i, msg)
|
||||
}
|
||||
if ev.Seq != i {
|
||||
t.Errorf("iter %d: expected seq %d, got %d", i, i, ev.Seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
const eid = "test-event-id-123"
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: eid,
|
||||
EventType: "t",
|
||||
Timestamp: time.UnixMilli(1234567890123),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.EventID != eid {
|
||||
t.Errorf("expected EventID %q, got %q", eid, ev.EventID)
|
||||
}
|
||||
if ev.SourceTime != "1234567890123" {
|
||||
t.Errorf("expected SourceTime \"1234567890123\", got %q", ev.SourceTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit SourceTime (upstream header.create_time) must win over local Timestamp.
|
||||
func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
const upstreamTs = "1700000000000"
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "t",
|
||||
SourceTime: upstreamTs,
|
||||
Timestamp: time.UnixMilli(1999999999999),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.SourceTime != upstreamTs {
|
||||
t.Errorf("SourceTime: got %q, want %q", ev.SourceTime, upstreamTs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
h := NewHub()
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
h.Publish(&event.RawEvent{
|
||||
EventID: "evt-2",
|
||||
EventType: "t",
|
||||
Timestamp: time.UnixMilli(42),
|
||||
})
|
||||
|
||||
msg := <-c.SendCh()
|
||||
ev := msg.(*protocol.Event)
|
||||
if ev.SourceTime != "42" {
|
||||
t.Errorf("SourceTime fallback: got %q, want %q", ev.SourceTime, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func testNetPipe(t *testing.T) (net.Conn, net.Conn) {
|
||||
t.Helper()
|
||||
return net.Pipe()
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Under concurrent Publish with a tiny channel, Received must equal actual enqueues (sendMu + enqueued gate).
|
||||
func TestPublishRaceBookkeepingAccurate(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := newRaceSubscriber("race.key", []string{"race.type"}, 2)
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
const publishers = 50
|
||||
const perPublisher = 500
|
||||
const N = publishers * perPublisher
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < publishers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perPublisher; j++ {
|
||||
h.Publish(&event.RawEvent{
|
||||
EventType: "race.type",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
const trySenders = 20
|
||||
for i := 0; i < trySenders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perPublisher; j++ {
|
||||
sub.TrySend("source-status")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("publishers did not complete in 10s")
|
||||
}
|
||||
|
||||
received := sub.Received()
|
||||
enqueued := atomic.LoadInt64(&sub.actualEnqueued)
|
||||
returnedFalse := atomic.LoadInt64(&sub.returnedFalse)
|
||||
|
||||
if received != enqueued {
|
||||
t.Errorf("counter drift: Received=%d actual_enqueued=%d (diff=%d)",
|
||||
received, enqueued, received-enqueued)
|
||||
}
|
||||
|
||||
if received > int64(N) {
|
||||
t.Errorf("Received=%d > N=%d", received, N)
|
||||
}
|
||||
|
||||
if returnedFalse > 0 {
|
||||
t.Errorf("PushDropOldest returned enqueued=false %d times — sendMu missing or broken",
|
||||
returnedFalse)
|
||||
}
|
||||
|
||||
totalPublishes := int64(N)
|
||||
if enqueued+returnedFalse != totalPublishes {
|
||||
t.Errorf("publish accounting drift: enqueued=%d + returnedFalse=%d != total=%d",
|
||||
enqueued, returnedFalse, totalPublishes)
|
||||
}
|
||||
}
|
||||
|
||||
// Hub.Publish must gate IncrementReceived on enqueued=true.
|
||||
func TestPublishDoesNotIncrementWhenPushDropOldestFails(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := &alwaysFailSubscriber{
|
||||
eventKey: "fail.key",
|
||||
eventTypes: []string{"fail.type"},
|
||||
sendCh: make(chan interface{}, 1),
|
||||
}
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
h.Publish(&event.RawEvent{
|
||||
EventType: "fail.type",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
})
|
||||
}
|
||||
|
||||
if got := sub.Received(); got != 0 {
|
||||
t.Errorf("Received=%d after 100 Publishes that all failed to enqueue", got)
|
||||
}
|
||||
}
|
||||
|
||||
type alwaysFailSubscriber struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
received atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
func (s *alwaysFailSubscriber) IncrementReceived() { s.received.Add(1) }
|
||||
func (s *alwaysFailSubscriber) Received() int64 { return s.received.Load() }
|
||||
func (s *alwaysFailSubscriber) DroppedCount() int64 { return s.dropped.Load() }
|
||||
func (s *alwaysFailSubscriber) IncrementDropped() { s.dropped.Add(1) }
|
||||
func (s *alwaysFailSubscriber) NextSeq() uint64 { return 0 }
|
||||
func (s *alwaysFailSubscriber) TrySend(msg interface{}) bool {
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func (s *alwaysFailSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
type raceSubscriber struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
pid int
|
||||
received atomic.Int64
|
||||
actualEnqueued int64
|
||||
returnedFalse int64
|
||||
dropped atomic.Int64
|
||||
sendMu sync.Mutex
|
||||
}
|
||||
|
||||
func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber {
|
||||
return &raceSubscriber{
|
||||
eventKey: key,
|
||||
eventTypes: types,
|
||||
sendCh: make(chan interface{}, capacity),
|
||||
pid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
func (s *raceSubscriber) IncrementReceived() { s.received.Add(1) }
|
||||
func (s *raceSubscriber) Received() int64 { return s.received.Load() }
|
||||
func (s *raceSubscriber) DroppedCount() int64 { return s.dropped.Load() }
|
||||
func (s *raceSubscriber) IncrementDropped() { s.dropped.Add(1) }
|
||||
func (s *raceSubscriber) NextSeq() uint64 { return 0 }
|
||||
|
||||
func (s *raceSubscriber) TrySend(msg interface{}) bool {
|
||||
s.sendMu.Lock()
|
||||
defer s.sendMu.Unlock()
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
s.sendMu.Lock()
|
||||
defer s.sendMu.Unlock()
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
atomic.AddInt64(&s.actualEnqueued, 1)
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-s.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.sendCh <- msg:
|
||||
atomic.AddInt64(&s.actualEnqueued, 1)
|
||||
return true, dropped
|
||||
default:
|
||||
atomic.AddInt64(&s.returnedFalse, 1)
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestHub_Subscribe(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.event.v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
if h.ConnCount() != 1 {
|
||||
t.Errorf("expected 1 conn, got %d", h.ConnCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_RoutesToSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im.msg", []string{"im.message.receive_v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
|
||||
select {
|
||||
case msg := <-c.sendCh:
|
||||
evt, ok := msg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Event, got %T", msg)
|
||||
}
|
||||
if evt.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("got event_type %q", evt.EventType)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timeout waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_SkipsUnmatchedSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("mail.new", []string{"mail.event.v1"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventID: "evt-1",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
t.Fatal("should not receive unmatched event")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Publish_NonBlocking(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.message.receive_v1"})
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
c.sendCh <- &protocol.Event{}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
raw := &event.RawEvent{
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
h.Publish(raw)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Publish blocked on full channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Unregister(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
h.UnregisterAndIsLast(c)
|
||||
|
||||
if h.ConnCount() != 0 {
|
||||
t.Errorf("expected 0 conns, got %d", h.ConnCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_NeverRegistered(t *testing.T) {
|
||||
h := NewHub()
|
||||
real := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(real)
|
||||
ghost := newTestConn("im", []string{"im.msg"})
|
||||
|
||||
if h.UnregisterAndIsLast(ghost) {
|
||||
t.Error("ghost unregister returned true: must be false when subscriber never registered")
|
||||
}
|
||||
if got := h.EventKeyCount("im"); got != 1 {
|
||||
t.Errorf("keyCount for 'im' = %d after ghost unregister; want 1 (real still registered)", got)
|
||||
}
|
||||
if !h.UnregisterAndIsLast(real) {
|
||||
t.Error("real unregister returned false; expected true (sole subscriber)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_DoubleUnregister(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := newTestConn("im", []string{"im.msg"})
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
if !h.UnregisterAndIsLast(c) {
|
||||
t.Fatal("first unregister returned false; expected true (sole subscriber)")
|
||||
}
|
||||
if h.UnregisterAndIsLast(c) {
|
||||
t.Error("second unregister returned true: duplicate unregister must report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
|
||||
c2 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
|
||||
h.RegisterAndIsFirst(c1)
|
||||
h.RegisterAndIsFirst(c2)
|
||||
|
||||
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 2 {
|
||||
t.Errorf("expected 2, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
|
||||
}
|
||||
|
||||
h.UnregisterAndIsLast(c1)
|
||||
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 1 {
|
||||
t.Errorf("expected 1 after unregister, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_RegisterAndIsFirst_Concurrent(t *testing.T) {
|
||||
h := NewHub()
|
||||
const N = 200
|
||||
eventKey := "mail.user_mailbox.event.message_received_v1"
|
||||
|
||||
var firstCount int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
c := newTestConn(eventKey, []string{"mail.v1"})
|
||||
if h.RegisterAndIsFirst(c) {
|
||||
atomic.AddInt32(&firstCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&firstCount); got != 1 {
|
||||
t.Errorf("RegisterAndIsFirst returned true %d times across %d concurrent registrants; want exactly 1", got, N)
|
||||
}
|
||||
if got := h.EventKeyCount(eventKey); got != N {
|
||||
t.Errorf("EventKeyCount = %d, want %d", got, N)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnregisterAndIsLast_Concurrent(t *testing.T) {
|
||||
h := NewHub()
|
||||
const N = 200
|
||||
eventKey := "im.message.receive_v1"
|
||||
|
||||
conns := make([]*testConn, N)
|
||||
for i := 0; i < N; i++ {
|
||||
conns[i] = newTestConn(eventKey, []string{"im.v1"})
|
||||
h.RegisterAndIsFirst(conns[i])
|
||||
}
|
||||
|
||||
var lastCount int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < N; i++ {
|
||||
c := conns[i]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if h.UnregisterAndIsLast(c) {
|
||||
atomic.AddInt32(&lastCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&lastCount); got != 1 {
|
||||
t.Errorf("UnregisterAndIsLast returned true %d times; want exactly 1", got)
|
||||
}
|
||||
if got := h.EventKeyCount(eventKey); got != 0 {
|
||||
t.Errorf("EventKeyCount after all unregister = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type testConn struct {
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
sendCh chan interface{}
|
||||
pid int
|
||||
received atomic.Int64
|
||||
}
|
||||
|
||||
func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
return &testConn{
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
sendCh: make(chan interface{}, 100),
|
||||
pid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
|
||||
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
|
||||
func (c *testConn) SubscriptionID() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
func (c *testConn) IncrementReceived() { c.received.Add(1) }
|
||||
func (c *testConn) Received() int64 { return c.received.Load() }
|
||||
|
||||
func (c *testConn) DroppedCount() int64 { return 0 }
|
||||
|
||||
func (c *testConn) IncrementDropped() {}
|
||||
|
||||
func (c *testConn) NextSeq() uint64 { return 0 }
|
||||
|
||||
func (c *testConn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.sendCh:
|
||||
dropped = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true, dropped
|
||||
default:
|
||||
return false, dropped
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) TrySend(msg interface{}) bool {
|
||||
select {
|
||||
case c.sendCh <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscriptionID_Isolation(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 should be first for its subscription")
|
||||
}
|
||||
if !h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 should ALSO be first (different SubscriptionID)")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s1) {
|
||||
t.Error("s1 should be last for mail.x:alice")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s2) {
|
||||
t.Error("s2 should be last for mail.x:bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 first")
|
||||
}
|
||||
if h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 same SubscriptionID should NOT be first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
h.RegisterAndIsFirst(s2)
|
||||
if got := h.EventKeyCount("mail.x"); got != 2 {
|
||||
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:alice"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:bob"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
consumers := h.Consumers()
|
||||
if len(consumers) != 1 {
|
||||
t.Fatalf("got %d consumers, want 1", len(consumers))
|
||||
}
|
||||
if consumers[0].SubscriptionID != "mail.x:alice" {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive(t *testing.T) {
|
||||
h := NewHub()
|
||||
first := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
first.pid = 100
|
||||
ok, _ := h.TryRegisterExclusive(first)
|
||||
if !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
|
||||
second := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
second.pid = 200
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected")
|
||||
}
|
||||
if !strings.Contains(reason, "pid 100") {
|
||||
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
|
||||
}
|
||||
if got := h.SubCount("k.exclusive"); got != 1 {
|
||||
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
|
||||
// A cleanup lock that never releases must not wedge a new exclusive consumer
|
||||
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
|
||||
saved := exclusiveCleanupWaitTimeout
|
||||
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
|
||||
defer func() { exclusiveCleanupWaitTimeout = saved }()
|
||||
|
||||
h := NewHub()
|
||||
first := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
if ok, _ := h.TryRegisterExclusive(first); !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
// Hold the cleanup lock and never release it.
|
||||
if !h.AcquireCleanupLock("k.timeout") {
|
||||
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
second := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
|
||||
}
|
||||
if !strings.Contains(reason, "timed out") {
|
||||
t.Errorf("reject reason = %q, want a timeout reason", reason)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
a := newTestConn("k.a", []string{"k.a"})
|
||||
b := newTestConn("k.b", []string{"k.b"})
|
||||
if ok, _ := h.TryRegisterExclusive(a); !ok {
|
||||
t.Fatal("register a failed")
|
||||
}
|
||||
if ok, _ := h.TryRegisterExclusive(b); !ok {
|
||||
t.Error("distinct subscription b should register")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// While a subscriber holds the cleanup lock for its key, Register for same key must block until release.
|
||||
func TestConcurrentPreShutdownAndHelloRaceFree(t *testing.T) {
|
||||
h := NewHub()
|
||||
subA := newTestConn("mail.key", []string{"mail.receive"})
|
||||
subA.pid = 1001
|
||||
h.RegisterAndIsFirst(subA)
|
||||
|
||||
if !h.AcquireCleanupLock("mail.key") {
|
||||
t.Fatal("A should acquire cleanup lock — it's the only subscriber")
|
||||
}
|
||||
|
||||
subB := newTestConn("mail.key", []string{"mail.receive"})
|
||||
subB.pid = 1002
|
||||
|
||||
registered := make(chan bool, 1)
|
||||
go func() {
|
||||
isFirst := h.RegisterAndIsFirst(subB)
|
||||
registered <- isFirst
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-registered:
|
||||
t.Fatal("B registered DURING A's cleanup — TOCTOU race not fixed")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
h.ReleaseCleanupLock("mail.key")
|
||||
|
||||
select {
|
||||
case isFirst := <-registered:
|
||||
_ = isFirst
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("B never registered after cleanup released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfMultipleSubscribers(t *testing.T) {
|
||||
h := NewHub()
|
||||
subA := newTestConn("shared.key", []string{"t"})
|
||||
subA.pid = 1
|
||||
subB := newTestConn("shared.key", []string{"t"})
|
||||
subB.pid = 2
|
||||
h.RegisterAndIsFirst(subA)
|
||||
h.RegisterAndIsFirst(subB)
|
||||
|
||||
if h.AcquireCleanupLock("shared.key") {
|
||||
t.Fatal("AcquireCleanupLock should reject when >1 subscribers exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfAlreadyLocked(t *testing.T) {
|
||||
h := NewHub()
|
||||
sub := newTestConn("exclusive.key", []string{"t"})
|
||||
sub.pid = 1
|
||||
h.RegisterAndIsFirst(sub)
|
||||
|
||||
if !h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("first acquire should succeed")
|
||||
}
|
||||
if h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("second acquire should fail — already locked")
|
||||
}
|
||||
|
||||
h.ReleaseCleanupLock("exclusive.key")
|
||||
if !h.AcquireCleanupLock("exclusive.key") {
|
||||
t.Fatal("re-acquire after release should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCleanupLockIsIdempotent(t *testing.T) {
|
||||
h := NewHub()
|
||||
h.ReleaseCleanupLock("never.locked.key")
|
||||
h.ReleaseCleanupLock("never.locked.key")
|
||||
}
|
||||
|
||||
func TestAcquireCleanupLockRejectsIfZeroSubscribers(t *testing.T) {
|
||||
h := NewHub()
|
||||
|
||||
if h.AcquireCleanupLock("never.registered.key") {
|
||||
t.Error("AcquireCleanupLock should reject for a never-registered key (count==0)")
|
||||
}
|
||||
|
||||
sub := newTestConn("transient.key", []string{"t"})
|
||||
sub.pid = 1
|
||||
h.RegisterAndIsFirst(sub)
|
||||
h.UnregisterAndIsLast(sub)
|
||||
if h.AcquireCleanupLock("transient.key") {
|
||||
t.Error("AcquireCleanupLock should reject after all subscribers have unregistered (count==0)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLogSize = 5 * 1024 * 1024 // 5 MB
|
||||
logFileName = "bus.log"
|
||||
logBackupName = "bus.log.1"
|
||||
)
|
||||
|
||||
// SetupBusLogger writes to eventsDir/bus.log with one-shot size-based rotation at startup only.
|
||||
func SetupBusLogger(eventsDir string) (*log.Logger, error) {
|
||||
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := filepath.Join(eventsDir, logFileName)
|
||||
backupPath := filepath.Join(eventsDir, logBackupName)
|
||||
|
||||
if info, err := vfs.Stat(logPath); err == nil && info.Size() > maxLogSize {
|
||||
_ = vfs.Remove(backupPath)
|
||||
_ = vfs.Rename(logPath, backupPath)
|
||||
}
|
||||
|
||||
f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return log.New(f, "", log.LstdFlags), nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package busctl is the wire-level control client for the event bus daemon.
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
const readTimeout = 5 * time.Second // matches protocol.WriteTimeout
|
||||
|
||||
func QueryStatus(tr transport.IPC, appID string) (*protocol.StatusResponse, error) {
|
||||
conn, err := tr.Dial(tr.Address(appID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := protocol.EncodeWithDeadline(conn, protocol.NewStatusQuery(), protocol.WriteTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := protocol.ReadFrame(bufio.NewReader(conn))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, ok := msg.(*protocol.StatusResponse)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type from bus: %T", msg)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SendShutdown sends a Shutdown command; caller polls Dial to confirm exit.
|
||||
func SendShutdown(tr transport.IPC, appID string) error {
|
||||
conn, err := tr.Dial(tr.Address(appID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return protocol.EncodeWithDeadline(conn, protocol.NewShutdown(), protocol.WriteTimeout)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package busdiscover enumerates live bus daemons via per-AppID PID files protected by a process-lifetime advisory lock.
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type Process struct {
|
||||
PID int
|
||||
AppID string
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type Scanner interface {
|
||||
ScanBusProcesses() ([]Process, error)
|
||||
}
|
||||
|
||||
func Default() Scanner {
|
||||
return &fsScanner{eventsDir: filepath.Join(core.GetConfigDir(), "events")}
|
||||
}
|
||||
|
||||
type fsScanner struct {
|
||||
eventsDir string
|
||||
}
|
||||
|
||||
func (s *fsScanner) ScanBusProcesses() ([]Process, error) {
|
||||
return scanLiveBuses(s.eventsDir)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
pidFileName = "bus.pid"
|
||||
aliveLockFileName = "bus.alive.lock"
|
||||
)
|
||||
|
||||
// Handle keeps the lifetime lock fd alive; OS releases on process exit.
|
||||
type Handle struct {
|
||||
lock *lockfile.LockFile
|
||||
}
|
||||
|
||||
// Release is for tests only; production lets process exit release the lock.
|
||||
func (h *Handle) Release() error {
|
||||
if h == nil || h.lock == nil {
|
||||
return nil
|
||||
}
|
||||
return h.lock.Unlock()
|
||||
}
|
||||
|
||||
// WritePIDFile takes the alive lock and atomically writes pid + RFC3339 start time.
|
||||
// Returns lockfile.ErrHeld if another bus holds the lock.
|
||||
func WritePIDFile(eventsDir string, pid int) (*Handle, error) {
|
||||
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("busdiscover: mkdir %s: %w", eventsDir, err)
|
||||
}
|
||||
lock := lockfile.New(filepath.Join(eventsDir, aliveLockFileName))
|
||||
if err := lock.TryLock(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pidPath := filepath.Join(eventsDir, pidFileName)
|
||||
tmpPath := pidPath + ".tmp"
|
||||
payload := fmt.Sprintf("%d\n%s\n", pid, time.Now().UTC().Format(time.RFC3339))
|
||||
if err := vfs.WriteFile(tmpPath, []byte(payload), 0600); err != nil {
|
||||
_ = lock.Unlock()
|
||||
return nil, fmt.Errorf("busdiscover: write pid tmp: %w", err)
|
||||
}
|
||||
if err := vfs.Rename(tmpPath, pidPath); err != nil {
|
||||
_ = vfs.Remove(tmpPath)
|
||||
_ = lock.Unlock()
|
||||
return nil, fmt.Errorf("busdiscover: rename pid file: %w", err)
|
||||
}
|
||||
return &Handle{lock: lock}, nil
|
||||
}
|
||||
|
||||
func readPIDFile(eventsDir string) (int, time.Time, error) {
|
||||
pidPath := filepath.Join(eventsDir, pidFileName)
|
||||
data, err := vfs.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)
|
||||
if len(lines) < 2 {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid file %s", pidPath)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(lines[0]))
|
||||
if err != nil {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid in %s: %w", pidPath, err)
|
||||
}
|
||||
startTime, err := time.Parse(time.RFC3339, strings.TrimSpace(lines[1]))
|
||||
if err != nil {
|
||||
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed timestamp in %s: %w", pidPath, err)
|
||||
}
|
||||
return pid, startTime, nil
|
||||
}
|
||||
|
||||
// isBusAlive: try-lock the alive file. ErrHeld = live holder; success = stale (release immediately).
|
||||
func isBusAlive(appDir string) bool {
|
||||
lockPath := filepath.Join(appDir, aliveLockFileName)
|
||||
if _, err := vfs.Stat(lockPath); err != nil {
|
||||
return false
|
||||
}
|
||||
probe := lockfile.New(lockPath)
|
||||
err := probe.TryLock()
|
||||
if errors.Is(err, lockfile.ErrHeld) {
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[busdiscover] probe %s: %v\n", lockPath, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
|
||||
return false
|
||||
}
|
||||
_ = probe.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
func scanLiveBuses(eventsDir string) ([]Process, error) {
|
||||
entries, err := vfs.ReadDir(eventsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("busdiscover: read events dir: %w", err)
|
||||
}
|
||||
var result []Process
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
appID := e.Name()
|
||||
appDir := filepath.Join(eventsDir, appID)
|
||||
if !isBusAlive(appDir) {
|
||||
continue
|
||||
}
|
||||
pid, startTime, err := readPIDFile(appDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[busdiscover] live bus at %s but pid file unreadable: %v\n", appDir, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
|
||||
result = append(result, Process{PID: 0, AppID: appID})
|
||||
continue
|
||||
}
|
||||
result = append(result, Process{PID: pid, AppID: appID, StartTime: startTime})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package busdiscover
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
)
|
||||
|
||||
func TestWritePIDFile_WritesPIDAndTimestamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h, err := WritePIDFile(dir, 4242)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "bus.pid"))
|
||||
if err != nil {
|
||||
t.Fatalf("read pid file: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 lines, got %d: %q", len(lines), string(data))
|
||||
}
|
||||
if lines[0] != "4242" {
|
||||
t.Errorf("pid line = %q, want %q", lines[0], "4242")
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, lines[1])
|
||||
if err != nil {
|
||||
t.Errorf("timestamp parse: %v (line: %q)", err, lines[1])
|
||||
}
|
||||
if time.Since(ts) > time.Minute {
|
||||
t.Errorf("timestamp = %v, expected within last minute", ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePIDFile_SecondCallReturnsErrHeld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h1, err := WritePIDFile(dir, 1111)
|
||||
if err != nil {
|
||||
t.Fatalf("first WritePIDFile: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h1.Release() })
|
||||
|
||||
_, err = WritePIDFile(dir, 2222)
|
||||
if !errors.Is(err, lockfile.ErrHeld) {
|
||||
t.Errorf("second WritePIDFile err = %v, want lockfile.ErrHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePIDFile_ReleaseAllowsReacquire(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h1, err := WritePIDFile(dir, 1111)
|
||||
if err != nil {
|
||||
t.Fatalf("first WritePIDFile: %v", err)
|
||||
}
|
||||
if err := h1.Release(); err != nil {
|
||||
t.Fatalf("Release: %v", err)
|
||||
}
|
||||
h2, err := WritePIDFile(dir, 2222)
|
||||
if err != nil {
|
||||
t.Fatalf("re-acquire after Release: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h2.Release() })
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_ReturnsLiveBusOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
liveDir := filepath.Join(root, "cli_live")
|
||||
hLive, err := WritePIDFile(liveDir, 7777)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile live: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hLive.Release() })
|
||||
|
||||
deadDir := filepath.Join(root, "cli_dead")
|
||||
hDead, err := WritePIDFile(deadDir, 8888)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "empty"), 0700); err != nil {
|
||||
t.Fatalf("mkdir empty: %v", err)
|
||||
}
|
||||
|
||||
procs, err := scanLiveBuses(root)
|
||||
if err != nil {
|
||||
t.Fatalf("scanLiveBuses: %v", err)
|
||||
}
|
||||
if len(procs) != 1 {
|
||||
t.Fatalf("expected 1 live proc, got %d: %+v", len(procs), procs)
|
||||
}
|
||||
if procs[0].AppID != "cli_live" {
|
||||
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_live")
|
||||
}
|
||||
if procs[0].PID != 7777 {
|
||||
t.Errorf("PID = %d, want 7777", procs[0].PID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_MissingDirIsNotError(t *testing.T) {
|
||||
procs, err := scanLiveBuses(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if len(procs) != 0 {
|
||||
t.Errorf("expected empty result, got %+v", procs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLiveBuses_LiveBusWithCorruptPIDFileSurfaced(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
appDir := filepath.Join(root, "cli_corrupt")
|
||||
if err := os.MkdirAll(appDir, 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
lock := lockfile.New(filepath.Join(appDir, aliveLockFileName))
|
||||
if err := lock.TryLock(); err != nil {
|
||||
t.Fatalf("TryLock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = lock.Unlock() })
|
||||
if err := os.WriteFile(filepath.Join(appDir, pidFileName), []byte("garbage"), 0600); err != nil {
|
||||
t.Fatalf("write corrupt pid: %v", err)
|
||||
}
|
||||
|
||||
procs, err := scanLiveBuses(root)
|
||||
if err != nil {
|
||||
t.Fatalf("scanLiveBuses: %v", err)
|
||||
}
|
||||
if len(procs) != 1 {
|
||||
t.Fatalf("expected 1 entry (live bus surfaced anonymously), got %d: %+v", len(procs), procs)
|
||||
}
|
||||
if procs[0].AppID != "cli_corrupt" {
|
||||
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_corrupt")
|
||||
}
|
||||
if procs[0].PID != 0 {
|
||||
t.Errorf("PID = %d, want 0 (anonymous)", procs[0].PID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBoundedLoop_MaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var emitted atomic.Int64
|
||||
opts := Options{MaxEvents: 3, ErrOut: io.Discard}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
emitted.Add(1)
|
||||
stopNow := checkMaxEvents(opts, &emitted)
|
||||
if (i + 1) >= 3 {
|
||||
if !stopNow {
|
||||
t.Fatalf("checkMaxEvents should return true at emit %d (max=3)", i+1)
|
||||
}
|
||||
} else {
|
||||
if stopNow {
|
||||
t.Fatalf("checkMaxEvents should not return true at emit %d (max=3)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func TestBoundedLoop_NoLimitWhenZero(t *testing.T) {
|
||||
var emitted atomic.Int64
|
||||
opts := Options{MaxEvents: 0, ErrOut: io.Discard}
|
||||
for i := 0; i < 100; i++ {
|
||||
emitted.Add(1)
|
||||
if checkMaxEvents(opts, &emitted) {
|
||||
t.Fatalf("checkMaxEvents should never return true when MaxEvents=0; returned true at emit %d", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Limit(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
opts := Options{MaxEvents: 5, Timeout: 0}
|
||||
reason := exitReason(ctx, 5, opts)
|
||||
if reason != "limit" {
|
||||
t.Errorf("reason = %q, want \"limit\"", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Timeout(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
opts := Options{MaxEvents: 5, Timeout: 1 * time.Millisecond}
|
||||
reason := exitReason(ctx, 0, opts)
|
||||
if reason != "timeout" {
|
||||
t.Errorf("reason = %q, want \"timeout\"", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitReason_Signal(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
opts := Options{MaxEvents: 0, Timeout: 0}
|
||||
reason := exitReason(ctx, 0, opts)
|
||||
if reason != "signal" {
|
||||
t.Errorf("reason = %q, want \"signal\"", reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package consume drives the consume-side half of the events pipeline.
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
EventKey string
|
||||
Params map[string]string
|
||||
JQExpr string
|
||||
Quiet bool
|
||||
OutputDir string
|
||||
Runtime event.APIClient
|
||||
Out io.Writer // nil falls back to os.Stdout
|
||||
ErrOut io.Writer
|
||||
RemoteAPIClient APIClient // nil disables remote-connection preflight
|
||||
|
||||
MaxEvents int // 0 = unlimited
|
||||
Timeout time.Duration // 0 = no timeout
|
||||
IsTTY bool
|
||||
}
|
||||
|
||||
// Run ensures bus is up, performs hello handshake, runs PreConsume for first subscriber,
|
||||
// enters the consume loop, and runs cleanup on exit if we were the last subscriber.
|
||||
func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain string, opts Options) error {
|
||||
errOut := opts.ErrOut
|
||||
if errOut == nil {
|
||||
errOut = os.Stderr //nolint:forbidigo // library-caller fallback
|
||||
}
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate jq before any side effects (bus daemon, PreConsume server-side subscriptions).
|
||||
if opts.JQExpr != "" {
|
||||
if _, err := CompileJQ(opts.JQExpr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
|
||||
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
|
||||
// SubscriptionID we send to bus reflects canonical values.
|
||||
if keyDef.NormalizeParams != nil {
|
||||
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute subscription identity from normalized params + SubscriptionKey flags.
|
||||
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if !opts.Quiet {
|
||||
if profileName != "" {
|
||||
fmt.Fprintf(errOut, "[event] consuming as %s (%s)\n", profileName, appID)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] consuming as %s\n", appID)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := EnsureBus(ctx, tr, appID, profileName, domain, opts.RemoteAPIClient, errOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
|
||||
return rejErr
|
||||
}
|
||||
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
lastForKey := false
|
||||
var emitted atomic.Int64
|
||||
startTime := time.Now()
|
||||
|
||||
// On panic, run cleanup unconditionally — leaking server state is worse than
|
||||
// unsubscribing a still-live co-consumer (recoverable).
|
||||
defer func() {
|
||||
r := recover()
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
|
||||
opts.EventKey)
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
|
||||
}
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
|
||||
cleanupErr)
|
||||
} else if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opts.Quiet && r == nil {
|
||||
reason := exitReason(ctx, emitted.Load(), opts)
|
||||
fmt.Fprintf(errOut, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
emitted.Load(), truncateDuration(time.Since(startTime)), reason)
|
||||
}
|
||||
if r != nil {
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
// rejectionError converts a rejected hello_ack into a structured precondition
|
||||
// error; returns nil when the ack is absent or not a rejection.
|
||||
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
|
||||
if ack == nil || !ack.Rejected {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"cannot start consumer: %s", ack.RejectReason).
|
||||
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
return d.Truncate(time.Second)
|
||||
}
|
||||
|
||||
func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if _, ok := params[p.Name]; !ok && p.Default != "" {
|
||||
params[p.Name] = p.Default
|
||||
}
|
||||
}
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
known := make(map[string]bool, len(def.Params))
|
||||
validNames := make([]string, 0, len(def.Params))
|
||||
for _, p := range def.Params {
|
||||
known[p.Name] = true
|
||||
validNames = append(validNames, p.Name)
|
||||
}
|
||||
sort.Strings(validNames)
|
||||
for k := range params {
|
||||
if known[k] {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMaxEvents(opts Options, emitted *atomic.Int64) bool {
|
||||
if opts.MaxEvents <= 0 {
|
||||
return false
|
||||
}
|
||||
return emitted.Load() >= int64(opts.MaxEvents)
|
||||
}
|
||||
|
||||
func listeningText(opts Options) string {
|
||||
base := fmt.Sprintf("[event] listening for events (key=%s)", opts.EventKey)
|
||||
if opts.IsTTY {
|
||||
return base + ", ctrl+c to stop"
|
||||
}
|
||||
switch {
|
||||
case opts.MaxEvents > 0 && opts.Timeout > 0:
|
||||
return fmt.Sprintf("%s; will exit after %d event(s) or %s timeout", base, opts.MaxEvents, opts.Timeout)
|
||||
case opts.MaxEvents > 0:
|
||||
return fmt.Sprintf("%s; will exit after %d event(s)", base, opts.MaxEvents)
|
||||
case opts.Timeout > 0:
|
||||
return fmt.Sprintf("%s; will exit after %s timeout", base, opts.Timeout)
|
||||
default:
|
||||
return base + "; send SIGTERM or close stdin to stop"
|
||||
}
|
||||
}
|
||||
|
||||
// exitReason: count-first; --max-events races --timeout via inner-vs-outer ctx, do not reorder.
|
||||
func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
if opts.MaxEvents > 0 && emitted >= int64(opts.MaxEvents) {
|
||||
return "limit"
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
// writeReadyMarker emits the stable AI-facing "ready" contract line; do not add fields.
|
||||
func writeReadyMarker(w io.Writer, opts Options) {
|
||||
if opts.Quiet {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[event] ready event_key=%s\n", opts.EventKey)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeRT is a minimal event.APIClient mock.
|
||||
type fakeRT struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
|
||||
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
|
||||
// bus is contacted, yet the production error-wrapping is exercised — if Run()
|
||||
// ever stops wrapping, this test fails.
|
||||
const key = "test.evt_normalize_fail"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
|
||||
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
|
||||
return errors.New("simulated normalize failure")
|
||||
},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
err := Run(context.Background(), transport.New(), "app", "", "", Options{
|
||||
EventKey: key,
|
||||
Runtime: &fakeRT{},
|
||||
Quiet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail when NormalizeParams errors")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
|
||||
t.Errorf("error not wrapped with EventKey prefix: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated normalize failure") {
|
||||
t.Errorf("underlying error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
// Server-side: read Hello, decode, assert SubscriptionID, send ack
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
if hello, ok := msg.(*protocol.Hello); ok {
|
||||
done <- hello.SubscriptionID
|
||||
// send ack so client can return
|
||||
ack := protocol.NewHelloAck("v1", true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
} else {
|
||||
done <- "WRONG_TYPE"
|
||||
}
|
||||
}()
|
||||
|
||||
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("doHello error: %v", err)
|
||||
}
|
||||
if ack == nil {
|
||||
t.Fatal("got nil ack")
|
||||
}
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
|
||||
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
|
||||
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
|
||||
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
|
||||
//
|
||||
// Stability contract: same EventKey + same normalized param values -> same ID
|
||||
// across CLI versions; changing the encoding requires a wire-format bump.
|
||||
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
|
||||
type kv struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
var subParams []kv
|
||||
for _, p := range def.Params {
|
||||
if !p.SubscriptionKey {
|
||||
continue
|
||||
}
|
||||
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
|
||||
}
|
||||
if len(subParams) == 0 {
|
||||
return def.Key
|
||||
}
|
||||
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
|
||||
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
|
||||
sum := sha256.Sum256(raw)
|
||||
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestComputeSubscriptionID(t *testing.T) {
|
||||
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
|
||||
def := &event.KeyDefinition{Key: "test.evt"}
|
||||
marked := make(map[string]bool, len(subKeyNames))
|
||||
for _, n := range subKeyNames {
|
||||
marked[n] = true
|
||||
}
|
||||
for _, n := range []string{"alpha", "beta", "gamma"} {
|
||||
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
|
||||
def := makeDef()
|
||||
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
|
||||
if got != "test.evt" {
|
||||
t.Errorf("got %q, want %q", got, "test.evt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
|
||||
if id1 == id2 {
|
||||
t.Errorf("different values produced same ID: %q", id1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Stability(t *testing.T) {
|
||||
// Param order in the ParamDef list must not affect the result (sorted by name internally).
|
||||
def1 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
def2 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
|
||||
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Format(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "mail.user_mailbox.event.message_received_v1",
|
||||
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
}
|
||||
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
|
||||
prefix := "mail.user_mailbox.event.message_received_v1:"
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
t.Fatalf("missing prefix: %q", id)
|
||||
}
|
||||
suffix := strings.TrimPrefix(id, prefix)
|
||||
if len(suffix) != 16 {
|
||||
t.Errorf("fingerprint length = %d, want 16", len(suffix))
|
||||
}
|
||||
for _, c := range suffix {
|
||||
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
|
||||
if !isValid {
|
||||
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
|
||||
}
|
||||
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
|
||||
id := ComputeSubscriptionID(def, map[string]string{"value": val})
|
||||
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
|
||||
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
|
||||
if id1 != id2 {
|
||||
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
|
||||
}
|
||||
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
|
||||
if id1 == id3 {
|
||||
t.Errorf("empty and nonempty produced same ID: %q", id1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read deadline
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(helloAckTimeout)); err != nil {
|
||||
return nil, nil, fmt.Errorf("set hello_ack deadline: %w", err)
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("no hello_ack received: %w", err)
|
||||
}
|
||||
// best-effort clear; if the conn is already broken, the loop's first read will surface it
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("expected hello_ack, got %T", msg)
|
||||
}
|
||||
return ack, br, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doHello must apply a read deadline on HelloAck so a wedged bus doesn't hang the consumer.
|
||||
func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := server.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("doHello returned nil error when server never replied; must fail with deadline-driven error")
|
||||
}
|
||||
if elapsed > helloAckTimeout+2*time.Second {
|
||||
t.Errorf("doHello returned %v after %v; deadline should fire within ~%v", err, elapsed, helloAckTimeout)
|
||||
}
|
||||
case <-time.After(helloAckTimeout + 3*time.Second):
|
||||
t.Fatal("doHello hung past deadline + 3s slack: read deadline is missing or not being honoured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// applyJQ returns (nil, nil) when the expression filters out the event (e.g. select).
|
||||
func applyJQ(code *gojq.Code, data json.RawMessage) (json.RawMessage, error) {
|
||||
var input interface{}
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return nil, fmt.Errorf("jq: unmarshal input: %w", err)
|
||||
}
|
||||
|
||||
iter := code.Run(input)
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return nil, fmt.Errorf("jq: %w", err)
|
||||
}
|
||||
|
||||
result, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq: marshal result: %w", err)
|
||||
}
|
||||
return json.RawMessage(result), nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListeningText_TTY(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: true})
|
||||
want := "[event] listening for events (key=im.message.receive_v1), ctrl+c to stop"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_Default(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); send SIGTERM or close stdin to stop"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_MaxEvents(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s)"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_Timeout(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, Timeout: 30 * time.Second})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 30s timeout"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1, Timeout: 30 * time.Second})
|
||||
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s) or 30s timeout"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyMarker_EmittedAfterListening(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1"})
|
||||
|
||||
got := buf.String()
|
||||
want := "[event] ready event_key=im.message.receive_v1\n"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyMarker_SuppressedWhenQuiet(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1", Quiet: true})
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("Quiet=true must suppress ready marker; got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
sink, err := newSink(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Compile before worker goroutines start to avoid a data race on jqCode.
|
||||
var jqCode *gojq.Code
|
||||
if opts.JQExpr != "" {
|
||||
jqCode, err = CompileJQ(opts.JQExpr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
bufSize := keyDef.BufferSize
|
||||
if bufSize <= 0 {
|
||||
bufSize = event.DefaultBufferSize
|
||||
}
|
||||
socketCh := make(chan *protocol.Event, bufSize)
|
||||
|
||||
// stopReader lets shutdown preempt the reader so PreShutdownCheck can reuse conn.
|
||||
stopReader := make(chan struct{})
|
||||
readerDone := make(chan struct{})
|
||||
|
||||
// ReadBytes (not Scanner) so mid-frame read deadlines don't drop buffered bytes.
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
defer close(socketCh)
|
||||
var buf []byte
|
||||
var lastSeq uint64 // per-conn monotonic; gaps = bus drop-oldest backpressure
|
||||
for {
|
||||
select {
|
||||
case <-stopReader:
|
||||
return
|
||||
default:
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
chunk, err := br.ReadBytes('\n')
|
||||
if len(chunk) > 0 {
|
||||
// Cap accumulator: dribbling multi-MB lines past 200ms deadlines could grow buf unbounded.
|
||||
if len(buf)+len(chunk) > protocol.MaxFrameBytes {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut,
|
||||
"WARN: dropping oversized frame (>%d bytes) from bus\n", protocol.MaxFrameBytes)
|
||||
}
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
}
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
line := buf
|
||||
if n := len(line); n > 0 && line[n-1] == '\n' {
|
||||
line = line[:n-1]
|
||||
}
|
||||
buf = nil
|
||||
|
||||
msg, decErr := protocol.Decode(line)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
switch m := msg.(type) {
|
||||
case *protocol.Event:
|
||||
if lastSeq > 0 && m.Seq > 0 && m.Seq > lastSeq+1 {
|
||||
gap := m.Seq - lastSeq - 1
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut,
|
||||
"WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
|
||||
lastSeq, m.Seq, gap)
|
||||
}
|
||||
}
|
||||
// Only advance forward — concurrent publishers can deliver out-of-order.
|
||||
if m.Seq > lastSeq {
|
||||
lastSeq = m.Seq
|
||||
}
|
||||
select {
|
||||
case socketCh <- m:
|
||||
default:
|
||||
// drop-oldest back-pressure
|
||||
select {
|
||||
case <-socketCh:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case socketCh <- m:
|
||||
default:
|
||||
}
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: consume backpressure, dropped oldest event\n")
|
||||
}
|
||||
}
|
||||
case *protocol.SourceStatus:
|
||||
if !opts.Quiet {
|
||||
if m.Detail != "" {
|
||||
fmt.Fprintf(opts.ErrOut, "[source] %s: %s (%s)\n", m.Source, m.State, m.Detail)
|
||||
} else {
|
||||
fmt.Fprintf(opts.ErrOut, "[source] %s: %s\n", m.Source, m.State)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// forward-compatible: ignore unknown message types
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
workers := keyDef.Workers
|
||||
if workers <= 0 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for evt := range socketCh {
|
||||
wrote, err := processAndOutput(ctx, keyDef, evt, opts, sink, jqCode)
|
||||
if wrote {
|
||||
emitted.Add(1)
|
||||
// cancel inner ctx so shutdown goes through normal cleanup, not conn rip.
|
||||
if checkMaxEvents(opts, emitted) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if isTerminalSinkError(err) {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "consume: output pipe closed (%v), shutting down\n", err)
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: sink write failed, skipping event: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
allDone := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(allDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain reader so PreShutdownCheck has exclusive conn.
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
*lastForKey = true
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
|
||||
// Synchronous Match filter runs before any work (Process / sink write).
|
||||
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if result == nil {
|
||||
return false, nil
|
||||
}
|
||||
} else {
|
||||
result = evt.Payload
|
||||
}
|
||||
|
||||
if jqCode != nil {
|
||||
filtered, err := applyJQ(jqCode, result)
|
||||
if err != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(opts.ErrOut, "WARN: JQ error: %v\n", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if filtered == nil {
|
||||
return false, nil
|
||||
}
|
||||
result = filtered
|
||||
}
|
||||
|
||||
if err := sink.Write(result); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isTerminalSinkError reports if the output channel is permanently broken (EPIPE/ErrClosed).
|
||||
func isTerminalSinkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, syscall.EPIPE) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, fs.ErrClosed) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
_, err := CompileJQ("invalid{{{")
|
||||
if err == nil {
|
||||
t.Fatal("expected compile error for invalid jq expression")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
code, err := CompileJQ(".foo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code == nil {
|
||||
t.Fatal("expected non-nil code")
|
||||
}
|
||||
|
||||
input := json.RawMessage(`{"foo":"bar"}`)
|
||||
result, err := applyJQ(code, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(result) != `"bar"` {
|
||||
t.Errorf("expected \"bar\", got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQReusesCompiledCode(t *testing.T) {
|
||||
code, err := CompileJQ(".foo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := json.RawMessage(`{"foo":"bar"}`)
|
||||
for i := 0; i < 10000; i++ {
|
||||
result, err := applyJQ(code, data)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: %v", i, err)
|
||||
}
|
||||
if string(result) != `"bar"` {
|
||||
t.Fatalf("iteration %d: unexpected result %s", i, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQFilterReturnsNilOnNoOutput(t *testing.T) {
|
||||
code, err := CompileJQ(`select(.type == "match")`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := applyJQ(code, json.RawMessage(`{"type":"nomatch"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("should not error on filter-out: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for filtered-out event, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJQConcurrentSafe(t *testing.T) {
|
||||
code, err := CompileJQ(".value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const goroutines = 32
|
||||
const iterationsPerGoroutine = 1000
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, goroutines)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(gid int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterationsPerGoroutine; i++ {
|
||||
input := json.RawMessage(fmt.Sprintf(`{"value":"goroutine-%d-iter-%d"}`, gid, i))
|
||||
result, err := applyJQ(code, input)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("goroutine %d iter %d: %w", gid, i, err)
|
||||
return
|
||||
}
|
||||
expected := fmt.Sprintf(`"goroutine-%d-iter-%d"`, gid, i)
|
||||
if string(result) != expected {
|
||||
errs <- fmt.Errorf("goroutine %d iter %d: expected %s, got %s", gid, i, expected, string(result))
|
||||
return
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// Mirrors the inline gap-detection logic from consumeLoop's reader; keep in sync with loop.go.
|
||||
type seqGapDetector struct {
|
||||
lastSeq uint64
|
||||
errOut io.Writer
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func (d *seqGapDetector) observe(m *protocol.Event) {
|
||||
if d.lastSeq > 0 && m.Seq > 0 && m.Seq > d.lastSeq+1 {
|
||||
gap := m.Seq - d.lastSeq - 1
|
||||
if !d.quiet {
|
||||
fmt.Fprintf(d.errOut, "WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
|
||||
d.lastSeq, m.Seq, gap)
|
||||
}
|
||||
}
|
||||
// CRITICAL: only advance forward — concurrent Publishers may deliver Seq out-of-order.
|
||||
if m.Seq > d.lastSeq {
|
||||
d.lastSeq = m.Seq
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorNoWarningOnFirstEvent(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
if strings.Contains(buf.String(), "gap") {
|
||||
t.Errorf("unexpected gap warning on first event: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorNoWarningOnContiguous(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
for i := uint64(1); i <= 10; i++ {
|
||||
d.observe(&protocol.Event{Seq: i})
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected output on contiguous seqs: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorWarnsOnActualGap(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 1})
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "gap 1->5") {
|
||||
t.Errorf("expected 'gap 1->5' in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "missed 3 events") {
|
||||
t.Errorf("expected 'missed 3 events' in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorHandlesOutOfOrderWithoutFalsePositive(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 6})
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
d.observe(&protocol.Event{Seq: 7})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected warning for out-of-order (no actual gap): %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorQuietMode(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf, quiet: true}
|
||||
d.observe(&protocol.Event{Seq: 1})
|
||||
d.observe(&protocol.Event{Seq: 10})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("quiet mode should suppress warnings, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqGapDetectorZeroSeqIgnored(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
d := &seqGapDetector{errOut: &buf}
|
||||
d.observe(&protocol.Event{Seq: 5})
|
||||
d.observe(&protocol.Event{Seq: 0})
|
||||
d.observe(&protocol.Event{Seq: 6})
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("unexpected warning across legacy zero-seq event: %s", buf.String())
|
||||
}
|
||||
if d.lastSeq != 6 {
|
||||
t.Errorf("expected lastSeq=6 after legacy skip, got %d", d.lastSeq)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func echoKeyDef(key string) *event.KeyDefinition {
|
||||
return &event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
BufferSize: 32,
|
||||
Workers: 1,
|
||||
Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func busSide(t *testing.T, server net.Conn, events []*protocol.Event, ackLast bool) {
|
||||
t.Helper()
|
||||
for _, evt := range events {
|
||||
if err := protocol.Encode(server, evt); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
br := bufio.NewReader(server)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = server.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
msg, decErr := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := msg.(*protocol.PreShutdownCheck); ok {
|
||||
_ = protocol.Encode(server, protocol.NewPreShutdownAck(ackLast))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
MaxEvents: 2,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
t.Errorf("emitted = %d, want 2", got)
|
||||
}
|
||||
if !lastForKey {
|
||||
t.Error("lastForKey = false, want true (bus acked LastForKey=true)")
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`{"n":1}`, `{"n":2}`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("stdout missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: &stderr,
|
||||
Quiet: false,
|
||||
MaxEvents: 2,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
t.Errorf("emitted = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "WARN: event seq gap 1->5") {
|
||||
t.Errorf("stderr missing seq-gap warning; got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
events := []*protocol.Event{
|
||||
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)),
|
||||
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)),
|
||||
}
|
||||
go busSide(t, server, events, true)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: &stdout,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
JQExpr: "select(.keep) | .n",
|
||||
MaxEvents: 1,
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
t.Errorf("emitted = %d, want 1", got)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out != "1" {
|
||||
t.Errorf("stdout = %q, want %q", out, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
opts := Options{
|
||||
EventKey: "test.key",
|
||||
Out: io.Discard,
|
||||
ErrOut: io.Discard,
|
||||
Quiet: true,
|
||||
JQExpr: "not a valid jq expression (((",
|
||||
}
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
||||
type captureSink struct {
|
||||
written []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Write(data json.RawMessage) error {
|
||||
s.written = append(s.written, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
||||
calledProcess := false
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
return false
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
calledProcess = true
|
||||
return json.RawMessage(`{}`), nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("Match returned false but event was written")
|
||||
}
|
||||
if calledProcess {
|
||||
t.Error("Process was called even though Match returned false")
|
||||
}
|
||||
if len(sink.written) != 0 {
|
||||
t.Errorf("sink received %d events, want 0", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
||||
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil || !wrote {
|
||||
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
||||
}
|
||||
if len(sink.written) != 1 {
|
||||
t.Errorf("sink received %d events, want 1", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
||||
// Record the actual call sequence — a bare call-count check would still
|
||||
// pass if Process ran before Match.
|
||||
var order []string
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
order = append(order, "match")
|
||||
return true
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
order = append(order, "process")
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Error("expected wrote=true")
|
||||
}
|
||||
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
||||
t.Errorf("call order = %v, want [match process]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"EPIPE raw", syscall.EPIPE, true},
|
||||
{"EPIPE wrapped", fmt.Errorf("write: %w", syscall.EPIPE), true},
|
||||
{"ErrClosed", io.ErrClosedPipe, false},
|
||||
{"transient disk full", errors.New("no space left on device"), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isTerminalSinkError(tc.err); got != tc.want {
|
||||
t.Errorf("isTerminalSinkError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestRejectionError_Rejected(t *testing.T) {
|
||||
ack := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, Rejected: true, RejectReason: "another consumer (pid 9) is already running"}
|
||||
err := rejectionError(ack, "im.message.receive_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected ack")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Category != errs.CategoryValidation || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition; err=%q", prob, err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already running") {
|
||||
t.Errorf("error = %q, want reject reason", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectionError_NotRejected(t *testing.T) {
|
||||
if err := rejectionError(&protocol.HelloAck{Type: protocol.MsgTypeHelloAck}, "k"); err != nil {
|
||||
t.Errorf("expected nil for non-rejected ack, got %v", err)
|
||||
}
|
||||
if err := rejectionError(nil, "k"); err != nil {
|
||||
t.Errorf("expected nil for nil ack, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type APIClient = event.APIClient
|
||||
|
||||
// CheckRemoteConnections returns the count of active WebSocket connections for this app.
|
||||
func CheckRemoteConnections(ctx context.Context, client APIClient) (int, error) {
|
||||
raw, err := client.CallAPI(ctx, "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("connection check: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OnlineInstanceCnt int `json:"online_instance_cnt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return 0, fmt.Errorf("connection check: decode: %w (body=%s)", err, truncateForError(raw))
|
||||
}
|
||||
// Distinguish "verified zero" from "check failed" — non-zero code decodes Cnt=0.
|
||||
if result.Code != 0 {
|
||||
return 0, fmt.Errorf("connection check: api error code=%d msg=%q", result.Code, result.Msg)
|
||||
}
|
||||
return result.Data.OnlineInstanceCnt, nil
|
||||
}
|
||||
|
||||
// truncateForError bounds length and collapses control chars to defang log injection.
|
||||
func truncateForError(b []byte) string {
|
||||
const max = 256
|
||||
s := string(b)
|
||||
if len(s) > max {
|
||||
s = s[:max] + "…(truncated)"
|
||||
}
|
||||
out := make([]byte, 0, len(s))
|
||||
for _, r := range s {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
out = append(out, ' ')
|
||||
continue
|
||||
}
|
||||
out = append(out, string(r)...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
)
|
||||
|
||||
func TestCheckRemoteConnections_Success(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"msg":"success","data":{"online_instance_cnt":1}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("count = %d, want 1", count)
|
||||
}
|
||||
if c.GotMethod != "GET" || c.GotPath != "/open-apis/event/v1/connection" {
|
||||
t.Errorf("wrong request: %s %s", c.GotMethod, c.GotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_ZeroConnections(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"online_instance_cnt":0}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_APIErrorPropagated(t *testing.T) {
|
||||
want := errors.New("API GET /open-apis/event/v1/connection: [99991663] token is invalid")
|
||||
c := &testutil.StubAPIClient{Err: want}
|
||||
_, err := CheckRemoteConnections(context.Background(), c)
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v, want wrapping %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRemoteConnections_MalformedJSON(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `not json at all`}
|
||||
_, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
}
|
||||
|
||||
// Non-zero OAPI business code must surface as error so callers don't mistake it for "verified zero remote buses".
|
||||
func TestCheckRemoteConnections_NonZeroAPICodeSurfaced(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":99991663,"msg":"token is invalid","data":{}}`}
|
||||
count, err := CheckRemoteConnections(context.Background(), c)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero OAPI code, got nil")
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0 on error", count)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "99991663") {
|
||||
t.Errorf("error message missing code 99991663: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "token is invalid") {
|
||||
t.Errorf("error message missing msg field: %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(time.Now().Add(preShutdownAckTimeout)); err != nil {
|
||||
return true
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
resp, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ack, ok := resp.(*protocol.PreShutdownAck); ok {
|
||||
return ack.LastForKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// checkLastForKey must skip non-ack frames buffered before PreShutdownAck.
|
||||
func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
if _, err := server.Read(buf); err != nil && err != io.EOF {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
evt := protocol.NewEvent("im.msg", "evt_1", "", 1, json.RawMessage(`{}`))
|
||||
if err := protocol.Encode(server, evt); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(false)
|
||||
if err := protocol.Encode(server, ack); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
t.Fatalf("server goroutine error: %v", err)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = server.Read(buf)
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := server.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true (default on timeout)", got)
|
||||
}
|
||||
if elapsed > preShutdownAckTimeout+2*time.Second {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR"
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR"
|
||||
return
|
||||
}
|
||||
check, ok := msg.(*protocol.PreShutdownCheck)
|
||||
if !ok {
|
||||
done <- "WRONG_TYPE"
|
||||
return
|
||||
}
|
||||
done <- check.SubscriptionID
|
||||
// Reply with ack so client returns
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
}()
|
||||
|
||||
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
Write(data json.RawMessage) error
|
||||
}
|
||||
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
}
|
||||
out := opts.Out
|
||||
if out == nil {
|
||||
out = os.Stdout //nolint:forbidigo // library-caller fallback; cmd path always sets Options.Out
|
||||
}
|
||||
return &WriterSink{W: out, ErrOut: opts.ErrOut}, nil
|
||||
}
|
||||
|
||||
// WriterSink writes one JSON event per line; mu serialises concurrent worker writes.
|
||||
type WriterSink struct {
|
||||
W io.Writer
|
||||
Pretty bool
|
||||
ErrOut io.Writer
|
||||
prettyWarned atomic.Bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *WriterSink) Write(data json.RawMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.Pretty {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(data, &v); err == nil {
|
||||
pretty, _ := json.MarshalIndent(v, "", " ")
|
||||
_, err := fmt.Fprintln(s.W, string(pretty))
|
||||
return err
|
||||
}
|
||||
// non-JSON payload (e.g. --jq output): fall through to raw, log once
|
||||
if s.ErrOut != nil && s.prettyWarned.CompareAndSwap(false, true) {
|
||||
fmt.Fprintln(s.ErrOut, "WARN: --pretty: payload is not valid JSON; falling back to raw output (this and future malformed events)")
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(s.W, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
// DirSink writes one JSON file per event; nanos+pid+seq filename avoids cross-process collisions.
|
||||
type DirSink struct {
|
||||
Dir string
|
||||
pid int
|
||||
seq atomic.Int64
|
||||
}
|
||||
|
||||
func (s *DirSink) Write(data json.RawMessage) error {
|
||||
name := fmt.Sprintf("%d_%d_%d.json", time.Now().UnixNano(), s.pid, s.seq.Add(1))
|
||||
return vfs.WriteFile(filepath.Join(s.Dir, name), data, 0600) // 0600: payloads may carry PII
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriterSink_PrettyFallbackWarnsOnce(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true, ErrOut: &errOut}
|
||||
|
||||
if err := s.Write(json.RawMessage("not json {{{")); err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
if err := s.Write(json.RawMessage("still not json")); err != nil {
|
||||
t.Fatalf("second write: %v", err)
|
||||
}
|
||||
|
||||
warnings := strings.Count(errOut.String(), "WARN:")
|
||||
if warnings != 1 {
|
||||
t.Errorf("expected exactly 1 WARN line, got %d: %q", warnings, errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "pretty") {
|
||||
t.Errorf("warning should mention pretty: %q", errOut.String())
|
||||
}
|
||||
|
||||
if strings.Count(out.String(), "not json") != 2 {
|
||||
t.Errorf("expected 2 raw passthrough lines in W, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSink_PrettyHappyPath(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true, ErrOut: &errOut}
|
||||
|
||||
if err := s.Write(json.RawMessage(`{"k":"v"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Errorf("expected no warning on valid JSON, got: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "\n \"k\"") {
|
||||
t.Errorf("expected indented output, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSink_PrettyNoErrOut(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
s := &WriterSink{W: &out, Pretty: true}
|
||||
|
||||
if err := s.Write(json.RawMessage("not json")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "not json") {
|
||||
t.Errorf("expected raw passthrough, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSink_FilenameIncludesPID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := &DirSink{Dir: dir, pid: os.Getpid()}
|
||||
|
||||
if err := s.Write(json.RawMessage(`{"a":1}`)); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d: %v", len(entries), err)
|
||||
}
|
||||
name := entries[0].Name()
|
||||
wantPID := fmt.Sprintf("_%d_", os.Getpid())
|
||||
if !strings.Contains(name, wantPID) {
|
||||
t.Errorf("filename %q should contain PID segment %q", name, wantPID)
|
||||
}
|
||||
if filepath.Ext(name) != ".json" {
|
||||
t.Errorf("filename %q should have .json extension", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSink_FilenameFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := &DirSink{Dir: dir, pid: 12345}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.Write(json.RawMessage(`{}`)); err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 files, got %d", len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
trimmed := strings.TrimSuffix(name, ".json")
|
||||
parts := strings.Split(trimmed, "_")
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("filename %q should split into 3 underscore parts, got %d", name, len(parts))
|
||||
continue
|
||||
}
|
||||
if parts[1] != "12345" {
|
||||
t.Errorf("filename %q should have PID=12345 as middle segment, got %q", name, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
dialRetryInterval = 50 * time.Millisecond
|
||||
dialTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// EnsureBus dials the bus daemon for appID, forking a new one if none is running.
|
||||
// apiClient nil skips remote-connection probe. Local-bus hits skip remote check (see `event status`).
|
||||
func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain string, apiClient APIClient, errOut io.Writer) (net.Conn, error) {
|
||||
if errOut == nil {
|
||||
errOut = os.Stderr //nolint:forbidigo // library-caller fallback
|
||||
}
|
||||
addr := tr.Address(appID)
|
||||
|
||||
if conn, err := probeAndDialBus(tr, addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
fmt.Fprintf(errOut, "[event] local bus not found; checking remote connections...\n")
|
||||
|
||||
if apiClient != nil {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
count, checkErr := CheckRemoteConnections(ctx, apiClient)
|
||||
if checkErr != nil {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check failed: %v (proceeding to start local bus)\n", checkErr)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] no API client supplied; skipping remote connection check\n")
|
||||
}
|
||||
|
||||
// ErrHeld = another consume is forking; let dial retry catch its bus.
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(dialTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(dialRetryInterval):
|
||||
}
|
||||
if conn, err := tr.Dial(addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
logPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.log")
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
func probeAndDialBus(tr transport.IPC, addr string) (net.Conn, error) {
|
||||
probe, err := tr.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probe.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if err := protocol.Encode(probe, protocol.NewStatusQuery()); err != nil {
|
||||
probe.Close()
|
||||
return nil, fmt.Errorf("bus probe: encode: %w", err)
|
||||
}
|
||||
br := bufio.NewReader(probe)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
probe.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bus probe: read status: %w", err)
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bus probe: decode status: %w", err)
|
||||
}
|
||||
if _, ok := msg.(*protocol.StatusResponse); !ok {
|
||||
return nil, fmt.Errorf("bus probe: expected StatusResponse, got %T", msg)
|
||||
}
|
||||
|
||||
return tr.Dial(addr)
|
||||
}
|
||||
|
||||
// forkBus holds bus.fork.lock until the spawned daemon is dial-able, so concurrent callers can't race past the empty-socket gap and fork independent buses.
|
||||
func forkBus(tr transport.IPC, appID, profileName, domain string) (int, error) {
|
||||
lockPath := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.fork.lock")
|
||||
if err := vfs.MkdirAll(filepath.Dir(lockPath), 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
lock := lockfile.New(lockPath)
|
||||
if err := lock.TryLock(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer lock.Unlock()
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
args := buildForkArgs(profileName, domain)
|
||||
cmd := exec.Command(exe, args...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetachAttrs(cmd)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
addr := tr.Address(appID)
|
||||
deadline := time.Now().Add(dialTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if conn, dialErr := tr.Dial(addr); dialErr == nil {
|
||||
conn.Close()
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
time.Sleep(dialRetryInterval)
|
||||
}
|
||||
return cmd.Process.Pid, fmt.Errorf("bus did not become ready within %v", dialTimeout)
|
||||
}
|
||||
|
||||
func buildForkArgs(profileName, domain string) []string {
|
||||
args := []string{"event", "_bus", "--profile", profileName}
|
||||
if domain != "" {
|
||||
args = append(args, "--domain", domain)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// announceForkedBus: "auto-exits 30s" must track bus.idleTimeout.
|
||||
func announceForkedBus(w io.Writer, pid int) {
|
||||
fmt.Fprintf(w, "[event] started bus daemon pid=%d (auto-exits 30s after last consumer)\n", pid)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnnounceForkedBus(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
announceForkedBus(&buf, 12345)
|
||||
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
"[event] started bus daemon",
|
||||
"pid=12345",
|
||||
"auto-exits 30s after last consumer",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Fork argv ("event", "_bus", "--profile", appID) is a contract with internal/event/busdiscover orphan detector.
|
||||
func TestBuildForkArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profile string
|
||||
domain string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no domain (lark default)",
|
||||
profile: "cli_XXXXXXXXXXXXXXXX",
|
||||
domain: "",
|
||||
want: []string{"event", "_bus", "--profile", "cli_XXXXXXXXXXXXXXXX"},
|
||||
},
|
||||
{
|
||||
name: "custom domain appended",
|
||||
profile: "cli_x",
|
||||
domain: "https://open.feishu.cn",
|
||||
want: []string{
|
||||
"event", "_bus",
|
||||
"--profile", "cli_x",
|
||||
"--domain", "https://open.feishu.cn",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty profile still keeps flag skeleton",
|
||||
profile: "",
|
||||
domain: "",
|
||||
want: []string{"event", "_bus", "--profile", ""},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := buildForkArgs(tc.profile, tc.domain)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildForkArgs(%q, %q) = %v, want %v", tc.profile, tc.domain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForkArgs_SubcommandStable(t *testing.T) {
|
||||
got := buildForkArgs("cli_x", "")
|
||||
if len(got) < 2 || got[0] != "event" || got[1] != "_bus" {
|
||||
t.Fatalf("argv[0:2] = %v, want [event _bus]", got[:min(2, len(got))])
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantHints := []string{
|
||||
"remote event connection",
|
||||
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
|
||||
"stop the owner host/process",
|
||||
"wait for the platform connection timeout",
|
||||
}
|
||||
for _, want := range wantHints {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type probeMockTransport struct {
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
addr string
|
||||
|
||||
wg sync.WaitGroup
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
func newProbeMockTransport(t *testing.T) *probeMockTransport {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
return &probeMockTransport{listener: ln, addr: ln.Addr().String()}
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return m.listener, nil
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.Dial("tcp", m.addr)
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) Address(appID string) string { return m.addr }
|
||||
func (m *probeMockTransport) Cleanup(addr string) {}
|
||||
|
||||
func (m *probeMockTransport) trackConn(c net.Conn) {
|
||||
m.mu.Lock()
|
||||
m.conns = append(m.conns, c)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *probeMockTransport) stop() {
|
||||
m.mu.Lock()
|
||||
_ = m.listener.Close()
|
||||
conns := append([]net.Conn(nil), m.conns...)
|
||||
m.conns = nil
|
||||
m.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func runHealthyBus(t *testing.T, m *probeMockTransport) {
|
||||
t.Helper()
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
probeConn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(probeConn)
|
||||
br := bufio.NewReader(probeConn)
|
||||
line, _ := br.ReadBytes('\n')
|
||||
msg, _ := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if _, ok := msg.(*protocol.StatusQuery); ok {
|
||||
_ = protocol.Encode(probeConn, protocol.NewStatusResponse(12345, 10, 0, nil))
|
||||
}
|
||||
_ = probeConn.Close()
|
||||
|
||||
realConn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(realConn)
|
||||
}()
|
||||
}
|
||||
|
||||
func runDeadBus(t *testing.T, m *probeMockTransport) {
|
||||
t.Helper()
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
for {
|
||||
conn, err := m.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.trackConn(conn)
|
||||
m.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer m.wg.Done()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func TestProbeAndDialBusHealthy(t *testing.T) {
|
||||
m := newProbeMockTransport(t)
|
||||
t.Cleanup(m.stop)
|
||||
runHealthyBus(t, m)
|
||||
|
||||
conn, err := probeAndDialBus(m, m.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("expected non-nil conn")
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestProbeAndDialBusUnresponsive(t *testing.T) {
|
||||
m := newProbeMockTransport(t)
|
||||
t.Cleanup(m.stop)
|
||||
runDeadBus(t, m)
|
||||
|
||||
start := time.Now()
|
||||
conn, err := probeAndDialBus(m, m.addr)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("expected error on unresponsive bus")
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Errorf("expected ~2s timeout, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// applyDetachAttrs: Setsid prevents SIGHUP-on-shell-exit from killing the bus.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// applyDetachAttrs: Windows daemonize via DETACHED_PROCESS + new process group + HideWindow.
|
||||
func applyDetachAttrs(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDedupTTL = 5 * time.Minute
|
||||
defaultRingSize = 10000
|
||||
)
|
||||
|
||||
// DedupFilter: seen map is sole authority; ring only bounds map size via overflow eviction.
|
||||
type DedupFilter struct {
|
||||
seen map[string]time.Time
|
||||
ring []string
|
||||
pos int
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewDedupFilter() *DedupFilter {
|
||||
return NewDedupFilterWithSize(defaultRingSize, defaultDedupTTL)
|
||||
}
|
||||
|
||||
func NewDedupFilterWithSize(ringSize int, ttl time.Duration) *DedupFilter {
|
||||
return &DedupFilter{
|
||||
seen: make(map[string]time.Time),
|
||||
ring: make([]string, ringSize),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DedupFilter) IsDuplicate(eventID string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if ts, ok := d.seen[eventID]; ok {
|
||||
if now.Sub(ts) < d.ttl {
|
||||
return true
|
||||
}
|
||||
delete(d.seen, eventID)
|
||||
}
|
||||
|
||||
d.seen[eventID] = now
|
||||
|
||||
if old := d.ring[d.pos]; old != "" && old != eventID {
|
||||
delete(d.seen, old)
|
||||
}
|
||||
d.ring[d.pos] = eventID
|
||||
d.pos = (d.pos + 1) % len(d.ring)
|
||||
|
||||
if d.pos%1000 == 0 {
|
||||
d.cleanupExpired(now)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *DedupFilter) cleanupExpired(now time.Time) {
|
||||
for id, ts := range d.seen {
|
||||
if now.Sub(ts) >= d.ttl {
|
||||
delete(d.seen, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDedupFilter_FirstSeen(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
if d.IsDuplicate("evt-1") {
|
||||
t.Error("first occurrence should not be duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_SecondSeen(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
d.IsDuplicate("evt-1")
|
||||
if !d.IsDuplicate("evt-1") {
|
||||
t.Error("second occurrence within TTL should be duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_TTLExpiry(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(defaultRingSize, 10*time.Millisecond)
|
||||
d.IsDuplicate("evt-1")
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if d.IsDuplicate("evt-1") {
|
||||
t.Error("should not be duplicate after TTL expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_RingBuffer(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(5, 10*time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
d.IsDuplicate("evt-" + string(rune('a'+i)))
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if !d.IsDuplicate("evt-" + string(rune('a'+i))) {
|
||||
t.Errorf("evt-%c should still be duplicate", rune('a'+i))
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
for i := 5; i < 10; i++ {
|
||||
d.IsDuplicate("evt-" + string(rune('a'+i)))
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if d.IsDuplicate("evt-" + string(rune('a'+i))) {
|
||||
t.Errorf("evt-%c should not be duplicate after ring eviction + TTL expiry", rune('a'+i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_ConcurrentSafe(t *testing.T) {
|
||||
d := NewDedupFilter()
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
go func(id string) {
|
||||
d.IsDuplicate(id)
|
||||
done <- struct{}{}
|
||||
}("evt-" + string(rune(i)))
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Under N concurrent writers, exactly N IsDuplicate calls must observe first-seen.
|
||||
func TestDedupFilter_ConcurrentFirstSeenExactlyOnce(t *testing.T) {
|
||||
const n = 200
|
||||
d := NewDedupFilter()
|
||||
|
||||
ids := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
ids[i] = "evt-unique-" + string(rune('A'+i%26)) + string(rune('a'+(i/26)%26)) + string(rune('0'+i%10))
|
||||
}
|
||||
|
||||
results := make(chan bool, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(id string) {
|
||||
results <- d.IsDuplicate(id)
|
||||
}(ids[i])
|
||||
}
|
||||
|
||||
firstSeen := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if !<-results {
|
||||
firstSeen++
|
||||
}
|
||||
}
|
||||
if firstSeen != n {
|
||||
t.Errorf("first-seen count = %d, want %d", firstSeen, n)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if !d.IsDuplicate(id) {
|
||||
t.Errorf("ID %q not flagged as duplicate on second call", id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reinserting an ID that already occupies its own ring slot must not delete the fresh seen entry.
|
||||
func TestDedupFilter_SelfEvictionPreservesFreshEntry(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(2, time.Hour)
|
||||
d.ring[0] = "X"
|
||||
d.pos = 0
|
||||
|
||||
if d.IsDuplicate("X") {
|
||||
t.Fatal("first call should not be duplicate (seen empty)")
|
||||
}
|
||||
if !d.IsDuplicate("X") {
|
||||
t.Error("self-slot reinsert wiped seen[X] — duplicate signal lost")
|
||||
}
|
||||
}
|
||||
|
||||
// After cleanupExpired, an ID past its TTL must not be reported as duplicate even if still in the ring.
|
||||
func TestDedupFilter_TTLExpiryAfterCleanupRunRespected(t *testing.T) {
|
||||
d := NewDedupFilterWithSize(10, 10*time.Millisecond)
|
||||
if d.IsDuplicate("A") {
|
||||
t.Fatal("first IsDuplicate(A) should be false")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
for i := 0; i < 9; i++ {
|
||||
d.IsDuplicate("f" + string(rune('0'+i)))
|
||||
}
|
||||
if d.IsDuplicate("A") {
|
||||
t.Error("A is past TTL — must NOT be reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupFilter_ConcurrentRingEviction(t *testing.T) {
|
||||
const ringSize = 16
|
||||
const writers = 8
|
||||
const perWriter = 40
|
||||
d := NewDedupFilterWithSize(ringSize, 5*time.Millisecond)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(writers)
|
||||
for w := 0; w < writers; w++ {
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWriter; i++ {
|
||||
d.IsDuplicate("evt-w" + string(rune('0'+w)) + "-" + string(rune('0'+i%10)) + string(rune('a'+i/10)))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
for i := 0; i < ringSize*4; i++ {
|
||||
d.IsDuplicate("evt-fill-" + string(rune('0'+i%10)) + string(rune('a'+i/10)))
|
||||
}
|
||||
if d.IsDuplicate("evt-w0-0a") {
|
||||
t.Error("evicted ID should not be reported as duplicate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/source"
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
type integTestOut struct{ A string }
|
||||
|
||||
func integNativeSchema() event.SchemaDef {
|
||||
return event.SchemaDef{Native: &event.SchemaSpec{Type: reflect.TypeOf(integTestOut{})}}
|
||||
}
|
||||
|
||||
func waitForBusReady(t *testing.T, tr transport.IPC, addr string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if conn, err := tr.Dial(addr); err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("bus at %s did not come up within 2s", addr)
|
||||
}
|
||||
|
||||
func runBus(t *testing.T, b *bus.Bus, ctx context.Context) {
|
||||
t.Helper()
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- b.Run(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("bus.Run returned unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Log("bus did not exit within 2s of test cleanup (non-fatal)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type mockIntegSource struct {
|
||||
mu sync.Mutex
|
||||
emitFn func(*event.RawEvent)
|
||||
}
|
||||
|
||||
func (s *mockIntegSource) Name() string { return "mock-integration" }
|
||||
|
||||
func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ source.StatusNotifier) error {
|
||||
s.mu.Lock()
|
||||
s.emitFn = emit
|
||||
s.mu.Unlock()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockIntegSource) emit(e *event.RawEvent) {
|
||||
s.mu.Lock()
|
||||
fn := s.emitFn
|
||||
s.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_BusToConsume(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "test.event.v1",
|
||||
EventType: "test.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "t.sock")
|
||||
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-bus] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-app", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "test.event.v1",
|
||||
EventTypes: []string{"test.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
if err := protocol.Encode(conn, hello); err != nil {
|
||||
t.Fatalf("encode hello: %v", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("no hello_ack received")
|
||||
}
|
||||
msg, err := protocol.Decode(scanner.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode hello_ack: %v", err)
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("expected HelloAck, got %T", msg)
|
||||
}
|
||||
if !ack.FirstForKey {
|
||||
t.Error("expected first_for_key to be true")
|
||||
}
|
||||
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-integration-1",
|
||||
EventType: "test.event.v1",
|
||||
Payload: json.RawMessage(`{"test": true}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("no event received")
|
||||
}
|
||||
evtMsg, err := protocol.Decode(scanner.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
evt, ok := evtMsg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected Event, got %T", evtMsg)
|
||||
}
|
||||
if evt.EventType != "test.event.v1" {
|
||||
t.Errorf("expected event_type %q, got %q", "test.event.v1", evt.EventType)
|
||||
}
|
||||
var payloadMap map[string]interface{}
|
||||
if err := json.Unmarshal(evt.Payload, &payloadMap); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if v, ok := payloadMap["test"]; !ok || v != true {
|
||||
t.Errorf("unexpected payload: %s", string(evt.Payload))
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestIntegration_MultipleConsumers(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "multi.event.v1",
|
||||
EventType: "multi.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "m.sock")
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-multi] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-multi", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
connectConsumer := func(name string) (net.Conn, *bufio.Scanner) {
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", name, err)
|
||||
}
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "multi.event.v1",
|
||||
EventTypes: []string{"multi.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
protocol.Encode(conn, hello)
|
||||
sc := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatalf("%s: no hello_ack", name)
|
||||
}
|
||||
msg, _ := protocol.Decode(sc.Bytes())
|
||||
if _, ok := msg.(*protocol.HelloAck); !ok {
|
||||
t.Fatalf("%s: expected HelloAck, got %T", name, msg)
|
||||
}
|
||||
return conn, sc
|
||||
}
|
||||
|
||||
conn1, sc1 := connectConsumer("consumer-1")
|
||||
defer conn1.Close()
|
||||
conn2, sc2 := connectConsumer("consumer-2")
|
||||
defer conn2.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-multi-1",
|
||||
EventType: "multi.event.v1",
|
||||
Payload: json.RawMessage(`{"fan":"out"}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
conn net.Conn
|
||||
sc *bufio.Scanner
|
||||
}{
|
||||
{"consumer-1", conn1, sc1},
|
||||
{"consumer-2", conn2, sc2},
|
||||
} {
|
||||
tc.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !tc.sc.Scan() {
|
||||
t.Fatalf("%s: no event received", tc.name)
|
||||
}
|
||||
evtMsg, err := protocol.Decode(tc.sc.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decode event: %v", tc.name, err)
|
||||
}
|
||||
evt, ok := evtMsg.(*protocol.Event)
|
||||
if !ok {
|
||||
t.Fatalf("%s: expected Event, got %T", tc.name, evtMsg)
|
||||
}
|
||||
if evt.EventType != "multi.event.v1" {
|
||||
t.Errorf("%s: expected event_type %q, got %q", tc.name, "multi.event.v1", evt.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestIntegration_DedupFilter(t *testing.T) {
|
||||
event.ResetRegistryForTest()
|
||||
source.ResetForTest()
|
||||
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "dedup.event.v1",
|
||||
EventType: "dedup.event.v1",
|
||||
Schema: integNativeSchema(),
|
||||
})
|
||||
|
||||
mockSrc := &mockIntegSource{}
|
||||
source.Register(mockSrc)
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "d.sock")
|
||||
tr := transport.New()
|
||||
logger := log.New(os.Stderr, "[test-dedup] ", log.LstdFlags)
|
||||
|
||||
testTr := testutil.NewWrappedFake(tr, addr)
|
||||
b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runBus(t, b, ctx)
|
||||
waitForBusReady(t, testTr, addr)
|
||||
|
||||
conn, err := testTr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
hello := &protocol.Hello{
|
||||
Type: protocol.MsgTypeHello,
|
||||
PID: os.Getpid(),
|
||||
EventKey: "dedup.event.v1",
|
||||
EventTypes: []string{"dedup.event.v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
protocol.Encode(conn, hello)
|
||||
sc := bufio.NewScanner(conn)
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatal("no hello_ack")
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
mockSrc.emit(&event.RawEvent{
|
||||
EventID: "evt-dedup-same",
|
||||
EventType: "dedup.event.v1",
|
||||
Payload: json.RawMessage(`{"dup": true}`),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if !sc.Scan() {
|
||||
t.Fatal("expected at least one event")
|
||||
}
|
||||
evtMsg, _ := protocol.Decode(sc.Bytes())
|
||||
if _, ok := evtMsg.(*protocol.Event); !ok {
|
||||
t.Fatalf("expected Event, got %T", evtMsg)
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
if sc.Scan() {
|
||||
t.Error("received duplicate event; dedup filter should have blocked it")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package protocol defines the newline-delimited JSON wire format used over IPC.
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxFrameBytes = 1 << 20 // reject larger frames to bound reader buffer growth
|
||||
|
||||
// ErrFrameTooLarge is returned by ReadFrame when a single frame exceeds MaxFrameBytes.
|
||||
var ErrFrameTooLarge = errors.New("protocol: frame exceeds MaxFrameBytes")
|
||||
|
||||
const WriteTimeout = 5 * time.Second // bound writes against wedged peer kernel buffer
|
||||
|
||||
type typeEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func Encode(w io.Writer, msg interface{}) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("protocol encode: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func EncodeWithDeadline(conn net.Conn, msg interface{}, timeout time.Duration) error {
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
return Encode(conn, msg)
|
||||
}
|
||||
|
||||
// ReadFrame reads one newline-delimited message; caps at MaxFrameBytes to defang slowloris.
|
||||
func ReadFrame(br *bufio.Reader) ([]byte, error) {
|
||||
var buf []byte
|
||||
for {
|
||||
chunk, err := br.ReadSlice('\n')
|
||||
switch err {
|
||||
case nil:
|
||||
if len(buf) == 0 {
|
||||
return chunk, nil
|
||||
}
|
||||
if len(buf)+len(chunk) > MaxFrameBytes {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
return append(buf, chunk...), nil
|
||||
case bufio.ErrBufferFull:
|
||||
if len(buf)+len(chunk) > MaxFrameBytes {
|
||||
return nil, ErrFrameTooLarge
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Decode(line []byte) (interface{}, error) {
|
||||
var env typeEnvelope
|
||||
if err := json.Unmarshal(line, &env); err != nil {
|
||||
return nil, fmt.Errorf("protocol decode type: %w", err)
|
||||
}
|
||||
|
||||
var msg interface{}
|
||||
switch env.Type {
|
||||
case MsgTypeHello:
|
||||
msg = &Hello{}
|
||||
case MsgTypeHelloAck:
|
||||
msg = &HelloAck{}
|
||||
case MsgTypeEvent:
|
||||
msg = &Event{}
|
||||
case MsgTypeBye:
|
||||
msg = &Bye{}
|
||||
case MsgTypePreShutdownCheck:
|
||||
msg = &PreShutdownCheck{}
|
||||
case MsgTypePreShutdownAck:
|
||||
msg = &PreShutdownAck{}
|
||||
case MsgTypeStatusQuery:
|
||||
msg = &StatusQuery{}
|
||||
case MsgTypeStatusResponse:
|
||||
msg = &StatusResponse{}
|
||||
case MsgTypeShutdown:
|
||||
msg = &Shutdown{}
|
||||
case MsgTypeSourceStatus:
|
||||
msg = &SourceStatus{}
|
||||
default:
|
||||
return nil, fmt.Errorf("protocol: unknown message type %q", env.Type)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(line, msg); err != nil {
|
||||
return nil, fmt.Errorf("protocol decode %s: %w", env.Type, err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeHello(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := Decode(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.PID != 12345 || hello.EventKey != "mail.user_mailbox.event.message_received_v1" {
|
||||
t.Errorf("unexpected hello: %+v", hello)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEvent(t *testing.T) {
|
||||
payload := json.RawMessage(`{"foo":"bar"}`)
|
||||
msg := &Event{
|
||||
Type: MsgTypeEvent,
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := Decode(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
evt, ok := decoded.(*Event)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Event, got %T", decoded)
|
||||
}
|
||||
if evt.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("got event_type %q", evt.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeAddsNewline(t *testing.T) {
|
||||
msg := &Bye{Type: MsgTypeBye}
|
||||
var buf bytes.Buffer
|
||||
Encode(&buf, msg)
|
||||
if buf.Bytes()[buf.Len()-1] != '\n' {
|
||||
t.Error("encoded message should end with newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUnknownType(t *testing.T) {
|
||||
_, err := Decode([]byte(`{"type":"unknown_xyz"}`))
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := buf.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
|
||||
t.Errorf("subscription_id not serialized: %s", string(line))
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 1,
|
||||
EventKey: "k",
|
||||
EventTypes: []string{"k"},
|
||||
Version: "v1",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
|
||||
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
|
||||
}
|
||||
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
hello := decoded.(*Hello)
|
||||
if hello.SubscriptionID != "" {
|
||||
t.Errorf("got %q, want empty", hello.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
|
||||
msg := &PreShutdownCheck{
|
||||
Type: MsgTypePreShutdownCheck,
|
||||
EventKey: "mail.x",
|
||||
SubscriptionID: "mail.x:abc",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decoded.(*PreShutdownCheck)
|
||||
if got.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
|
||||
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
|
||||
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
|
||||
})
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
|
||||
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
MsgTypeHello = "hello"
|
||||
MsgTypeHelloAck = "hello_ack"
|
||||
MsgTypeEvent = "event"
|
||||
MsgTypeBye = "bye"
|
||||
MsgTypePreShutdownCheck = "pre_shutdown_check"
|
||||
MsgTypePreShutdownAck = "pre_shutdown_ack"
|
||||
MsgTypeStatusQuery = "status_query"
|
||||
MsgTypeStatusResponse = "status_response"
|
||||
MsgTypeShutdown = "shutdown"
|
||||
MsgTypeSourceStatus = "source_status"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceStateConnecting = "connecting"
|
||||
SourceStateConnected = "connected"
|
||||
SourceStateDisconnected = "disconnected"
|
||||
SourceStateReconnecting = "reconnecting"
|
||||
)
|
||||
|
||||
// SourceStatus is best-effort: hub drops it when consumer's send channel is full.
|
||||
type SourceStatus struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type Hello struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
EventType string `json:"event_type"`
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
SourceTime string `json:"source_time,omitempty"` // ms-precision unix timestamp, stringified
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type Bye struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
|
||||
type PreShutdownCheck struct {
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
|
||||
}
|
||||
|
||||
type PreShutdownAck struct {
|
||||
Type string `json:"type"`
|
||||
LastForKey bool `json:"last_for_key"`
|
||||
}
|
||||
|
||||
type StatusQuery struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ConsumerInfo struct {
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
UptimeSec int `json:"uptime_sec"`
|
||||
ActiveConns int `json:"active_conns"`
|
||||
Consumers []ConsumerInfo `json:"consumers"`
|
||||
}
|
||||
|
||||
type Shutdown struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
|
||||
return &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
SubscriptionID: subscriptionID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
|
||||
return &HelloAck{
|
||||
Type: MsgTypeHelloAck,
|
||||
BusVersion: busVersion,
|
||||
FirstForKey: firstForKey,
|
||||
}
|
||||
}
|
||||
|
||||
// NewHelloAckRejected builds a hello_ack that tells the consumer the bus refused
|
||||
// registration (e.g. a SingleConsumer EventKey already has a running consumer).
|
||||
func NewHelloAckRejected(busVersion, reason string) *HelloAck {
|
||||
return &HelloAck{
|
||||
Type: MsgTypeHelloAck,
|
||||
BusVersion: busVersion,
|
||||
Rejected: true,
|
||||
RejectReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
|
||||
return &Event{
|
||||
Type: MsgTypeEvent,
|
||||
EventType: eventType,
|
||||
EventID: eventID,
|
||||
SourceTime: sourceTime,
|
||||
Seq: seq,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
|
||||
}
|
||||
|
||||
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {
|
||||
return &PreShutdownAck{Type: MsgTypePreShutdownAck, LastForKey: lastForKey}
|
||||
}
|
||||
|
||||
func NewStatusQuery() *StatusQuery {
|
||||
return &StatusQuery{Type: MsgTypeStatusQuery}
|
||||
}
|
||||
|
||||
func NewStatusResponse(pid int, uptimeSec int, activeConns int, consumers []ConsumerInfo) *StatusResponse {
|
||||
return &StatusResponse{
|
||||
Type: MsgTypeStatusResponse,
|
||||
PID: pid,
|
||||
UptimeSec: uptimeSec,
|
||||
ActiveConns: activeConns,
|
||||
Consumers: consumers,
|
||||
}
|
||||
}
|
||||
|
||||
func NewShutdown() *Shutdown { return &Shutdown{Type: MsgTypeShutdown} }
|
||||
|
||||
func NewSourceStatus(source, state, detail string) *SourceStatus {
|
||||
return &SourceStatus{
|
||||
Type: MsgTypeSourceStatus,
|
||||
Source: source,
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
|
||||
func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
|
||||
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
|
||||
}
|
||||
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
|
||||
t.Errorf("NewHelloAck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
|
||||
t.Errorf("NewEvent mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
|
||||
t.Errorf("NewPreShutdownAck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewStatusQuery(); got.Type != MsgTypeStatusQuery {
|
||||
t.Errorf("NewStatusQuery.Type = %q", got.Type)
|
||||
}
|
||||
if got := NewStatusResponse(42, 10, 2, []ConsumerInfo{{PID: 1}, {PID: 2}}); got.Type != MsgTypeStatusResponse || got.PID != 42 || len(got.Consumers) != 2 {
|
||||
t.Errorf("NewStatusResponse mismatch: %+v", got)
|
||||
}
|
||||
if got := NewShutdown(); got.Type != MsgTypeShutdown {
|
||||
t.Errorf("NewShutdown.Type = %q", got.Type)
|
||||
}
|
||||
if got := NewSourceStatus("feishu-ws", SourceStateConnected, "ok"); got.Type != MsgTypeSourceStatus || got.Detail != "ok" {
|
||||
t.Errorf("NewSourceStatus mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
|
||||
roundtrip := func(t *testing.T, msg interface{}, want interface{}) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, msg); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
line := bytes.TrimRight(buf.Bytes(), "\n")
|
||||
got, err := Decode(line)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if gotT, wantT := fmt.Sprintf("%T", got), fmt.Sprintf("%T", want); gotT != wantT {
|
||||
t.Errorf("decoded type = %s, want %s", gotT, wantT)
|
||||
}
|
||||
}
|
||||
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
|
||||
roundtrip(t, NewStatusQuery(), &StatusQuery{})
|
||||
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})
|
||||
roundtrip(t, NewShutdown(), &Shutdown{})
|
||||
roundtrip(t, NewSourceStatus("feishu", SourceStateReconnecting, "attempt 2"), &SourceStatus{})
|
||||
roundtrip(t, &Bye{Type: MsgTypeBye}, &Bye{})
|
||||
}
|
||||
|
||||
// EncodeWithDeadline must apply a write deadline so a wedged peer can't stall the writer forever.
|
||||
func TestEncodeWithDeadline_AppliesDeadline(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
start := time.Now()
|
||||
err := EncodeWithDeadline(client, NewShutdown(), 100*time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("expected deadline error, got nil")
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("EncodeWithDeadline didn't honour deadline: took %v (want ~100ms)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrame_RejectsOversized(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("a"), MaxFrameBytes+1)
|
||||
big = append(big, '\n')
|
||||
br := bufio.NewReader(bytes.NewReader(big))
|
||||
_, err := ReadFrame(br)
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame on oversized input: err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrame_PropagatesEOF(t *testing.T) {
|
||||
br := bufio.NewReader(bytes.NewReader(nil))
|
||||
_, err := ReadFrame(br)
|
||||
if err != io.EOF {
|
||||
t.Errorf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloAckRejected_RoundTrip(t *testing.T) {
|
||||
ack := NewHelloAckRejected("v1", "another consumer (pid 42) is already running for this subscription")
|
||||
if !ack.Rejected || ack.RejectReason == "" {
|
||||
t.Fatalf("NewHelloAckRejected fields: %+v", ack)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, ack); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
msg, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got, ok := msg.(*HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("decoded type = %T, want *HelloAck", msg)
|
||||
}
|
||||
if !got.Rejected || got.RejectReason != ack.RejectReason {
|
||||
t.Errorf("roundtrip = %+v, want Rejected with reason", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
keys = map[string]*KeyDefinition{}
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// RegisterKey panics on duplicate Key, empty EventType, or schema/process contract violations.
|
||||
func RegisterKey(def KeyDefinition) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if _, exists := keys[def.Key]; exists {
|
||||
panic(fmt.Sprintf("duplicate EventKey: %s", def.Key))
|
||||
}
|
||||
if def.EventType == "" {
|
||||
panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key))
|
||||
}
|
||||
|
||||
if def.SubscriptionType == "" {
|
||||
def.SubscriptionType = SubTypeEvent
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback {
|
||||
panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q",
|
||||
def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType))
|
||||
}
|
||||
|
||||
validateSchema(def)
|
||||
validateParams(def)
|
||||
validateAuth(def)
|
||||
|
||||
if def.BufferSize > MaxBufferSize {
|
||||
def.BufferSize = MaxBufferSize
|
||||
}
|
||||
if def.BufferSize <= 0 {
|
||||
def.BufferSize = DefaultBufferSize
|
||||
}
|
||||
if def.Workers <= 0 {
|
||||
def.Workers = 1
|
||||
}
|
||||
keys[def.Key] = &def
|
||||
}
|
||||
|
||||
// validateSchema: exactly one of Native/Custom; Native incompatible with Process.
|
||||
func validateSchema(def KeyDefinition) {
|
||||
nativeSet := def.Schema.Native != nil
|
||||
customSet := def.Schema.Custom != nil
|
||||
if nativeSet && customSet {
|
||||
panic(fmt.Sprintf("EventKey %s: Schema.Native and Schema.Custom are mutually exclusive", def.Key))
|
||||
}
|
||||
if !nativeSet && !customSet {
|
||||
panic(fmt.Sprintf("EventKey %s: Schema requires either Native or Custom", def.Key))
|
||||
}
|
||||
if nativeSet && def.Process != nil {
|
||||
panic(fmt.Sprintf("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key))
|
||||
}
|
||||
if spec := def.Schema.Native; spec != nil {
|
||||
validateSpec(def.Key, "Schema.Native", spec)
|
||||
}
|
||||
if spec := def.Schema.Custom; spec != nil {
|
||||
validateSpec(def.Key, "Schema.Custom", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func validateSpec(key, field string, s *SchemaSpec) {
|
||||
typeSet := s.Type != nil
|
||||
rawSet := len(s.Raw) > 0
|
||||
if typeSet == rawSet {
|
||||
panic(fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field))
|
||||
}
|
||||
}
|
||||
|
||||
func validateParams(def KeyDefinition) {
|
||||
for _, p := range def.Params {
|
||||
switch p.Type {
|
||||
case "", ParamString, ParamBool, ParamInt:
|
||||
case ParamEnum, ParamMulti:
|
||||
if len(p.Values) == 0 {
|
||||
panic(fmt.Sprintf("EventKey %s: param %q type %q requires Values", def.Key, p.Name, p.Type))
|
||||
}
|
||||
for _, v := range p.Values {
|
||||
if v.Desc == "" {
|
||||
panic(fmt.Sprintf("EventKey %s: param %q value %q requires non-empty Desc", def.Key, p.Name, v.Value))
|
||||
}
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("EventKey %s: param %q has unknown type %q", def.Key, p.Name, p.Type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateAuth(def KeyDefinition) {
|
||||
for _, t := range def.AuthTypes {
|
||||
if t != "user" && t != "bot" {
|
||||
panic(fmt.Sprintf("EventKey %s: AuthTypes elements must be \"user\" or \"bot\"; got %q", def.Key, t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Lookup(key string) (*KeyDefinition, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
def, ok := keys[key]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
// ListAll returns all KeyDefinitions sorted by Key.
|
||||
func ListAll() []*KeyDefinition {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make([]*KeyDefinition, 0, len(keys))
|
||||
for _, def := range keys {
|
||||
result = append(result, def)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Key < result[j].Key
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func resetRegistry() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
keys = map[string]*KeyDefinition{}
|
||||
}
|
||||
|
||||
func ResetRegistryForTest() { resetRegistry() }
|
||||
|
||||
// UnregisterKeyForTest removes one key — use this (not Reset) in tests with synthetic keys
|
||||
// alongside production keys to keep -count=N reruns idempotent.
|
||||
func UnregisterKeyForTest(key string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
delete(keys, key)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustPanic(t *testing.T, substring string) {
|
||||
t.Helper()
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("expected panic, got none")
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if msg == "" {
|
||||
if err, ok := r.(error); ok {
|
||||
msg = err.Error()
|
||||
} else {
|
||||
msg = fmt.Sprintf("%v", r)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, substring) {
|
||||
t.Errorf("panic %q does not contain %q", msg, substring)
|
||||
}
|
||||
}
|
||||
|
||||
type emptyOut struct {
|
||||
A string `json:"a"`
|
||||
}
|
||||
|
||||
func nativeSchema() SchemaDef {
|
||||
return SchemaDef{Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}}
|
||||
}
|
||||
|
||||
func customSchema() SchemaDef {
|
||||
return SchemaDef{Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}}
|
||||
}
|
||||
|
||||
func customProcess() func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_NativeOnly(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.native",
|
||||
EventType: "t.native",
|
||||
Schema: nativeSchema(),
|
||||
})
|
||||
def, ok := Lookup("t.native")
|
||||
if !ok {
|
||||
t.Fatal("Lookup failed")
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Native not stored")
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Error("Process should be nil for Native")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_CustomWithProcess(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.custom",
|
||||
EventType: "t.custom",
|
||||
Schema: customSchema(),
|
||||
Process: customProcess(),
|
||||
})
|
||||
def, ok := Lookup("t.custom")
|
||||
if !ok {
|
||||
t.Fatal("Lookup failed")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Custom not stored")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_DuplicatePanics(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()})
|
||||
defer mustPanic(t, "duplicate EventKey")
|
||||
RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()})
|
||||
}
|
||||
|
||||
func TestRegisterKey_EmptyEventTypePanics(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "EventType must not be empty")
|
||||
RegisterKey(KeyDefinition{Key: "t.no_type", Schema: nativeSchema()})
|
||||
}
|
||||
|
||||
func TestRegisterKey_PanicsWhenBothSchemasSet(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "mutually exclusive")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.both",
|
||||
EventType: "t.both",
|
||||
Schema: SchemaDef{
|
||||
Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})},
|
||||
Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_PanicsWhenNoSchemaSet(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "Schema requires either Native or Custom")
|
||||
RegisterKey(KeyDefinition{Key: "t.empty", EventType: "t.empty"})
|
||||
}
|
||||
|
||||
func TestRegisterKey_PanicsWhenNativeWithProcess(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "Schema.Native forbids Process")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.badcombo",
|
||||
EventType: "t.badcombo",
|
||||
Schema: nativeSchema(),
|
||||
Process: customProcess(),
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_PanicsWhenSpecHasBothTypeAndRaw(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "requires exactly one of Type or Raw")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.bothsrc",
|
||||
EventType: "t.bothsrc",
|
||||
Schema: SchemaDef{
|
||||
Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{}), Raw: json.RawMessage(`{}`)},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_PanicsWhenSpecHasNeitherTypeNorRaw(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "requires exactly one of Type or Raw")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.nosrc",
|
||||
EventType: "t.nosrc",
|
||||
Schema: SchemaDef{
|
||||
Custom: &SchemaSpec{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_ParamMultiRequiresValues(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "requires Values")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.paramnovalues",
|
||||
EventType: "t.paramnovalues",
|
||||
Schema: nativeSchema(),
|
||||
Params: []ParamDef{{Name: "fields", Type: ParamMulti}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_ParamEnumRequiresValues(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "requires Values")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.enumnovalues",
|
||||
EventType: "t.enumnovalues",
|
||||
Schema: nativeSchema(),
|
||||
Params: []ParamDef{{Name: "mode", Type: ParamEnum}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_ParamValueRequiresDesc(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "requires non-empty Desc")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.paramdesc",
|
||||
EventType: "t.paramdesc",
|
||||
Schema: nativeSchema(),
|
||||
Params: []ParamDef{{
|
||||
Name: "f",
|
||||
Type: ParamEnum,
|
||||
Values: []ParamValue{{Value: "x"}},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_UnknownParamType(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "unknown type")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.badtype",
|
||||
EventType: "t.badtype",
|
||||
Schema: nativeSchema(),
|
||||
Params: []ParamDef{{Name: "x", Type: ParamType("wtf")}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_InvalidAuthTypesPanics(t *testing.T) {
|
||||
resetRegistry()
|
||||
defer mustPanic(t, "AuthTypes elements must be")
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "t.badauth",
|
||||
EventType: "t.badauth",
|
||||
Schema: nativeSchema(),
|
||||
AuthTypes: []string{"invalid"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterKey_ValidAuthTypes(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{Key: "u.e", EventType: "u.e", Schema: nativeSchema(), AuthTypes: []string{"user"}})
|
||||
RegisterKey(KeyDefinition{Key: "b.e", EventType: "b.e", Schema: nativeSchema(), AuthTypes: []string{"bot"}})
|
||||
RegisterKey(KeyDefinition{Key: "ub.e", EventType: "ub.e", Schema: nativeSchema(), AuthTypes: []string{"bot", "user"}})
|
||||
RegisterKey(KeyDefinition{Key: "na.e", EventType: "na.e", Schema: nativeSchema()})
|
||||
}
|
||||
|
||||
func TestListAll_SortedByKey(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{Key: "z.event", EventType: "z", Schema: nativeSchema()})
|
||||
RegisterKey(KeyDefinition{Key: "a.event", EventType: "a", Schema: nativeSchema()})
|
||||
RegisterKey(KeyDefinition{Key: "m.event", EventType: "m", Schema: nativeSchema()})
|
||||
all := ListAll()
|
||||
if len(all) != 3 || all[0].Key != "a.event" || all[1].Key != "m.event" || all[2].Key != "z.event" {
|
||||
t.Errorf("keys not sorted: %v", []string{all[0].Key, all[1].Key, all[2].Key})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferSize_Clamped(t *testing.T) {
|
||||
resetRegistry()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "big", EventType: "big", Schema: nativeSchema(),
|
||||
BufferSize: 5000,
|
||||
})
|
||||
def, _ := Lookup("big")
|
||||
if def.BufferSize != MaxBufferSize {
|
||||
t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) {
|
||||
const key = "test.subtype.default"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, ok := Lookup(key)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", key)
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent)
|
||||
}
|
||||
if def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = true, want false (default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) {
|
||||
const key = "test.subtype.callback"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SubscriptionType: SubTypeCallback,
|
||||
SingleConsumer: true,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, _ := Lookup(key)
|
||||
if def.SubscriptionType != SubTypeCallback {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback)
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("expected panic for invalid SubscriptionType")
|
||||
}
|
||||
}()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "test.subtype.bogus",
|
||||
EventType: "test.subtype.bogus",
|
||||
SubscriptionType: "bogus",
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// v2Envelope is the Feishu V2 envelope JSON Schema; bump when upstream changes header shape.
|
||||
var v2Envelope = map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "飞书事件",
|
||||
"properties": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"2.0"},
|
||||
"description": "飞书事件协议版本",
|
||||
},
|
||||
"header": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "事件头,所有事件结构一致",
|
||||
"properties": map[string]interface{}{
|
||||
"event_id": map[string]string{"type": "string", "description": "事件唯一 ID"},
|
||||
"event_type": map[string]string{"type": "string", "description": "事件类型,用于路由"},
|
||||
"create_time": map[string]string{"type": "string", "description": "事件创建时间,毫秒时间戳字符串"},
|
||||
"token": map[string]string{"type": "string", "description": "回调校验 token"},
|
||||
"tenant_key": map[string]string{"type": "string", "description": "租户唯一标识"},
|
||||
"app_id": map[string]string{"type": "string", "description": "接收事件的应用 ID"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// WrapV2Envelope splices body into the `event` property; passes body through unchanged on parse fail.
|
||||
func WrapV2Envelope(body json.RawMessage) json.RawMessage {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return body
|
||||
}
|
||||
envelope := map[string]interface{}{}
|
||||
for k, v := range v2Envelope {
|
||||
envelope[k] = v
|
||||
}
|
||||
// Rebuild properties so we don't mutate the package-level template.
|
||||
props := map[string]interface{}{}
|
||||
for k, v := range v2Envelope["properties"].(map[string]interface{}) {
|
||||
props[k] = v
|
||||
}
|
||||
props["event"] = parsed
|
||||
envelope["properties"] = props
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package schemas derives JSON Schema fragments from Go types via reflection.
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FromType derives a JSON Schema for t (cached per reflect.Type).
|
||||
func FromType(t reflect.Type) json.RawMessage {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if cached, ok := cacheLoad(t); ok {
|
||||
return cached
|
||||
}
|
||||
// per-call cache so shared subtypes are walked once; not coupled to the marshaled-JSON cache.
|
||||
localCache := map[reflect.Type]*schemaNode{}
|
||||
node := reflectSchema(t, map[reflect.Type]bool{}, localCache)
|
||||
out, err := json.Marshal(node)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
raw := json.RawMessage(out)
|
||||
cacheStore(t, raw)
|
||||
return raw
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.RWMutex
|
||||
cache = map[reflect.Type]json.RawMessage{}
|
||||
)
|
||||
|
||||
func cacheLoad(t reflect.Type) (json.RawMessage, bool) {
|
||||
cacheMu.RLock()
|
||||
defer cacheMu.RUnlock()
|
||||
v, ok := cache[t]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func cacheStore(t reflect.Type, v json.RawMessage) {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
cache[t] = v
|
||||
}
|
||||
|
||||
type schemaNode struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Properties map[string]*schemaNode `json:"properties,omitempty"`
|
||||
Items *schemaNode `json:"items,omitempty"`
|
||||
AdditionalProperties *schemaNode `json:"additionalProperties,omitempty"`
|
||||
}
|
||||
|
||||
// reflectSchema walks t; visiting breaks cycles, cache memoises shared subtypes.
|
||||
func reflectSchema(t reflect.Type, visiting map[reflect.Type]bool, cache map[reflect.Type]*schemaNode) *schemaNode {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if visiting[t] {
|
||||
return &schemaNode{Type: "object"}
|
||||
}
|
||||
if cached, ok := cache[t]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
var node *schemaNode
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
node = &schemaNode{Type: "string"}
|
||||
case reflect.Bool:
|
||||
node = &schemaNode{Type: "boolean"}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
node = &schemaNode{Type: "integer"}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
node = &schemaNode{Type: "number"}
|
||||
case reflect.Slice, reflect.Array:
|
||||
elem := t.Elem()
|
||||
if elem.Kind() == reflect.Uint8 {
|
||||
node = &schemaNode{Type: "string"} // []byte → string
|
||||
} else {
|
||||
node = &schemaNode{
|
||||
Type: "array",
|
||||
Items: reflectSchema(elem, visiting, cache),
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
node = &schemaNode{
|
||||
Type: "object",
|
||||
AdditionalProperties: reflectSchema(t.Elem(), visiting, cache),
|
||||
}
|
||||
case reflect.Interface:
|
||||
node = &schemaNode{}
|
||||
case reflect.Struct:
|
||||
node = reflectStruct(t, visiting, cache)
|
||||
default:
|
||||
node = &schemaNode{}
|
||||
}
|
||||
|
||||
cache[t] = node
|
||||
return node
|
||||
}
|
||||
|
||||
func reflectStruct(t reflect.Type, visiting map[reflect.Type]bool, cache map[reflect.Type]*schemaNode) *schemaNode {
|
||||
visiting[t] = true
|
||||
defer delete(visiting, t)
|
||||
|
||||
node := &schemaNode{
|
||||
Type: "object",
|
||||
Properties: map[string]*schemaNode{},
|
||||
}
|
||||
|
||||
collectFields(t, node.Properties, visiting, cache)
|
||||
|
||||
if len(node.Properties) == 0 {
|
||||
node.Properties = nil
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func collectFields(t reflect.Type, props map[string]*schemaNode, visiting map[reflect.Type]bool, cache map[reflect.Type]*schemaNode) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
|
||||
// Anonymous embed must precede the IsExported check — embedded fields of
|
||||
// lowercase types still promote through encoding/json.
|
||||
if f.Anonymous {
|
||||
embedded := f.Type
|
||||
for embedded.Kind() == reflect.Ptr {
|
||||
embedded = embedded.Elem()
|
||||
}
|
||||
if embedded.Kind() == reflect.Struct {
|
||||
collectFields(embedded, props, visiting, cache)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := parseJSONTag(f)
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
child := reflectSchema(f.Type, visiting, cache)
|
||||
|
||||
// Clone before mutating: the cache shares *schemaNode across all fields of the same type,
|
||||
// so direct mutation would leak one field's annotation onto another.
|
||||
// For arrays, enum/kind apply to items; desc stays on the outer field.
|
||||
desc := f.Tag.Get("desc")
|
||||
enumTag := f.Tag.Get("enum")
|
||||
kindTag := f.Tag.Get("kind")
|
||||
|
||||
hasTagAnnotation := desc != "" || enumTag != "" || kindTag != ""
|
||||
if hasTagAnnotation {
|
||||
isArray := child != nil && child.Type == "array" && child.Items != nil
|
||||
|
||||
if isArray {
|
||||
itemsClone := *child.Items
|
||||
if enumTag != "" {
|
||||
itemsClone.Enum = splitCSV(enumTag)
|
||||
}
|
||||
if kindTag != "" {
|
||||
itemsClone.Format = kindTag
|
||||
}
|
||||
newArr := *child
|
||||
newArr.Items = &itemsClone
|
||||
if desc != "" {
|
||||
newArr.Description = desc
|
||||
}
|
||||
child = &newArr
|
||||
} else {
|
||||
cloned := *child
|
||||
if desc != "" {
|
||||
cloned.Description = desc
|
||||
}
|
||||
if enumTag != "" {
|
||||
cloned.Enum = splitCSV(enumTag)
|
||||
}
|
||||
if kindTag != "" {
|
||||
cloned.Format = kindTag
|
||||
}
|
||||
child = &cloned
|
||||
}
|
||||
}
|
||||
|
||||
props[name] = child
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseJSONTag returns the wire name; "-" propagates so callers can skip.
|
||||
func parseJSONTag(f reflect.StructField) string {
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "" {
|
||||
return f.Name
|
||||
}
|
||||
name := strings.SplitN(tag, ",", 2)[0]
|
||||
if name == "" {
|
||||
return f.Name
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type inner struct {
|
||||
OpenID *string `json:"open_id,omitempty"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
UnionID *string `json:"union_id,omitempty"`
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
Name string `json:"name"`
|
||||
Optional *string `json:"optional,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Reader *inner `json:"reader,omitempty"`
|
||||
Count int `json:"count"`
|
||||
Flag bool `json:"flag"`
|
||||
Skipped string `json:"-"`
|
||||
unexportedStr string //nolint:unused // exercises reflection-skip path
|
||||
}
|
||||
|
||||
func TestFromType_ScalarAndOptional(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(sample{}))
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if parsed["type"] != "object" {
|
||||
t.Errorf("type = %v, want object", parsed["type"])
|
||||
}
|
||||
props, _ := parsed["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
t.Fatal("properties missing")
|
||||
}
|
||||
if got := props["name"].(map[string]interface{})["type"]; got != "string" {
|
||||
t.Errorf("name.type = %v, want string", got)
|
||||
}
|
||||
if got := props["optional"].(map[string]interface{})["type"]; got != "string" {
|
||||
t.Errorf("optional.type = %v, want string", got)
|
||||
}
|
||||
tagsNode := props["tags"].(map[string]interface{})
|
||||
if tagsNode["type"] != "array" {
|
||||
t.Errorf("tags.type = %v, want array", tagsNode["type"])
|
||||
}
|
||||
if items, ok := tagsNode["items"].(map[string]interface{}); !ok || items["type"] != "string" {
|
||||
t.Errorf("tags.items = %v, want string type", tagsNode["items"])
|
||||
}
|
||||
readerNode := props["reader"].(map[string]interface{})
|
||||
if readerNode["type"] != "object" {
|
||||
t.Errorf("reader.type = %v, want object", readerNode["type"])
|
||||
}
|
||||
if props["flag"].(map[string]interface{})["type"] != "boolean" {
|
||||
t.Errorf("flag.type wrong")
|
||||
}
|
||||
if props["count"].(map[string]interface{})["type"] != "integer" {
|
||||
t.Errorf("count.type wrong")
|
||||
}
|
||||
if _, ok := props["Skipped"]; ok {
|
||||
t.Error("Skipped should not be in schema")
|
||||
}
|
||||
if _, ok := props["-"]; ok {
|
||||
t.Error("- should not be in schema")
|
||||
}
|
||||
if _, ok := props["unexportedStr"]; ok {
|
||||
t.Error("unexported field should not be in schema")
|
||||
}
|
||||
}
|
||||
|
||||
type descSharedInner struct {
|
||||
V string `json:"v"`
|
||||
}
|
||||
|
||||
type descSharedOuter struct {
|
||||
Owner descSharedInner `json:"owner" desc:"the owner"`
|
||||
Member descSharedInner `json:"member" desc:"the member"`
|
||||
}
|
||||
|
||||
// Two fields of the same struct type must carry their own desc, not a shared/cached one.
|
||||
func TestFromType_SharedSubtypeDistinctDescriptions(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(descSharedOuter{}))
|
||||
var parsed struct {
|
||||
Properties map[string]struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Properties["owner"].Description; got != "the owner" {
|
||||
t.Errorf("owner.description = %q, want %q", got, "the owner")
|
||||
}
|
||||
if got := parsed.Properties["member"].Description; got != "the member" {
|
||||
t.Errorf("member.description = %q, want %q", got, "the member")
|
||||
}
|
||||
}
|
||||
|
||||
type mapSample struct {
|
||||
Attrs map[string]int `json:"attrs"`
|
||||
}
|
||||
|
||||
func TestFromType_MapAdditionalProperties(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(mapSample{}))
|
||||
var parsed struct {
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
AdditionalProperties struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"additionalProperties"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
attrs := parsed.Properties["attrs"]
|
||||
if attrs.Type != "object" {
|
||||
t.Errorf("attrs.type = %q, want object", attrs.Type)
|
||||
}
|
||||
if attrs.AdditionalProperties.Type != "integer" {
|
||||
t.Errorf("attrs.additionalProperties.type = %q, want integer", attrs.AdditionalProperties.Type)
|
||||
}
|
||||
}
|
||||
|
||||
type cyclic struct {
|
||||
Name string `json:"name"`
|
||||
Child *cyclic `json:"child,omitempty"`
|
||||
Kids []cyclic `json:"kids,omitempty"`
|
||||
}
|
||||
|
||||
func TestFromType_HandlesCycles(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(cyclic{}))
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("expected schema for cyclic type")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if parsed["type"] != "object" {
|
||||
t.Errorf("cyclic.type = %v", parsed["type"])
|
||||
}
|
||||
}
|
||||
|
||||
type embedBase struct {
|
||||
EventID string `json:"event_id"`
|
||||
}
|
||||
|
||||
type embedOuter struct {
|
||||
*embedBase
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
func TestFromType_FlattensEmbeds(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(embedOuter{}))
|
||||
var parsed struct {
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := parsed.Properties["event_id"]; !ok {
|
||||
t.Error("embedded field event_id should appear at top level")
|
||||
}
|
||||
if _, ok := parsed.Properties["payload"]; !ok {
|
||||
t.Error("payload should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromType_NilSafe(t *testing.T) {
|
||||
if got := FromType(nil); got != nil {
|
||||
t.Errorf("FromType(nil) = %s, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
type tagSample struct {
|
||||
ChatType string `json:"chat_type" enum:"p2p,group"`
|
||||
OpenID string `json:"open_id" kind:"open_id"`
|
||||
InternalDate string `json:"internal_date" kind:"timestamp_ms"`
|
||||
Recipients []string `json:"recipients" kind:"email"`
|
||||
States []string `json:"states" enum:"unread,read,flagged"`
|
||||
Plain []string `json:"plain"`
|
||||
}
|
||||
|
||||
func TestFromType_EnumAndKindTags(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(tagSample{}))
|
||||
var parsed struct {
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Enum []string `json:"enum"`
|
||||
Format string `json:"format"`
|
||||
Items *struct {
|
||||
Type string `json:"type"`
|
||||
Enum []string `json:"enum"`
|
||||
Format string `json:"format"`
|
||||
} `json:"items"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := parsed.Properties["chat_type"].Enum; len(got) != 2 || got[0] != "p2p" || got[1] != "group" {
|
||||
t.Errorf("chat_type enum = %v, want [p2p group]", got)
|
||||
}
|
||||
|
||||
if got := parsed.Properties["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("open_id format = %q, want open_id", got)
|
||||
}
|
||||
if got := parsed.Properties["internal_date"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("internal_date format = %q, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
recipients := parsed.Properties["recipients"]
|
||||
if recipients.Format != "" {
|
||||
t.Errorf("recipients array.format = %q, want empty", recipients.Format)
|
||||
}
|
||||
if recipients.Items == nil || recipients.Items.Format != "email" {
|
||||
t.Errorf("recipients.items.format = %q, want email", recipients.Items)
|
||||
}
|
||||
|
||||
states := parsed.Properties["states"]
|
||||
if len(states.Enum) != 0 {
|
||||
t.Errorf("states array.enum = %v, want empty", states.Enum)
|
||||
}
|
||||
if states.Items == nil || len(states.Items.Enum) != 3 {
|
||||
t.Errorf("states.items.enum = %v, want 3 values", states.Items)
|
||||
}
|
||||
|
||||
plain := parsed.Properties["plain"]
|
||||
if plain.Items != nil && (plain.Items.Format != "" || len(plain.Items.Enum) != 0) {
|
||||
t.Errorf("plain.items = {format:%q, enum:%v}, want clean", plain.Items.Format, plain.Items.Enum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import "sort"
|
||||
|
||||
// FieldMeta overrides win over struct tags; non-empty fields replace schema annotations.
|
||||
type FieldMeta struct {
|
||||
Description string
|
||||
Enum []string
|
||||
Kind string // renders to JSON Schema "format" (open_id / chat_id / timestamp_ms …)
|
||||
}
|
||||
|
||||
// ApplyFieldOverrides mutates schema in place; returns unresolved pointer paths (orphans).
|
||||
func ApplyFieldOverrides(schema map[string]interface{}, overrides map[string]FieldMeta) []string {
|
||||
var orphans []string
|
||||
for path, meta := range overrides {
|
||||
nodes := ResolvePointer(schema, path)
|
||||
if len(nodes) == 0 {
|
||||
orphans = append(orphans, path)
|
||||
continue
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if meta.Description != "" {
|
||||
node["description"] = meta.Description
|
||||
}
|
||||
if len(meta.Enum) > 0 {
|
||||
arr := make([]interface{}, len(meta.Enum))
|
||||
for i, v := range meta.Enum {
|
||||
arr[i] = v
|
||||
}
|
||||
node["enum"] = arr
|
||||
}
|
||||
if meta.Kind != "" {
|
||||
node["format"] = meta.Kind
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(orphans)
|
||||
return orphans
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyFieldOverrides_AddDescriptionEnumFormat(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"message_type":{"type":"string"},
|
||||
"sender_id":{"type":"string"}
|
||||
}
|
||||
}`)
|
||||
overrides := map[string]FieldMeta{
|
||||
"/message_type": {Enum: []string{"text", "post"}, Description: "消息类型"},
|
||||
"/sender_id": {Kind: "open_id"},
|
||||
}
|
||||
orphans := ApplyFieldOverrides(schema, overrides)
|
||||
if len(orphans) != 0 {
|
||||
t.Fatalf("unexpected orphans: %v", orphans)
|
||||
}
|
||||
|
||||
msgType := schema["properties"].(map[string]interface{})["message_type"].(map[string]interface{})
|
||||
if msgType["description"] != "消息类型" {
|
||||
t.Errorf("description not applied: %v", msgType)
|
||||
}
|
||||
if enumRaw, ok := msgType["enum"].([]interface{}); !ok || len(enumRaw) != 2 {
|
||||
t.Errorf("enum not applied: %v", msgType)
|
||||
}
|
||||
|
||||
senderID := schema["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})
|
||||
if senderID["format"] != "open_id" {
|
||||
t.Errorf("format not applied: %v", senderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFieldOverrides_OverridesStructTagValues(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"chat_type":{"type":"string","description":"from tag","enum":["old_a","old_b"]}
|
||||
}
|
||||
}`)
|
||||
overrides := map[string]FieldMeta{
|
||||
"/chat_type": {Description: "from overlay", Enum: []string{"new_a"}},
|
||||
}
|
||||
ApplyFieldOverrides(schema, overrides)
|
||||
ct := schema["properties"].(map[string]interface{})["chat_type"].(map[string]interface{})
|
||||
if ct["description"] != "from overlay" {
|
||||
t.Errorf("overlay description should override tag: %v", ct)
|
||||
}
|
||||
enumRaw := ct["enum"].([]interface{})
|
||||
if len(enumRaw) != 1 || enumRaw[0] != "new_a" {
|
||||
t.Errorf("overlay enum should override tag: %v", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFieldOverrides_OrphanPathsReported(t *testing.T) {
|
||||
schema := parseSchema(t, `{"type":"object","properties":{"a":{"type":"string"}}}`)
|
||||
overrides := map[string]FieldMeta{
|
||||
"/a": {Kind: "open_id"},
|
||||
"/x/y": {Kind: "chat_id"},
|
||||
"/a/bad": {Kind: "chat_id"},
|
||||
}
|
||||
orphans := ApplyFieldOverrides(schema, overrides)
|
||||
want := []string{"/a/bad", "/x/y"}
|
||||
if !reflect.DeepEqual(orphans, want) {
|
||||
t.Errorf("orphans = %v, want %v", orphans, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFieldOverrides_ArrayItemsWildcard(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ids":{"type":"array","items":{"type":"string"}}
|
||||
}
|
||||
}`)
|
||||
overrides := map[string]FieldMeta{
|
||||
"/ids/*": {Kind: "message_id"},
|
||||
}
|
||||
if orphans := ApplyFieldOverrides(schema, overrides); len(orphans) != 0 {
|
||||
t.Fatalf("unexpected orphans: %v", orphans)
|
||||
}
|
||||
items := schema["properties"].(map[string]interface{})["ids"].(map[string]interface{})["items"].(map[string]interface{})
|
||||
if items["format"] != "message_id" {
|
||||
t.Errorf("items.format not applied: %v", items)
|
||||
}
|
||||
}
|
||||
|
||||
type overlaySample struct {
|
||||
Type string `json:"type"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
func TestApplyFieldOverrides_OnReflectedSchema(t *testing.T) {
|
||||
raw := FromType(reflect.TypeOf(overlaySample{}))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ApplyFieldOverrides(m, map[string]FieldMeta{
|
||||
"/message_id": {Kind: "message_id"},
|
||||
})
|
||||
props := m["properties"].(map[string]interface{})
|
||||
mid := props["message_id"].(map[string]interface{})
|
||||
if mid["format"] != "message_id" {
|
||||
t.Errorf("format not applied to reflected schema: %v", mid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import "strings"
|
||||
|
||||
// ResolvePointer extends RFC 6901 with `/*` for array items; tolerates structural mismatches.
|
||||
func ResolvePointer(schema map[string]interface{}, path string) []map[string]interface{} {
|
||||
if path == "" || path == "/" {
|
||||
return []map[string]interface{}{schema}
|
||||
}
|
||||
trimmed := strings.TrimPrefix(path, "/")
|
||||
parts := strings.Split(trimmed, "/")
|
||||
|
||||
current := []map[string]interface{}{schema}
|
||||
for _, part := range parts {
|
||||
next := []map[string]interface{}{}
|
||||
for _, node := range current {
|
||||
if part == "*" {
|
||||
items, ok := node["items"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next = append(next, items)
|
||||
continue
|
||||
}
|
||||
props, ok := node["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
child, ok := props[part].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next = append(next, child)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schemas
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func parseSchema(t *testing.T, s string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestResolvePointer_SimpleField(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"chat_id":{"type":"string"}
|
||||
}
|
||||
}`)
|
||||
nodes := ResolvePointer(schema, "/chat_id")
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("want 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["type"] != "string" {
|
||||
t.Errorf("got %v", nodes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePointer_Nested(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"message":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"chat_type":{"type":"string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
nodes := ResolvePointer(schema, "/message/chat_type")
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("want 1 node, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePointer_ArrayElementWildcard(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"message_id_list":{
|
||||
"type":"array",
|
||||
"items":{"type":"string"}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
nodes := ResolvePointer(schema, "/message_id_list/*")
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("want 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["type"] != "string" {
|
||||
t.Errorf("want string items, got %v", nodes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePointer_ArrayElementField(t *testing.T) {
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"attachments":{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"mime_type":{"type":"string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
nodes := ResolvePointer(schema, "/attachments/*/mime_type")
|
||||
if len(nodes) != 1 || nodes[0]["type"] != "string" {
|
||||
t.Errorf("want mime_type node, got %v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePointer_MissingReturnsEmpty(t *testing.T) {
|
||||
schema := parseSchema(t, `{"type":"object","properties":{"a":{"type":"string"}}}`)
|
||||
nodes := ResolvePointer(schema, "/b/c/d")
|
||||
if len(nodes) != 0 {
|
||||
t.Errorf("want empty for missing path, got %v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePointer_RootReturnsSelf(t *testing.T) {
|
||||
schema := parseSchema(t, `{"type":"object"}`)
|
||||
nodes := ResolvePointer(schema, "")
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("want 1 root node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["type"] != "object" {
|
||||
t.Errorf("root resolution broken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads
|
||||
|
||||
type FeishuSource struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
Domain string
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func (s *FeishuSource) Name() string { return "feishu-websocket" }
|
||||
|
||||
func (s *FeishuSource) Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error {
|
||||
d := dispatcher.NewEventDispatcher("", "")
|
||||
|
||||
rawHandler := s.buildRawHandler(emit)
|
||||
|
||||
for _, et := range eventTypes {
|
||||
d.OnCustomizedEvent(et, rawHandler)
|
||||
}
|
||||
|
||||
opts := []larkws.ClientOption{larkws.WithEventHandler(d)}
|
||||
if s.Domain != "" {
|
||||
opts = append(opts, larkws.WithDomain(s.Domain))
|
||||
}
|
||||
if s.Logger != nil || notify != nil {
|
||||
opts = append(opts, larkws.WithLogLevel(larkcore.LogLevelInfo))
|
||||
opts = append(opts, larkws.WithLogger(&sdkLogger{l: s.Logger, notify: notify}))
|
||||
}
|
||||
|
||||
if notify != nil {
|
||||
notify(protocol.SourceStateConnecting, "")
|
||||
}
|
||||
cli := larkws.NewClient(s.AppID, s.AppSecret, opts...)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- cli.Start(ctx) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawHandler is extracted from Start so unit tests can exercise it without a WS client.
|
||||
func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context.Context, *larkevent.EventReq) error {
|
||||
return func(_ context.Context, e *larkevent.EventReq) error {
|
||||
if e.Body == nil {
|
||||
return nil
|
||||
}
|
||||
if len(e.Body) > maxEventBodyBytes {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Printf("[feishu] drop oversized event: %d bytes > cap %d", len(e.Body), maxEventBodyBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Body, &envelope); err != nil {
|
||||
if s.Logger != nil {
|
||||
preview := string(e.Body)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "...(truncated)"
|
||||
}
|
||||
s.Logger.Printf("[feishu] drop malformed event: unmarshal error: %v body=%s", err, preview)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope.Header.EventID == "" || envelope.Header.EventType == "" {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Printf("[feishu] drop event missing header fields: event_id=%q event_type=%q",
|
||||
envelope.Header.EventID, envelope.Header.EventType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
emit(&event.RawEvent{
|
||||
EventID: envelope.Header.EventID,
|
||||
EventType: envelope.Header.EventType,
|
||||
SourceTime: envelope.Header.CreateTime,
|
||||
Payload: json.RawMessage(e.Body),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sdkLogger forwards every SDK line to bus.log; lifecycle lines also fire notify.
|
||||
type sdkLogger struct {
|
||||
l *log.Logger
|
||||
notify StatusNotifier
|
||||
}
|
||||
|
||||
func (a *sdkLogger) Debug(_ context.Context, _ ...interface{}) {}
|
||||
func (a *sdkLogger) Info(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK] "+msg)
|
||||
}
|
||||
a.tryNotify(msg, "")
|
||||
}
|
||||
func (a *sdkLogger) Warn(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK WARN] "+msg)
|
||||
}
|
||||
a.tryNotify(msg, "")
|
||||
}
|
||||
func (a *sdkLogger) Error(_ context.Context, args ...interface{}) {
|
||||
msg := fmt.Sprint(args...)
|
||||
if a.l != nil {
|
||||
a.l.Output(2, "[SDK ERROR] "+msg)
|
||||
}
|
||||
// Errors usually manifest as disconnects; pass msg as detail.
|
||||
a.tryNotify(msg, msg)
|
||||
}
|
||||
|
||||
var reconnectAttemptRe = regexp.MustCompile(`reconnect:?\s*(\d+)`)
|
||||
|
||||
// tryNotify uses HasPrefix (not Contains): "connected to" matches inside "disconnected to" otherwise.
|
||||
func (a *sdkLogger) tryNotify(msg, errDetail string) {
|
||||
if a.notify == nil {
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, sdkLogReconnecting):
|
||||
detail := ""
|
||||
if m := reconnectAttemptRe.FindStringSubmatch(lower); len(m) == 2 {
|
||||
detail = "attempt " + m[1]
|
||||
}
|
||||
a.notify(protocol.SourceStateReconnecting, detail)
|
||||
case strings.HasPrefix(lower, sdkLogDisconnected):
|
||||
a.notify(protocol.SourceStateDisconnected, errDetail)
|
||||
case strings.HasPrefix(lower, sdkLogConnected):
|
||||
a.notify(protocol.SourceStateConnected, "")
|
||||
}
|
||||
}
|
||||
|
||||
var _ larkcore.Logger = (*sdkLogger)(nil)
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestRawHandlerLogsMalformedJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: []byte("not-json-{{{")}
|
||||
if err := handler(context.Background(), req); err != nil {
|
||||
t.Fatalf("handler returned err: %v", err)
|
||||
}
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits, got %d", emitted)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "malformed") {
|
||||
t.Errorf("expected log to mention 'malformed', got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not-json") {
|
||||
t.Errorf("expected log to include body preview, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerLogsMissingHeaderFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: []byte(`{"header":{"event_type":"im.receive"}}`)}
|
||||
handler(context.Background(), req)
|
||||
req2 := &larkevent.EventReq{Body: []byte(`{"header":{"event_id":"abc"}}`)}
|
||||
handler(context.Background(), req2)
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits (both missing fields), got %d", emitted)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Count(out, "missing header fields") != 2 {
|
||||
t.Errorf("expected 2 'missing header fields' logs, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerNilBodyNoLog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := &FeishuSource{Logger: log.New(&buf, "", 0)}
|
||||
emitted := 0
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) { emitted++ })
|
||||
|
||||
req := &larkevent.EventReq{Body: nil}
|
||||
handler(context.Background(), req)
|
||||
|
||||
if emitted != 0 {
|
||||
t.Errorf("expected 0 emits, got %d", emitted)
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("expected no log output, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerValidEnvelopeEmits(t *testing.T) {
|
||||
s := &FeishuSource{}
|
||||
var captured *event.RawEvent
|
||||
handler := s.buildRawHandler(func(e *event.RawEvent) { captured = e })
|
||||
|
||||
body := []byte(`{"header":{"event_id":"evt-42","event_type":"im.message.receive_v1","create_time":"1700000000000"}}`)
|
||||
handler(context.Background(), &larkevent.EventReq{Body: body})
|
||||
|
||||
if captured == nil {
|
||||
t.Fatal("expected emit to fire")
|
||||
}
|
||||
if captured.EventID != "evt-42" {
|
||||
t.Errorf("EventID: got %q, expected evt-42", captured.EventID)
|
||||
}
|
||||
if captured.EventType != "im.message.receive_v1" {
|
||||
t.Errorf("EventType: got %q, expected im.message.receive_v1", captured.EventType)
|
||||
}
|
||||
if captured.SourceTime != "1700000000000" {
|
||||
t.Errorf("SourceTime: got %q, expected 1700000000000", captured.SourceTime)
|
||||
}
|
||||
if string(captured.Payload) != string(body) {
|
||||
t.Errorf("Payload should be raw bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHandlerNilLoggerDoesNotPanic(t *testing.T) {
|
||||
s := &FeishuSource{Logger: nil}
|
||||
handler := s.buildRawHandler(func(_ *event.RawEvent) {})
|
||||
handler(context.Background(), &larkevent.EventReq{Body: []byte("bad json")})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// "disconnected to <url>" contains "connected to ws" — must use HasPrefix to avoid misclassifying as connect.
|
||||
func TestTryNotify_Classify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
errDetail string
|
||||
wantState string
|
||||
wantDetail string
|
||||
wantCalled bool
|
||||
}{
|
||||
{
|
||||
name: "connected (SDK connect success)",
|
||||
msg: "connected to wss://example.com/gw [conn_id=abc]",
|
||||
wantState: protocol.SourceStateConnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "disconnected must not be misclassified as connected",
|
||||
msg: "disconnected to wss://example.com/gw [conn_id=abc]",
|
||||
wantState: protocol.SourceStateDisconnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "disconnected carries errDetail through",
|
||||
msg: "disconnected to wss://example.com/gw [conn_id=abc]",
|
||||
errDetail: "read tcp: broken pipe",
|
||||
wantState: protocol.SourceStateDisconnected,
|
||||
wantDetail: "read tcp: broken pipe",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "reconnecting with attempt 1",
|
||||
msg: "trying to reconnect: 1 [conn_id=abc]",
|
||||
wantState: protocol.SourceStateReconnecting,
|
||||
wantDetail: "attempt 1",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "reconnecting with attempt 12",
|
||||
msg: "trying to reconnect: 12",
|
||||
wantState: protocol.SourceStateReconnecting,
|
||||
wantDetail: "attempt 12",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive connected",
|
||||
msg: "CONNECTED TO WSS://example.com",
|
||||
wantState: protocol.SourceStateConnected,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "ignore generic connect-failed error",
|
||||
msg: "connect failed, err: dial tcp: i/o timeout",
|
||||
errDetail: "connect failed, err: dial tcp: i/o timeout",
|
||||
wantCalled: false,
|
||||
},
|
||||
{
|
||||
name: "ignore read-loop failure",
|
||||
msg: "receive message failed, err: websocket: close 1006",
|
||||
errDetail: "receive message failed, err: websocket: close 1006",
|
||||
wantCalled: false,
|
||||
},
|
||||
{
|
||||
name: "ignore heartbeat noise",
|
||||
msg: "receive pong",
|
||||
wantCalled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotState, gotDetail string
|
||||
called := false
|
||||
lg := &sdkLogger{notify: func(state, detail string) {
|
||||
called = true
|
||||
gotState = state
|
||||
gotDetail = detail
|
||||
}}
|
||||
lg.tryNotify(tc.msg, tc.errDetail)
|
||||
|
||||
if called != tc.wantCalled {
|
||||
t.Fatalf("called=%v, want %v (msg=%q)", called, tc.wantCalled, tc.msg)
|
||||
}
|
||||
if !called {
|
||||
return
|
||||
}
|
||||
if gotState != tc.wantState {
|
||||
t.Errorf("state = %q, want %q", gotState, tc.wantState)
|
||||
}
|
||||
if gotDetail != tc.wantDetail {
|
||||
t.Errorf("detail = %q, want %q", gotDetail, tc.wantDetail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryNotify_NilNotifySafe(t *testing.T) {
|
||||
lg := &sdkLogger{notify: nil}
|
||||
lg.tryNotify("disconnected to wss://example.com", "")
|
||||
lg.tryNotify("connected to wss://example.com", "")
|
||||
lg.tryNotify("trying to reconnect: 1", "")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
// DO NOT trim trailing spaces — the HasPrefix disambiguator depends on them.
|
||||
const (
|
||||
sdkLogReconnecting = "trying to reconnect"
|
||||
sdkLogConnected = "connected to "
|
||||
sdkLogDisconnected = "disconnected to "
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// Samples preserve the real SDK shape ("<verb> to <url>[conn_id=...]" — no space before bracket).
|
||||
func TestSDKLogPatternsMatchKnownSDKOutput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sdkLogSample string
|
||||
expectedState string
|
||||
}{
|
||||
{
|
||||
name: "reconnect with attempt number",
|
||||
sdkLogSample: "trying to reconnect: 2[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateReconnecting,
|
||||
},
|
||||
{
|
||||
name: "reconnect high attempt",
|
||||
sdkLogSample: "trying to reconnect: 12",
|
||||
expectedState: protocol.SourceStateReconnecting,
|
||||
},
|
||||
{
|
||||
name: "connected success with conn_id",
|
||||
sdkLogSample: "connected to wss://open.feishu.cn/gateway[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
{
|
||||
name: "connected to custom gateway",
|
||||
sdkLogSample: "connected to wss://internal.example.com/gw",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
{
|
||||
name: "disconnected does not alias connected",
|
||||
sdkLogSample: "disconnected to wss://open.feishu.cn/gateway[conn_id=abc123]",
|
||||
expectedState: protocol.SourceStateDisconnected,
|
||||
},
|
||||
{
|
||||
name: "connected uppercase",
|
||||
sdkLogSample: "CONNECTED TO WSS://OPEN.FEISHU.CN/GATEWAY",
|
||||
expectedState: protocol.SourceStateConnected,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotState string
|
||||
called := false
|
||||
notify := func(state, detail string) {
|
||||
mu.Lock()
|
||||
gotState = state
|
||||
called = true
|
||||
mu.Unlock()
|
||||
}
|
||||
logger := &sdkLogger{notify: notify}
|
||||
logger.Info(context.Background(), tc.sdkLogSample)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !called {
|
||||
t.Fatalf("SDK log sample %q did not trigger notify — SDK log format may have changed", tc.sdkLogSample)
|
||||
}
|
||||
if gotState != tc.expectedState {
|
||||
t.Errorf("SDK log sample %q classified as %q, want %q", tc.sdkLogSample, gotState, tc.expectedState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKLogPatternsConstantsContainExpectedSubstrings(t *testing.T) {
|
||||
if !strings.Contains(sdkLogReconnecting, "reconnect") {
|
||||
t.Errorf("sdkLogReconnecting should contain 'reconnect', got %q", sdkLogReconnecting)
|
||||
}
|
||||
if !strings.Contains(sdkLogConnected, "connected") {
|
||||
t.Errorf("sdkLogConnected should contain 'connected', got %q", sdkLogConnected)
|
||||
}
|
||||
if !strings.Contains(sdkLogDisconnected, "disconnected") {
|
||||
t.Errorf("sdkLogDisconnected should contain 'disconnected', got %q", sdkLogDisconnected)
|
||||
}
|
||||
if sdkLogReconnecting != strings.ToLower(sdkLogReconnecting) {
|
||||
t.Errorf("sdkLogReconnecting must be lowercase, got %q", sdkLogReconnecting)
|
||||
}
|
||||
if sdkLogConnected != strings.ToLower(sdkLogConnected) {
|
||||
t.Errorf("sdkLogConnected must be lowercase, got %q", sdkLogConnected)
|
||||
}
|
||||
if sdkLogDisconnected != strings.ToLower(sdkLogDisconnected) {
|
||||
t.Errorf("sdkLogDisconnected must be lowercase, got %q", sdkLogDisconnected)
|
||||
}
|
||||
if strings.HasPrefix(sdkLogDisconnected, sdkLogConnected) {
|
||||
t.Errorf("sdkLogConnected %q is a prefix of sdkLogDisconnected %q — restore the trailing space.",
|
||||
sdkLogConnected, sdkLogDisconnected)
|
||||
}
|
||||
if !strings.HasSuffix(sdkLogConnected, " ") {
|
||||
t.Error("sdkLogConnected must keep its trailing space")
|
||||
}
|
||||
if !strings.HasSuffix(sdkLogDisconnected, " ") {
|
||||
t.Error("sdkLogDisconnected must keep its trailing space")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package source is a pluggable event source abstraction (separate package to keep
|
||||
// business registrations free of SDK transitive deps).
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// StatusNotifier surfaces SourceState* lifecycle states; detail is free-form context.
|
||||
type StatusNotifier func(state, detail string)
|
||||
|
||||
// Source produces events; emit MUST return quickly (anything slow stalls the SDK read loop).
|
||||
type Source interface {
|
||||
Name() string
|
||||
Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error
|
||||
}
|
||||
|
||||
var (
|
||||
registry []Source
|
||||
registryMu sync.Mutex
|
||||
)
|
||||
|
||||
func Register(s Source) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(registry, s)
|
||||
}
|
||||
|
||||
func All() []Source {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
out := make([]Source, len(registry))
|
||||
copy(out, registry)
|
||||
return out
|
||||
}
|
||||
|
||||
func ResetForTest() {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type mockSource struct {
|
||||
name string
|
||||
events []*event.RawEvent
|
||||
}
|
||||
|
||||
func (s *mockSource) Name() string { return s.name }
|
||||
func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ StatusNotifier) error {
|
||||
for _, e := range s.events {
|
||||
emit(e)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
ResetForTest()
|
||||
|
||||
src := &mockSource{name: "test-source"}
|
||||
Register(src)
|
||||
|
||||
sources := All()
|
||||
if len(sources) != 1 || sources[0].Name() != "test-source" {
|
||||
t.Errorf("unexpected sources: %v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockSource_EmitsEvents(t *testing.T) {
|
||||
src := &mockSource{
|
||||
name: "test",
|
||||
events: []*event.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1"},
|
||||
{EventID: "2", EventType: "im.message.receive_v1"},
|
||||
},
|
||||
}
|
||||
|
||||
received := make(chan *event.RawEvent, 10)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
go src.Start(ctx, nil, func(e *event.RawEvent) {
|
||||
received <- e
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if len(received) != 2 {
|
||||
t.Errorf("expected 2 events, got %d", len(received))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package testutil holds test-only helpers shared across event subsystem tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// FakeTransport delegates to inner with a fixed addr, so tests can use t.TempDir paths.
|
||||
type FakeTransport struct {
|
||||
addr string
|
||||
inner transport.IPC
|
||||
mu sync.Mutex
|
||||
cleaned bool
|
||||
cleanups int
|
||||
}
|
||||
|
||||
func NewWrappedFake(inner transport.IPC, addr string) *FakeTransport {
|
||||
return &FakeTransport{addr: addr, inner: inner}
|
||||
}
|
||||
|
||||
func (t *FakeTransport) Listen(_ string) (net.Listener, error) {
|
||||
return t.inner.Listen(t.addr)
|
||||
}
|
||||
|
||||
func (t *FakeTransport) Dial(_ string) (net.Conn, error) {
|
||||
return t.inner.Dial(t.addr)
|
||||
}
|
||||
|
||||
func (t *FakeTransport) Address(_ string) string { return t.addr }
|
||||
|
||||
func (t *FakeTransport) Cleanup(_ string) {
|
||||
t.mu.Lock()
|
||||
t.cleaned = true
|
||||
t.cleanups++
|
||||
t.mu.Unlock()
|
||||
t.inner.Cleanup(t.addr)
|
||||
}
|
||||
|
||||
func (t *FakeTransport) DidCleanup() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleaned
|
||||
}
|
||||
|
||||
func (t *FakeTransport) CleanupCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleanups
|
||||
}
|
||||
|
||||
// StubAPIClient records the last call and returns Body or Err.
|
||||
type StubAPIClient struct {
|
||||
Body string
|
||||
Err error
|
||||
|
||||
mu sync.Mutex
|
||||
GotMethod string
|
||||
GotPath string
|
||||
GotBody interface{}
|
||||
Calls int
|
||||
}
|
||||
|
||||
func (s *StubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.GotMethod = method
|
||||
s.GotPath = path
|
||||
s.GotBody = body
|
||||
s.Calls++
|
||||
s.mu.Unlock()
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
if s.Body == "" {
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
return json.RawMessage(s.Body), nil
|
||||
}
|
||||
|
||||
var ErrStubUnconfigured = errors.New("testutil.StubAPIClient: no body or err configured")
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport: Unix sockets on POSIX, named pipes on Windows.
|
||||
package transport
|
||||
|
||||
import "net"
|
||||
|
||||
type IPC interface {
|
||||
Listen(addr string) (net.Listener, error)
|
||||
Dial(addr string) (net.Conn, error)
|
||||
Address(appID string) string
|
||||
Cleanup(addr string)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUnixTransport_Address(t *testing.T) {
|
||||
tr := New()
|
||||
addr := tr.Address("cli_test123")
|
||||
if addr == "" {
|
||||
t.Fatal("address should not be empty")
|
||||
}
|
||||
if !contains(addr, "cli_test123") {
|
||||
t.Errorf("address %q should contain appID", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixTransport_ListenAndDial(t *testing.T) {
|
||||
tr := New()
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "t.sock") // macOS unix socket path limit is 103 bytes
|
||||
|
||||
ln, err := tr.Listen(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err == nil {
|
||||
accepted <- conn
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := tr.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
serverConn := <-accepted
|
||||
defer serverConn.Close()
|
||||
|
||||
_, err = conn.Write([]byte("hello\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, err := serverConn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "hello\n" {
|
||||
t.Errorf("got %q, want %q", string(buf[:n]), "hello\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixTransport_ListenTwiceFails(t *testing.T) {
|
||||
tr := New()
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "s")
|
||||
|
||||
ln1, err := tr.Listen(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("first listen: %v", err)
|
||||
}
|
||||
defer ln1.Close()
|
||||
|
||||
_, err = tr.Listen(addr)
|
||||
if err == nil {
|
||||
t.Error("second listen on same addr should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixTransport_Cleanup(t *testing.T) {
|
||||
tr := New()
|
||||
dir := t.TempDir()
|
||||
addr := filepath.Join(dir, "t.sock") // macOS unix socket path limit is 103 bytes
|
||||
|
||||
ln, _ := tr.Listen(addr)
|
||||
ln.Close()
|
||||
tr.Cleanup(addr)
|
||||
|
||||
if _, err := os.Stat(addr); !os.IsNotExist(err) {
|
||||
t.Error("sock file should be removed after Cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
// Dial must fail-fast or honor 5s timeout when nothing is listening — never block forever.
|
||||
func TestUnixDialTimeout(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "n.sock")
|
||||
|
||||
os.Remove(sockPath)
|
||||
|
||||
tr := &unixTransport{}
|
||||
start := time.Now()
|
||||
conn, err := tr.Dial(sockPath)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("expected error dialing non-listening socket")
|
||||
}
|
||||
if elapsed > 6*time.Second {
|
||||
t.Errorf("Dial took %v; should fail-fast or honor 5s timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const dialTimeout = 5 * time.Second // matches winio.DialPipe for cross-platform symmetry
|
||||
|
||||
type unixTransport struct{}
|
||||
|
||||
func New() IPC {
|
||||
return &unixTransport{}
|
||||
}
|
||||
|
||||
func (t *unixTransport) Listen(addr string) (net.Listener, error) {
|
||||
if err := vfs.MkdirAll(filepath.Dir(addr), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.Listen("unix", addr)
|
||||
}
|
||||
|
||||
func (t *unixTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", addr, dialTimeout)
|
||||
}
|
||||
|
||||
// Address: NOT os.UserHomeDir — honours LARKSUITE_CLI_CONFIG_DIR override.
|
||||
func (t *unixTransport) Address(appID string) string {
|
||||
return filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(appID), "bus.sock")
|
||||
}
|
||||
|
||||
func (t *unixTransport) Cleanup(addr string) {
|
||||
_ = vfs.Remove(addr)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
// Windows Named Pipe transport via go-winio; pipe is a kernel object so Cleanup is a no-op.
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const pipeBufferSize = 65536 // per-direction; one event payload always fits
|
||||
|
||||
type windowsTransport struct{}
|
||||
|
||||
func New() IPC {
|
||||
return &windowsTransport{}
|
||||
}
|
||||
|
||||
func (t *windowsTransport) Listen(addr string) (net.Listener, error) {
|
||||
// Empty SecurityDescriptor → per-user IPC (the creating user only).
|
||||
return winio.ListenPipe(addr, &winio.PipeConfig{
|
||||
InputBufferSize: pipeBufferSize,
|
||||
OutputBufferSize: pipeBufferSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (t *windowsTransport) Dial(addr string) (net.Conn, error) {
|
||||
timeout := 5 * time.Second
|
||||
return winio.DialPipe(addr, &timeout)
|
||||
}
|
||||
|
||||
// Address: SanitizeAppID prevents corrupt AppID from reshaping the pipe path.
|
||||
func (t *windowsTransport) Address(appID string) string {
|
||||
return `\\.\pipe\lark-cli-` + event.SanitizeAppID(appID)
|
||||
}
|
||||
|
||||
func (t *windowsTransport) Cleanup(addr string) {}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package event owns the EventKey registry, RawEvent, APIClient, and dedup filter.
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBufferSize = 100
|
||||
MaxBufferSize = 1000
|
||||
)
|
||||
|
||||
// RawEvent: SourceTime is upstream create_time; Timestamp is local source observation time.
|
||||
type RawEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
SourceTime string `json:"source_time,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// APIClient: identity is opaque so business code can't bypass pre-flight checks.
|
||||
type APIClient interface {
|
||||
CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type ParamType string
|
||||
|
||||
const (
|
||||
ParamString ParamType = "string"
|
||||
ParamEnum ParamType = "enum"
|
||||
ParamMulti ParamType = "multi"
|
||||
ParamBool ParamType = "bool"
|
||||
ParamInt ParamType = "int"
|
||||
)
|
||||
|
||||
// SubscriptionType marks whether an EventKey is delivered via Lark event
|
||||
// subscription or interactive callback subscription. It is a sibling of
|
||||
// EventType (which holds the concrete Lark event_type string).
|
||||
type SubscriptionType string
|
||||
|
||||
const (
|
||||
// SubTypeEvent: checked against the published app_versions event_infos.
|
||||
SubTypeEvent SubscriptionType = "event"
|
||||
// SubTypeCallback: checked against application/get subscribed_callbacks.
|
||||
SubTypeCallback SubscriptionType = "callback"
|
||||
)
|
||||
|
||||
// ParamValue.Desc is mandatory so AI consumers can decide which value to pick.
|
||||
type ParamValue struct {
|
||||
Value string `json:"value"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
|
||||
type ParamDef struct {
|
||||
Name string `json:"name"`
|
||||
Type ParamType `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Values []ParamValue `json:"values,omitempty"`
|
||||
|
||||
// SubscriptionKey marks this param as part of the subscription identity.
|
||||
// Two consumers of the same EventKey but different values for any
|
||||
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
|
||||
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
|
||||
// (EventKey, SubscriptionID).
|
||||
//
|
||||
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
|
||||
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
|
||||
// subscription record by (app, user, event_type) and overwrites it rather
|
||||
// than reference-counting, so for a non-per-resource API the cleanup of one
|
||||
// resource's last consumer unsubscribes the shared record and silently cuts
|
||||
// off every other resource sharing that event_type.
|
||||
//
|
||||
// Default false = the param is a filter / formatting / metadata param
|
||||
// and does not affect subscription identity.
|
||||
SubscriptionKey bool `json:"subscription_key,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
|
||||
|
||||
// SchemaDef: exactly one of Native or Custom must be set.
|
||||
// Native auto-wraps the SDK type in the V2 envelope; Custom passes through verbatim.
|
||||
type SchemaDef struct {
|
||||
Native *SchemaSpec `json:"native,omitempty"`
|
||||
Custom *SchemaSpec `json:"custom,omitempty"`
|
||||
FieldOverrides map[string]schemas.FieldMeta `json:"field_overrides,omitempty"`
|
||||
}
|
||||
|
||||
// SchemaSpec: exactly one of Type or Raw.
|
||||
type SchemaSpec struct {
|
||||
Type reflect.Type `json:"-"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type KeyDefinition struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
|
||||
// SubscriptionType selects which console "底账" the precheck reads.
|
||||
// Empty is normalized to SubTypeEvent at RegisterKey.
|
||||
SubscriptionType SubscriptionType `json:"subscription_type,omitempty"`
|
||||
|
||||
Params []ParamDef `json:"params,omitempty"`
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
|
||||
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
|
||||
// PreConsume, Match, and Process. Mutates the params map in place.
|
||||
// May call OAPI; runs once per consumer at startup.
|
||||
//
|
||||
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
|
||||
// trim whitespace. On error, consume fails (no retry); caller gets the
|
||||
// wrapped error.
|
||||
//
|
||||
// Default nil = no normalization, params pass through unchanged.
|
||||
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
|
||||
|
||||
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
|
||||
//
|
||||
// Convention: returning (nil, nil) signals "drop this event" — the
|
||||
// consumer loop will skip writing it to sink and not advance the
|
||||
// emitted counter. Useful for async filtering (e.g. fetch metadata,
|
||||
// drop if folder doesn't match). For sync filters that don't need
|
||||
// OAPI, use Match instead.
|
||||
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
|
||||
|
||||
// Match is a synchronous payload filter run on every received event
|
||||
// BEFORE Process. Return false to drop the event without further work.
|
||||
//
|
||||
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
|
||||
// calls in Match". For filters that need a metadata fetch first, use
|
||||
// Process and return nil to drop.
|
||||
//
|
||||
// Default nil = accept all events.
|
||||
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
|
||||
|
||||
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
|
||||
// is first for that scope. Returns a cleanup function that the framework
|
||||
// invokes when this consumer is the last for its scope.
|
||||
//
|
||||
// The cleanup's error return is honored: on nil the framework prints
|
||||
// "[event] cleanup done."; on non-nil it prints a WARN with an
|
||||
// idempotency note.
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
|
||||
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
|
||||
// AuthTypes: whitelist of identities the EventKey accepts. Empty = no identity required.
|
||||
AuthTypes []string `json:"auth_types,omitempty"`
|
||||
|
||||
RequiredConsoleEvents []string `json:"required_console_events,omitempty"`
|
||||
|
||||
BufferSize int `json:"buffer_size,omitempty"`
|
||||
Workers int `json:"workers,omitempty"`
|
||||
|
||||
// SingleConsumer rejects a second consumer for the same SubscriptionID at
|
||||
// the bus handshake. Default false = unlimited consumers (fan-out).
|
||||
SingleConsumer bool `json:"single_consumer,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user