Skip to content

Perfect-SMTP 6.0.1

Latest

Choose a tag to compare

@taplin taplin released this 26 Oct 11:31
36f1d5e

Perfect-SMTP 6.0.1 is a patch release. Most of it is for NIOMTASTSHTTPFetcher, the default MTA-STS policy fetcher:

  • a policy host can no longer keep a finished fetch's connection busy;
  • the fetch deadline and task cancellation now end every stage of a fetch.

It also fixes a Linux crash in LocalMTATransport. There are no API changes.

.package(url: "https://github.com/PerfectlySoft/Perfect-SMTP.git", from: "6.0.1"),

Fixes

A capped, timed-out or refused fetch stops reading at once. Closing a NIOSSL channel starts a clean TLS shutdown. NIOSSL sends close_notify and then keeps reading, and discarding, whatever the peer sends until the peer's close_notify arrives or shutdownTimeout expires (5 s by default). A policy host that streams an endless body and ignores close_notify therefore kept each connection busy for 5 s after the fetch had already returned responseTooLarge. The fix: the fetcher turns autoRead off before closing, and uses a 1 ms TLS shutdownTimeout. A client that already has its response, or has given up, has no use for a clean TLS shutdown. The fetcher overrides tlsConfiguration.shutdownTimeout; the rest of a custom TLSConfiguration is used as given.

These are 50 back-to-back capped HTTPS fetches against a local server that streams an endless body. The bench runs Linux in the Apple container CLI with --memory 6g, as release builds; amd64 is emulated. "Normal" is a NIOSSL server that answers close_notify. "Ignores close_notify" is one that never reads it.

Platform 6.0.0, normal server 6.0.0, ignores close_notify 6.0.1, either server
macOS, Swift 6.4 0.7–1.8 s, 1.0–2.6 GB 15.7–21.4 s, 40–56 GB 0.06–0.08 s, 33–46 MB
Linux 6.2-noble arm64 0.6–0.9 s, 1.9–2.8 GB 28.5–30.5 s, 68–77 GB 0.06–0.07 s, 193–201 MB
Linux 6.4-noble arm64 1.1–1.5 s, 2.8–3.9 GB 33.8–40.1 s, 81–104 GB 0.07–0.09 s, 181–201 MB
Linux 6.2-noble amd64 1.4–1.6 s, 1.7–2.5 GB 68.2–88.3 s, 144–189 GB 0.16–0.17 s, 145–182 MB
Linux 6.4-noble amd64 1.6–2.5 s, 1.5–3.3 GB 87.2–91.7 s, 148–178 GB 0.15–0.16 s, 168–187 MB

Each cell is the time for all 50 fetches and the total bytes the server wrote to loopback. The ranges cover three body framings (no length, chunked, and a huge Content-Length) for the normal server and two for the other. Against the server that ignores close_notify, 6.0.0 left every connection open for 5.0 s after its fetch returned; 6.0.1 closes it within 0.01 s. Peak RSS stayed under 60 MB in every run, and open fds went back to baseline. Every fetch ended in responseTooLarge.

A partial body is still never accepted. A response counts as complete only when one of these holds:

  • its Content-Length has been read;
  • the chunked terminator arrived;
  • for a body that ends at the close, the server sent TLS close_notify.

New tests confirm that the 1 ms shutdown doesn't change this. A short Content-Length body and an unterminated chunked body are refused, whether the server ends with close_notify (HTTPParserError.invalidEOFState) or with a bare TCP close (NIOSSLError.uncleanShutdown). A close-delimited body without close_notify is refused too. As before, over http:// (meant for tests and local tooling) a bare TCP close ends a close-delimited body. Hardening: the response collector also fails an incomplete response when any close passes through it. A close that ends through NIOSSL's own shutdown timer leaves no uncleanShutdown error behind, so without this a close-delimited body could otherwise look complete.

