diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go index 5afc37d3..11f2cf09 100644 --- a/components/execd/pkg/runtime/command.go +++ b/components/execd/pkg/runtime/command.go @@ -149,12 +149,13 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest } // runBackgroundCommand executes shell commands in detached mode. -func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCodeRequest) error { +func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { session := c.newContextID() request.Hooks.OnExecuteInit(session) pipe, err := c.combinedOutputDescriptor(session) if err != nil { + cancel() return fmt.Errorf("failed to get combined output descriptor: %w", err) } stdoutPath := c.combinedOutputFileName(session) @@ -167,7 +168,7 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod startAt := time.Now() log.Info("received command: %v", request.Code) - cmd := exec.CommandContext(context.Background(), "bash", "-c", request.Code) + cmd := exec.CommandContext(ctx, "bash", "-c", request.Code) cmd.Dir = request.Cwd cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -178,33 +179,34 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod // use DevNull as stdin so interactive programs exit immediately. cmd.Stdin = os.NewFile(uintptr(syscall.Stdin), os.DevNull) + err = cmd.Start() + kernel := &commandKernel{ + pid: -1, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: true, + content: request.Code, + isBackground: true, + } + if err != nil { + cancel() + log.Error("CommandExecError: error starting commands: %v", err) + kernel.running = false + c.storeCommandKernel(session, kernel) + c.markCommandFinished(session, 255, err.Error()) + return fmt.Errorf("failed to start commands: %w", err) + } + safego.Go(func() { defer pipe.Close() - err := cmd.Start() - kernel := &commandKernel{ - pid: -1, - stdoutPath: stdoutPath, - stderrPath: stderrPath, - startedAt: startAt, - running: true, - content: request.Code, - isBackground: true, - } - - if err != nil { - log.Error("CommandExecError: error starting commands: %v", err) - kernel.running = false - c.storeCommandKernel(session, kernel) - c.markCommandFinished(session, 255, err.Error()) - return - } - kernel.running = true kernel.pid = cmd.Process.Pid c.storeCommandKernel(session, kernel) err = cmd.Wait() + cancel() if err != nil { log.Error("CommandExecError: error running commands: %v", err) exitCode := 1 @@ -218,6 +220,14 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod c.markCommandFinished(session, 0, "") }) + // ensure we kill the whole process group if the context is cancelled (e.g., timeout). + safego.Go(func() { + <-ctx.Done() + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // best-effort + } + }) + request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } diff --git a/components/execd/pkg/runtime/command_status_test.go b/components/execd/pkg/runtime/command_status_test.go index 9d9c5d9b..8eb8a6d6 100644 --- a/components/execd/pkg/runtime/command_status_test.go +++ b/components/execd/pkg/runtime/command_status_test.go @@ -44,7 +44,8 @@ func TestGetCommandStatus_Running(t *testing.T) { }, } - if err := c.runBackgroundCommand(context.Background(), req); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + if err := c.runBackgroundCommand(ctx, cancel, req); err != nil { t.Fatalf("runBackgroundCommand error: %v", err) } if session == "" { @@ -142,7 +143,8 @@ func TestSeekBackgroundCommandOutput_WithRunBackgroundCommand(t *testing.T) { }, } - if err := c.runBackgroundCommand(context.Background(), req); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + if err := c.runBackgroundCommand(ctx, cancel, req); err != nil { t.Fatalf("runBackgroundCommand error: %v", err) } if session == "" { diff --git a/components/execd/pkg/runtime/command_windows.go b/components/execd/pkg/runtime/command_windows.go index 5e33853c..3c9aef11 100644 --- a/components/execd/pkg/runtime/command_windows.go +++ b/components/execd/pkg/runtime/command_windows.go @@ -103,7 +103,7 @@ func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest } // runBackgroundCommand executes shell commands in detached mode on Windows. -func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCodeRequest) error { +func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { session := c.newContextID() request.Hooks.OnExecuteInit(session) @@ -116,7 +116,7 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod startAt := time.Now() log.Info("received command: %v", request.Code) - cmd := exec.CommandContext(context.Background(), "cmd", "/C", request.Code) + cmd := exec.CommandContext(ctx, "cmd", "/C", request.Code) cmd.Dir = request.Cwd cmd.Stdout = pipe @@ -131,6 +131,7 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod if err != nil { log.Error("CommandExecError: error starting commands: %v", err) pipe.Close() // best-effort + cancel() return } @@ -145,7 +146,15 @@ func (c *Controller) runBackgroundCommand(_ context.Context, request *ExecuteCod } c.storeCommandKernel(session, kernel) + safego.Go(func() { + <-ctx.Done() + if cmd.Process != nil { + _ = cmd.Process.Kill() // best-effort + } + }) + err = cmd.Wait() + cancel() pipe.Close() // best-effort devNull.Close() // best-effort diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 20bbecc6..36c325b4 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -86,18 +86,21 @@ func (c *Controller) Execute(request *ExecuteCodeRequest) error { } else { ctx, cancel = context.WithCancel(context.Background()) } - defer cancel() switch request.Language { case Command: + defer cancel() return c.runCommand(ctx, request) case BackgroundCommand: - return c.runBackgroundCommand(ctx, request) + return c.runBackgroundCommand(ctx, cancel, request) case Bash, Python, Java, JavaScript, TypeScript, Go: + defer cancel() return c.runJupyter(ctx, request) case SQL: + defer cancel() return c.runSQL(ctx, request) default: + defer cancel() return fmt.Errorf("unknown language: %s", request.Language) } } diff --git a/components/execd/pkg/web/controller/command.go b/components/execd/pkg/web/controller/command.go index 4031da71..9d61308b 100644 --- a/components/execd/pkg/web/controller/command.go +++ b/components/execd/pkg/web/controller/command.go @@ -126,17 +126,20 @@ func (c *CodeInterpretingController) GetBackgroundCommandOutput() { } func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.RunCommandRequest) *runtime.ExecuteCodeRequest { + timeout := time.Duration(request.TimeoutMs) * time.Millisecond if request.Background { return &runtime.ExecuteCodeRequest{ Language: runtime.BackgroundCommand, Code: request.Command, Cwd: request.Cwd, + Timeout: timeout, } } else { return &runtime.ExecuteCodeRequest{ Language: runtime.Command, Code: request.Command, Cwd: request.Cwd, + Timeout: timeout, } } } diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go index e66976a4..0cc7ada9 100644 --- a/components/execd/pkg/web/model/codeinterpreting.go +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -49,6 +49,8 @@ type RunCommandRequest struct { Command string `json:"command" validate:"required"` Cwd string `json:"cwd,omitempty"` Background bool `json:"background,omitempty"` + // TimeoutMs caps execution duration; 0 uses server default. + TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` } func (r *RunCommandRequest) Validate() error { diff --git a/components/execd/pkg/web/model/codeinterpreting_test.go b/components/execd/pkg/web/model/codeinterpreting_test.go index c94f2175..efe8d202 100644 --- a/components/execd/pkg/web/model/codeinterpreting_test.go +++ b/components/execd/pkg/web/model/codeinterpreting_test.go @@ -39,6 +39,18 @@ func TestRunCommandRequestValidate(t *testing.T) { t.Fatalf("expected command validation success: %v", err) } + req.TimeoutMs = -100 + if err := req.Validate(); err == nil { + t.Fatalf("expected validation error when timeout is negative") + } + + req.TimeoutMs = 0 + req.Command = "ls" + if err := req.Validate(); err != nil { + t.Fatalf("expected success when timeout is omitted/zero: %v", err) + } + + req.TimeoutMs = 10 req.Command = "" if err := req.Validate(); err == nil { t.Fatalf("expected validation error when command is empty") diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index 3763a0a3..fd6a6f4e 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -289,6 +289,8 @@ paths: Executes a shell command and streams the output in real-time using SSE (Server-Sent Events). The command can run in foreground or background mode. The response includes stdout, stderr, execution status, and completion events. + Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will + terminate the process when the timeout is reached. operationId: runCommand tags: - Command @@ -305,12 +307,14 @@ paths: command: ls -la /workspace cwd: /workspace background: false + timeout: 30000 background: summary: Background command value: command: python server.py cwd: /app background: true + timeout: 120000 responses: "200": description: Stream of command execution events @@ -929,6 +933,11 @@ components: description: Whether to run command in detached mode default: false example: false + timeout: + type: integer + format: int64 + description: Maximum allowed execution time in milliseconds before the command is forcefully terminated. If omitted, the server default is used. + example: 60000 CommandStatusResponse: type: object