mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add timeout support to workspace bash tool (#19035)
# Add timeout support to workspace bash tool This PR adds a timeout feature to the workspace bash tool, allowing users to specify a maximum execution time for commands. Key changes include: - Added a `timeout_ms` parameter to control command execution time (defaults to 60 seconds, with a maximum of 5 minutes) - Implemented a new `executeCommandWithTimeout` function that properly handles command timeouts - Added proper output capturing during timeout scenarios, returning all output collected before the timeout - Updated documentation to explain the timeout feature and provide usage examples - Added comprehensive tests for the timeout functionality, including integration tests When a command times out, the tool now returns all captured output up to that point along with a cancellation message, making it clear to users what happened. Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
+141
-10
@@ -1,11 +1,14 @@
|
||||
package toolsdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
type WorkspaceBashArgs struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Command string `json:"command"`
|
||||
TimeoutMs int `json:"timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceBashResult struct {
|
||||
@@ -43,9 +47,12 @@ The workspace parameter supports various formats:
|
||||
- workspace.agent (specific agent)
|
||||
- owner/workspace.agent
|
||||
|
||||
The timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).
|
||||
If the command times out, all output captured up to that point is returned with a cancellation message.
|
||||
|
||||
Examples:
|
||||
- workspace: "my-workspace", command: "ls -la"
|
||||
- workspace: "john/dev-env", command: "git status"
|
||||
- workspace: "john/dev-env", command: "git status", timeout_ms: 30000
|
||||
- workspace: "my-workspace.main", command: "docker ps"`,
|
||||
Schema: aisdk.Schema{
|
||||
Properties: map[string]any{
|
||||
@@ -57,11 +64,17 @@ Examples:
|
||||
"type": "string",
|
||||
"description": "The bash command to execute in the workspace.",
|
||||
},
|
||||
"timeout_ms": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.",
|
||||
"default": 60000,
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
Required: []string{"workspace", "command"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceBashArgs) (WorkspaceBashResult, error) {
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceBashArgs) (res WorkspaceBashResult, err error) {
|
||||
if args.Workspace == "" {
|
||||
return WorkspaceBashResult{}, xerrors.New("workspace name cannot be empty")
|
||||
}
|
||||
@@ -69,6 +82,9 @@ Examples:
|
||||
return WorkspaceBashResult{}, xerrors.New("command cannot be empty")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, 5*time.Minute, xerrors.New("MCP handler timeout after 5 min"))
|
||||
defer cancel()
|
||||
|
||||
// Normalize workspace input to handle various formats
|
||||
workspaceName := NormalizeWorkspaceInput(args.Workspace)
|
||||
|
||||
@@ -119,23 +135,42 @@ Examples:
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Execute command and capture output
|
||||
output, err := session.CombinedOutput(args.Command)
|
||||
// Set default timeout if not specified (60 seconds)
|
||||
timeoutMs := args.TimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = 60000
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Execute command with timeout handling
|
||||
output, err := executeCommandWithTimeout(ctx, session, args.Command)
|
||||
outputStr := strings.TrimSpace(string(output))
|
||||
|
||||
// Handle command execution results
|
||||
if err != nil {
|
||||
// Check if it's an SSH exit error to get the exit code
|
||||
var exitErr *gossh.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// Check if the command timed out
|
||||
if errors.Is(context.Cause(ctx), context.DeadlineExceeded) {
|
||||
outputStr += "\nCommand canceled due to timeout"
|
||||
return WorkspaceBashResult{
|
||||
Output: outputStr,
|
||||
ExitCode: exitErr.ExitStatus(),
|
||||
ExitCode: 124,
|
||||
}, nil
|
||||
}
|
||||
// For other errors, return exit code 1
|
||||
|
||||
// Extract exit code from SSH error if available
|
||||
exitCode := 1
|
||||
var exitErr *gossh.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitStatus()
|
||||
}
|
||||
|
||||
// For other errors, use standard timeout or generic error code
|
||||
return WorkspaceBashResult{
|
||||
Output: outputStr,
|
||||
ExitCode: 1,
|
||||
ExitCode: exitCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -292,3 +327,99 @@ func NormalizeWorkspaceInput(input string) string {
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// executeCommandWithTimeout executes a command with timeout support
|
||||
func executeCommandWithTimeout(ctx context.Context, session *gossh.Session, command string) ([]byte, error) {
|
||||
// Set up pipes to capture output
|
||||
stdoutPipe, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderrPipe, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
// Start the command
|
||||
if err := session.Start(command); err != nil {
|
||||
return nil, xerrors.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
// Create a thread-safe buffer for combined output
|
||||
var output bytes.Buffer
|
||||
var mu sync.Mutex
|
||||
safeWriter := &syncWriter{w: &output, mu: &mu}
|
||||
|
||||
// Use io.MultiWriter to combine stdout and stderr
|
||||
multiWriter := io.MultiWriter(safeWriter)
|
||||
|
||||
// Channel to signal when command completes
|
||||
done := make(chan error, 1)
|
||||
|
||||
// Start goroutine to copy output and wait for completion
|
||||
go func() {
|
||||
// Copy stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(multiWriter, stdoutPipe)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(multiWriter, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for all output to be copied
|
||||
wg.Wait()
|
||||
|
||||
// Wait for the command to complete
|
||||
done <- session.Wait()
|
||||
}()
|
||||
|
||||
// Wait for either completion or context cancellation
|
||||
select {
|
||||
case err := <-done:
|
||||
// Command completed normally
|
||||
return safeWriter.Bytes(), err
|
||||
case <-ctx.Done():
|
||||
// Context was canceled (timeout or other cancellation)
|
||||
// Close the session to stop the command
|
||||
_ = session.Close()
|
||||
|
||||
// Give a brief moment to collect any remaining output
|
||||
timer := time.NewTimer(50 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Timer expired, return what we have
|
||||
case err := <-done:
|
||||
// Command finished during grace period
|
||||
return safeWriter.Bytes(), err
|
||||
}
|
||||
|
||||
return safeWriter.Bytes(), context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// syncWriter is a thread-safe writer
|
||||
type syncWriter struct {
|
||||
w *bytes.Buffer
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
func (sw *syncWriter) Write(p []byte) (n int, err error) {
|
||||
sw.mu.Lock()
|
||||
defer sw.mu.Unlock()
|
||||
return sw.w.Write(p)
|
||||
}
|
||||
|
||||
func (sw *syncWriter) Bytes() []byte {
|
||||
sw.mu.Lock()
|
||||
defer sw.mu.Unlock()
|
||||
return sw.w.Bytes()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user