Skip to content

Add configurable minimum TLS version and IMAP parser limits#185

Merged
odrobnik merged 9 commits into
Cocoanetics:mainfrom
davyd15:security/tls-minimum-and-parser-limits
Jul 17, 2026
Merged

Add configurable minimum TLS version and IMAP parser limits#185
odrobnik merged 9 commits into
Cocoanetics:mainfrom
davyd15:security/tls-minimum-and-parser-limits

Conversation

@davyd15

@davyd15 davyd15 commented Jul 16, 2026

Copy link
Copy Markdown

Two independent security hardening knobs, found while building a mail client on SwiftMail. Both were verified end-to-end against a real Hetzner IMAP/SMTP server (Dovecot, TLS 1.3).

They're separate commits and can be split or cherry-picked — happy to open two PRs instead if you prefer.


1. Minimum TLS version is not settable (59cd258)

MailTLSConfiguration.makeClientConfiguration builds on TLSConfiguration.makeClientConfiguration() and only overrides certificateVerification and trustRoots. It therefore inherits NIOSSL's default minimumTLSVersion of .tlsv1 — TLS 1.0, deprecated by RFC 8996 since March 2021. A caller has no way to raise that floor.

This adds MailTLSMinimumVersion and threads it through IMAPServer, SMTPServer, IMAPConnection and both SMTP TLS paths — the STARTTLS path at SMTPServer+Connection.swift:283 builds its own config and would otherwise have stayed on the old default.

The enum is deliberately SwiftMail's own type rather than NIOSSL's TLSVersion: SwiftMail imports NIOSSL without re-exporting it, so exposing TLSVersion publicly would push NIOSSL onto every consumer.

Behaviour change: the default floor moves from TLS 1.0 to TLS 1.2, the lowest version RFC 8996 still permits. Callers needing the old behaviour can pass .tlsv1. If you'd rather keep this strictly additive, say the word and I'll default it to .tlsv1.

// Servers you know speak TLS 1.3 — downgrade becomes impossible
let server = IMAPServer(host: "imap.example.com", port: 993, minimumTLSVersion: .tlsv1_3)

2. Parser body/attribute limits are hardcoded to .max (76d7a75)

IMAPConnection.makeBootstrap pins the response parser to messageAttributeLimit: .max and bodySizeLimit: .max, with no way for a caller to bound them.

A server controls both the length prefixes and the attribute count it sends, so a hostile or malfunctioning server can force unbounded allocation from a single response. responseBufferLimit doesn't cover this — it bounds the parser's working buffer, whereas these bound what a single response may declare.

This is the class of issue that produced advisories in other IMAP clients: Ruby net-imap GHSA-j3g3-5qv5-52mj (unbounded literal size → memory exhaustion, fixed by adding a configurable max_response_size) and mutt CVE-2021-3657 ("reject excessively large IMAP literals").

Adds IMAPParserLimits. Strictly additive — defaults are .max/.max, exactly today's behaviour, so existing callers see no change.

let server = IMAPServer(
    host: "imap.example.com",
    port: 993,
    parserLimits: IMAPParserLimits(bodySizeLimit: 64 * 1024 * 1024, messageAttributeLimit: 1024)
)

Bounded defaults would arguably be the better long-term choice, but that would silently break anyone fetching large attachments — left that call to you.

Credit where due: the recursion depth is already bounded (StackTracker(maximumParserStackDepth: 100)), so the deeply-nested-MIME stack overflow that hit Stalwart and Dovecot (CVE-2020-12100) doesn't apply here.


Testing

  • Full suite passes: 331 tests in 42 suites
  • New: MailTLSConfigurationTests (default floor, each enum mapping, both server types) and IMAPParserLimitsTests (defaults, partial overrides, propagation)
  • Verified against a live Hetzner server: with minimumTLSVersion: .tlsv1_3 enforced, connect + login + CAPABILITY all succeed

Thanks for SwiftMail — it was the only actively maintained Swift IMAP client we could build on after MailCore2 went quiet.

🤖 Generated with Claude Code

David Lemke added 2 commits July 16, 2026 16:01
`MailTLSConfiguration.makeClientConfiguration` builds on
`TLSConfiguration.makeClientConfiguration()` and only overrides
`certificateVerification` and `trustRoots`. It therefore inherits NIOSSL's
default `minimumTLSVersion` of `.tlsv1` — TLS 1.0, which RFC 8996 deprecated
in March 2021. There was no way for a caller to raise that floor.

This adds `MailTLSMinimumVersion` and threads it through `IMAPServer`,
`SMTPServer`, `IMAPConnection` and both SMTP TLS paths (implicit TLS and
STARTTLS).

The enum is deliberately SwiftMail's own type rather than NIOSSL's
`TLSVersion`: SwiftMail imports NIOSSL without re-exporting it, so exposing
`TLSVersion` in the public API would push NIOSSL onto every consumer.

Behaviour change: the default floor moves from TLS 1.0 to TLS 1.2, the
lowest version RFC 8996 still permits. Callers needing the old behaviour can
pass `.tlsv1` explicitly. Happy to default to `.tlsv1` instead if you would
rather keep this strictly additive.

Callers who know their servers speak TLS 1.3 can now pass `.tlsv1_3` and make
downgrades impossible regardless of what the server offers.

Tests cover the default, each enum mapping, and that both server types carry
the caller's choice. Full suite passes (325 tests).
`IMAPConnection.makeBootstrap` hardcodes the response parser to
`messageAttributeLimit: .max` and `bodySizeLimit: .max`. A server controls
both the length prefixes and the attribute count it sends, so a hostile or
malfunctioning server can force unbounded allocation from a single response,
and a caller has no way to bound it.

`responseBufferLimit` does not cover this: it bounds the parser's working
buffer, whereas these bound what a single response may declare.

This is the class of issue that produced advisories in other IMAP clients —
Ruby net-imap GHSA-j3g3-5qv5-52mj (unbounded literal size → memory
exhaustion, fixed by adding a configurable `max_response_size`) and mutt
CVE-2021-3657 ("reject excessively large IMAP literals").

Adds `IMAPParserLimits`, threaded through `IMAPServer` and `IMAPConnection`.
Strictly additive: the defaults are `.max`/`.max`, exactly today's behaviour,
so existing callers see no change. Callers who want bounds can now set them:

    IMAPServer(
        host: "imap.example.com",
        port: 993,
        parserLimits: IMAPParserLimits(
            bodySizeLimit: 64 * 1024 * 1024,
            messageAttributeLimit: 1024
        )
    )

`literalSizeLimit` is included for completeness — it was pinned to
`IMAPDefaults.literalSizeLimit` with no way to adjust it.

Bounded defaults would arguably be the better long-term choice, but that
would silently break anyone fetching large attachments, so I left it to you
whether to make that call separately.

Tests cover defaults, partial overrides, and that IMAPServer carries the
caller's choice. Full suite passes (331 tests).

@odrobnik odrobnik 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.

Requesting changes: the IMAP TLS floor is not honored for implicit TLS or spawned named/IDLE connections, spawned connections also bypass the configured parser limits, and the pinned response parser makes both advertised custom maxima effectively exclusive/off by one. SMTP's implicit and STARTTLS paths are consistently wired. Review was performed read-only; builds and tests were not run as requested.

Findings:

  • HIGH Sources/SwiftMail/IMAP/IMAPConnection.swift:47 — Implicit-TLS IMAP connections never forward this value: makeConnectionBootstrap calls makeTLSHandler without minimumTLSVersion, so the handler silently uses its .tlsv1_2 default. For example, IMAPServer(port: 993, minimumTLSVersion: .tlsv1_3) can still connect through a TLS-1.2-only endpoint, violating the caller's downgrade floor; choosing .tlsv1 for a legacy TLS-1.0-only server likewise still fails because the actual floor remains 1.2.
  • HIGH Sources/SwiftMail/IMAP/IMAPServer.swift:184 — The configured TLS floor is passed only to primaryConnection; makeIdleConnection and makeNamedConnection omit it and construct connections with the .tlsv1_2 default. A caller can successfully establish a TLS-1.3-only primary session and then have connection(named:) or idle(on:) negotiate TLS 1.2 through a downgraded endpoint, despite the server-level policy claiming to govern every transport.
  • HIGH Sources/SwiftMail/IMAP/IMAPServer.swift:186 — The configured parser limits are passed only to primaryConnection; both spawned-connection factories omit parserLimits and therefore use .default (.max body and attribute limits). A hostile server can send an oversized or attribute-heavy FETCH response on a named or dedicated IDLE connection and bypass the caller's memory-exhaustion guards entirely.
  • MEDIUM Sources/SwiftMail/IMAP/IMAPParserLimits.swift:31 — This property is documented as an inclusive maximum, but the pinned NIOIMAP ResponseParser enforces size < bodySizeLimit. Consequently, a caller setting a 64 MiB maximum has a valid body of exactly 64 MiB rejected with ExceededMaximumBodySizeError, even though only data larger than the documented maximum should fail.
  • MEDIUM Sources/SwiftMail/IMAP/IMAPParserLimits.swift:36 — The pinned parser checks attributeCount < messageAttributeLimit again when parsing the FETCH closing delimiter, making the effective maximum one less than this documented value. With messageAttributeLimit: 1, for example, a normal one-attribute FETCH (FLAGS (...)) parses the attribute and then fails at ), so callers cannot successfully receive the advertised maximum count.

Comment thread Sources/SwiftMail/IMAP/IMAPConnection.swift Outdated
Comment thread Sources/SwiftMail/IMAP/IMAPServer.swift
Comment thread Sources/SwiftMail/IMAP/IMAPServer.swift
Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
Addresses the review on Cocoanetics#185. All five findings were reproduced before fixing; the two
MEDIUM ones were confirmed against the pinned parser rather than taken on faith.

HIGH — implicit TLS ignored minimumTLSVersion. makeConnectionBootstrap omitted the argument,
so makeTLSHandler's `.tlsv1_2` default applied and IMAPServer(port: 993, minimumTLSVersion:
.tlsv1_3) negotiated TLS 1.2 with a downgraded peer. Port 993 is the port every IMAPS client
uses, so the floor was effectively inert for the common case.

HIGH — makeIdleConnection/makeNamedConnection omitted both minimumTLSVersion and parserLimits,
so a server configured with a TLS 1.3 floor and 64 MiB limits spawned IDLE and named
connections with a TLS 1.2 floor and unbounded limits — on precisely the long-lived
connections that sit and listen to whatever the server sends.

MEDIUM ×2 — the limits are documented as maxima, but the parser enforces `size <
bodySizeLimit` and `attributeCount < messageAttributeLimit`, the latter again on the FETCH
closing delimiter. A 64 MiB limit rejected a 64 MiB body; messageAttributeLimit: 1 rejected a
one-attribute FETCH. Rather than redefine "maximum" as "one less than this", the translation
now adds one, saturating at .max where the value already means unbounded. It lives in
IMAPParserLimits.makeParserOptions(bufferLimit:) so there is one place to get it right instead
of one per call site — the previous arrangement, where each site spelled it out, was how only
the primary connection ended up with limits at all.

The root cause of all three HIGH findings is the same: security policy with default values.
A default makes "forgot to pass the floor" indistinguishable from "chose TLS 1.2", and neither
the compiler nor a reviewer can tell them apart. IMAPConnection's designated initializer and
makeTLSHandler no longer default minimumTLSVersion or parserLimits, so omitting one is a build
error. That immediately surfaced a fourth call site — the legacy useTLS convenience initializer
— which neither grep nor the review had found.

Tests. The reason this shipped is worth recording: MailTLSConfigurationTests asserts that
makeClientConfiguration returns what it is given, and one of its cases was named "Raising the
floor to TLS 1.3 makes a downgrade impossible". It was green the whole time. It could not have
caught this, because the defect was not a wrong return value — it was that nobody passed the
value in. A test that cannot produce the failure proves nothing about it.

So the new tests assert at the level where the defect lived:

- IMAPTLSFloorEnforcementTests spins up a real TLS endpoint pinned to maximumTLSVersion
  .tlsv1_2 and connects a real IMAPConnection to it. A TLS 1.3 floor must fail; a TLS 1.2
  floor must succeed (the control — without it the first test would also pass against a closed
  port). Verified by reverting the fix: it fails with "an error was expected but none was
  thrown", and passes with it.
- IMAPSecurityPolicyPropagationTests asserts on the spawned connections themselves.
- IMAPParserLimitsTranslationTests covers the off-by-one and the saturation at .max.

The misleading test name is corrected, and MailTLSConfigurationTests now says what it does and
does not cover.

339 tests pass. Built and tested with Xcode's toolchain.

@odrobnik odrobnik 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.

Requesting changes. The five findings from the previous review are fixed at this head, but three blockers remain: bodySizeLimit is enforced per BODY attribute rather than across the single FETCH response that SwiftMail documents and may accumulate; an IDLE parser-limit violation leaves the socket and AsyncStream open while the decoder retains rejected bytes; and custom protocol-literal limits above the pinned handler's fixed 8 KiB aggregate cap fail depending on network fragmentation. Review was performed read-only; builds and tests were not run as requested.

Findings:

  • HIGH Sources/SwiftMail/IMAP/IMAPParserLimits.swift:31bodySizeLimit is documented as the maximum body data in one response, but the pinned ResponseParser calls its size guard independently for every streaming BODY attribute and never tracks a response-wide total. FetchPartHandler appends every streaming attribute until the FETCH finishes. With IMAPParserLimits(bodySizeLimit: 64 * 1024 * 1024) and the default unbounded attribute count, a hostile server can return arbitrarily many 50 MiB BODY sections in one FETCH; every section passes the 64 MiB check while SwiftMail accumulates all of them into one Data, so the advertised memory-exhaustion guard remains bypassable.
  • HIGH Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift:59 — Parser-limit errors are installed on IDLE connections, but they do not fail closed. During IDLE, an oversized BODY or over-limit attribute count makes IMAPClientHandler fire an error; IdleHandler.errorCaught only fails its private promise, without finishing the AsyncStream or closing the channel, and no task is awaiting that promise until a later DONE/checkpoint. The decoder retains the rejected response and each subsequent network read is appended before the same parse error is raised again (the post-decode buffer cap is skipped on throws), allowing a malicious peer to keep growing memory while the caller's IDLE stream hangs.
  • MEDIUM Sources/SwiftMail/IMAP/IMAPParserLimits.swift:42literalSizeLimit is exposed as a configurable maximum, but the pinned IMAPClientHandler constructs its byte-to-message processor with maximumBufferSize: IMAPDefaults.lineLengthLimit (8,192 bytes), regardless of these parser options or SwiftMail's 1 MiB response buffer limit. For example, a valid 16 KiB mailbox-name literal with literalSizeLimit: 32 * 1024 succeeds only if delivered complete enough to parse immediately; if network fragmentation leaves more than 8 KiB buffered before the literal completes, it throws PayloadTooLargeError even though it is below every configured limit. Thus raised literal limits are not reliably honored.

Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
Comment thread Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift
Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
The SwiftLint job has been failing on this branch since the first commit; I only noticed when
CI mailed about it. Four of the seven violations came from the original PR commits, three from
the review fixes. Neither round ran the linter locally, which the repo's CI runs with --strict.

MailTLSMinimumVersion's cases are renamed tlsv1_1/tlsv1_2/tlsv1_3 -> tlsv11/tlsv12/tlsv13.
identifier_name rejects the underscores, and rather than add an exception for a brand-new API,
the cases now match NIOSSL's own spelling (TLSVersion.tlsv11/tlsv12/tlsv13) — which is what
they map onto anyway. The API is unreleased, so the rename is free.

Also: trailing comma in the parameterized test, and the TLS test server's nested types moved to
file scope (nesting depth).

Verified locally with swiftlint --strict: 0 violations in 262 files. 339 tests still pass.
@odrobnik

Copy link
Copy Markdown
Contributor

@davyd15 Please be sure to comment and resolve the individual review items as you fix them (or tell your agent to do that) For a final merge all threads must be resolved and all CI be green.

@odrobnik odrobnik 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.

Requesting changes. The TLS-floor propagation and prior inclusive-bound fixes are correct at this head, but the parser hardening still does not meet the PR's hostile-server memory-bounding goal. The three previously reported parser blockers remain, and the pipelined FETCH path also swallows parser-limit failures as an empty success while leaving the poisoned decoder and socket active. Review was performed read-only; builds and tests were not run as requested.

Findings:

  • HIGH Sources/SwiftMail/IMAP/IMAPParserLimits.swift:31bodySizeLimit is documented as the maximum body data in one response, but the pinned ResponseParser checks each streaming BODY attribute independently and never tracks the response-wide total. FetchPartHandler appends every streaming attribute until the FETCH finishes. With a 64 MiB limit and the default unbounded attribute count, a hostile server can send arbitrarily many 50 MiB BODY sections in one FETCH; every section passes while SwiftMail accumulates them all into one Data, bypassing the advertised memory guard.
  • HIGH Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift:59 — Parser-limit errors on an IDLE connection do not fail closed. An oversized BODY or over-limit attribute count makes IMAPClientHandler fire an error, but IdleHandler only fails its private promise; it neither finishes the AsyncStream nor closes the channel, and nothing awaits that promise until a later DONE/checkpoint. The decoder retains the rejected bytes and appends each subsequent network read before throwing again, so a malicious peer can keep growing memory while the caller's IDLE stream hangs.
  • MEDIUM Sources/SwiftMail/IMAP/IMAPParserLimits.swift:42literalSizeLimit can be configured above 8 KiB, but the pinned IMAPClientHandler always uses an independent 8,192-byte aggregate buffer cap. A valid 16 KiB mailbox-name literal with literalSizeLimit: 32 * 1024 can parse if delivered nearly whole, yet fragmented delivery that leaves more than 8 KiB buffered throws PayloadTooLargeError despite remaining below both the configured literal limit and SwiftMail's response buffer limit. Raised literal limits are therefore network-fragmentation dependent and not reliably honored.
  • HIGH Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift:59 — The pipelined FETCH path also mishandles the newly installed parser-limit errors. PipelinedCommandDispatcher fails all per-request promises, but collectPipelinedFetchResults catches every failure and returns an empty result; the method then removes the dispatcher and reports success without closing the still-active channel. Because the decoder retained the rejected response, later peer writes are appended and rethrow the same error, allowing continued memory growth and leaving subsequent commands on a poisoned connection.

Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
Comment thread Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift
Comment thread Sources/SwiftMail/IMAP/IMAPParserLimits.swift
Comment thread Sources/SwiftMail/IMAP/IMAPConnection+Connection.swift
@odrobnik

Copy link
Copy Markdown
Contributor

@davyd15 and please excuse me inundating you with review feedback. This is a new automation that needs some more polishing.

Addresses the four findings from the second and third reviews. They share one root: the limits
advertised a bound the surrounding code did not keep. Each is reproduced by a test that fails
without its fix.

bodySizeLimit across the whole response (HIGH). ResponseParser checks each streaming section on
its own and keeps no total, while FetchPartHandler and PipelinedFetchPartHandler append every
section into one Data — so any number of 50 MiB sections passed a 64 MiB limit. The total could
be counted in those two handlers, but then it would hold only for the two that exist today. The
limit is a transport promise, so it is enforced on the transport: IMAPResponseLimitGuard sits
directly behind the decoder, sums the byteCount each section declares, resets per message, and
covers every consumer including future ones.

Fail closed (HIGH ×2). The guard closes the channel on any limit violation — the peer has
already proven itself, and the one thing that must not happen is to keep reading from it. That
alone was not enough for IDLE: IdleHandler recorded channelInactive and errorCaught but never
finished the AsyncStream, so "fail closed" would have meant socket shut, caller still hanging.
It now finishes on both. The pipelined path swallowed every failure and returned an empty
result, which is indistinguishable from "this message has no such section" — it now rethrows
limit violations while still tolerating ordinary per-request failures, which is the point of
pipelining.

literalSizeLimit (MEDIUM). Not fixable here: IMAPClientHandler constructs its processor with
maximumBufferSize: IMAPDefaults.lineLengthLimit, hardcoded. Above 8 KiB the outcome depends on
how the network fragments the literal, and a configuration that depends on packet boundaries is
not a limit. The initializer now rejects such values instead of accepting a promise it cannot
keep.

The error-close is deliberately narrow — only ExceededMaximumBodySizeError,
ExceededMaximumMessageAttributesError and ExceededResponseBodySizeError. errorCaught sees
unrelated failures too, and closing on those would change behaviour far outside this feature.

Note on the tests: the first version of the fail-closed tests passed against an EmbeddedChannel
that was never connected — isActive is false there to begin with, so they were green for the
wrong reason. They now connect first and assert the precondition.

Verified locally: 346 tests pass, swiftlint --strict reports 0 violations in 264 files.
@davyd15

davyd15 commented Jul 17, 2026

Copy link
Copy Markdown
Author

Nothing to excuse — the automation earned its keep. Nine distinct findings across three rounds, and every one of them was real: I checked each against the code before touching it, and none turned out to be a false positive. Two were the kind that tests do not catch. The TLS floor was silently inert on port 993, the only port that matters for IMAPS — while a test named "Raising the floor to TLS 1.3 makes a downgrade impossible" sat there green, because it asserted that a factory returns what it is handed, and the defect was that nobody handed it the value. If it is useful signal while you tune the thing: the findings got sharper each round rather than repeating themselves, and the pipelined-FETCH one was genuinely new.

The noise on this branch was mine, though: I pushed twice without answering the threads, the second time for a lint fix only. That is what turned one review into three. Threads get answered before anything lands here from now on.

All findings are addressed at 32b5536. Every fix has a test that fails without it — I reverted each one to check, including the TLS floor and the response-wide body total. Ten threads answered and resolved above; the two literalSizeLimit ones I left open on purpose, see below.

Two things worth your attention rather than mine:

The fixes reach past the limits type. bodySizeLimit needed response-wide accounting that the pinned parser does not do and FetchPartHandler defeats. I could have counted the total in the two handlers that accumulate, but it would hold only for those two and only until a third appears — so it is enforced on the transport instead (IMAPResponseLimitGuard, directly behind the decoder). Failing closed then reached IdleHandler (which recorded channelInactive and errorCaught without ever finishing the AsyncStream) and the pipelined FETCH path (which reported limit violations as an empty success). That is a broader change than "make the existing knobs configurable". If you would rather have the parser limits as their own PR so the TLS floor can land on its own, say the word and I will split it — I would not restructure your PR queue unasked.

literalSizeLimit is the one I could not honour. IMAPClientHandler hardcodes maximumBufferSize: IMAPDefaults.lineLengthLimit, so above 8 KiB the outcome depends on how the network fragments the literal. Rather than document a bound that packet boundaries decide, the initializer now rejects those values. Dropping the property entirely, or carrying a configurable buffer size upstream into NIOIMAP first, are both defensible alternatives — your call, and I left those two threads open for it.

Verified locally: 346 tests pass, swiftlint --strict clean across 264 files.

The Swift workflow has been cancelled on this branch since ac5ba8b, and I only looked once all
the review threads were closed. It was not supersession: build-macos hits its `timeout-minutes:
10` at "Test (macOS)" every time, on every head. main and other branches are green, so this is
ours — mine, specifically, introduced with the TLS floor tests.

Cause: `try? group.syncShutdownGracefully()` called straight from a swift-testing test blocks a
cooperative-pool thread. On a core-constrained runner that violates the pool's forward-progress
guarantee and deadlocks the whole run. Locally, on a machine with cores to spare, 346 tests
finish in 1.6 seconds and nothing hangs — which is why it never showed up here.

None of this is new. Cocoanetics#179 hit exactly this, documented it in IMAPResponseBufferLimitTests
("a 7-minute hang to the job timeout was observed"), and solved it by dispatching the blocking
call to a non-cooperative GCD queue. I wrote the same trap next to the note warning about it.

The fix is that same pattern, extracted to `shutDownGracefully(_:)` in a shared test-support
file rather than copied a third time — so the next test that needs an event loop group finds
the answer instead of the trap. The new suites also adopt `.serialized` and
`.timeLimit(.minutes(1))`, matching the ones already in the repo: a test that hangs should fail
in a minute and say so, not consume the job timeout and report as "cancelled", which is a lot
easier to miss than a red X.

Also dropped a `defer { Task { ... } }` that would have let a shutdown outlive its test.

Verified locally: 346 tests pass, swiftlint --strict clean across 265 files.
@davyd15

davyd15 commented Jul 17, 2026

Copy link
Copy Markdown
Author

One more, and this one was hiding in plain sight: the Swift workflow has been failing on this branch since ac5ba8b — my first round of fixes. I only checked once the threads were closed.

It read as cancelled rather than failed, which is why neither of us caught it: build-macos hits its timeout-minutes: 10 at "Test (macOS)" on every head. main and other branches are green, so it is ours.

Cause: try? group.syncShutdownGracefully() called straight from a swift-testing test blocks a cooperative-pool thread; on a core-constrained runner that violates the pool's forward-progress guarantee and deadlocks the run. Locally it never showed — 346 tests in 1.6 seconds on a machine with cores to spare.

Which is the annoying part: #179 already hit this, documented it in IMAPResponseBufferLimitTests ("a 7-minute hang to the job timeout was observed"), and fixed it by dispatching the blocking call to a non-cooperative GCD queue. I wrote the same trap directly next to the note warning about it.

66c7433 uses that pattern, extracted to a shared shutDownGracefully(_:) rather than copied a third time, and the new suites adopt .serialized + .timeLimit(.minutes(1)) like the existing ones — a hanging test should fail in a minute and say so, rather than eat the job timeout and report as "cancelled".

Verified locally: 346 tests, swiftlint --strict clean. Watching CI on this head; I will confirm here once macOS and iOS are green rather than leave you to check.

David Lemke added 3 commits July 17, 2026 09:59
The previous commit fixed the shutdown and missed the start. `build-macos` still hit its
timeout, and the log named the culprit: "Implicit TLS refuses a TLS 1.2 peer when the floor is
TLS 1.3" — started, never finished.

`TLS12OnlyServer.init()` called `bind(...).wait()`. Same trap as `syncShutdownGracefully()`
wearing a different hat: any `.wait()` reached from a swift-testing test blocks a
cooperative-pool thread, and a core-constrained runner then loses its forward-progress
guarantee. It also explains why `.timeLimit(.minutes(1))` did not save the job — a blocked
thread cannot be cancelled, so the suite ran into the 10-minute job timeout instead of failing
in one.

The hint was there to be read: nothing else in this test suite calls `.wait()`. Not one call
site. I added the only three.

Now `async` throughout, with `.get()` instead of `.wait()`.

Verified locally: 346 tests pass, swiftlint --strict clean. Verified on CI is the part that
matters here, and I will confirm it there rather than claim it.
Third attempt at the macOS CI job, and the first one aimed at the right target. The previous
two fixed blocking calls one at a time — syncShutdownGracefully(), then bind().wait() — and
each time it hung somewhere else. The real problem was not any single call: it was that this
test needed real sockets at all.

What the last run showed: 29 of 346 tests completed, then the whole run stalled until the job
timeout. Not one hanging test — the cooperative pool starving. Real sockets need a
MultiThreadedEventLoopGroup and its blocking teardown, and every blocking step reachable from a
swift-testing test costs a pool thread. On a core-constrained runner that is fatal, and it
defeats .timeLimit too, since a blocked thread cannot be cancelled. Locally, with cores to
spare, all 346 pass in 1.6 seconds — which is why I kept not seeing it.

EmbeddedChannel has no threads, no sockets, and no event loop to shut down. The handshake is
still real: same NIOSSLClientHandler from the same makeTLSHandler the bootstrap calls, same
BoringSSL, same version negotiation — only the transport is a byte pump. 8 milliseconds instead
of 80, and nothing to starve. Added a third case while there: a TLS 1.2 floor must still
negotiate 1.3 with a capable peer — a floor, not an exact match.

Verified by reverting the fix: "A TLS 1.3 floor refuses a peer that stops at TLS 1.2" fails.

One thing stated plainly in the file: these prove the floor is enforced once it reaches the
handler, not that the call sites pass it — which is what the defect actually was. Calling
makeTLSHandler directly hands it the value the bootstrap forgot. That gap is closed by removing
the defaults, so an omission is a build error; the compiler is a better guarantee than a test,
but it is a different one, and claiming otherwise here would repeat the mistake this file
documents.

347 tests pass, swiftlint --strict clean.
…y has

The in-memory handshake did not fix the macOS job either: 27 of 347 tests, then a stall. So it
was never my TLS test. I had been fixing the thing I had just written, twice, instead of
looking at what changed.

