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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+24
View File
@@ -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
}
+49
View File
@@ -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)
}
})
}
}
+384
View File
@@ -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)
}
}
+90
View File
@@ -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")
}
}
+216
View File
@@ -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()
}
+164
View File
@@ -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")
}
}
+256
View File
@@ -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)
}
}
+316
View File
@@ -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()
}
+200
View File
@@ -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
}
}
+424
View File
@@ -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")
}
}
+100
View File
@@ -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)")
}
}
+40
View File
@@ -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
}
+57
View File
@@ -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)
}
+34
View File
@@ -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)
}
+129
View File
@@ -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
}
+151
View File
@@ -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)
}
}
+80
View File
@@ -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)
}
}
+284
View File
@@ -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)
}
+101
View File
@@ -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")
}
}
+41
View File
@@ -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])
}
+126
View File
@@ -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)
}
}
+46
View File
@@ -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
}
+46
View File
@@ -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")
}
}
+51
View File
@@ -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())
}
}
+262
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
}
+307
View File
@@ -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)
}
})
}
}
+36
View File
@@ -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)
}
}
+42
View File
@@ -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
}
}
}
+133
View File
@@ -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")
}
}
+78
View File
@@ -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
}
+118
View File
@@ -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])
}
}
}
+178
View File
@@ -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)
}
}
+16
View File
@@ -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}
}
+21
View File
@@ -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)
}
}
+71
View File
@@ -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)
}
}
}
+160
View File
@@ -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")
}
}
+352
View File
@@ -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()
}
+106
View File
@@ -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
}
+164
View File
@@ -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())
}
}
+175
View File
@@ -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,
}
}
+129
View File
@@ -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)
}
}
+144
View File
@@ -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)
}
+301
View File
@@ -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"}`)}},
})
}
+55
View File
@@ -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
}
+224
View File
@@ -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
}
+239
View File
@@ -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)
}
}
+42
View File
@@ -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
}
+116
View File
@@ -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)
}
}
+44
View File
@@ -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
}
+111
View File
@@ -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")
}
}
+168
View File
@@ -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)
+107
View File
@@ -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")})
}
+113
View File
@@ -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", "")
}
+11
View File
@@ -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")
}
}
+47
View File
@@ -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
}
+61
View File
@@ -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))
}
}
+88
View File
@@ -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")
+14
View File
@@ -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)
}
+128
View File
@@ -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) {}
+171
View File
@@ -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"`
}