diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index 6f60f23a..272c4c2f 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -7,6 +7,8 @@ import ( "context" "fmt" "io" + "os" + "strconv" "sync" "time" @@ -160,10 +162,32 @@ const ( // maxOutputLines is the per-statement line limit. Statements that produce more // lines than this are aborted to prevent runaway output from infinite loops. maxOutputLines = 10_000 - // executeTimeout is the maximum wall-clock time allowed for a single statement. - executeTimeout = 5 * time.Minute + // defaultExecuteTimeout is the maximum wall-clock time allowed for a single + // statement when MXCLI_EXEC_TIMEOUT is not set. + defaultExecuteTimeout = 5 * time.Minute ) +// configuredExecuteTimeout returns the per-statement wall-clock timeout. The +// value is read from the MXCLI_EXEC_TIMEOUT environment variable on every call +// so long-running audits can opt into a higher ceiling without recompiling. +// +// Accepts either a Go duration ("12m", "2h30m") or a bare number of seconds +// ("900"). Falls back to defaultExecuteTimeout when the variable is unset, +// empty, or fails to parse. +func configuredExecuteTimeout() time.Duration { + raw := os.Getenv("MXCLI_EXEC_TIMEOUT") + if raw == "" { + return defaultExecuteTimeout + } + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + return d + } + if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + return defaultExecuteTimeout +} + // BackendFactory creates a new backend instance for connecting to a project. type BackendFactory func() backend.FullBackend @@ -221,7 +245,7 @@ func (e *Executor) SetLogger(l *diaglog.Logger) { // Execute runs a single MDL statement with output-line and wall-clock guards. // Each statement gets a fresh line budget. If the statement exceeds maxOutputLines -// lines of output or runs longer than executeTimeout, it is aborted with an error. +// lines of output or runs longer than the configured timeout, it is aborted with an error. func (e *Executor) Execute(stmt ast.Statement) error { start := time.Now() @@ -233,6 +257,7 @@ func (e *Executor) Execute(stmt ast.Statement) error { // Enforce wall-clock timeout via context.WithTimeout. // The goroutine pattern is retained because handlers are not yet // context-aware; threading context through handlers is a follow-up. + executeTimeout := configuredExecuteTimeout() ctx, cancel := context.WithTimeout(context.Background(), executeTimeout) defer cancel() diff --git a/mdl/executor/executor_timeout_test.go b/mdl/executor/executor_timeout_test.go new file mode 100644 index 00000000..8f82f63d --- /dev/null +++ b/mdl/executor/executor_timeout_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + "time" +) + +func TestConfiguredExecuteTimeoutUsesDurationEnv(t *testing.T) { + t.Setenv("MXCLI_EXEC_TIMEOUT", "12m") + + if got := configuredExecuteTimeout(); got != 12*time.Minute { + t.Fatalf("configured timeout = %v, want 12m", got) + } +} + +func TestConfiguredExecuteTimeoutUsesSecondEnv(t *testing.T) { + t.Setenv("MXCLI_EXEC_TIMEOUT", "900") + + if got := configuredExecuteTimeout(); got != 15*time.Minute { + t.Fatalf("configured timeout = %v, want 15m", got) + } +} + +func TestConfiguredExecuteTimeoutFallsBackForInvalidEnv(t *testing.T) { + t.Setenv("MXCLI_EXEC_TIMEOUT", "invalid") + + if got := configuredExecuteTimeout(); got != defaultExecuteTimeout { + t.Fatalf("configured timeout = %v, want default %v", got, defaultExecuteTimeout) + } +}