You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a syswrite on the IMAP socket returns EAGAIN (send buffer temporarily full), _send_bytes() is supposed to wait a little and retry, using an adaptive backoff in _optimal_sleep(). Instead, the wait time collapses toward zero and the retry loop turns into a CPU busy-spin — thousands of syswrite/select iterations per second while a message is being sent.
The adaptive backoff never actually works, because the value it tunes against ($maxwrite) is always 0.
Root cause
Two bugs in the same mechanism:
1. $maxwrite is initialized to 0 and never updated.
$maxwrite is passed to _optimal_sleep() on every EAGAIN, but it's always 0.
2. _optimal_sleep() compares against 0, so the wait halves every time.
sub_optimal_sleep($$$) {
my ( $self, $maxwrite, $waittime, $last5writes ) = @_;
push@$last5writes, $waittime; # pushes the WAIT TIME ...shift@$last5writesif@$last5writes > 5;
my$bufferavail = ( sum @$last5writes ) / @$last5writes; # ... so this is avg wait, not bytesif ( $bufferavail < .4 * $maxwrite ) { # .4 * 0 = 0 -> false$waittime *= 1.3;
}
elsif ( $bufferavail > .9 * $maxwrite ) { # .9 * 0 = 0 -> always TRUE$waittime *= .5; # -> waittime halved on every EAGAIN
}
CORE::select( undef, undef, undef, $waittime );
$waittime;
}
With $maxwrite == 0, the first if can never be true and the elsif is always true, so $waittime is halved on every call:
0.02, 0.01, 0.005, 0.0025, ... -> ~0
select(undef, undef, undef, ~0) returns immediately, so _send_bytes retries syswrite as fast as the CPU allows.
There's also a secondary defect: @last5writes is meant to hold recent write sizes (so $bufferavail can be compared to $maxwrite), but _optimal_sleep pushes the wait time into it. So even if $maxwrite were nonzero, $bufferavail would be comparing wait-times against byte-counts. (Seeding the array with realistic write sizes actually makes $waittimeexplode by ×1.3 per call, i.e. multi-second select() sleeps — the same broken logic in the other direction.)
Impact
100% CPU busy-spin whenever the send buffer backs up (slow/tls destination, large messages, APPEND of big bodies).
The documented adaptive backoff in _optimal_sleep never engages.
On macOS this busy-spin is especially damaging: each spin iteration reads $! after the failed syswrite, and macOS's locale-aware strerror_l() allocates on every read under a UTF-8 LC_MESSAGES. Millions of allocate/free cycles per second outrun the allocator's scavenger and RSS balloons into the GBs. This is the underlying cause of imapsync issue #312 (200GB+ memory usage on Mac M1 imapsync/imapsync#312), where imapsync's --darwinfix01/02 options work around it by injecting 1 ms sleeps — i.e. by throttling this exact loop.
Reproduction
Minimal, no server needed (Linux/macOS). Fills a non-blocking socket so every write returns EAGAIN, then drives the real _send_bytes bounded by Maxtemperrors and measures the retry rate:
use strict; use warnings;
use Mail::IMAPClient;
use Socket; use Fcntl;
use Time::HiRes qw(time);
socketpair(my$w, my$r, AF_UNIX, SOCK_STREAM, PF_UNSPEC) ordie"socketpair: $!";
my$fl = fcntl($w, F_GETFL, 0); fcntl($w, F_SETFL, $fl | O_NONBLOCK);
1 whiledefinedsyswrite($w, "x"x65536); # fill the send buffermy$imap = Mail::IMAPClient->new;
$imap->RawSocket($w);
$imap->Maxtemperrors(100); # bound the loop so it returnsmy$payload = "y"x1000;
my$t0 = time;
$imap->_send_bytes(\$payload); # returns undef after 100 EAGAINsmy$dt = time - $t0;
printf"100 EAGAIN retries in %.4fs => %.0f retries/sec\n", $dt, 100/$dt;
Observed on 3.43:
100 EAGAIN retries in 0.0250s => 4003 retries/sec
i.e. ~4000 retries/second — the retry loop is spinning, not waiting.
Disclaimer: This bug report was generated with an LLM. I have however manually looked at the code and confirmed the suspicion to the best oft my abilities.
Summary
When a
syswriteon the IMAP socket returnsEAGAIN(send buffer temporarily full),_send_bytes()is supposed to wait a little and retry, using an adaptive backoff in_optimal_sleep(). Instead, the wait time collapses toward zero and the retry loop turns into a CPU busy-spin — thousands ofsyswrite/selectiterations per second while a message is being sent.The adaptive backoff never actually works, because the value it tunes against (
$maxwrite) is always0.Root cause
Two bugs in the same mechanism:
1.
$maxwriteis initialized to0and never updated.In
_send_bytes():$maxwriteis passed to_optimal_sleep()on everyEAGAIN, but it's always0.2.
_optimal_sleep()compares against0, so the wait halves every time.With
$maxwrite == 0, the firstifcan never be true and theelsifis always true, so$waittimeis halved on every call:select(undef, undef, undef, ~0)returns immediately, so_send_bytesretriessyswriteas fast as the CPU allows.There's also a secondary defect:
@last5writesis meant to hold recent write sizes (so$bufferavailcan be compared to$maxwrite), but_optimal_sleeppushes the wait time into it. So even if$maxwritewere nonzero,$bufferavailwould be comparing wait-times against byte-counts. (Seeding the array with realistic write sizes actually makes$waittimeexplode by ×1.3 per call, i.e. multi-secondselect()sleeps — the same broken logic in the other direction.)Impact
APPENDof big bodies)._optimal_sleepnever engages.$!after the failedsyswrite, and macOS's locale-awarestrerror_l()allocates on every read under a UTF-8LC_MESSAGES. Millions of allocate/free cycles per second outrun the allocator's scavenger and RSS balloons into the GBs. This is the underlying cause of imapsync issue #312 (200GB+ memory usage on Mac M1 imapsync/imapsync#312), where imapsync's--darwinfix01/02options work around it by injecting 1 ms sleeps — i.e. by throttling this exact loop.Reproduction
Minimal, no server needed (Linux/macOS). Fills a non-blocking socket so every write returns
EAGAIN, then drives the real_send_bytesbounded byMaxtemperrorsand measures the retry rate:Observed on 3.43:
i.e. ~4000 retries/second — the retry loop is spinning, not waiting.
Disclaimer: This bug report was generated with an LLM. I have however manually looked at the code and confirmed the suspicion to the best oft my abilities.