Skip to content

Commit

Permalink
✨ Introduce analysis timeout option (QD-8156)
Browse files Browse the repository at this point in the history
`analysis-timeout` – timeout in ms, `analysis-timeout-exit-code` – exit code of CLI process in case timeout is met. If timeout is met, the IDE process is terminated
  • Loading branch information
MekhailS authored and tiulpin committed Jan 22, 2024
1 parent 1326824 commit 20be11c
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 48 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ qodana scan [flags]
--cleanup Run project cleanup
--property stringArray Set a JVM property to be used while running Qodana using the --property property.name=value1,value2,...,valueN notation
-s, --save-report Generate HTML report (default true)
--timeout int Qodana analysis time limit in milliseconds. If reached, the analysis is terminated, process exits with code timeout-exit-code. Negative – no timeout (default -1)
--timeout-exit-code int See timeout option (default 1)
-e, --env stringArray Only for container runs. Define additional environment variables for the Qodana container (you can use the flag multiple times). CLI is not reading full host environment variables and does not pass it to the Qodana container for security reasons
-v, --volume stringArray Only for container runs. Define additional volumes for the Qodana container (you can use the flag multiple times)
-u, --user string Only for container runs. User to run Qodana container as. Please specify user id – '$UID' or user id and group id $(id -u):$(id -g). Use 'root' to run as the root user (default: the current user)
Expand Down
10 changes: 8 additions & 2 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ But you can always override qodana.yaml options with the following command-line
options.FetchAnalyzerSettings()
exitCode := core.RunAnalysis(ctx, options)

checkExitCode(exitCode, options.ResultsDir)
checkExitCode(exitCode, options.ResultsDir, options)
core.ReadSarif(filepath.Join(options.ResultsDir, core.QodanaSarifName), options.PrintProblems)
if core.IsInteractive() {
options.ShowReport = core.AskUserConfirm("Do you want to open the latest report")
Expand Down Expand Up @@ -120,6 +120,9 @@ But you can always override qodana.yaml options with the following command-line
flags.StringArrayVar(&options.Property, "property", []string{}, "Set a JVM property to be used while running Qodana using the --property property.name=value1,value2,...,valueN notation")
flags.BoolVarP(&options.SaveReport, "save-report", "s", true, "Generate HTML report")

flags.IntVar(&options.AnalysisTimeoutMs, "timeout", -1, "Qodana analysis time limit in milliseconds. If reached, the analysis is terminated, process exits with code timeout-exit-code. Negative – no timeout")
flags.IntVar(&options.AnalysisTimeoutExitCode, "timeout-exit-code", 1, "See timeout option")

// Third-party linter options
flags.BoolVar(&options.NoStatistics, "no-statistics", false, "(qodana-cdnet/qodana-clang) Don't collect anonymous statistics")
// Cdnet specific options
Expand Down Expand Up @@ -175,14 +178,17 @@ func checkProjectDir(projectDir string) {
}
}

func checkExitCode(exitCode int, resultsDir string) {
func checkExitCode(exitCode int, resultsDir string, options *core.QodanaOptions) {
if exitCode == core.QodanaEapLicenseExpiredExitCode && core.IsInteractive() {
core.EmptyMessage()
core.ErrorMessage(
"Your license expired: update your license or token. If you are using EAP, make sure you are using the latest CLI version and update to the latest linter by running %s ",
core.PrimaryBold("qodana init"),
)
os.Exit(exitCode)
} else if exitCode == core.QodanaTimeoutExitCodePlaceholder {
core.ErrorMessage("Qodana analysis reached timeout %s", options.GetAnalysisTimeout())
os.Exit(options.AnalysisTimeoutExitCode)
} else if exitCode != core.QodanaSuccessExitCode && exitCode != core.QodanaFailThresholdExitCode {
core.ErrorMessage("Qodana exited with code %d", exitCode)
core.WarningMessage("Check ./logs/ in the results directory for more information")
Expand Down
17 changes: 16 additions & 1 deletion core/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,22 @@ package core
import (
"errors"
log "github.com/sirupsen/logrus"
"math"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
)

// RunCmd executes subprocess with forwarding of signals, and returns its exit code.
func RunCmd(cwd string, args ...string) int {
return RunCmdWithTimeout(cwd, time.Duration(math.MaxInt64), 1, args...)
}

// RunCmdWithTimeout executes subprocess with forwarding of signals, and returns its exit code.
// If timeout occurs, subprocess is terminated, timeoutExitCode is returned
func RunCmdWithTimeout(cwd string, timeout time.Duration, timeoutExitCode int, args ...string) int {
log.Debugf("Running command: %v", args)
cmd := exec.Command(args[0], args[1:]...)
if //goland:noinspection GoBoolExpressions
Expand Down Expand Up @@ -63,12 +70,20 @@ func RunCmd(cwd string, args ...string) int {
close(sigChan)
}()

var timeoutCh = time.After(timeout)

for {
select {
case sig := <-sigChan:
if err := cmd.Process.Signal(sig); err != nil && err.Error() != "os: process already finished" {
log.Print("error sending signal", sig, err)
}
case <-timeoutCh:
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
log.Fatal("failed to kill process on timeout: ", err)
}
_, _ = cmd.Process.Wait()
return timeoutExitCode
case err := <-waitCh:
var exitError *exec.ExitError
if errors.As(err, &exitError) {
Expand Down
3 changes: 3 additions & 0 deletions core/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const (
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
// officialImagePrefix is the prefix of official Qodana images.
officialImagePrefix = "jetbrains/qodana"
dockerSpecialCharsLength = 8
Expand Down
32 changes: 32 additions & 0 deletions core/core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,38 @@ func Test_runCmd(t *testing.T) {
}
}

func Test_runCmdWithTimeout(t *testing.T) {
if //goland:noinspection ALL
runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
var sleepFor2SecondsCmd = []string{"sh", "-c", "sleep 2 && exit 255"}
for _, tc := range []struct {
name string
timeout time.Duration
timeoutExitCode int
cmd []string
res int
expectedTimeS int
}{
{"timeout not reached exit code 255", time.Duration(3) * time.Second, 0, sleepFor2SecondsCmd, 255, 2},
{"timeout reached exit code 42", time.Duration(1) * time.Second, 42, sleepFor2SecondsCmd, 42, 1},
} {
t.Run(tc.name, func(t *testing.T) {
startTime := time.Now()
got := RunCmdWithTimeout("", tc.timeout, tc.timeoutExitCode, tc.cmd...)
endTime := time.Now()

if got != tc.res {
t.Errorf("runCmdWithTimeout: %v, Got: %v, Expected: %v", tc.cmd, got, tc.res)
}
durationSeconds := int(endTime.Sub(startTime).Seconds())
if tc.expectedTimeS > durationSeconds || durationSeconds >= tc.expectedTimeS+1 {
t.Errorf("runCmdWithTimeout: %v, Duration: %vs, Expected: %vs", tc.cmd, durationSeconds, tc.expectedTimeS)
}
})
}
}
}

func Test_createUser(t *testing.T) {
if runtime.GOOS == "windows" {
return
Expand Down
2 changes: 1 addition & 1 deletion core/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func getIdeExitCode(resultsDir string, c int) (res int) {

func runQodanaLocal(opts *QodanaOptions) int {
args := getIdeRunCommand(opts)
res := getIdeExitCode(opts.ResultsDir, RunCmd("", args...))
res := getIdeExitCode(opts.ResultsDir, RunCmdWithTimeout("", opts.GetAnalysisTimeout(), QodanaTimeoutExitCodePlaceholder, args...))
if res > QodanaSuccessExitCode && res != QodanaFailThresholdExitCode {
postAnalysis(opts)
return res
Expand Down
99 changes: 55 additions & 44 deletions core/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,57 +19,61 @@ package core
import (
"fmt"
log "github.com/sirupsen/logrus"
"math"
"os"
"path/filepath"
"strings"
"time"
)

// QodanaOptions is a struct that contains all the options to run a Qodana linter.
type QodanaOptions struct {
ResultsDir string
CacheDir string
ProjectDir string
ReportDir string
CoverageDir string
Linter string
Ide string
SourceDirectory string
DisableSanity bool
ProfileName string
ProfilePath string
RunPromo string
StubProfile string // note: deprecated option
Baseline string
BaselineIncludeAbsent bool
SaveReport bool
ShowReport bool
Port int
Property []string
Script string
FailThreshold string
Commit string
AnalysisId string
Env []string
Volumes []string
User string
PrintProblems bool
SkipPull bool
ClearCache bool
YamlName string
GitReset bool
FullHistory bool
ApplyFixes bool
Cleanup bool
FixesStrategy string // note: deprecated option
_id string
NoStatistics bool // thirdparty common option
Solution string // cdnet specific options
Project string
Configuration string
Platform string
NoBuild bool
CompileCommands string // clang specific options
ClangArgs string
ResultsDir string
CacheDir string
ProjectDir string
ReportDir string
CoverageDir string
Linter string
Ide string
SourceDirectory string
DisableSanity bool
ProfileName string
ProfilePath string
RunPromo string
StubProfile string // note: deprecated option
Baseline string
BaselineIncludeAbsent bool
SaveReport bool
ShowReport bool
Port int
Property []string
Script string
FailThreshold string
Commit string
AnalysisId string
Env []string
Volumes []string
User string
PrintProblems bool
SkipPull bool
ClearCache bool
YamlName string
GitReset bool
FullHistory bool
ApplyFixes bool
Cleanup bool
FixesStrategy string // note: deprecated option
_id string
NoStatistics bool // thirdparty common option
Solution string // cdnet specific options
Project string
Configuration string
Platform string
NoBuild bool
CompileCommands string // clang specific options
ClangArgs string
AnalysisTimeoutMs int
AnalysisTimeoutExitCode int
}

func (o *QodanaOptions) FetchAnalyzerSettings() {
Expand Down Expand Up @@ -132,6 +136,13 @@ func (o *QodanaOptions) unsetenv(key string) {
}
}

func (o *QodanaOptions) GetAnalysisTimeout() time.Duration {
if o.AnalysisTimeoutMs <= 0 {
return time.Duration(math.MaxInt64)
}
return time.Duration(o.AnalysisTimeoutMs) * time.Millisecond
}

func (o *QodanaOptions) id() string {
if o._id == "" {
var analyzer string
Expand Down
Loading

0 comments on commit 20be11c

Please sign in to comment.