bf9395e022
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
179 lines
5.9 KiB
Go
179 lines
5.9 KiB
Go
// 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)
|
|
}
|