@@ -2,7 +2,10 @@ package builtin
22
33import (
44 "os"
5+ "os/exec"
6+ "runtime"
57 "testing"
8+ "time"
69
710 "github.com/stretchr/testify/assert"
811 "github.com/stretchr/testify/require"
@@ -177,3 +180,72 @@ func TestShellTool_RelativeCwdResolvesAgainstWorkingDir(t *testing.T) {
177180 assert .Contains (t , result .Output , subdir ,
178181 "relative cwd must resolve against the configured workingDir, not the process cwd" )
179182}
183+
184+ // Regression test for a shell-tool hang caused by backgrounded grandchildren.
185+ //
186+ // A command like `sleep 10 &` makes the shell exit immediately, but the
187+ // backgrounded sleep inherits stdout/stderr. Without cmd.WaitDelay, Go's
188+ // exec.Cmd.Wait() blocks reading the pipe until the configured timeout,
189+ // which makes the tool call hang (observed in eval runs where the agent
190+ // launched a server with `docker run ... &`).
191+ //
192+ // With the WaitDelay safeguard the tool must return within a small fraction
193+ // of the configured timeout.
194+ func TestShellTool_BackgroundedChildDoesNotBlockReturn (t * testing.T ) {
195+ if runtime .GOOS == "windows" {
196+ t .Skip ("POSIX shell backgrounding semantics; skipped on Windows" )
197+ }
198+
199+ tool := NewShellTool (nil , & config.RuntimeConfig {Config : config.Config {WorkingDir : t .TempDir ()}})
200+
201+ start := time .Now ()
202+ result , err := tool .handler .RunShell (t .Context (), RunShellArgs {
203+ // sleep inherits stdout/stderr from the shell and holds the pipe
204+ // open for 30s. The tool must return as soon as the shell exits.
205+ Cmd : "sleep 30 &" ,
206+ Timeout : 20 ,
207+ })
208+ elapsed := time .Since (start )
209+
210+ require .NoError (t , err )
211+ require .NotNil (t , result )
212+ assert .Less (t , elapsed , 5 * time .Second ,
213+ "shell tool must return promptly when the command backgrounds a child " +
214+ "that inherits stdout/stderr; elapsed=%s" , elapsed )
215+ }
216+
217+ // Even when the backgrounded child detaches into its own session (so the
218+ // shell tool's process-group kill cannot reach it on timeout), cmd.WaitDelay
219+ // must still allow the tool call to return.
220+ func TestShellTool_DetachedBackgroundedChildDoesNotBlockReturn (t * testing.T ) {
221+ if runtime .GOOS == "windows" {
222+ t .Skip ("POSIX shell backgrounding semantics; skipped on Windows" )
223+ }
224+ if _ , err := exec .LookPath ("setsid" ); err != nil {
225+ t .Skip ("setsid not available" )
226+ }
227+
228+ tool := NewShellTool (nil , & config.RuntimeConfig {Config : config.Config {WorkingDir : t .TempDir ()}})
229+
230+ done := make (chan struct {})
231+ var result * tools.ToolCallResult
232+ var err error
233+ go func () {
234+ defer close (done )
235+ result , err = tool .handler .RunShell (t .Context (), RunShellArgs {
236+ // setsid places sleep in its own session/process group, so the
237+ // process-group kill fallback in the timeout path cannot reach
238+ // it. Only cmd.WaitDelay can unblock Wait() here.
239+ Cmd : "setsid sleep 30 &" ,
240+ Timeout : 20 ,
241+ })
242+ }()
243+
244+ select {
245+ case <- done :
246+ require .NoError (t , err )
247+ require .NotNil (t , result )
248+ case <- time .After (10 * time .Second ):
249+ t .Fatal ("shell tool hung when command backgrounded a detached child" )
250+ }
251+ }
0 commit comments