Co-authored-by: zearlin <ruomiao.linrm@alibaba-inc.com> Co-authored-by: chichuan <30925823+haofeng0705@users.noreply.github.com>
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func signalSelf(t *testing.T, sig syscall.Signal) {
|
|
t.Helper()
|
|
process, err := os.FindProcess(os.Getpid())
|
|
if err != nil {
|
|
t.Fatalf("find current process: %v", err)
|
|
}
|
|
if err := process.Signal(sig); err != nil {
|
|
t.Skipf("current platform does not support process signal delivery: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
|
|
originalFind, originalExit := rootFindProcess, rootExitProcess
|
|
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
|
|
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
|
|
exitCode := 0
|
|
rootExitProcess = func(code int) { exitCode = code }
|
|
rootEscalateSignal(syscall.SIGTERM)
|
|
if exitCode != 143 {
|
|
t.Fatalf("escalation exit=%d", exitCode)
|
|
}
|
|
exitCode = 0
|
|
redeliverProcessSignal(syscall.SIGTERM)
|
|
if exitCode != 143 {
|
|
t.Fatalf("fallback exit=%d", exitCode)
|
|
}
|
|
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
|
|
exitCode = 0
|
|
redeliverProcessSignal(syscall.SIGINT)
|
|
if exitCode != 130 {
|
|
t.Fatalf("signal fallback exit=%d", exitCode)
|
|
}
|
|
interrupted := &processInterruption{signal: syscall.SIGINT}
|
|
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
|
|
t.Fatalf("interruption=%v", interrupted)
|
|
}
|
|
terminated := &processInterruption{signal: syscall.SIGTERM}
|
|
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
|
|
t.Fatalf("termination=%v", terminated)
|
|
}
|
|
state := &processSignalState{}
|
|
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
|
|
t.Fatal("signal state did not reject a second interruption")
|
|
}
|
|
}
|
|
|
|
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
|
|
signals := make(chan os.Signal, 3)
|
|
stopped, escalated := false, make(chan os.Signal, 1)
|
|
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
|
|
signals <- nil
|
|
signals <- syscall.SIGINT
|
|
<-ctx.Done()
|
|
signals <- syscall.SIGTERM
|
|
if got := <-escalated; got != syscall.SIGTERM {
|
|
t.Fatalf("escalated=%v", got)
|
|
}
|
|
stop()
|
|
stop()
|
|
if !stopped {
|
|
t.Fatal("signal notification was not stopped")
|
|
}
|
|
}
|
|
|
|
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
|
|
t.Helper()
|
|
testseam.Protect(t, &os.Args)
|
|
os.Args = []string{"dws"}
|
|
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
|
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
|
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
|
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
|
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
|
if unified {
|
|
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
|
}
|
|
cmd.SetContext(ctx)
|
|
cmd.SetOut(stdout)
|
|
cmd.SetErr(stderr)
|
|
return cmd
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
signal syscall.Signal
|
|
code int
|
|
subtype string
|
|
}{
|
|
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
|
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
installSignalExecuteSeams(t, true, &stdout, &stderr)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
signalSelf(t, tc.signal)
|
|
<-cmd.Context().Done()
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
|
|
if code := Execute(); code != tc.code {
|
|
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
|
}
|
|
var env output.Envelope
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
|
}
|
|
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
|
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
|
|
}
|
|
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
|
|
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
signal syscall.Signal
|
|
code int
|
|
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
|
|
t.Run(tc.signal.String(), func(t *testing.T) {
|
|
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
signalSelf(t, tc.signal)
|
|
<-cmd.Context().Done()
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
if code := Execute(); code != tc.code {
|
|
t.Fatalf("Execute code=%d, want %d", code, tc.code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
return cmd, context.DeadlineExceeded
|
|
})
|
|
if code := Execute(); code != 5 {
|
|
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
|
|
}
|
|
var env output.Envelope
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
|
|
t.Fatalf("deadline error=%+v", env.Error)
|
|
}
|
|
}
|
|
|
|
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
cmd.SetOut(failingWriter{})
|
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, _ = output.EmitStoredResult(cmd)
|
|
signalSelf(t, syscall.SIGINT)
|
|
<-cmd.Context().Done()
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
if code := Execute(); code != 5 {
|
|
t.Fatalf("Execute code=%d, want publication failure code 5", code)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("second envelope emitted: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
// Record cancellation before publication begins, then simulate a command
|
|
// hook that has already committed its result and completes publication.
|
|
// The wire result must remain authoritative over the earlier signal.
|
|
signalSelf(t, syscall.SIGINT)
|
|
<-cmd.Context().Done()
|
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
if code := Execute(); code != 0 {
|
|
t.Fatalf("Execute code=%d, want published success code 0", code)
|
|
}
|
|
var env output.Envelope
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
|
|
}
|
|
if !env.OK || env.Outcome != output.OutcomeSuccess {
|
|
t.Fatalf("published envelope=%+v, want successful outcome", env)
|
|
}
|
|
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
|
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signalSelf(t, syscall.SIGINT)
|
|
<-cmd.Context().Done()
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
if code := Execute(); code != 0 {
|
|
t.Fatalf("Execute code=%d, want established success code 0", code)
|
|
}
|
|
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
|
|
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
|
|
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
|
|
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
|
|
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
|
_, _ = fmt.Fprintln(os.Stderr, "READY")
|
|
<-cmd.Context().Done()
|
|
return cmd, cmd.Context().Err()
|
|
})
|
|
os.Exit(Execute())
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
signal syscall.Signal
|
|
code int
|
|
subtype string
|
|
}{
|
|
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
|
|
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
|
|
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
|
|
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
|
|
}
|
|
if err := cmd.Process.Signal(tc.signal); err != nil {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
|
|
}
|
|
payload, readErr := io.ReadAll(stdout)
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
waitErr := cmd.Wait()
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
|
|
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
|
|
}
|
|
var env output.Envelope
|
|
if err := json.Unmarshal(payload, &env); err != nil {
|
|
t.Fatalf("decode helper output: %v; output=%q", err, payload)
|
|
}
|
|
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
|
|
t.Fatalf("helper error=%+v", env.Error)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
|
|
signals := make(chan os.Signal, 2)
|
|
escalated := make(chan os.Signal, 1)
|
|
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
|
|
escalated <- sig
|
|
})
|
|
signals <- syscall.SIGINT
|
|
<-ctx.Done()
|
|
if !errors.Is(context.Cause(ctx), context.Canceled) {
|
|
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
|
|
}
|
|
signals <- syscall.SIGTERM
|
|
if got := <-escalated; got != syscall.SIGTERM {
|
|
t.Fatalf("escalated %v, want SIGTERM", got)
|
|
}
|
|
stop()
|
|
}
|
|
|
|
type failingWriter struct{}
|
|
|
|
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|