DEV Community

Super Funicular
Super Funicular

Posted on

rm *.mp4 Said Argument list too long and Deleted Nothing: What execve Counts That the Shell Does Not

I ran rm clips/*.mp4 on a folder of sixty thousand camera clips. Bash printed Argument list too long, the exit status was 126, and every file was still there.

Swapping rm for ls failed the same way. Swapping it for printf did not. Same glob, same folder, same shell. The difference was not the pattern or the number of files. It was whether the command had to start a new program.

Disclosure: I make an Android camera app. Nothing below uses its code or describes how it names or stores files. Every script ran on one Linux machine (kernel 6.8, aarch64) with GNU bash 5.1.16, coreutils 8.32 and findutils 4.8.0, against empty files the setup script creates under /tmp.

What the tests showed:

  • The error comes from the kernel, not the shell. Bash expanded the glob without complaint. The call that starts /usr/bin/rm refused the result.
  • Builtins are exempt. printf and a for loop handled all sixty thousand names, because the shell runs them itself.
  • Your environment shares the budget. A command that worked failed once 800 KB of variables were exported, with no change to its arguments.
  • One long string fails by itself. A single argument of 131072 bytes was refused on a machine whose total limit is 2 MiB.
  • The limit moves with ulimit -s. It is not a constant, and getconf ARG_MAX reports the current value.

Setup

make_clips.py creates sixty thousand empty files with names shaped like a camera's output.

import os
d = "/tmp/argmax_demo/clips"
os.makedirs(d, exist_ok=True)
for i in range(60000):
    open(os.path.join(d, "cam1_2026-10-23_%06d.mp4" % i), "w").close()
print(len(os.listdir(d)), "files")
Enter fullscreen mode Exit fullscreen mode
60000 files
Enter fullscreen mode Exit fullscreen mode

Test one: which commands fail

#!/bin/bash
cd /tmp/argmax_demo
echo "--- external ls with the glob"
ls clips/*.mp4 | wc -l
echo "exit: ${PIPESTATUS[0]}"
echo "--- external rm with the glob (nothing deleted if this fails)"
rm clips/*.mp4
echo "exit: $?"
ls clips | wc -l
echo "--- builtin printf with the same glob"
printf '%s\n' clips/*.mp4 | wc -l
echo "--- /usr/bin/printf with the same glob"
/usr/bin/printf '%s\n' clips/*.mp4 | wc -l
echo "exit: ${PIPESTATUS[0]}"
echo "--- builtin for loop"
n=0; for f in clips/*.mp4; do n=$((n+1)); done; echo "$n"
Enter fullscreen mode Exit fullscreen mode

Output:

--- external ls with the glob
glob_test.sh: line 4: /usr/bin/ls: Argument list too long
0
exit: 126
--- external rm with the glob (nothing deleted if this fails)
glob_test.sh: line 7: /usr/bin/rm: Argument list too long
exit: 126
60000
--- builtin printf with the same glob
60000
--- /usr/bin/printf with the same glob
glob_test.sh: line 13: /usr/bin/printf: Argument list too long
0
exit: 126
--- builtin for loop
60000
Enter fullscreen mode Exit fullscreen mode

The glob is expanded by bash before anything runs. When the command is an external program, bash hands the list to execve, the system call that replaces a process with a new program. The execve(2) manual page lists the failure: "E2BIG The total number of bytes in the environment (envp) and argument list (argv) is too large, an argument or environment string is too long, or the full path of the executable is too long." Bash prints that as Argument list too long and returns 126, the status it uses for a command that was found but could not be executed.

printf has two versions on this machine. The bash builtin needs no new program, so the list stays inside the shell and the call that has the limit is not made. /usr/bin/printf is a separate binary and fails exactly like ls. A for loop is shell syntax, so it walked all sixty thousand names too.

Two practical consequences. A cleanup script that fails here deletes nothing, so the folder keeps growing and the next run fails the same way. And the message comes from bash, not from rm, so anything that greps rm's own error text will miss it.

Test two: what the limit is on this machine

#!/bin/bash
echo "ARG_MAX (getconf): $(getconf ARG_MAX)"
echo "stack soft limit:  $(ulimit -s) KiB"
echo "page size:         $(getconf PAGESIZE)"
echo "environment bytes: $(env | wc -c)"
xargs --show-limits --no-run-if-empty < /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode
ARG_MAX (getconf): 2097152
stack soft limit:  8192 KiB
page size:         4096
environment bytes: 1011
Your environment variables take up 1013 bytes
POSIX upper limit on argument length (this system): 2094091
POSIX smallest allowable upper limit on argument length (all systems): 4096
Maximum length of command we could actually use: 2093078
Size of command buffer we are actually using: 131072
Maximum parallelism (--max-procs must be no greater): 2147483647
Enter fullscreen mode Exit fullscreen mode

The man page explains the 2097152: "For these architectures, the total size is limited to 1/4 of the allowed stack size." The stack limit is 8192 KiB, and a quarter of that is 2 MiB. The names in test one came to about 1.98 MB including their null bytes, and the environment was about 1 KB. That is under 2097152, yet the call failed, so the kernel is counting something beyond the string bytes. The man page does not itemise it and I did not read the kernel source, so treat the reported number as a ceiling with unknown overhead, not a budget to fill.

GNU xargs --show-limits reports the same ceiling with the environment already subtracted, plus the smaller buffer it uses by default.

Test three: the environment counts

#!/bin/bash
cd /tmp/argmax_demo
# 40000 names: fits on its own
set -- clips/cam1_2026-10-23_0[0-3]*.mp4
echo "names: $#   bytes: $(printf '%s\0' "$@" | wc -c)"
/bin/true "$@"; echo "plain environment:             exit $?"
chunk=$(head -c 100000 /dev/zero | tr '\0' x)
for i in 1 2 3 4 5 6 7 8; do export "PAD$i=$chunk"; done
echo "environment now: $(env | wc -c) bytes"
/bin/true "$@"; echo "same names, bigger environment: exit $?"
/bin/true;      echo "no names, bigger environment:   exit $?"
for i in 1 2 3 4 5 6 7 8; do unset "PAD$i"; done
export ONE_BIG=$(head -c 200000 /dev/zero | tr '\0' x)
/bin/true;      echo "no names, one 200 KB variable:  exit $?"
Enter fullscreen mode Exit fullscreen mode

Output:

names: 40000   bytes: 1320000
plain environment:             exit 0
environment now: 801043 bytes
env_test.sh: line 10: /bin/true: Argument list too long
same names, bigger environment: exit 126
no names, bigger environment:   exit 0
env_test.sh: line 14: /bin/true: Argument list too long
no names, one 200 KB variable:  exit 126
Enter fullscreen mode Exit fullscreen mode

Forty thousand names fit with a normal environment. Exporting eight 100 KB variables pushed the same command over, with its arguments unchanged. That is the "total number of bytes in the environment (envp) and argument list (argv)" part of the error text.

The last line is a different failure. One 200 KB variable broke /bin/true with no arguments at all, which brings in the per-string rule.

Test four: one argument that is too long

#!/bin/bash
for n in 131071 131072; do
  s=$(head -c $n /dev/zero | tr '\0' a)
  /bin/true "$s"; echo "one argument of $n bytes: exit $?"
done
Enter fullscreen mode Exit fullscreen mode

Output:

one argument of 131071 bytes: exit 0
long_arg.sh: line 4: /bin/true: Argument list too long
one argument of 131072 bytes: exit 126
Enter fullscreen mode Exit fullscreen mode

The man page: "the limit per string is 32 pages (the kernel constant MAX_ARG_STRLEN)". With 4096-byte pages that is 131072 bytes, and "The terminating null byte is counted as part of the string length." So 131071 characters plus the null fits and 131072 does not.

This one is worth knowing because the usual fixes do not help. Splitting a list into batches does nothing for a single string that is too long. It shows up when a script passes a whole file's contents, or a large JSON blob, as one argument or one exported variable. In the second case every external command started from that shell fails until the variable is unset.

Test five: the limit is set by the stack limit

#!/bin/bash
echo "default:            ARG_MAX $(getconf ARG_MAX)"
( ulimit -s 1024;  echo "ulimit -s 1024:     ARG_MAX $(getconf ARG_MAX)" )
( ulimit -s 65536; echo "ulimit -s 65536:    ARG_MAX $(getconf ARG_MAX)" )
cd /tmp/argmax_demo
( ulimit -s 65536; echo "ls with the glob under ulimit -s 65536: $(ls clips/*.mp4 | wc -l) names" )
Enter fullscreen mode Exit fullscreen mode

Output:

default:            ARG_MAX 2097152
ulimit -s 1024:     ARG_MAX 262144
ulimit -s 65536:    ARG_MAX 6291456
ls with the glob under ulimit -s 65536: 60000 names
Enter fullscreen mode Exit fullscreen mode

The man page covers both directions. On the way down, the reported value followed the stack limit, which it says will happen: "if the RLIMIT_STACK resource limit changes, then the value reported by _SC_ARG_MAX will also change". On the way up it stopped at 6291456, which matches the cap it gives: "the total size is limited to 3/4 of the value of the kernel constant _STK_LIM (8 MiB)."

Raising the stack limit made the sixty-thousand-name ls work. I would not rely on it. It is a fix that holds until the folder grows again, and a cron job or service may run with different limits from your terminal.

The fix that scales

#!/bin/bash
cd /tmp/argmax_demo
echo "--- find -exec ... + batches the names itself"
find clips -name '*.mp4' -exec ls {} + | wc -l
echo "--- how many separate ls runs that took"
find clips -name '*.mp4' -exec sh -c 'echo "$#"' sh {} + 
Enter fullscreen mode Exit fullscreen mode

Output:

--- find -exec ... + batches the names itself
60000
--- how many separate ls runs that took
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
3971
435
Enter fullscreen mode Exit fullscreen mode

find ... -exec cmd {} + builds the batches itself. It ran sh sixteen times here, and each full batch held 3971 names. At 33 bytes per name including the null, that is 131043 bytes, and one more name would have been 131076, just past the 131072-byte buffer xargs reported in test two. Batching this way handles any number of files, and it passes names without word splitting.

The other common route, piping names into xargs, batches the same way but has its own filename hazards. That is a separate post.

What to do instead

  • For external commands over a glob that can grow, use find dir -name '*.mp4' -exec cmd {} +.
  • If you only need to loop, a shell for loop over the glob avoids the limit.
  • Check exit status 126 in cleanup scripts and alert on it.
  • Keep large data out of environment variables and single arguments. Pass a file path instead.
  • Check getconf ARG_MAX in the same context the job runs in, not only in your terminal.

Things I did not test

  • Other shells and systems. Only bash on Linux was tested. macOS and BSD have their own limits.
  • Android. Nothing here ran on a phone or through adb shell.
  • Pages larger than 4 KiB. The per-string limit is in pages, so it differs on kernels with bigger pages.
  • Very long paths to the executable, which the same error also covers.

Sources. Quotes are from the Linux execve(2) manual page (man-pages 6.19). Results come from the scripts above, each run once, with output pasted unedited.

If you want an old phone to record clips in the background so there is a folder worth cleaning up, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.

The glob was fine; the program it was handed to could not be started.

Top comments (1)

Collapse
 
c-wind profile image
c-wind •

The part that stuck is that bash expands the glob fine and the failure is execve returning E2BIG, which bash reports as exit 126 before rm ever runs. A cleanup that greps rm's own stderr will miss a no-op delete, and the folder just keeps growing.

The find -exec {} + numbers make the two limits concrete: sixteen runs, 3971 names in a full batch, sitting just under the 131072-byte MAX_ARG_STRLEN (32 pages) rather than under the 2 MiB ARG_MAX. Raising ulimit -s got the sixty-thousand-name ls through the total budget, but it would not help a single argument or env var already over that per-string cap. That is exactly what the 200 KB variable showed: /bin/true failed with no argv at all until the variable was unset.