mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-07-21 08:15:23 -04:00
[GH-ISSUE #108] [Bug] Goroutine and Resource Leak in Terminal Exec Timeout Handling #55
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @zesty-clawd on GitHub (Feb 21, 2026).
Original GitHub issue: https://github.com/vxcontrol/pentagi/issues/108
Goroutine and Resource Leak in Terminal Tool Exec Timeout Handling
Summary
The
getExecResultmethod inbackend/pkg/tools/terminal.gocontains a goroutine leak when command execution times out. When the context expires, the function returns early but leaves a goroutine blocked onio.Copy, which continues reading fromresp.Readerindefinitely.Location
File:
backend/pkg/tools/terminal.goMethod:
func (t *terminal) getExecResult(ctx context.Context, id string, timeout time.Duration) (string, error)Lines: Approximately 220-260
The Bug
Problem Analysis
When
ctx.Done()is triggered (timeout), the function:defer resp.Close()which closes the connectionio.Copy(&dst, resp.Reader)may still be blocked on the readWhy This Is Problematic
Goroutine Leak: The goroutine never terminates because:
io.Copymay be blocked waiting for dataresp.Close(), the read might not immediately unblock depending on the underlying implementationResource Accumulation: In a long-running PentAGI system executing many commands:
Unpredictable Behavior:
Impact
runtime.NumGoroutine())Reproduction Steps
Recommended Fix
Use a context-aware copy operation or ensure the goroutine can be interrupted:
Option 1: Context-Aware Copy (Preferred)
Option 2: Use io.CopyN or Buffered Reading
Alternatively, use buffered reads with context checking in a loop instead of
io.Copy, allowing interruption between reads.Additional Notes
goleakEnvironment
This bug represents a fundamental issue with resource management in concurrent systems and should be addressed to ensure system stability during extended penetration testing operations.
@HassanSystems commented on GitHub (Feb 21, 2026):
Good catch — this is a real goroutine leak.
The root issue is that io.Copy runs in a goroutine that is not guaranteed to exit when the context times out. Returning on ctx.Done() without synchronizing on that goroutine leaves it blocked on resp.Reader.
The fix needs to:
Force-unblock the reader (resp.Close())
Synchronously wait for the copy goroutine to exit
Ensure the goroutine can never block on channel send
Example pattern:
errChan := make(chan error, 1)
go func() {
_, err := io.Copy(&dst, resp.Reader)
select {
case errChan <- err:
default:
}
}()
select {
case err := <-errChan:
// normal completion
case <-ctx.Done():
_ = resp.Close()
<-errChan
return "", ctx.Err()
}
This guarantees deterministic goroutine shutdown and avoids resource leakage under repeated timeouts.
@ChiragHS-MYS commented on GitHub (Feb 22, 2026):
Assign it to me and I can fix it for you sir
@Muitamax commented on GitHub (Feb 23, 2026):
Advanced hardening options:
Use errgroup.WithContext
Wrap reader in io.LimitedReader
Add goleak.VerifyNone(t) in tests
Add Docker client timeout tuning
Add integration test simulating hung container
@Vaibhavee89 commented on GitHub (Feb 23, 2026):
Fix Implemented
I've created PR #134 that implements the fix for this goroutine leak issue.
Implementation Details
The fix follows the recommended Option 1 from this issue with the following approach:
Key Changes:
donechannel with a buffered error channel (errChan) that captures theio.Copyresultselectto wait up todefaultExtraExecTimeout(5 seconds) for the goroutine to completedefer resp.Close()ensures the connection is closed, which helps interrupt the blockedio.CopyWhy This Works
Normal Execution:
io.Copyand sends error (or nil) toerrChanTimeout Scenario:
ctx.Done()caseselectwaits up to 5 seconds for goroutine to finish gracefullyresp.Close()takes effect)Advantages Over Issue Recommendation
Our implementation uses
defaultExtraExecTimeout(5 seconds) instead of 100ms for the grace period, which:The fix prevents the goroutine leak while maintaining backward compatibility with existing timeout behavior.
Please review PR #134 when you have a chance. The CI tests should run automatically to verify the fix doesn't break existing functionality.
@Muitamax commented on GitHub (Feb 23, 2026):
Fix (PR #134)
The fix uses Option 1: context-aware copy with buffered error channel:
Key points:
Buffered Error Channel
errChan := make(chan error, 1)
go func() {
_, err := io.Copy(&dst, resp.Reader)
errChan <- err // non-blocking send because channel is buffered
}()
Graceful Shutdown on Timeout
select {
case err := <-errChan:
// normal completion
case <-ctx.Done():
resp.Close() // force unblock io.Copy
// wait up to 5 seconds for goroutine to exit
select {
case <-errChan:
case <-time.After(defaultExtraExecTimeout):
}
return "", fmt.Errorf("timeout: %w, partial output: %s", ctx.Err(), dst.String())
}
defaultExtraExecTimeout (5s) is used instead of 100ms to allow Docker I/O to finish cleanly.
Ensures no orphaned goroutines remain.
Proper Cleanup
defer resp.Close() ensures the Docker connection is closed.
errChan ensures goroutine errors are captured safely.