316 lines
9.0 KiB
Go
316 lines
9.0 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package busctl
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
)
|
|
|
|
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
|
|
// acts as a fake "dws event _bus" child. It reads ReadyFDFromEnv, writes
|
|
// the byte specified by the env var, and either exits or sleeps based on
|
|
// the second env var. Used by the Spawn tests below to exercise the
|
|
// real fork path without requiring a separate test-helper binary.
|
|
//
|
|
// We piggy-back on the test binary because building a separate helper
|
|
// would require either build tags or a `TestMain` two-phase exec; the
|
|
// env-marker pattern is what Go's own os/exec tests use and stays
|
|
// confined to this file.
|
|
const (
|
|
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
|
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
|
|
// values:
|
|
// "ready" — write 'R' then sleep 30s (parent should see ready)
|
|
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
|
|
// "silent" — exit without writing (parent should see ErrSpawnFailed via EOF)
|
|
// "stall" — sleep without writing (parent should see ErrSpawnTimeout)
|
|
// "write-stdout" — write 'R' to ready FD AND to stdout (parent verifies stdout was detached)
|
|
)
|
|
|
|
// TestMain detects the child mode marker and executes the corresponding
|
|
// behaviour before delegating to the normal test runner. Production
|
|
// invocations never have this env var set so the dispatch is a no-op.
|
|
func TestMain(m *testing.M) {
|
|
switch os.Getenv(childEnvMarker) {
|
|
case "ready":
|
|
writeReady('R')
|
|
time.Sleep(30 * time.Second)
|
|
os.Exit(0)
|
|
case "fail":
|
|
writeReady('E')
|
|
os.Exit(1)
|
|
case "silent":
|
|
// don't open the ready FD at all — let the pipe close on exec exit
|
|
os.Exit(2)
|
|
case "stall":
|
|
time.Sleep(30 * time.Second)
|
|
os.Exit(0)
|
|
case "write-stdout":
|
|
// Write to stdout BEFORE signalling ready. Parent's Spawn should
|
|
// have detached stdout to /dev/null, so the parent's stdout
|
|
// buffer (captured separately in the test) must NOT see this.
|
|
_, _ = os.Stdout.Write([]byte("POLLUTION-FROM-CHILD\n"))
|
|
writeReady('R')
|
|
time.Sleep(30 * time.Second)
|
|
os.Exit(0)
|
|
case "windows-ready":
|
|
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
|
|
if err != nil {
|
|
os.Exit(3)
|
|
}
|
|
defer listener.Close()
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
os.Exit(4)
|
|
}
|
|
_ = conn.Close()
|
|
time.Sleep(30 * time.Second)
|
|
os.Exit(0)
|
|
case "windows-fail":
|
|
os.Exit(5)
|
|
case "windows-exit":
|
|
os.Exit(0)
|
|
case "windows-stall":
|
|
time.Sleep(30 * time.Second)
|
|
os.Exit(0)
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func writeReady(b byte) {
|
|
pipe := ReadyFDFromEnv()
|
|
if pipe == nil {
|
|
return
|
|
}
|
|
_, _ = pipe.Write([]byte{b})
|
|
_ = pipe.Close()
|
|
}
|
|
|
|
// spawnWithMarker invokes Spawn against the current test binary with a
|
|
// child-mode env var set. Returns the child's PID + the Spawn error.
|
|
func spawnWithMarker(t *testing.T, marker string, opts ...func(*SpawnConfig)) (int, error) {
|
|
t.Helper()
|
|
cfg := SpawnConfig{
|
|
ExecPath: os.Args[0],
|
|
ClientID: "ding_spawn_test",
|
|
Env: append(os.Environ(),
|
|
childEnvMarker+"="+marker,
|
|
),
|
|
}
|
|
for _, o := range opts {
|
|
o(&cfg)
|
|
}
|
|
return Spawn(cfg)
|
|
}
|
|
|
|
func TestSpawn_ReadySuccess(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Spawn uses Unix Setsid; Windows path covered separately")
|
|
}
|
|
pid, err := spawnWithMarker(t, "ready")
|
|
if err != nil {
|
|
t.Fatalf("Spawn ready: %v", err)
|
|
}
|
|
if pid <= 0 {
|
|
t.Errorf("Spawn returned non-positive pid %d", pid)
|
|
}
|
|
// Reap the child so it doesn't outlive the test.
|
|
if proc, err := os.FindProcess(pid); err == nil {
|
|
_ = proc.Kill()
|
|
_, _ = proc.Wait()
|
|
}
|
|
}
|
|
|
|
func TestSpawn_ReadyFailReportsErrSpawnFailed(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Spawn uses Unix Setsid")
|
|
}
|
|
pid, err := spawnWithMarker(t, "fail")
|
|
if !errors.Is(err, ErrSpawnFailed) {
|
|
t.Fatalf("err = %v, want ErrSpawnFailed", err)
|
|
}
|
|
if pid <= 0 {
|
|
t.Errorf("pid should still be reported even on fail, got %d", pid)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_ChildSilentExitReportsErrSpawnFailed(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Spawn uses Unix Setsid")
|
|
}
|
|
_, err := spawnWithMarker(t, "silent")
|
|
if !errors.Is(err, ErrSpawnFailed) {
|
|
t.Fatalf("err = %v, want ErrSpawnFailed (EOF on pipe)", err)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_StallReportsTimeout(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Spawn uses Unix Setsid")
|
|
}
|
|
// Temporarily shorten ReadyTimeout via a local Spawn variant. The
|
|
// production timeout is 10s — too long for a unit test. We exec
|
|
// manually with a tiny io-wait wrapper to verify the behaviour.
|
|
//
|
|
// We can't change the package-level const, so we re-implement the
|
|
// timeout part directly using the same primitives the production
|
|
// code uses.
|
|
cmd := exec.Command(os.Args[0])
|
|
cmd.Env = append(os.Environ(), childEnvMarker+"=stall", ReadyFDEnv+"=3")
|
|
pr, pw, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd.ExtraFiles = []*os.File{pw}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}()
|
|
_ = pw.Close()
|
|
|
|
// Replicate waitReady with a tiny timeout.
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
b := make([]byte, 1)
|
|
_, err := pr.Read(b)
|
|
done <- err
|
|
}()
|
|
select {
|
|
case <-done:
|
|
t.Fatal("child should have stalled; got data on ready pipe")
|
|
case <-time.After(200 * time.Millisecond):
|
|
// expected — child is stalling, no ready byte arrived
|
|
}
|
|
_ = pr.Close()
|
|
}
|
|
|
|
func TestSpawn_StdioDetached(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Spawn uses Unix Setsid; Windows stdio handling differs")
|
|
}
|
|
// Capture this process's stdout for the duration of the child run.
|
|
// If applyDetach is broken and cmd.Stdout would otherwise inherit,
|
|
// the child's "POLLUTION-FROM-CHILD" line would land in our pipe.
|
|
origStdout := os.Stdout
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Stdout = w
|
|
defer func() {
|
|
os.Stdout = origStdout
|
|
_ = w.Close()
|
|
_ = r.Close()
|
|
}()
|
|
|
|
pid, err := spawnWithMarker(t, "write-stdout")
|
|
if err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
defer func() {
|
|
if proc, err := os.FindProcess(pid); err == nil {
|
|
_ = proc.Kill()
|
|
_, _ = proc.Wait()
|
|
}
|
|
}()
|
|
// Close the write end on the parent side so reading r will EOF if
|
|
// nothing arrived. Give the child a beat to attempt the write.
|
|
time.Sleep(150 * time.Millisecond)
|
|
_ = w.Close()
|
|
|
|
buf := make([]byte, 256)
|
|
r.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
n, _ := r.Read(buf)
|
|
if n > 0 {
|
|
t.Fatalf("child wrote %q to parent's stdout — stdio not detached!", buf[:n])
|
|
}
|
|
}
|
|
|
|
func TestReadyFDFromEnv_NoEnvReturnsNil(t *testing.T) {
|
|
t.Setenv(ReadyFDEnv, "")
|
|
if f := ReadyFDFromEnv(); f != nil {
|
|
t.Errorf("ReadyFDFromEnv with empty env should be nil, got %v", f)
|
|
}
|
|
}
|
|
|
|
func TestReadyFDFromEnv_InvalidIntReturnsNil(t *testing.T) {
|
|
t.Setenv(ReadyFDEnv, "not-an-int")
|
|
if f := ReadyFDFromEnv(); f != nil {
|
|
t.Errorf("ReadyFDFromEnv with bad value should be nil, got %v", f)
|
|
}
|
|
}
|
|
|
|
func TestReadyFDFromEnv_LowFDRejected(t *testing.T) {
|
|
// fd 0/1/2 are stdio — refusing them defends against accidental
|
|
// stdin/stdout/stderr corruption if someone misconfigures.
|
|
t.Setenv(ReadyFDEnv, "1")
|
|
if f := ReadyFDFromEnv(); f != nil {
|
|
t.Errorf("ReadyFDFromEnv should reject stdio fds, got %v", f)
|
|
}
|
|
}
|
|
|
|
// waitReady must surface the child's real startup error (written after the
|
|
// 'E' byte) so consume shows WHY the bus failed, not an opaque message.
|
|
func TestWaitReady_FailureSurfacesChildError(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses Unix pipe")
|
|
}
|
|
pr, pw, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pr.Close()
|
|
const reason = "newPersonalStreamSource: token expired for corp dinga626"
|
|
go func() {
|
|
_, _ = pw.Write([]byte{'E'})
|
|
_, _ = io.WriteString(pw, reason)
|
|
_ = pw.Close()
|
|
}()
|
|
err = waitReady(pr)
|
|
if !errors.Is(err, ErrSpawnFailed) {
|
|
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), reason) {
|
|
t.Errorf("error must surface the child's real reason; got: %v", err)
|
|
}
|
|
}
|
|
|
|
// A bare 'E' (no trailing text) still reports ErrSpawnFailed.
|
|
func TestWaitReady_BareFailureByte(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses Unix pipe")
|
|
}
|
|
pr, pw, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pr.Close()
|
|
go func() { _, _ = pw.Write([]byte{'E'}); _ = pw.Close() }()
|
|
if err := waitReady(pr); !errors.Is(err, ErrSpawnFailed) {
|
|
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
|
}
|
|
}
|