Skip to content

[BUG] Shell tool leaks PTY file descriptors #460

@remi-delmas-3000

Description

@remi-delmas-3000

Checks

  • I have updated to the lastest minor and patch version of Strands
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

Strands Version

1.20.0

Tools Package Version

0.5.1

Tools used

Using too shell

Python Version

3.13.5

Operating System

AL2, macOS

Installation Method

pip

Steps to Reproduce

"""Reproducer for PTY fd leak in strands_tools.shell.

Run: python repro_pty_leak.py

Expected: FD count stays constant.
Actual: FD count increases by 1 per shell call, never decreases.
"""
import os

os.environ["BYPASS_TOOL_CONSENT"] = "true"

from strands_tools.shell import execute_single_command


def open_fds():
    return len(os.listdir("/dev/fd"))


before = open_fds()
print(f"Open FDs before: {before}")

for i in range(50):
    execute_single_command(f"echo iteration_{i}", os.getcwd(), timeout=10, non_interactive_mode=True)
    if (i + 1) % 10 == 0:
        current = open_fds()
        print(f"After {i+1} calls: {current} FDs (leaked {current - before})")

after = open_fds()
print(f"\nOpen FDs after 50 calls: {after}")
print(f"Leaked: {after - before} file descriptors")
assert after == before, f"LEAK DETECTED: {after - before} FDs leaked in 50 calls"

the bug is at

File: strands_tools/shell.py

- **Line 125**: pid, fd = pty.fork() — PTY allocated, fd is the master fd
- **Line 185**: return exit_code, ... — returns without closing fd
- **Lines 187-189**: finally block — only restores terminal settings, no os.close(fd)

The fix is adding os.close(fd) in the finally block at line 187.

Expected Behavior

File descriptors should not accumulate across shell tool invocations.

Actual Behavior

python3 repro_pty_leak.py
Open FDs before: 4
iteration_0
iteration_1
iteration_2
iteration_3
iteration_4
iteration_5
iteration_6
iteration_7
iteration_8
iteration_9
After 10 calls: 14 FDs (leaked 10)
iteration_10
iteration_11
iteration_12
iteration_13
iteration_14
iteration_15
iteration_16
iteration_17
iteration_18
iteration_19
After 20 calls: 24 FDs (leaked 20)
iteration_20
iteration_21
iteration_22
iteration_23
iteration_24
iteration_25
iteration_26
iteration_27
iteration_28
iteration_29
After 30 calls: 34 FDs (leaked 30)
iteration_30
iteration_31
iteration_32
iteration_33
iteration_34
iteration_35
iteration_36
iteration_37
iteration_38
iteration_39
After 40 calls: 44 FDs (leaked 40)
iteration_40
iteration_41
iteration_42
iteration_43
iteration_44
iteration_45
iteration_46
iteration_47
iteration_48
iteration_49
After 50 calls: 54 FDs (leaked 50)

Open FDs after 50 calls: 54
Leaked: 50 file descriptors
Traceback (most recent call last):
  File "/local/home/delmasrd/repro_pty_leak.py", line 31, in <module>
    assert after == before, f"LEAK DETECTED: {after - before} FDs leaked in 50 calls"
           ^^^^^^^^^^^^^^^
AssertionError: LEAK DETECTED: 50 FDs leaked in 50 calls

Additional Context

No response

Possible Solution

The PTY master file descriptor (fd) returned by pty.fork() at line 125 of shell.py should be closed via os.close(fd) after the child process exits. The finally block (line 187) should include:

finally:
   try:
       os.close(fd)
   except OSError:
       pass
   if not non_interactive_mode and old_tty:
       termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, old_tty)

Related Issues

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions