Files
larksuite--cli/cmd/event/stop_integration_test.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

341 lines
7.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bufio"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
type mockTransport struct {
mu sync.Mutex
addr string
cleaned bool
}
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
return net.Listen("tcp", addr)
}
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
}
func (t *mockTransport) Address(appID string) string {
t.mu.Lock()
defer t.mu.Unlock()
return t.addr
}
func (t *mockTransport) Cleanup(addr string) {
t.mu.Lock()
t.cleaned = true
t.mu.Unlock()
}
func (t *mockTransport) didCleanup() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.cleaned
}
type fakeBus struct {
listener net.Listener
pid int
exitDelay time.Duration
unresponsive bool
shutdownCount int32
wg sync.WaitGroup
stopOnce sync.Once
done chan struct{}
}
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
b := &fakeBus{
listener: ln,
pid: pid,
exitDelay: exitDelay,
unresponsive: unresponsive,
done: make(chan struct{}),
}
b.wg.Add(1)
go b.serve()
return b
}
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
func (b *fakeBus) serve() {
defer b.wg.Done()
for {
conn, err := b.listener.Accept()
if err != nil {
return
}
b.wg.Add(1)
go b.handle(conn)
}
}
func (b *fakeBus) handle(conn net.Conn) {
defer b.wg.Done()
defer conn.Close()
r := bufio.NewReader(conn)
line, err := r.ReadBytes('\n')
if err != nil {
return
}
msg, err := protocol.Decode(line)
if err != nil {
return
}
switch msg.(type) {
case *protocol.StatusQuery:
_ = protocol.Encode(conn, &protocol.StatusResponse{
Type: protocol.MsgTypeStatusResponse,
PID: b.pid,
UptimeSec: 1,
ActiveConns: 0,
Consumers: nil,
})
case *protocol.Shutdown:
atomic.AddInt32(&b.shutdownCount, 1)
if b.unresponsive {
return
}
if b.exitDelay > 0 {
go func() {
time.Sleep(b.exitDelay)
b.stop()
}()
} else {
go b.stop()
}
}
}
func (b *fakeBus) stop() {
b.stopOnce.Do(func() {
_ = b.listener.Close()
close(b.done)
})
}
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
b.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(budget):
t.Fatalf("fakeBus did not shut down within %v", budget)
}
}
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
const pid = 44441
const exitDelay = 500 * time.Millisecond
bus := newFakeBus(t, pid, exitDelay, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Fatalf("pid = %d; want %d", res.PID, pid)
}
if elapsed < 400*time.Millisecond {
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
}
if elapsed > 3*time.Second {
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
}
bus.wait(t, 2*time.Second)
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
}
}
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
const pid = 44442
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "error" {
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "did not exit within") {
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 0 {
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
}
if tr.didCleanup() {
t.Errorf("Cleanup should not be called when --force is false")
}
}
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
const pid = 44443
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", true)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "killed") {
t.Errorf("reason %q should mention 'killed'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
}
if !tr.didCleanup() {
t.Errorf("Cleanup was not invoked after force-kill")
}
}
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
const pid = 12345
bus := newFakeBus(t, pid, 0, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("expected PID=%d, got %d", pid, res.PID)
}
if elapsed > 500*time.Millisecond {
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
}
}
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
const pid = 99999
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return os.ErrProcessDone
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
res := stopBusOne(tr, "test-app", true)
if res.Status != "stopped" {
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
}
killMu.Lock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
}
killMu.Unlock()
if !tr.didCleanup() {
t.Error("expected Cleanup to be called even when kill reported already-dead")
}
if !strings.Contains(res.Reason, "exited during kill attempt") {
t.Errorf("expected reason about race, got %q", res.Reason)
}
}