The deadline and cancellation end every stage. These problems were already in 6.0.0 and were found in this release's review.

  • The fetch deadline only failed the response. A server that accepted the connection and never finished the TLS handshake held the fetch, and its fd, until the server hung up. A slow connect could also run past the deadline and still send the GET.
  • Cancelling the calling task also cancelled the deadline, so the fetch then waited for the server indefinitely.
  • A resolver error (any NXDOMAIN or SERVFAIL for mta-sts.<domain>) left a promise uncompleted, which crashes debug builds with "leaking promise".

The deadline now runs on the event loop, and cancellation has a handler. Either one aborts resolution, every connect attempt, the handshake and the response. It closes the channel, including one whose connect is still in flight, which then fails at once. A cancelled fetch throws CancellationError. timeout now bounds the whole fetch, as the documentation already said.

The request target is sent as the URL has it. The target used to come from url.path, which Foundation percent-decodes and strips of a trailing slash:

  • /api/ went out as /api;
  • /%3F/? went out as /??;
  • paths containing %20 or non-ASCII characters failed with "invalid character in header".

It is now the URL's percent-encoded path (an empty path becomes /) and query, with the fragment dropped. As defence in depth, a URL with no components is refused (FetchError.unsupportedURL), and so is a target that would contain a space, a control character or a non-ASCII byte. NIO doesn't check the request URI, and older Foundations differ in how they encode such characters. MTASTSPolicyManager always fetches /.well-known/mta-sts.txt, so the policy manager was never affected; only code that calls NIOMTASTSHTTPFetcher directly could be. The bug was found in Perfect-Authentication's NIO client, which was copied from this fetcher.

LocalMTATransport no longer crashes when a pipe read is interrupted (Linux). It drained sendmail's stdout and stderr with FileHandle.availableData. Corelibs Foundation turns any read error from that call into a fatalError, EINTR included, so a signal interrupting the read crashed the whole process. The 6.0.1 test suite hit it 3 times out of 3 on emulated linux/amd64; 6.0.0's suite passes there because it puts less load on the machine. The drains now use a read(2) loop that retries EINTR and throws any other error. Writes to the child's stdin were never affected, because corelibs already retries EINTR on write. A new test interrupts a blocked reader five times with a signal and checks that the full contents still come back.

Upgrading

Nothing to change. Code that catches errors from NIOMTASTSHTTPFetcher.fetch may now see CancellationError when the calling task is cancelled; 6.0.0 hung in that case.

Known issues and follow-ups

  • SMTP connections themselves (relay, direct MX) still close TLS with NIOSSL's default 5 s shutdownTimeout. A hostile MX that ignores close_notify after QUIT or an eviction can keep a pooled connection reading for up to 5 s. This release doesn't measure or change that; it's a candidate for 6.0.2 or 6.1.
  • An aborted MTA-STS fetch doesn't stop its DNS lookup. The lookup runs on until DNSResolver's own query timeouts, because the resolver doesn't check for task cancellation. A custom MTASTSAddressResolving that never returns would keep its task alive indefinitely. The fetch itself still returns on time.
  • If the deadline or a cancellation lands at the same moment as a real failure (responseTooLarge, a refused certificate), the fetch may report timedOut or CancellationError instead of the real error.
  • LocalMTATransport, unchanged: the stdin write runs blocking on a cooperative-pool task, and an MTA that exits without reading stdin raises SIGPIPE, which nothing in the package ignores.

Verification

  • macOS: Xcode 27.0 (Swift 6.4) and Xcode 26.3 (Swift 6.2.4), debug and release, with -warnings-as-errors; the full suite passes (498 tests). CI: Xcode 26.0.1 and 26.6.
  • Linux: swift:6.2-noble and swift:6.4-noble, arm64 and amd64, with -warnings-as-errors; the full suite passes. CI: Swift 6.2 and 6.4, including a job with no network.
  • On 6.0.0 (or the old code), every new test fails, hangs or traps.