Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions components/execd/pkg/runtime/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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
}
6 changes: 4 additions & 2 deletions components/execd/pkg/runtime/command_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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 == "" {
Expand Down
13 changes: 11 additions & 2 deletions components/execd/pkg/runtime/command_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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

Expand Down
7 changes: 5 additions & 2 deletions components/execd/pkg/runtime/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions components/execd/pkg/web/controller/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
2 changes: 2 additions & 0 deletions components/execd/pkg/web/model/codeinterpreting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions components/execd/pkg/web/model/codeinterpreting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions specs/execd-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading