Skip to content

Fix Folder::idle() throwing after silent periods and busy-looping on … - #624

Open
mathe42 wants to merge 1 commit into
Webklex:masterfrom
accountdesk:fix-idle-busy-loop-on-connection-close
Open

Fix Folder::idle() throwing after silent periods and busy-looping on …#624
mathe42 wants to merge 1 commit into
Webklex:masterfrom
accountdesk:fix-idle-busy-loop-on-connection-close

Conversation

@mathe42

@mathe42 mathe42 commented Jul 21, 2026

Copy link
Copy Markdown

Fixes #558.

The bugs

Two defects in the catch block of Folder::idle() combine to make long-running IDLE unreliable:

} catch (Exceptions\RuntimeException $e) {
    if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) {
        continue;
    }
    if(!str_contains($e->getMessage(), "connection closed")) {
        throw $e;
    }
}

1. The strpos comparison is always true. strpos() returns false when the needle is not found, and in PHP false >= 0 evaluates to true (the intended check was !== false). So the first guard
matches every RuntimeException and the decision rests entirely on connected().

2. connected() cannot be used as a liveness probe here. ImapProtocol::connected() sends a NOOP via requestAndResponse() — but the $idle_client session is in IDLE state, where the only valid client
input is DONE (RFC 2177). Depending on the server, the probe gets a BAD Expected DONE (→ ImapServerErrorException) or no response for its tag at all (→ another read timeout), so it reports false even
though the connection is perfectly alive.

Resulting behaviour

  • Quiet mailbox: after $timeout seconds without any server event, the stream read times out, nextLine() throws empty response, the NOOP probe fails (see above), the guard falls through, and since the
    message does not contain connection closed, idle() throws empty response — instead of simply continuing to wait. idle() therefore cannot outlive a single silent timeout window. This matches many
    long-standing "empty response" reports.
  • Server-side disconnect: servers commonly drop IDLE connections after ~30 minutes (RFC 2177). Measured against IONOS (imap.ionos.de:993): * BYE timeout after 1811s, then EOF (stream meta
    timed_out=true eof=true). Upstream code throws here as well instead of re-establishing the session.
  • Busy loop at 100% CPU: wherever connected() reports true on a dead stream — e.g. the base Protocol::connected() implementation, which is just return (bool)$this->stream; and stays true after the
    peer closes the socket — the always-true strpos guard turns the loop into while (true) { continue; }: at EOF, reads return instantly, so the loop spins at 100% CPU forever. (Verified: with the NOOP probe
    absent, the IONOS disconnect above sends the loop into a permanent full-core spin.)

The fix

Drop the in-IDLE liveness probe entirely. After a failed read, stream_get_meta_data() (already exposed via Protocol::meta()) reliably tells the two cases apart — the meta data is inconclusive only
before a read attempt, not after one (this sidesteps the stale-resource detection problem described in the comment further down in idle()):

  • timed_out and not eof → plain read timeout, the connection is alive: keep waiting for the next event. No NOOP is sent, so the IDLE session stays intact.
  • eof (or anything else) → the connection is gone: reset the idle client, reconnect, reopen the folder and re-issue IDLE, then continue waiting.

This also removes a fall-through: previously a connection closed exception dropped out of the catch block and re-processed the stale $line from the previous loop iteration.

With this change idle() survives quiet periods and server-side disconnects indefinitely (verified against the same IONOS server: 15 silent timeout windows handled cleanly, reconnect after * BYE works, CPU
stays at ~0% while idling).

Relation to #613

#613 addresses the NOOP-during-IDLE protocol violation from #558 by making connected() skip the probe while idling. That fixes the protocol violation, but on its own it arms the busy-loop path described
above: with the probe skipped, connected() reports true on a dead stream, and the always-true strpos guard then turns a server-side disconnect into a permanent 100% CPU spin (this is exactly the
configuration we measured the busy loop with). This PR removes the probe from the decision entirely, so both the silent-period case and the disconnect case are handled without relying on connected().

disclaimer

I used AI to debug and fix this issue. I verified the result and the bug and understand the code change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

V6: Idle connection breaks due to NOOP command being sent during active IDLE session

1 participant