What changed: this PR added two suites that construct `IMAPServer`/`SMTPServer` —
MailTLSConfigurationTests (4) and IMAPParserLimitsTests (2) — and neither carries `.serialized`.
Every pre-existing suite here that constructs a server does. That is not a style detail:
constructing a server allocates a MultiThreadedEventLoopGroup, and SMTPServer.deinit shuts it
down with the blocking syncShutdownGracefully(). Released from a Swift Concurrency context, each
one costs a cooperative-pool thread. Six of them running in parallel with everything else, on a
core-constrained runner, starves the pool — which is why unrelated tests ("ASCII display name
behaves like description") hang too, and why nothing reproduces locally on a machine with cores
to spare.

So: `.serialized` + `.timeLimit(.minutes(1))` on both, matching the convention. `.timeLimit` on
the two guard suites as well — they construct nothing, but a hanging test should fail in a
minute and say so rather than consume the job timeout and report as "cancelled".

The convention was visible in every neighbouring file. I wrote past it.

347 tests pass, swiftlint --strict clean.
@davyd15

davyd15 commented Jul 17, 2026

Copy link
Copy Markdown
Author

I need to ask for help on CI, before I burn more of your review runs guessing.

build-macos has never been green on this branch — it hits timeout-minutes: 10 at "Test (macOS)" on every head since ac5ba8b. It reports as cancelled rather than failed, which is why it went unnoticed for so long. main does the whole job in ~5 minutes, so this is ours.

What the logs show: it is not one hanging test. Roughly 27 of 347 complete, then the run stalls for ~7 minutes until the timeout — and the tests left unfinished include ones with no relation to this PR ("ASCII display name behaves like description"). That reads like cooperative-pool starvation rather than a deadlock in any single place.

It does not reproduce locally. 347 tests in 1.6s, and still 1.6s with LIBDISPATCH_COOPERATIVE_POOL_STRICT=1. This machine has cores to spare, which I suspect is exactly the difference.

Four attempts, none of which helped:

  1. Removed syncShutdownGracefully() from the new tests, using the GCD-dispatch pattern Make the IMAP response parser buffer limit configurable #179 established.
  2. Removed bind(...).wait() from the test server — nothing else in the suite calls .wait(); I had added the only three.
  3. Replaced the real-socket TLS server entirely with an in-memory EmbeddedChannel handshake — no threads, no sockets, no event loop group. Still stalls.
  4. Added .serialized + .timeLimit(.minutes(1)) to the two new suites that construct servers, matching every pre-existing suite that does.

One observation I will offer as a lead rather than a diagnosis, since I could not confirm it: SMTPServer.deinit calls the blocking syncShutdownGracefully(), while IMAPServer.deinit dispatches to a Task. This PR adds six server constructions across two test suites. That is the only mechanism I can find by which new tests would starve the pool for unrelated ones — but attempt 4 should then have fixed it, and it did not, so I am probably still wrong somewhere.

Have you seen this before on this runner? And is there a variant you would prefer: dropping the new suites and keeping only what does not construct servers, or something else entirely? I would rather ask than push a fifth guess at it.

Unrelated, for completeness: build-ios failed on the last two runs with iOS 26.0 is not installed from xcodebuild — runner image, not this branch.

Everything else stands: all findings addressed, all twelve threads resolved, SwiftLint green, Linux and Android green, 347 tests and swiftlint --strict clean locally.

@odrobnik

Copy link
Copy Markdown
Contributor

Let's See what Fable Ultra finds... stand by...

@odrobnik

Copy link
Copy Markdown
Contributor

Thanks for this contribution — both changes are solid and they're being merged. The merge happens via #186, which contains this PR's commits verbatim (your authorship intact; GitHub will mark this PR merged automatically when #186 lands) plus follow-up commits that got CI green and tightened a few things found in review:

  • The CI hangs you fought were real, and only one-third yours. Your diagnosis in 66c7433 was on the right track but incomplete: the blocking syncShutdownGracefully() lives in SMTPServer.deinit, so .serialized on the new suites couldn't fix it — three suites concurrently releasing servers (your new MailTLSConfigurationTests plus the two pre-existing SMTP suites) park all three cooperative-pool threads on a 3-core runner, and the shutdown's own completion callback needs one of those threads. Thread samples from a hung runner confirmed it. Fixed in the library: both server deinits now use the non-blocking callback form.
  • Linux hung separately: EmbeddedChannel.finish() after a completed TLS handshake does close().wait(), and NIOSSL's graceful shutdown waits for a close_notify the stopped byte pump can never deliver (macOS completes the close inline, which is why you never saw it). The teardown now pumps the close_notify exchange and advances embedded time.
  • build-ios was pure infrastructure: the macos-latest image rotated on July 15 and stranded the pinned Xcode 26.0 without its iOS platform — unmodified main failed identically. The workflow now tracks latest-stable.
  • One functional fix worth knowing about: ResponseDecoder wraps every parser error in IMAPDecoderError before it reaches the pipeline, so isLimitViolation never matched a production-raised violation — the fail-closed close only ever fired for the bare error types the tests injected. It now unwraps the wrapper (and also matches ExceededLiteralSizeLimitError), with an end-to-end test that drives raw wire bytes through the real decoder.

The TLS floor and parser-limit design, the propagation to IDLE/named connections, and the no-defaults principle all survive unchanged — good work, and the write-ups in your commit messages made the archaeology much easier.

🤖 Generated with Claude Code

odrobnik added a commit that referenced this pull request Jul 17, 2026
…9f5454

PR #185 + CI/deadlock fixes: TLS floor, parser limits, and four hang root-causes
@odrobnik
odrobnik merged commit 20b4296 into Cocoanetics:main Jul 17, 2026
3 of 6 checks passed
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.

2 participants