Ctrl K
ComunidadGuías…

utils/signal_others.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//go:build linux || darwin

package utils

import (
	"bytes"
	"context"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/creack/pty"
	"go.uber.org/zap"
	"golang.org/x/term"
)

func SendSignal(logger *zap.Logger, pid int, sig syscall.Signal) error {
	err := syscall.Kill(pid, sig)
	if err != nil {
		// ignore the ESRCH error as it means the process is already dead
		if errno, ok := err.(syscall.Errno); ok && errno == syscall.ESRCH {
			return nil
		}
		logger.Error("failed to send signal to process", zap.Int("pid", pid), zap.Error(err))
		return err
	}
	logger.Debug("signal sent to process successfully", zap.Int("pid", pid), zap.String("signal", sig.String()))

	return nil
}

func ExecuteCommand(ctx context.Context, logger *zap.Logger, userCmd string, kind CmdType, cancel func(cmd *exec.Cmd) func() error, waitDelay time.Duration, stdin []byte, extraEnv []string, opts ...ExecOption) CmdError {
	o := execOptionsOf(opts)
	// Run the app as the user who invoked sudo

	// Run through `sh -c` when a shell is available; fall back to a direct exec
	// (no /bin/sh) on distroless images. See utils.CommandContext.
	cmd, err := CommandContext(ctx, userCmd)
	if err != nil {
		return CmdError{Type: Init, Err: err}
	}

	// Set the cancel function for the command
	cmd.Cancel = cancel(cmd)

	// extraEnv is added to what the command inherits from keploy, for this
	// command alone — a secret that one child needs is not exported to every
	// other process keploy starts.
	if len(extraEnv) > 0 {
		cmd.Env = append(os.Environ(), extraEnv...)
	}

	// wait after sending the interrupt signal, before sending the kill signal
	cmd.WaitDelay = waitDelay

	// Check if the command is docker-compose related and output is a TTY.
	//
	// kind is the resolved command type, not a second guess from the command
	// string. Re-deriving here mislabelled every command --cmd-type exists
	// for — a wrapper like "make up", and `sudo -E docker compose up`, both
	// come back "native" — which drops compose onto the Setpgid path and the
	// background process group that SIGTTOU/SIGTTIN stalls.
	cmdType := kind
	if cmdType == Empty {
		cmdType = FindDockerCmd(userCmd)
	}
	isTTY := term.IsTerminal(int(os.Stdout.Fd()))

	// When in-memory compose content is provided, pipe it via stdin and skip PTY.
	// PTY takes over the terminal's stdin/stdout so it cannot coexist with an
	// explicit stdin reader; the non-PTY path handles this case correctly.
	// We check for nil (not length) so that a non-nil empty slice still disables PTY.
	if stdin != nil {
		cmd.Stdin = bytes.NewReader(stdin)
	}

	// Use PTY for Docker Compose when running in a TTY to avoid SIGTTOU/SIGTTIN issues
	// Docker Compose needs to read terminal size for progress bars, but Setpgid: true
	// puts it in a background process group which causes the OS to pause it.
	// A PTY gives Docker Compose its own terminal to work with.
	// Skip PTY when stdin content is provided (in-memory compose mode).
	if cmdType == DockerCompose && isTTY && stdin == nil {
		// For PTY, we use Setsid to create a new session instead of Setpgid
		// This allows the PTY to become the controlling terminal
		cmd.SysProcAttr = &syscall.SysProcAttr{
			Setsid: true,
		}
		logger.Debug("Output is a TTY (Docker Compose -> PTY)")
		return executeWithPTY(ctx, logger, cmd, o.outputTee)
	}

	// For non-PTY execution, use Setpgid for process group management
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Setpgid: true,
	}

	if cmdType == DockerCompose {
		logger.Debug("Output is NOT a TTY (Docker Compose -> Stdout/Stderr)")
	}
	// Set the output of the command to stdout/stderr, cut of the values the
	// redactor holds when it holds any (ChildConsole).
	console, err := SetChildConsoleTee(cmd, o.outputTee)
	if err != nil {
		return CmdError{Type: Init, Err: err}
	}

	logger.Info("Starting Application :", zap.String("executing_cmd", cmd.String()))
	err = cmd.Start()
	console.Started()
	if err != nil {
		return CmdError{Type: Init, Err: err}
	}
	if kind == Native {
		// The application's own process: a wait for it to listen can name
		// the ports it listens on instead (AppListenPorts).
		NoteAppPID(ctx, cmd.Process.Pid)
	}

	err = cmd.Wait()
	console.Exited()
	if err != nil {
		return CmdError{Type: Runtime, Err: err}
	}

	return CmdError{}
}

