Skip to content

[FIX] ImapRequestFrameDecoder drops a pipelined command after a literal (3.9.x backport) - #3145

Open
gkm2164 wants to merge 1 commit into
apache:3.9.xfrom
gkm2164:backport-3.9.x/imap-pipelined-literal
Open

[FIX] ImapRequestFrameDecoder drops a pipelined command after a literal (3.9.x backport)#3145
gkm2164 wants to merge 1 commit into
apache:3.9.xfrom
gkm2164:backport-3.9.x/imap-pipelined-literal

Conversation

@gkm2164

@gkm2164 gkm2164 commented Aug 31, 2026

Copy link
Copy Markdown

Backport of #3144 to 3.9.x, as discussed there.

What this is

ImapRequestFrameDecoder silently drops a pipelined command that arrives in the same read as a preceding literal-bearing command (two LITERAL+ APPENDs in one write: the second gets no response and is never persisted). Full analysis, wire captures and root cause are in #3144 — this PR carries the exact same decoder change (+76/−77, byte-identical to the master commit — I'll update this reference with the merged SHA once #3144 lands; opening as a draft until then).

Worth noting: the bug was originally discovered on a 3.9.0 deployment (the imaptest stalls in #3144 were recorded against James 3.9.0), so 3.9.x is confirmed affected, not just theoretically.

Differences from the master PR

  • Decoder: none. The cherry-pick applied cleanly; the only drift between 3.9.x and master in this file is the LeakAware constructor signature on a line the fix does not touch.
  • Regression test placement: master splits IMAP server tests into per-topic classes on top of AbstractIMAPServerTest, which does not exist on 3.9.x. The same test method (secondPipelinedAppendAfterLiteralShouldNotBeLost, unchanged) therefore lives in IMAPServerTest's existing AppendNonSynchronizedLitterals nested class, right next to partialCommandAfterNonSynchronizedLiteralShouldNotFail — the same neighborhood it occupies on master.

Testing on 3.9.x

  • Bidirectional check in a Linux container (maven:3.9-eclipse-temurin-21): with the pristine 3.9.x decoder plus only the new test, secondPipelinedAppendAfterLiteralShouldNotBeLost fails with execution timed out after 5000 ms (bug reproduced); with the patch it passes.
  • Full server/protocols/protocols-imap4 module suite with the patch: 612 tests run, 0 failures (1 skipped), BUILD SUCCESS — including all 28 nested classes of IMAPServerTest.

One unrelated observation from validating this on a macOS host: IMAPServerTest contains nested classes Ssl and SSL, whose class files collide on case-insensitive filesystems. The surviving file then fails class loading (NoClassDefFoundError ... wrong name), and JUnit silently drops the whole class from discovery — a macOS developer running this module locally gets a green build with 568 of the tests never executed. Linux CI is unaffected. Happy to file that separately if useful.

When a command carrying a literal is followed by another command in the
same write - two LITERAL+ APPENDs, say - the second one silently vanishes:
no error, no timeout, nothing persisted.

obtainReader() copies everything Netty currently holds into `pending`, so
the bytes of that next command are swept in as well. parseImapMessage()
then clears `pending` on a successful parse, and they are gone.

Copying less is not a viable fix on its own: the byte count carried by
NotEnoughDataException is `size + read + crlf`, where `read` only counts
characters pulled through nextChar(), so it under-reports for a command
holding several literals and cannot be trusted as an exact bound.

Instead, stop consuming before we know how much was used. obtainReader()
now parses against a view - `pending` followed by a slice of Netty's
cumulation - and on a successful parse the cumulation is advanced by what
the command actually consumed. Whatever it did not touch stays where it is
and gets decoded as the next command. On failure everything is kept and
the parse is simply retried once more bytes arrive, which is what keeps
multi-literal commands working.

NEEDED_DATA keeps its historical meaning - how many bytes must still
arrive beyond what `pending` holds - because uploadToAFile()'s completion
check depends on it; since the reader now counts from the start of
`pending`, requestMoreData() rebases the reported size to that delta
(leaving the UNKNOWN_SIZE sentinel untouched). The file path itself is
unchanged.

This also removes the `should_buffer` bookkeeping and the size comparison
in obtainReader(): both existed only to decide when a parse was worth
retrying, which the parser now decides for itself.

See apache#3144 for the imaptest repro and rawlog trace this was found with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chibenwa chibenwa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty much a cherry-pick right?

Ok with me too.

@chibenwa chibenwa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty much a cherry-pick right?

Ok with me too.

@chibenwa chibenwa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty much a cherry-pick right?

Ok with me too.

@chibenwa chibenwa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty much a cherry-pick right?

Ok with me too.

@gkm2164
gkm2164 marked this pull request as ready for review August 31, 2026 19:36
@gkm2164

gkm2164 commented Aug 31, 2026

Copy link
Copy Markdown
Author

Yeap. And also verified this also passes the Dovecot imap test

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

Labels

None yet

2 participants