internal/platform/utils/cmd.go (208 lines of code) (raw):
/*
* Copyright 2021-2024 JetBrains s.r.o.
*
* 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
*
* https://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 utils
import (
bt "bytes"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
log "github.com/sirupsen/logrus"
)
const (
// QodanaSuccessExitCode is Qodana exit code when the analysis is successfully completed.
QodanaSuccessExitCode = 0
// QodanaFailThresholdExitCode same as QodanaSuccessExitCode, but the threshold is set and exceeded.
QodanaFailThresholdExitCode = 255
// QodanaOutOfMemoryExitCode reports an interrupted process, sometimes because of an OOM.
QodanaOutOfMemoryExitCode = 137
// QodanaEapLicenseExpiredExitCode reports an expired license.
QodanaEapLicenseExpiredExitCode = 7
// QodanaTimeoutExitCodePlaceholder is not a real exit code (it is not obtained from IDE process! and not returned from CLI)
// Placeholder used to identify the case when the analysis reached timeout
QodanaTimeoutExitCodePlaceholder = 1000
// QodanaEmptyChangesetExitCodePlaceholder is not a real exit code (it is not obtained from IDE process! and not returned from CLI)
// Placeholder used to identify the case when the changeset for scoped analysis is empty
QodanaEmptyChangesetExitCodePlaceholder = 2000
)
// Bootstrap takes the given command (from CLI or qodana.yaml) and runs it.
func Bootstrap(command string, project string) {
if command == "" {
return
}
var executor string
var flag string
switch runtime.GOOS {
case "windows":
executor = "cmd"
flag = "/c"
default:
executor = "sh"
flag = "-c"
}
if res, err := RunCmd(project, executor, flag, "\""+command+"\""); res > 0 || err != nil {
log.Printf("Provided bootstrap command finished with error: %d. Exiting...", res)
os.Exit(res)
}
}
// RunCmd executes subprocess with forwarding of signals, and returns its exit code.
func RunCmd(cwd string, args ...string) (int, error) {
return RunCmdWithTimeout(cwd, os.Stdout, os.Stderr, time.Duration(math.MaxInt64), 1, args...)
}
// RunCmdWithTimeout executes subprocess with forwarding of signals, and returns its exit code.
func RunCmdWithTimeout(
cwd string,
stdout *os.File,
stderr *os.File,
timeout time.Duration,
timeoutExitCode int,
args ...string,
) (int, error) {
log.Debugf("Running command: %v", args)
cmd := exec.Command("bash", "-c", strings.Join(args, " ")) // TODO : Viktor told about set -e
var stdoutPipe, stderrPipe io.ReadCloser
var err error
if //goland:noinspection GoBoolExpressions
runtime.GOOS == "windows" {
cmd = prepareWinCmd(args...)
stdoutPipe, err = cmd.StdoutPipe()
if err != nil {
return 1, fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderrPipe, err = cmd.StderrPipe()
if err != nil {
return 1, fmt.Errorf("failed to get stderr pipe: %w", err)
}
} else {
cmd.Stdout = stdout
cmd.Stderr = stderr
}
if cmd.Dir, err = getCwdPath(cwd); err != nil {
return 1, err
}
cmd.Stdin = bt.NewBuffer([]byte{})
if err := cmd.Start(); err != nil {
return 1, fmt.Errorf("failed to start command: %w", err)
}
waitCh := make(chan error, 1)
go func() {
waitCh <- cmd.Wait()
close(waitCh)
}()
if //goland:noinspection GoBoolExpressions
runtime.GOOS == "windows" {
go readAndWrite(stdoutPipe, stdout)
go readAndWrite(stderrPipe, stderr)
}
return handleSignals(cmd, waitCh, timeout, timeoutExitCode)
}
// closePipe closes the pipe
func closePipe(file *os.File) {
err := file.Close()
if err != nil {
log.Error(err)
}
}
// RunCmdRedirectOutput executes subprocess with forwarding of signals, returns stdout, stderr and exit code.
func RunCmdRedirectOutput(cwd string, args ...string) (string, string, int, error) {
outReader, outWriter, err := os.Pipe()
if err != nil {
return "", "", -1, fmt.Errorf("failed to create stdout pipe: %w", err)
}
defer closePipe(outReader)
errReader, errWriter, err := os.Pipe()
if err != nil {
return "", "", -1, fmt.Errorf("failed to create stderr pipe: %w", err)
}
defer closePipe(errReader)
outChannel := make(chan string)
errChannel := make(chan string)
go copyToChannel(outReader, outChannel)
go copyToChannel(errReader, errChannel)
res, err := RunCmdWithTimeout(cwd, outWriter, errWriter, time.Duration(math.MaxInt64), 1, args...)
closePipes(outWriter, errWriter)
stdout := <-outChannel
stderr := <-errChannel
return stdout, stderr, res, err
}
// closePipes closes the pairs of pipes
func closePipes(outWriter *os.File, errWriter *os.File) {
err := outWriter.Close()
if err != nil {
log.Error("Error while closing stdout: ", err)
}
err = errWriter.Close()
if err != nil {
log.Error("Error while closing stderr: ", err)
}
}
// copyToChannel copies the content of a Reader to a channel
func copyToChannel(reader io.Reader, ch chan<- string) {
var buf bt.Buffer
_, err := io.Copy(&buf, reader)
if err != nil {
log.Error(err)
}
ch <- buf.String()
close(ch)
}
// getCwdPath gets the current working directory path
func getCwdPath(cwd string) (string, error) {
if cwd != "" {
return cwd, nil
}
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current working directory: %w", err)
}
return wd, nil
}
// handleSignals handles the signals from the subprocess
func handleSignals(cmd *exec.Cmd, waitCh <-chan error, timeout time.Duration, timeoutExitCode int) (int, error) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer func() {
signal.Stop(sigChan) // Use Stop to prevent panics
close(sigChan)
}()
var timeoutCh = time.After(timeout)
for {
select {
case <-sigChan:
if err := RequestTermination(cmd.Process); err != nil && !errors.Is(
err,
os.ErrProcessDone,
) { // Use errors.Is for semantic comparison
log.Error("Error terminating process: ", err)
}
case <-timeoutCh:
if err := RequestTermination(cmd.Process); err != nil {
log.Fatal("failed to kill process on timeout: ", err)
}
_, _ = cmd.Process.Wait()
return timeoutExitCode, nil
case ret := <-waitCh:
var exitError *exec.ExitError
if errors.As(ret, &exitError) {
log.Println(ret)
waitStatus := exitError.Sys().(syscall.WaitStatus)
if waitStatus.Exited() {
return waitStatus.ExitStatus(), nil
}
log.Println("Process killed (OOM?)")
return QodanaOutOfMemoryExitCode, nil
}
if ret != nil {
log.Println(ret)
}
return cmd.ProcessState.ExitCode(), ret
}
}
}
func readAndWrite(pipe io.ReadCloser, output *os.File) {
buf := make([]byte, 1024)
for {
n, err := pipe.Read(buf)
if n > 0 {
_, writeErr := output.Write(buf[:n])
if writeErr != nil {
log.Printf("failed to write to output: %v", writeErr)
break
}
}
if err != nil {
if err != io.EOF {
log.Printf("error reading from pipe: %v", err)
}
break
}
}
}