// executeWithPTY runs the command inside a dedicated PTY (Pseudo-Terminal).
// This is necessary for Docker Compose when Setpgid is true, as Docker Compose
// tries to read terminal size for rendering progress bars. Without a PTY,
// the OS would pause the background process with SIGTTOU/SIGTTIN.
func executeWithPTY(_ context.Context, logger *zap.Logger, cmd *exec.Cmd, tee func() io.Writer) CmdError {
	// Start the command with a PTY
	// pty.Start creates a PTY pair, assigns the slave PTY to cmd's stdin/stdout/stderr,
	// and starts the command
	ptmx, err := pty.Start(cmd)
	if err != nil {
		logger.Error("failed to start command with PTY", zap.Error(err))
		return CmdError{Type: Init, Err: err}
	}

	logger.Info("Starting Application:", zap.String("executing_cmd", cmd.String()))

	// Handle terminal resize - propagate size changes from real terminal to PTY
	resizeCh := make(chan os.Signal, 1)
	signal.Notify(resizeCh, syscall.SIGWINCH)
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for range resizeCh {
			if resizeErr := pty.InheritSize(os.Stdin, ptmx); resizeErr != nil {
				// We might get an error if the PTY is closed while we try to resize it.
				// This is expected during shutdown.
				if !isClosedPTYError(resizeErr) {
					logger.Debug("failed to resize PTY", zap.Error(resizeErr))
				}
			}
		}
	}()
	// Trigger initial resize
	resizeCh <- syscall.SIGWINCH

	// Copy PTY output to real stdout, cut as ChildConsole's would be, and to
	// the tee when one is set (WithOutputTee).
	output := StartChildOutputCopy(teeTo(os.Stdout, tee), ptmx, false)

	// Wait for the command to finish
	cmdErr := cmd.Wait()

	// Stop listening for resize signals first, then drain any pending signals
	// before closing the channel to avoid potential write to closed channel
	signal.Stop(resizeCh)
	// Drain any pending signals
	select {
	case <-resizeCh:
	default:
	}
	close(resizeCh)

	// Wait for the resize goroutine to finish to ensure it's done using the PTY
	wg.Wait()

	// The copy ends at EIO, once the command and all it left holding the
	// terminal have closed it and the kernel has moved to ptmx all they
	// wrote. Only then is ptmx closed: closing it first dropped what the
	// copy had not yet read.
	<-output.Done()
	if closeErr := ptmx.Close(); closeErr != nil {
		logger.Debug("failed to close PTY", zap.Error(closeErr))
	}

	if cmdErr != nil {
		return CmdError{Type: Runtime, Err: cmdErr}
	}

	return CmdError{}
}

// isClosedPTYError checks if the error is due to the PTY being closed,
// which is expected when the command finishes.
func isClosedPTYError(err error) bool {
	if err == nil {
		return false
	}
	errStr := err.Error()
	// When PTY is closed, io.Copy returns with read errors like:
	// - "read /dev/ptmx: input/output error" (Linux)
	// - "read /dev/ptmx: file already closed"
	// - EOF is also expected
	return err == io.EOF ||
		strings.Contains(errStr, "input/output error") ||
		strings.Contains(errStr, "file already closed") ||
		strings.Contains(errStr, "bad file descriptor")
}