<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Phase 3 &mdash; Auth and Cooperative Server &mdash; Arc Plan</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
    margin: 1.5em auto; padding: 0 1em; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 1.4em; }
h2 { font-size: 1.1em; margin-top: 1.4em; }
ol { margin: 0.3em 0; } li { margin: 0.25em 0; }
ol.slices { list-style: none; padding-left: 0; }
ol.items { list-style: none; padding-left: 0; }
.check { color: #1a7f37; font-weight: bold; }
.next { color: #b35900; font-weight: bold; }
.q { color: #8250df; }
code { background: #f2f2f2; padding: 0 0.25em; border-radius: 3px; }
em { color: #555; }
</style>
</head>
<body>
<h1>Phase 3 &mdash; Auth and Cooperative Server</h1>
<p style="margin:.8rem 2rem;padding:.7rem 1rem;background:#eef2f7">
<strong>Arc Post-Mortem Summary.</strong>
Started as authentication modernization and
grew a second thread after a production emergency showed the single-process
atto web server blocks every connection during long work (a huge mailbox
listing, an external IMAP probe, a password hash). It landed the immediate
server-stability fixes and then the cooperative non-blocking server, which
lets long work yield to the event loop and be re-entered, starting with IMAP.
The authentication rework proceeded alongside in the originally-listed
order.</p>
<p><em>Renamed from &ldquo;Auth Modernisation&rdquo; on 2026-06-19 once the
arc grew a second thread: a production emergency exposed that the
single-process atto web server blocks every connection whenever a request
does something long (a huge local-mailbox listing, an external IMAP probe,
a password hash). The stability fixes for those landed under &ldquo;Server
stability work&rdquo; below, and the next thread is the cooperative
non-blocking server that lets long work yield to the event loop and be
re-entered &mdash; starting with IMAP.</em></p>
<p><em>Arc started 2026-06-19; revised the same day after dropping TOTP.
Covers the v10 ToDo Phase 3 items <strong>in the order they were
originally listed</strong>: item 32 (modernise the image captcha) and
item 33 (replace the recovery-question system). Item 33(a) &mdash;
default new installs to
<code>EMAIL_RECOVERY</code> &mdash; and item 47 &mdash; the
database-backed session store &mdash; already landed.</em></p>

<h2>What exists today (verified 2026-06-19)</h2>
<ol>
  <li>Two captcha modes already exist: <code>IMAGE_CAPTCHA</code>
    (<code>CaptchaModel</code> draws letters with GD's built-in bitmap
    font) and <code>HASH_CAPTCHA</code> &mdash; a proof-of-work mode
    where the browser runs <code>hash_captcha.js</code> /
    <code>sha1.js</code> to find a nonce meeting a difficulty
    (<code>HASH_CAPTCHA_LEVEL</code>). The image mode is the one being
    retired.</li>
  <li>Recovery modes are <code>NO_RECOVERY</code> (0),
    <code>EMAIL_RECOVERY</code> (1), <code>EMAIL_AND_QUESTIONS_RECOVERY</code>
    (2); the default is already <code>EMAIL_RECOVERY</code> (33(a) done).
    Recovery questions are driven by fixed
    <code>register_view_recoveryN_*</code> locale keys.</li>
  <li>Yioop already sends mail through the same paths the rest of v10
    uses, so email one-time codes need no new transport &mdash; just a
    short-lived signed/stored token and a verify step.</li>
</ol>

<h2>Plan (in the originally-listed order)</h2>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>Item 32 &mdash; retire
    the image captcha; gate signup with proof-of-work, cheap invisible
    checks, and an email confirmation.</strong> Image captchas are a
    declining defence (machine solvers and captcha-farms beat them) and
    a burden for blind users. Self-contained replacement, landed in
    slices:
    <ol>
      <li><span class="check">&#10003;</span> Auth + suggest forms
        &rarr; proof-of-work only: remove the global
        <code>IMAGE_CAPTCHA</code> mode (the constant, the
        <code>CAPTCHA_MODE</code> setting, the admin Captcha-Type
        control, the <code>RegisterController</code> image branches, and
        the now-dead image blocks in the register / recover / resend /
        suggest views). <code>CaptchaModel</code> and
        <code>setupGraphicalCaptchaViewData</code> stay alive for the
        wiki placeholder, converted next. <em>This patch.</em>
        <ul><li><span class="check">&#10003;</span> Follow-up (folded in):
        the resend-activation and recover-account forms each lost their
        captcha, leaving the separator line above their button stranded
        &mdash; removed that <code>border-top</code> on both (the
        multi-field register / suggest forms keep theirs, where it still
        reads as an action separator).</li></ul></li>
      <li><span class="check">&#10003;</span> Wiki forms &rarr;
        proof-of-work automatically: <code>{{image-captcha}}</code> now
        expands to a hidden field whose browser solves a proof-of-work
        puzzle (no typing, no author-chosen type). A shared
        <code>meetsProofOfWork</code> on the base controller does the
        check; <code>setupGraphicalCaptchaViewData</code> became
        <code>setupCaptchaViewData</code> (keeps the per-render word that
        seeds the form's dedup hash, drops the image), and
        <code>WikiElement</code> renders the proof-of-work inputs.
        <code>SocialComponent</code> verification swaps the typed-word
        match for the puzzle check while keeping the existing
        new-row / reload / dedup behaviour. <code>CaptchaModel</code> and
        the image plumbing are removed. <strong>Kept
        <code>{{keyword-captcha}}</code></strong> &mdash; an author-chosen
        keyword is a deliberate tool. <em>This patch.</em>
        <ul><li><span class="check">&#10003;</span> Refinement: dropped the
        <code>{{image-captcha}}</code> syntax entirely (no legacy support)
        and made the proof-of-work automatic on <strong>every</strong>
        wiki form. Renamed to disambiguate &mdash; the
        anti-bot check is a <em>proof-of-work</em> form check, not a
        captcha: token <code>[{hash-captcha}]</code> &rarr;
        <code>[{proof-of-work}]</code>, and
        <code>setupCaptchaViewData</code> &rarr;
        <code>setupProofOfWorkViewData</code>. One consistent pair does the
        work: <code>WikiParser::proofOfWorkFormFields()</code> injects (from
        <code>GroupModel::setPageName</code> when it wraps a form) and
        <code>Controller::meetsProofOfWork()</code> checks. The proof-of-work
        is always required; <code>{{keyword-captcha}}</code> is an
        <strong>additional</strong> gate, so a form carrying one must pass
        both. The injected answer field is skipped only when a keyword
        captcha already supplies one (it shares the form's key column), so
        there is never a duplicate field. Removed the dead
        <code>system_component_image_captcha</code> /
        <code>_hash_captcha</code> locale labels.</li></ul></li>
      <li><span class="check">&#10003;</span> Cheap invisible checks: a
        honeypot field <strong>hidden from screen readers</strong> (off
        screen, out of tab order, <code>aria-hidden</code>,
        <code>autocomplete="off"</code>) is injected into every form by
        <code>proofOfWorkFormFields</code>; <code>processWikiFormData</code>
        rejects a submission that fills it, one that comes back faster than
        <code>MIN_WIKI_FORM_DELAY</code> seconds, or one from an address
        already inside the per-IP backoff (a new up-front
        <code>getVisitor</code> gate reusing the same
        <code>captcha_time_out</code> timeout the account forms use). All
        three answer with the generic captcha-failed message and feed
        <code>updateVisitor</code>, so a script cannot tell which check
        caught it and repeat trips grow the timeout. <em>This patch.</em></li>
      <li><span class="check">&#10003;</span> Email confirmation before an
        account activates (rides on Yioop's own mail; dovetails with item
        33). The flow already exists &mdash; the
        <code>email_registration</code> type adds the user inactive,
        <code>sendActivationMail</code> sends a DKIM-signed message with a
        verify link, and <code>emailVerification</code> activates the
        account. Audited for deliverability and security; the findings are
        being addressed in sequence:
        <ol>
          <li><span class="check">&#10003;</span> The Date header used a
            two-digit year (<code>DATE_RFC822</code>), a spam-filter signal
            on every outbound message; now <code>DATE_RFC2822</code> in
            <code>MimeMessage::build</code>.</li>
          <li><span class="check">&#10003;</span> The confirmation mail
            reused the admin-activation subject; it now has its own
            user-facing subject string.</li>
          <li><span class="check">&#10003;</span> The verify-link token was
            compared with <code>==</code>; now <code>hash_equals</code> so
            the check runs in constant time.</li>
          <li><span class="check">&#10003;</span> Resend re-sends the
            activation mail to an inactive address with no throttle &mdash;
            add a per-address / per-IP rate limit. Done: resendComplete
            now throttles per requester IP and per hashed target email via
            the VisitorModel timeout mechanism, so neither one IP nor one
            inbox can be flooded.</li>
          <li><span class="check">&#10003;</span> The verify URL carries
            the email address in plain text &mdash; replace it with an
            opaque token so no address rides in the link. Done: the link
            now carries a signed token holding only the user id (and an
            expiry), resolved by UserModel::getUserById; no email travels
            in the URL.</li>
          <li><span class="check">&#10003;</span> The activation link never
            expires &mdash; give the token an expiry (the short-lived token
            store item 33 wants). Done: the token now signs an expiry time
            alongside the user id and parseActivationToken rejects a stale
            or expiry-tampered link; lifetime is C\ACTIVATION_LINK_TIMEOUT
            (a stateless self-expiring token, the no-row option 33(b)
            weighs).</li>
          <li><span class="check">&#10003;</span> Verify and resend return
            account-existence-revealing
            messages &mdash; make them generic. The resend form took a
            username and answered with three different messages (no such
            account / already active / mail sent), so anyone could type
            usernames at the public form and learn from the reply alone
            which accounts exist and whether they are active &mdash;
            account enumeration. Fix (done): resendComplete now returns
            one neutral message in every case and only actually re-sends
            when a real, still-inactive, non-throttled account matches;
            the verify side keeps its helpful "already activated" message
            since that only shows to someone holding a valid signed
            link.</li>
          <li><span class="check">&#10003;</span> Optional polish: the
            mail carried no <code>Reply-To</code> and was plain text.
            Done: activation mail now sets a <code>Reply-To</code> from
            the new <code>MAIL_REPLY_TO</code> config (defaults to the
            sender mailbox, can be pointed at a staffed support address),
            and the body got a light HTML face lift. It now goes out as
            <code>multipart/alternative</code> &mdash; a plain-text part
            (kept for text-only mail apps and the spam filters that
            expect one) and a light HTML part with the activation and
            unsubscribe links made clickable and the user-supplied names
            and URLs escaped &mdash; assembled by a new
            <code>MimeMessage::alternativeBody</code> helper and carried
            through by letting <code>MimeMessage::build</code> honor a
            caller-supplied Content-Type.</li>
        </ol></li>
      <li>Update the Wiki Syntax document to match (image-captcha gone,
        keyword-captcha kept, proof-of-work applied automatically).</li>
    </ol></li>
  <li><strong>Item 33 &mdash; replace the recovery-question system.</strong>
    <ol type="a">
      <li><span class="check">&#10003;</span> Default new installs to
        <code>EMAIL_RECOVERY</code> &mdash; already done.</li>
      <li><span class="check">&#10003;</span> Email-based one-time login codes: generate a short,
        time-limited, single-use code (or signed link), mail it to the
        registered address, and verify it to sign the member in or let
        them recover, without a saved password and without any external
        app. Needs a short-lived token store (a small table or signed
        token with an expiry), a request step, a verify step, and rate
        limiting so the mailbox can't be hammered.
        <br><em>Built:</em> a single-use, table-backed
        code in the new <code>LOGIN_CODE</code> table (migration
        <code>upgradeDatabaseVersion113</code>, <code>DATABASE_VERSION</code>
        now 113), 8 chars from an unambiguous alphabet, 15-minute lifetime
        (<code>LOGIN_CODE_TIMEOUT</code>), at most 5 wrong guesses
        (<code>LOGIN_CODE_MAX_TRIES</code>). Storage lives on
        <code>SigninModel</code>; the controller (<code>loginCode</code>,
        <code>processLoginData</code>, <code>loginCodeComplete</code>)
        emails the code <em>and</em> a one-click button carrying a signed
        token plus the code, both verifying the same stored hash and
        signing the member straight in. The request form is reached from a
        new link on the sign-in page and is shown by a method on
        <code>SigninView</code>. Per-IP and per-email
        <code>ONE_MINUTE</code> throttling and a generic anti-enumeration
        message mirror the resend flow. Covered by
        <code>SigninModelTest</code>.</li>
      <li><span class="check">&#10003;</span> For an offline / airgapped install that keeps a question
        mode, let users type their own question and answer; drop the
        rigid <code>register_view_recoveryN_*</code> translation
        infrastructure and remove the
        <code>EMAIL_AND_QUESTIONS_RECOVERY</code> mode.</li>
    </ol></li>
</ol>

<h2>Server stability work (now in-arc)</h2>
<ol>
  <li><span class="check">&#10003;</span> Atto server hang hotfix
    (<code>WebSite.php</code>): a peer whose TLS faulted mid-stream (a
    &ldquo;bad record mac&rdquo; alert) left its socket perpetually
    readable, so the event loop spun at full CPU and
    <code>cullDeadStreams</code> never reaped it (the read path kept
    refreshing its activity time), which eventually blocked all new
    connections including the command-line restart. Fix: in
    <code>parseH1Request</code>, a readable socket whose read returns
    <code>false</code> (TLS fault) or empty at end-of-file is now torn
    down at once with <code>shutdownHttpStream</code>; the read's warning
    suppression also swallows the <code>SSL operation failed</code> notice
    (same client-side transport class as the existing &ldquo;reset by
    peer&rdquo;); and the activity-time refresh in
    <code>processRequestStreams</code> is guarded so a connection closed
    during the read leaves no stray timer.</li>
  <li><span class="check">&#10003;</span> Fuller hardening (same file):
    the read path no longer refreshes a connection's activity time on a
    zero-byte readable pass. A new <code>read_made_progress</code> flag is
    reset before each connection is handled and lowered by the H1 read
    when a readable socket returns no bytes yet is neither at end-of-file
    nor errored (a stalled or partial-record peer); only when the read
    actually pulled bytes does <code>processRequestStreams</code> refresh
    the time. So even a peer that stays readable while delivering nothing
    ages out on the idle timeout instead of holding the loop. Other
    protocols leave the flag true, so their behaviour is unchanged.</li>
  <li><span class="check">&#10003;</span> MailSite audit + matching fix
    (<code>MailSite.php</code>): checked the mail server for the same
    class of bug. The stalled-TLS-handshake case is already safe &mdash;
    handshakes are non-blocking with a 10-second deadline, watched
    read-only (so a connected, always-writable socket cannot spin the
    loop), and <code>cullDeadStreams</code> reaps any handshake past its
    deadline every tick (select is capped at 5s). The idle path was also
    already safe (it stamps the activity time only after a non-empty
    read), and the write path already closes on a failed write. The one
    gap, the same one fixed in <code>WebSite.php</code>: an established
    secure connection whose read faulted (a <code>false</code> return)
    just returned and spun until the 30-minute idle cull. Fixed
    <code>readClient</code> to close at once on a read fault or an
    end-of-file read, matching the web server.</li>
  <li><span class="check">&#10003;</span> Web mail-check hang fix
    (<code>ImapClient.php</code>): checking mail in the web UI could hang
    the whole site. The STARTTLS path called
    <code>stream_socket_enable_crypto</code> on a blocking socket, which
    ignores the read timeout &mdash; so an IMAP peer that accepted
    STARTTLS and then stalled mid-handshake blocked the single-process web
    server forever, and no other web request could be served. Added
    <code>enableCryptoBounded</code>, which runs the handshake
    non-blocking and waits only up to the connect timeout
    (<code>MAIL_IMAP_CONNECT_TIMEOUT</code>, 15s) before giving up;
    <code>connectStartTls</code> now uses it. The implicit-TLS path
    (<code>connectImaps</code>) already bounds its handshake through the
    <code>stream_socket_client</code> connect timeout. (<code>SmtpClient</code>
    has the same blocking-handshake pattern but runs in the background mail
    sender, not a web request, so it is a lower-priority follow-up.)</li>
  <li><span class="check">&#10003;</span> Huge local-mailbox inbox freeze
    fix (<code>MailSiteMailBackend::listMessages</code>): opening a large
    MailSite (local) inbox in the web UI froze the whole single-process web
    server, blocking every other connection. The listing shaped
    <em>every</em> message in the folder &mdash; reading one header block per
    message &mdash; before paging down to one window, so a 90,000-message
    INBOX did ~90,000 small file reads per window request. An SSD hid the
    cost (the first window returned, slowly); a spinning disk turned it into
    minutes of seeks that hung the server, and later windows failed. Changed
    to window-then-shape: the date sort, the unread/flagged filters and the
    windowing now run on the lightweight index records (which already carry
    uid, date and flags), and message files are read only for the ~25
    messages on the returned window. Added
    <code>listMessagesWindowReadsOnlyWindowTestCase</code> (a counting
    MailSite subclass) asserting that listing one window of a 60-message
    folder reads at most one window's worth of message headers, so a return
    to shaping the whole folder is caught.</li>
  <li><span class="check">&#10003;</span> Unread-badge login freeze fix
    (<code>MailUnreadProbe</code>, <code>Component</code>,
    <code>SocialComponent::userMailBaseData</code>): the unread-mail badge
    in the shared page chrome was computed by connecting to every external
    IMAP account (connect + LOGIN + STATUS) on a cache miss, inside the
    single-process web request. So one user logging in (or any page view
    after the 300s cache expired) with a slow or unreachable external
    account froze the whole server for every other visitor. Split the badge
    into a cache-only read used by the page chrome
    (<code>MailUnreadProbe::cachedCount</code>, which never opens a
    connection and returns the last cached value or 0) and the existing
    computing <code>count()</code>, now called only from the Mail activity
    (<code>userMailBaseData</code>), where talking to the mail servers is
    expected and the listing already connects. General browsing and login
    no longer wait on mail; the badge catches up the next time the user
    opens their mail. (Making even the Mail activity's external-IMAP calls
    non-blocking is the separate async-IMAP architectural follow-up.)</li>
</ol>

<h2>Cooperative non-blocking server</h2>
<ol class="slices">
  <li><span class="check">&#10003;</span> <strong>Slice 1 &mdash;
    cooperative scheduler.</strong> A CooperativeScheduler inside the
    WebSite atto file drives tasks a little at a time, interleaved, each
    with a completion callback (its result or the exception it threw) and a
    30-second work-time budget; WebSite holds one lazily and steps it each
    loop pass. Under Apache the work runs straight through. Back-ports to
    the atto project.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 1b &mdash; use
    fibers, not generators.</strong> The scheduler drives <code>\Fiber</code>
    tasks so a deep mail-socket read can suspend the whole request without
    rewriting the controller chain above it; <code>deferTask</code> takes a
    callable, wraps it in a fiber under atto, runs it inline under
    Apache.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 1c &mdash; route
    opt-in and deferred response.</strong>
    A route opts into running its whole handler inside a fiber that may
    suspend mid-render. Each such request's environment (the superglobals
    plus <code>header_data</code>/<code>content_type</code>/
    <code>current_session</code>) is swapped around every resume and its
    output captured per resume; the response is built and sent only when
    the fiber finishes (a 500 if it throws or runs over budget). Wired for
    HTTP/1.1, HTTP/2, and HTTP/3 (the last through <code>H3Listener</code>);
    the H1 round trip is core-tested in the sandbox, the live protocols
    need testing on the server.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 2 &mdash;
    fiber-suspending <code>ImapClient</code>.</strong> Reads
    (<code>readLine</code>/<code>readBytes</code>) and the bounded TLS
    handshake now hand the loop back with
    <code>Fiber::suspend(['read' =&gt; $sock])</code> at a would-block
    point when inside a fiber, and block exactly as before otherwise (so
    Apache and non-cooperative callers are unchanged); the loop polls
    in-flight tasks at a short interval (<code>COOPERATIVE_POLL</code>)
    rather than spinning. A socket-pair test proves a read suspends until
    the server answers. (Wiring the wait sockets straight into the loop's
    select to drop the poll is a possible later refinement; writes block
    briefly as now.)</li>
  <li><span class="check">&#10003;</span> <strong>Slice 3 &mdash; route web
    IMAP through the cooperative path.</strong> Web mail requests
    (<code>a=userMail</code>, bar attachment downloads) now run inside a
    deferred fiber, so the folder and message listing, message view, and
    the unread-count probe &mdash; everything that reads IMAP &mdash; yield
    to the loop while a slow or down mail server is being waited on rather
    than stalling every other connection. For this, <code>deferResponse</code>
    now treats a <code>webExit()</code> (redirect or normal early exit) as
    a clean finish, the way the synchronous path does, instead of a 500.
    Under Apache the requests run straight through unchanged.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 4 &mdash; login
    password hash via a short-lived worker.</strong> The bcrypt hash
    (<code>crawlCrypt</code>, cost 12, a good fraction of a second) cannot
    be split, so when it runs inside a fiber it is handed to a fresh
    short-lived PHP process (started with <code>proc_open(PHP_BINARY...)</code>;
    chosen over forking the live event-loop server, which would inherit
    its sockets and is not portable). The password and salt go to the
    helper over its stdin (never the command line), and the login task
    <code>Fiber::suspend(['read' =&gt; $pipe])</code>s until the helper
    writes the hash back. Outside a fiber <code>crawlCrypt</code> is the
    plain crypt it always was, so every other caller and Apache are
    unchanged. Signin requests (those carrying <code>u</code> and
    <code>p</code>) are now deferred so the hash actually runs in a fiber.
    A test proves the helper produces the identical hash and the fiber
    suspends on its pipe.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 5 &mdash;
    cooperative media-list assembly.</strong> A wiki page of type Media
    List with a thousand or more resources hung the server while its list
    was built. Two fixes: the per-resource has-thumbnail check was a linear
    <code>in_array</code> over every thumbnail, making the whole loop grow
    with the square of the count &mdash; it is now an O(1) keyed lookup; and
    the assembly loop in <code>GroupModel</code> gives the loop a turn every
    <code>RESOURCE_LIST_YIELD</code> resources (via
    <code>cooperativeYield()</code>, a no-op outside a fiber), with wiki
    requests now deferred so that yield takes effect. To keep that yield
    free, the scheduler now tells the loop when it has immediate work (a
    task that suspended plainly, not on a socket), so a CPU- or disk-bound
    job is resumed at once rather than waiting out the poll interval, while
    socket waits are still polled gently.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 6 &mdash;
    cooperative <code>SmtpClient</code>.</strong> Its response read used a
    blocking <code>fgets</code> and its STARTTLS handshake a single
    blocking call, both of which froze the loop waiting on the mail
    server. The reader is now buffered the way <code>ImapClient</code>'s
    is and waits for the socket to be readable (suspending the fiber when
    cooperative, blocking exactly as before otherwise), and the STARTTLS
    handshake uses the same bounded, fiber-yielding upgrade &mdash; which
    also fixes its TLS version to 1.2/1.3 only, dropping the obsolete bare
    method that allowed TLS 1.0/1.1. The web requests that send mail
    (registration, group feeds and group management; <code>userMail</code>
    was already deferred in Slice 3) are now deferred so those sends run in
    a fiber. A socket-pair test proves a response read suspends until the
    server answers. The plain (non-implicit-TLS) TCP connect is now
    cooperative too: in a fiber it asks for a non-blocking connect and
    suspends until the socket reports it has connected, so a slow or
    unreachable server no longer freezes the loop during the connect
    either (tested against a local listener); outside a fiber it is the
    same blocking connect as before. The seconds-to-microseconds
    conversion the bounded handshake and the loop clamp use is now the
    named constant <code>MICROSECONDS_PER_SECOND</code> rather than a bare
    1000000. (Two blocking points remain, both rare and bounded by the
    connect timeout: the implicit-TLS, port-465 connect, whose handshake
    is part of the connect; and the IMAP client's connect, which can take
    the same async treatment next.)</li>
  <li><span class="check">&#10003;</span> <strong>Slice 6b &mdash;
    <code>sendImmediate</code> moved to a worker process.</strong>
    Reconsidered the SMTP approach: rather than make each socket step in
    the web process cooperative, a send done inside a fiber now hands the
    <em>whole</em> exchange to a short-lived helper process &mdash; the
    same way the password hash gets off the loop &mdash; and suspends on
    the helper's pipe until it answers. The connect, the TLS handshake,
    and the conversation all happen in the helper, so none of them can
    block the loop, and the helper's success flag and log are copied back
    onto the client. The per-I/O suspend code added in Slice 6 (the
    buffered reader, the bounded handshake, the async connect) is removed
    as no longer needed; the TLS-1.2/1.3 fix is kept. Bulk mail already
    goes through <code>sendQueue</code> and the background updater, so only
    the occasional <code>sendImmediate</code> reaches a helper. A test
    drives a send in a fiber against a closed port and confirms it
    suspends on the helper pipe and carries the failure back. The helper
    discards any stray warning markup so only its JSON result reaches the
    pipe.</li>
  <li><span class="check">&#10003;</span> <strong>Slice 7 &mdash;
    cooperative read locks on the local <code>FileMailStorage</code>.</strong>
    This closes the freeze the whole arc was opened for. Clicking the
    local inbox runs, in the web process,
    <code>MailSiteMailBackend-&gt;listMessages</code> &rarr; in-process
    <code>FileMailStorage</code>, which takes a shared <code>flock</code>
    on the folder index while the mail daemon may hold it exclusively;
    Slice 3 put that request in a fiber, but a synchronous
    <code>flock</code> cannot suspend, so the loop froze. The four
    read-path shared locks (<code>readFolderIndex</code>,
    <code>readReuidJournal</code>, <code>readFlagsSnapshot</code>,
    <code>liveUidsFromIndex</code>) now go through a
    <code>cooperativeFlock</code> helper: inside a fiber it asks for the
    lock without blocking and, if it is held, hands the loop a turn and
    retries; outside a fiber (the daemon, the command-line tools) it is a
    plain blocking lock, unchanged. The local backend now yields like the
    IMAP backend already did. Tested: a shared-lock read started in a fiber
    suspends while the file is held exclusively and acquires the moment it
    frees.</li>
</ol>
<h2>Follow-on arc (B)</h2>
<p>The MailSite <em>daemon's</em> own <code>listen()</code> loop (a
separate process, the same single-process <code>stream_select</code> shape
WebSite had) still blocks one mail connection on another. Making it
cooperative is a much bigger surface and raises the scheduler question
(<code>CooperativeScheduler</code> lives in WebSite.php and atto files are
self-contained, so the daemon would get its own copy of the step logic
rather than reaching across files). That work, plus auditing the rest of
the atto servers for the same blocking pattern and writing worked examples
of the new fiber primitives, is its own arc, started right after this
patch.</p>
<h2>Other work</h2>
<ol>
  <li><span class="check">&#10003;</span> <strong>Deferred HTTP/2 send to
    a closed connection.</strong> When a client disconnected while a
    deferred response was still pending, <code>emitDeferredH2</code>
    fetched a now-null connection object and <code>sendH2Body</code>
    faulted taking a reference to <code>protocol_state</code> on null
    (uncaught Error in the cooperative loop). It also queued the
    response headers first, orphaning those bytes in the out-stream
    buffer for a connection that would never drain &mdash; a slow leak
    that can feed the out-of-memory crashes. <code>emitDeferredH2</code>
    now returns as soon as it sees the connection is gone (before
    queuing anything), and <code>sendH2Body</code> has a matching
    defensive guard.</li>
  <li><span class="check">&#10003;</span> <strong>MailSite.php long-line
    cleanup.</strong> Re-wrapped the 117 over-80-column lines flagged
    when the IMAP partial-fetch fix landed; all were docblock comment
    lines, so only wording wrapped &mdash; no code changed.</li>
  <li><span class="check">&#10003;</span> <strong>Manage Users table
    overflow.</strong> A long email address widened the user-listing
    table past the activity border, because the cells used
    <code>word-wrap: break-word</code>, which does not let a long
    unbroken token shrink the column. The cells now use
    <code>overflow-wrap: anywhere</code> so such tokens break, and the
    table is capped at <code>max-width: 100%</code> so it can never
    exceed its container.</li>
  <li><span class="check">&#10003;</span> <strong>IMAP partial fetch
    (<code>BODY[...]&lt;offset.count&gt;</code>).</strong> Apple Mail kept
    re-requesting the same message in a loop: it asked for a byte range
    with <code>UID FETCH ... BODY.PEEK[TEXT]&lt;4181.1215&gt;</code>, but
    the server ignored the range and returned the whole section with no
    origin-octet marker, so the client rejected the reply and retried
    forever (and never showed the activation mail). The FETCH-item parser
    now reads the <code>&lt;first.count&gt;</code> suffix and the renderer
    returns just that slice, labelled <code>BODY[TEXT]&lt;first&gt;</code>
    as RFC 3501 7.4.2 requires; a count past the end yields the shorter
    remainder and non-partial fetches are unchanged.</li>
  <li><span class="check">&#10003;</span> <strong>CodeTool unit colour.</strong>
    The <code>[x / y] passed</code> summary on each test method now prints
    green when all of that method's tests passed and red when any failed.
    The colour is added only when output is an interactive terminal (a log
    or pipe stays plain), and Windows VT100 support is switched on first so
    the same codes work on both Windows and Unix-like terminals.</li>
  <li><span class="check">&#10003;</span> <strong>HTTP/3 streaming
    methods.</strong> <code>H3Listener</code> called
    <code>$site-&gt;setStreamingProtocol()</code> and
    <code>takeStreamingProducer()</code>, which never existed on
    <code>WebSite</code> (the H3 request path would have fatalled).
    Implemented both, and let <code>stream()</code> park a producer for
    <code>h3</code> as it already did for <code>h2</code>, so the HTTP/3
    path can run as intended.</li>
  <li><span class="check">&#10003;</span> <strong>tl() locale
    extraction audit.</strong> Strings in views/elements/controllers
    were rendering as raw keys on pollett.org because the locale
    extractor only captures literal <code>tl("key")</code> calls. Fixed
    every place a key reached <code>tl()</code> through a variable, a
    concatenation, or a nested call. <code>MailElement</code>'s header
    and DKIM-detail row helpers now translate at the call site
    (<code>tl("literal")</code>) rather than inside the helper; the
    scheduled-status and DKIM-summary labels use explicit per-value
    <code>tl()</code> branches instead of
    <code>tl("prefix_" . $status)</code>; the IMAP/SMTP TLS-mode
    dropdowns pre-translate their option arrays; and
    <code>StoreComponent</code>'s doubled <code>tl(tl(...))</code> (the
    bogus "tl" missing string) became a single call. Added the four
    genuinely-untranslated keys
    (<code>machinestatus_view_no_queue_server</code>,
    <code>machinestatus_view_no_fetchers</code>,
    <code>accountaccess_component_activityname_doesnt_exists</code>,
    <code>accountaccess_component_modifier_doesnt_exists</code>) to
    <code>configure.ini</code>. Verified by re-running the extractor's
    own regex against the touched files: zero bogus captures, and every
    previously-pruned key now extracts as a literal. Rendered output is
    byte-identical.</li>
  <li><span class="check">&#10003;</span> <strong>Remote images in
    HTML mail: defer-and-reveal, no proxy.</strong>
    Blocked remote images are not stripped or swapped. The
    sanitiser leaves <code>src</code> intact and adds
    <code>loading="lazy"</code> plus a
    <code>mail-blocked-image</code> class
    (<code>display:none</code>): a lazy image with no layout box
    is never near the viewport, so the browser does not fetch it
    and no tracker pixel fires on render. The "load images" button
    removes the class; each revealed image then loads natively,
    directly from its host. This went through a same-origin proxy
    first (every image, then narrowed to an <code>onerror</code>
    fallback for the few hosts that send a
    Cross-Origin-Resource-Policy header and so refuse cross-origin
    embedding), but the proxy was removed entirely: its server-side
    fetch is a blocking <code>curl_exec</code> in
    <code>FetchUrl::getPage</code>, and atto is a single-process
    cooperative server, so each proxied image stalls the whole
    event loop for the length of the remote fetch. Over HTTP/2 the
    page document and every other resource multiplex on one
    connection, so a single blocked fetch truncates the in-flight
    page itself (<code>NS_ERROR_NET_PARTIAL_TRANSFER</code>) and
    aborts queued requests. Direct loading is the rule and the only
    rule. The cost: a handful of CORP-locked images (the sign-in
    mail's logo among them) show broken after "load images"; every
    other host loads fast. Restoring those would require a
    cooperative (yielding) fetch on atto rather than a blocking
    one -- deferred as future work, not built here.</li>
  <li><span class="check">&#10003;</span> <strong>Cooperative IMAP
    body reads: yield during a large transfer.</strong> A webmail
    request already runs inside a cooperative fiber (index.php routes
    userMail through <code>WebSite::deferResponse</code>), and
    <code>ImapClient</code>'s reads already suspend through
    <code>waitReadable()</code> -- but only when the socket would
    block. A mail server streaming a large message body keeps the
    socket continuously readable, so <code>readBytes()</code> read the
    whole literal in one tight 4096-byte loop that never yielded,
    monopolising the single-process server for the length of the
    transfer. On a heavy message (Peacock/Zillow) that froze the whole
    site while the body came in, and starved the sibling resource
    requests on the same HTTP/2 connection -- which is why
    mailmessages.js could arrive truncated and the "load images"
    button came up dead until a reload. <code>readBytes()</code> now
    counts bytes pulled from the socket and, once past a
    <code>COOPERATIVE_READ_YIELD</code> (64&nbsp;KB) run, suspends the
    fiber for one loop pass before continuing, so every other
    connection makes progress during a big fetch. Small messages read
    straight through with no yield; outside a fiber (Apache) the
    <code>Fiber::getCurrent()</code> guard skips it and the read blocks
    as before. Verified by driving a 293&nbsp;KB read inside a fiber
    over a socket pair: it yields several times and returns the
    complete payload. The DKIM verify's <code>dns_get_record</code> is
    a separate synchronous blocker on the same view path and is left
    for follow-up (a blocking resolver cannot be made to yield without
    an async DNS path).</li>
  <li><span class="check">&#10003;</span> <strong>Open a message
    without a full page navigation.</strong> Clicking an inbox subject
    used to be an ordinary link navigation: the browser threw away the
    loaded page and refetched, reparsed, and re-ran the whole Mail
    shell (every script, style, and init) just to show one message --
    slow, and on a heavy message it was during that reload that the
    blocking IMAP read (fixed above) starved sibling resources. The
    subject links now carry a <code>mail-open-message</code> class; a
    new <code>initMailMessageNav</code> intercepts a plain left click
    on one, fetches that same URL, lifts the
    <code>.account-messages</code> content out of the returned page,
    swaps it into the current pane, and re-runs
    <code>initMailMessageView</code> to wire the new view's controls.
    The shell, its scripts, and its styles stay put, so opening a
    message is now an in-place swap rather than a reload. Only the
    three message-view controls need re-wiring (raw toggle, load
    images, DKIM badge); reply / forward / back are ordinary links and
    move / delete are self-contained forms with inline handlers, so
    nothing else is stranded by the swap. Modifier and middle clicks
    are left alone so open-in-new-tab still works;
    <code>history.pushState</code> keeps reload and the back button
    honest (a back navigation reloads whatever the address bar then
    names); and any failure -- a network error or a response without
    the pane, such as a re-login redirect -- falls back to an ordinary
    navigation so a click is never lost. The server still renders the
    full page and only its message pane is used; a dedicated fragment
    response would save that extra render and is a sensible later
    optimisation. Going back to the inbox stays a normal navigation, so
    the inbox's own many handlers re-wire the ordinary way.</li>
  <li><span class="check">&#10003;</span> <strong>Load-images button:
    delegate the click and force the load.</strong> After the in-place
    open, the load-images button came up with no click listener -- only
    a full page reload wired it -- so re-wiring it per element at swap
    time was unreliable. The handler is now bound once on the document
    (<code>initMailLoadImages</code>) and matched by
    <code>closest('.mail-message-load-images-button')</code>, so it
    fires for the button no matter how or when the message view enters
    the page, with no dependence on a re-init running at the right
    moment. The reveal is also made deterministic: as well as dropping
    the <code>mail-blocked-image</code> class it removes
    <code>loading="lazy"</code> and re-assigns each image's
    <code>src</code>, which starts the fetch even for an image that
    arrived through <code>innerHTML</code> -- where a still-hidden lazy
    image otherwise never begins loading, the case that left "load
    images" looking dead until a reload.</li>
  <li><span class="check">&#10003;</span> <strong>Show a loading
    indicator while a message is fetched.</strong> The in-place open
    fetches in the background, so a click gave no feedback at all until
    the message appeared -- the browser's own page-load indicator no
    longer applies. The reading pane is now filled with a small
    spinner and a translated "Loading" label
    (<code>mail_element_loading</code>, passed to the script as
    <code>window.MAIL_LOADING</code> the same way the triangle glyphs
    are) the moment the fetch starts, and the message replaces it on
    arrival. The spinner is pure CSS and honours
    <code>prefers-reduced-motion</code>.</li>
  <li><span class="check">&#10003;</span> <strong>Fix invalid HTML in
    the folder-ops strings.</strong> The folder-delete confirmation
    text carried literal double quotes around its <code>%s</code>, and
    it is emitted into a double-quoted
    <code>data-mail-element-folder-delete-confirm</code> attribute, so
    the quotes closed the attribute early and the page no longer
    validated. The other strings in that span carry no quotes, matching
    the span's plain-attribute convention; the confirmation string is
    brought into line by using single quotes around the folder name,
    which leaves <code>%s</code> intact for the script's substitution
    and needs no change in the view or the script.</li>
  <li><span class="check">&#10003;</span> <strong>Make the resize
    handle on the right of the favorite column work.</strong> The
    favorite (star) column had a handle on its left (the subject
    column's resizer) but only a plain border on its right, which
    looked like a handle yet did nothing. A real
    <code>mail-col-resizer</code> is now placed in the star header
    cell (resizing the star column against From), and the redundant
    cell border it replaces is removed so the boundary is drawn once.
    The drag clamp is also corrected: it previously refused any move
    while a column was under the minimum width, which froze a handle
    next to the narrow star column once the list pane was wide; it now
    blocks only a column that is actively shrinking past the minimum,
    so an already-narrow column can still be widened.</li>
  <li><span class="check">&#10003;</span> <strong>Permit safe inline
    CSS in message bodies.</strong> The sanitizer used to strip every
    style attribute, so a formatted newsletter rendered unstyled and,
    worse, its preheader (a line meant to be hidden with
    <code>display:none</code>) showed at the top and fixed desktop
    widths read poorly on a phone. Inline style is now filtered rather
    than dropped: a curated property allowlist (colour, typography,
    spacing, borders, lists, display, visibility) is kept, while
    anything that can lift content over the page (position, z-index,
    float) is dropped, any value carrying a hazard
    (<code>url()</code>, which would beacon a tracker and bypass the
    remote-image block, plus <code>expression()</code>,
    <code>@import</code>, and escape tricks) is dropped, and a
    fixed-pixel <code>width</code> is dropped so a message stays fluid
    in a narrow pane (a percentage or <code>max-width</code> is kept).
    Because a blocked image may now carry its own
    <code>display:block</code>, the sanitizer strips display and
    visibility from blocked images and the hiding rule is marked
    important, so a tracker image cannot show -- and therefore load --
    through its own style. New unit tests cover the kept/dropped split,
    the width filter, and the blocked-image display strip; a stale test
    still asserting the superseded <code>data-mail-src</code> blocking
    contract was corrected to the current lazy-plus-class one.</li>
  <li><span class="check">&#10003;</span> <strong>Stop wide message
    tables overflowing the pane.</strong> With the fixed pixel widths
    now dropped, a newsletter's layout tables fall back to automatic
    width, which sizes a table to its longest single line rather than
    wrapping -- so a centred 24px heading pushed the body well past a
    phone's width. Message-body tables are now capped at the width of
    their container (<code>.mail-message-body-html table</code> gets
    <code>max-width: 100%</code>, matching the existing rule on
    images), so an over-wide table wraps to fit. A wide desktop pane is
    unaffected: the cap only binds when a table would otherwise exceed
    the pane, so a 600px newsletter still renders at its natural
    width.</li>
  <li><span class="check">&#10003;</span> <strong>Stop the app's
    paragraph minimum width leaking into a message.</strong> The cap
    above was not enough on a phone: the chrome's own
    <code>.mobile p</code> rule gives every paragraph a 300px minimum
    width, and that rule also matches the paragraphs inside a rendered
    message. In a two-column newsletter each column then demanded
    300px, so the layout table could not shrink below ~600px and the
    body overflowed regardless of the max-width cap (an intrinsic
    minimum beats a maximum). The minimum is now reset to zero for
    message paragraphs and table cells
    (<code>.mail-message-body-html</code> p/td/th/table), so the
    columns collapse and the text wraps; because mail.css loads after
    search.css the equal-specificity reset wins.</li>
  <li><span class="check">&#10003;</span> <strong>Keep a loaded image
    inside the pane.</strong> A message fit once its remote images were
    blocked, but loading them widened it again: a banner image carried
    an inline <code>max-width: 700px</code> together with a
    <code>width="600"</code> attribute, and an inline maximum outranks
    the rendering rule's <code>max-width: 100%</code>, so it drew at
    600px and overflowed a phone. The image cap on
    <code>.mail-message-body-html img</code> is now marked important so
    the responsive cap (and <code>height: auto</code> with it) wins
    over whatever width the message sets inline; an image can never
    exceed its column and stays in proportion.</li>
  <li><span class="check">&#10003;</span> <strong>Keep the DKIM detail
    on screen on a phone.</strong> The signature-detail panel hangs off
    the green badge, anchored to its right edge, and the badge sits 64px
    in from the header's right edge; on a narrow screen a 30em panel
    then ran off the left and the row labels were cut off. On mobile the
    panel is now pinned to the viewport as a full-width sheet along the
    bottom and scrolls within itself when it is taller than the screen,
    so every label and value is reachable.</li>
  <li><span class="check">&#10003;</span> <strong>Let the back link
    read correctly right to left.</strong> The folder back link drew a
    hardcoded <code>&laquo;</code> arrow in the view, outside the
    translated string, then appended the translated "Back to %s". A
    right-to-left locale could never move or mirror that arrow, because
    the directional glyph lived in the markup rather than in text a
    translator owns. The arrow now lives inside the translated string
    and the "Back to" wording is dropped, so the English value is
    "&laquo; %s" and the link reads "&laquo; Inbox"; a
    right-to-left locale can place the arrow on the trailing side in its
    own value. Only the English value is changed here; other locales
    keep their wording until retranslated.</li>
  <li><span class="check">&#10003;</span> <strong>Give the Raw button
    and signature badge their own line on a phone.</strong> Both float
    in the top-right corner of the header box, which is fine on a wide
    pane but overlapped the Subject on a narrow screen. On mobile they
    now sit in the normal flow on a single line just above the Subject,
    so nothing covers the subject text; the desktop corner placement is
    left unchanged.</li>
  <li><span class="check">&#10003;</span> <strong>ConfigureTool
    upgrade-locales command.</strong> During ongoing work, seeing edited
    help pages and resource locale files in a configured work directory
    meant bumping the resources-wiki version so the normal startup check
    would push them out. The configuration tool now takes an
    <code>upgrade-locales</code> command that force-pushes the locale
    strings together with the version-gated wiki and help pages and
    resource files from the source tree into the work directory,
    regardless of the stored version. The library
    <code>upgradeLocales()</code> gained a force argument that drives
    that unconditional push; its normal version-gated behavior on
    startup is unchanged.</li>
  <li><span class="check">&#10003;</span> <strong>Upgrade Locales as a
    menu step.</strong> The force-push is now item (4) Upgrade Locales
    on the configuration tool's main menu, between Set Default Locale
    and Debug Display Set-up. The menu method does the push and reports
    its outcome through the usual menu message; the upgrade-locales
    command runs that same menu method and prints the message it sets,
    so the command reuses the menu step's action rather than carrying
    its own copy, the way the other commands reuse their step.</li>
  <li><span class="check">&#10003;</span> <strong>Keep valid locales
    loading after the missing-locale guard.</strong> The new guard in
    the locale initializer tested the writing mode as truthy rather than
    empty, so every real locale row took the missing-locale branch: the
    locale name was dropped and the writing mode was forced to
    left-to-right, which also flattened right-to-left locales. The test
    now checks for an empty writing mode, so a present locale keeps its
    name and direction (for example the Persian locale loads as
    right-to-left again) while a row with no usable data still falls back
    to the defaults.</li>
  <li><span class="check">&#10003;</span> <strong>Hotfix: upgrade no
    longer fatals on the DKIM key step.</strong> Upgrading an older
    install ran the version-101 database step, which calls
    <code>DkimKey::ensureKeyPair()</code>, but the version-functions file
    sits in the library namespace and never imported the class, so the
    bare name resolved to a nonexistent library-namespace class and threw
    a fatal class-not-found. It now imports the mail
    <code>DkimKey</code> class, the same way the profile model and the
    bulk-email job do. The step was latent in development because a
    database already at the current version never runs it.</li>
  <li><span class="check">&#10003;</span> <strong>Hotfix: swept the
    upgrade code for unimported mail classes.</strong> The next upgrade
    step fataled the same way on <code>MailSiteFactory</code>, so rather
    than fix it alone the whole library-root was scanned (tokenized, so
    string literals and docblock mentions are not false positives) for
    any class used as <code>X::</code> or <code>new X</code> that lives
    in a library sub-namespace yet is neither imported nor defined in the
    library root. The only real hit besides the already-fixed
    <code>DkimKey</code> was <code>MailSiteFactory</code>; both are now
    imported, and the sweep reports nothing else outstanding.</li>
  <li><span class="check">&#10003;</span> <strong>Trending-term regex:
    escape feed terms and revive possessive matching.</strong> When
    counting trending terms, the feed term was interpolated straight into
    a regular expression, so a term carrying a backslash sequence (for
    example a stray <code>\g</code> or <code>\u</code> from feed text)
    made PCRE2 fail to compile the pattern and logged a warning on every
    such term. Each of the four match sites now passes the term through
    <code>preg_quote</code> so it is treated as literal text. The same
    pass fixed the long-dead possessive branch: a word like
    <code>engineering_pos_s</code> (the tokenizer's marker for
    "engineering's") never matched because the guard compared against a
    back-slashed <code>"\_pos\_s"</code> instead of the six-character
    <code>_pos_s</code> marker, then built its pattern from the marker
    rather than the word, and required whitespace after the apostrophe
    that "engineering's" does not have. It now strips the marker, builds
    the pattern from the word, and allows the apostrophe to be followed
    directly by the trailing letters, so possessives match in plain
    (<code>engineering's</code>), entity (<code>&apos;s</code>), and
    spaced forms.</li>
  <li><span class="check">&#10003;</span> <strong>ACME: replace the
    self-signed bootstrap placeholder.</strong> When the secure server
    starts with no managed certificate it writes a temporary self-signed
    certificate so port 443 can bind, and with Auto SSL Certs on the
    renewal job is supposed to replace it with a real one. It never did:
    the placeholder covers the configured domains and is valid for a
    year, so the renewal check (which renews only when the domains no
    longer match, an authority renewal window opens, or the certificate
    is within a month of expiry) saw nothing to do and left the
    placeholder in place forever. The renewal check now recognizes the
    placeholder &mdash; a self-signed certificate names itself as its own
    issuer, so its issuer and subject match, unlike a certificate from an
    authority &mdash; and treats it as due for renewal, so a fresh
    install moves from the placeholder to a real certificate on the first
    check. Once the real certificate is installed the normal
    window/expiry logic takes over, and a failed obtain retries within
    the hour.</li>
  <li><span class="check">&#10003;</span> <strong>Recommendation job:
    pad short stored term embeddings.</strong> The job was logging a
    stream of "Undefined array key" warnings while building wiki term
    embeddings. Each term's embedding is a fixed-length vector of
    doubles; the job reads one, adds weighted contributions at
    hash-chosen positions, and writes it back. A vector stored before
    the configured embedding size was increased (or one that failed to
    decode) unpacks to fewer positions than the current size, so adding
    a contribution at a high position touched a position that did not
    exist yet and warned. The reader now pads any stored vector with
    zeros up to the current size before returning it, so every caller
    gets a full-length vector; short vectors are healed to the current
    size the next time they are written.</li>
  <li><span class="check">&#10003;</span> <strong>getPages: don't freeze
    the cooperative server during a fetch, and fix a curl busy-loop.</strong>
    The multi-page fetcher waits for transfers in a loop that, inside the
    cooperative web server, blocked the single event loop for the whole
    fetch &mdash; so while the name server was fetching from other
    machines it could not answer any other request, including a media
    updater asking for its job list, which is how ACME (a media job)
    could never get a turn to run. When the fetch loop runs inside a
    request fiber it now hands the loop back between rounds of work so
    other connections keep being served (restoring the error handler
    around the hand-off so the work that runs in its place sees the
    normal one); outside the server, on the crawler or a stand-alone
    media updater, behaviour is unchanged except for one fix. That fix:
    the wait call returns -1 when the underlying library has no socket to
    wait on yet (for example mid-DNS), returning immediately, so the loop
    could spin at full CPU; a short sleep on that case keeps it calm.
    This is the missing half of getting ACME to run on a multi-machine
    instance &mdash; the placeholder-replacement check from earlier only
    helps once the job actually runs.</li>
  <li><span class="check">&#10003;</span> <strong>Network query: tolerate
    a non-numeric timing field from a machine.</strong> Once the media
    updater could run jobs again, the trending-highlights job ran a
    multi-machine query and hit a fatal: a machine's serialized reply
    carried an empty (or otherwise non-numeric) elapsed/total time, and
    number_format rejects a non-numeric string outright, taking down the
    whole job. The existing guard only filled in a missing field, not a
    field present but non-numeric. The two timing values from each
    machine are now coerced to numbers before formatting, so one
    machine's malformed reply no longer crashes the query; a real reply
    formats exactly as before.</li>
  <li><span class="check">&#10003;</span> <strong>Auto-derive the TLS
    server context from the managed certificate.</strong> System Settings
    reported "No certificate configured in SERVER_CONTEXT; secure mail
    ports cannot start" whenever the operator left SERVER_CONTEXT out of
    LocalConfig.php, even with Auto SSL Certs on and a real certificate
    sitting on disk. The secure web server already binds straight from
    the managed certificate and key files, but the mail side (and the
    settings check) only looked at SERVER_CONTEXT, so the certificate
    Auto SSL had obtained was invisible to them. Yioop now fills in
    SERVER_CONTEXT from those certificate and key files automatically
    when the operator has not pinned one and the files exist, so the
    secure mail ports and the settings panel find the certificate with
    no extra config line. An explicit SERVER_CONTEXT in LocalConfig.php
    still takes precedence.</li>
  <li><span class="check">&#10003;</span> <strong>Let the site send its
    own mail through its own mail server as the bot, with no stored
    password.</strong> The site's outgoing mail (registration mail and
    bulk mail) can now go out through the local mail server using the
    reserved bot account, authenticated by a short-lived shared secret
    rather than a password an operator has to set and store. When the
    SMTP client logs in as <code>bot</code> it generates a fresh random
    secret, writes it to <code>work_directory/security/bot_security.txt</code>
    (owner-readable only, beside the TLS key), and sends it as the
    password; the mail server's authenticator, seeing the bot login,
    compares the supplied password against that file in constant time
    instead of a stored credential, and on a match empties the file so
    the secret is single use and cannot be replayed (a wrong guess
    leaves it in place so it cannot wipe a secret a real send is about
    to use). Because the sender and the mail server are the same
    machine, the file is always shared between the two ends that need
    it. A unit test covers the accept-and-consume, reject, replay,
    empty-file, missing-file, and bot-name cases. Along the way: the
    SMTP client field on the bulk-mail job was renamed from
    <code>mail_server</code> to <code>smtp_client</code> and the same
    variable in the registration controller from <code>server</code> to
    <code>smtp_client</code> (both hold an SmtpClient, not a server),
    and a pointless local copy of the username in the password check was
    removed.</li>
  <li><span class="check">&#10003;</span> <strong>Move Account
    Registration below Mail Services on Server Settings.</strong> The
    Account Registration fieldset now sits after Mail Services, so the
    mail-mode choice that will drive the upcoming "handled by MailSite"
    registration option is made first. This is a pure reorder of the
    fieldset; its contents are unchanged.</li>
  <li><span class="check">&#10003;</span> <strong>Derive the outgoing
    mail connection when the bot route is on.</strong> A new setting,
    MAIL_BOT_HANDLED, says the site's own mail (registration and bulk
    mail) is sent through the built-in MailSite as the bot rather than
    an external relay. Both send paths now build their mail client from
    one factory, MailSiteFactory::outboundSmtpClient, instead of each
    constructing one from the raw mail-server settings. When the bot
    route is on the factory derives the connection: it connects to the
    local MailSite on the submission port as the reserved bot account
    over STARTTLS, sends from bot@&lt;first-domain&gt;, accepts the
    local certificate without name checking (it is the same machine),
    and carries no stored password since the bot secret is minted at
    login. Otherwise it builds the client from the configured external
    relay fields exactly as before. A unit test covers both routes. The
    user interface that turns the setting on is the next step.</li>
  <li><span class="check">&#10003;</span> <strong>Account Registration
    user interface for the MailSite route.</strong> The Account
    Registration fieldset now offers a "MailSite Handled" checkbox and
    relabels "Send Mail From Media Updater" to "Use a Queue". When Mail
    Services is set to run MailSite (mailsite or both), switching
    registration to Email Activation shows just the two checked boxes,
    MailSite Handled and Use a Queue, and the manual mail-server fields
    are hidden because their values are derived. Unchecking MailSite
    Handled reveals those fields again. When MailSite is not in use the
    checkbox is disabled and unchecked, so the manual fields are always
    shown. The checkbox saves to a new MAIL_BOT_HANDLED profile field,
    which is what the send path reads to choose the derived bot route
    over the manual relay. This completes the feature: an operator can
    turn on bot+MailSite sending entirely from Server Settings with no
    config-file editing.</li>
  <li><span class="check">&#10003;</span> <strong>Treat admin activation
    like email activation in the registration UI.</strong> Admin
    activation also sends mail (a notice to the admin that an account is
    waiting), so switching to it now defaults the MailSite Handled and
    Use a Queue boxes the same way email activation does, rather than
    only doing so for email activation. Any registration type that sends
    mail gets the same default.</li>
  <li><span class="check">&#10003;</span> <strong>Make MAIL_SENDER the
    site's single bot identity, readable by root.</strong> Instead of a
    hardcoded "bot" name, the configurable MAIL_SENDER field (default
    "bot") is now the bot identity everywhere the built-in MailSite is
    used: it is the login, envelope sender, and From address when the
    site sends its own mail, the address the List-Unsubscribe header
    points at, and the mailbox name. MailSiteFactory gained botLocalPart
    and botAddress helpers that read MAIL_SENDER (a bare name gets the
    first local domain appended; a full address is used as is), and the
    login check, outbound client, and unsubscribe address all key off
    them. So the admin can read the bot's mail, saving Server Settings
    with MailSite in use makes the bot address an alias of the root
    account; mail to the bot is then delivered into root's INBOX. The
    unsubscribe job follows: it now scans root's INBOX and acts only on
    messages whose subject begins "unsubscribe " and that were delivered
    to the bot address, removing just those and leaving root's other
    mail alone. In the Account Registration form MAIL_SENDER stays
    visible even under MailSite Handled (it is the bot address the
    operator may want to set), while the rest of the manual mail-server
    fields remain hidden. Also fixed the spacing between the Mail
    Services and Account Registration fieldsets, which shared a margin
    wrapper after the earlier reorder, so it now matches the other
    fieldset gaps. Note: MAIL_SENDER's local part is the bot identity,
    so it should not be the name of a real account.</li>
  <li><span class="check">&#10003;</span> <strong>Disable MailSite
    Handled when MailSite is not in use.</strong> The MailSite Handled
    checkbox is now rendered unchecked and greyed out from the server
    side whenever Mail Services is not set to run MailSite (anything
    other than the mailsite or both modes), rather than relying only on
    a script to do it after the page loads. So when the site sends only
    through external mail accounts, the checkbox cannot be left looking
    checked.</li>
  <li><span class="check">&#10003;</span> <strong>Sender Email default,
    validation, and local-part guard.</strong> The Sender Email field
    now shows a full address: an empty or bare-name setting appears as
    the bot address on the first mail domain (the default
    bot@&lt;primary-domain&gt;) instead of blank or a bare name. When the
    site runs its own mail, saving Server Settings now checks the sender:
    if it carries a domain, that domain must be one of the site's mail
    domains, and its local part must not already be the name of an
    account (so the bot identity cannot shadow a real user's mail login).
    Either problem stops the save with a clear message rather than
    storing a sender the bot route could not use.</li>
  <li><span class="check">&#10003;</span> <strong>Update the bot-mailbox
    tests for the root-INBOX behavior.</strong> The bulk-mail tests still
    described the old dedicated bot mailbox that cleared every message.
    They now match the current behavior: an unsubscribe message
    addressed to the bot is suppressed and removed, while a message that
    is not an unsubscribe to the bot (a stray message, or an unsubscribe
    addressed elsewhere) is left in root's INBOX untouched.</li>
  <li><span class="check">&#10003;</span> <strong>Registration UI reacts
    live to the Mail Services choice.</strong> Switching Mail Services to
    External Mail Accounts (without saving) used to leave the MailSite
    Handled checkbox checked and enabled, because the script read whether
    MailSite was in use from a value baked in at page load rather than
    from the current dropdown. The check now reads the live Mail Services
    value, and changing Mail Services re-runs the registration sync, so
    selecting External Mail Accounts immediately greys out and clears
    MailSite Handled and brings the manual relay fields back. The sync
    itself became a single flat helper in the style of
    selectMonetizationType, leaning on setDisplay rather than hand-rolled
    branches.</li>
  <li><span class="check">&#10003;</span> <strong>Server-Settings show
    and hide scripts simplified to the shared helpers.</strong> The
    page's own mail scripts were hand-rolling element lookups and direct
    style.display writes where the shared basic.js helpers already do
    the job. showHideWebServer and the insecure-delivery toggle now call
    setDisplay (which also keeps aria-hidden in step), the insecure
    toggle binds its change handler with listen, and the Suggested DNS
    Records link calls toggleDisplay directly, so its one-line wrapper
    function is gone. The dynamically built per-domain routing panels
    keep their direct style writes because those elements have no id for
    the id-based helpers to find.</li>
  <li><span class="check">&#10003;</span> <strong>Login no longer pads
    every sign-in out to a fixed interval.</strong> Apple Mail's
    Connection Doctor showed every AUTH on pollett.org taking about a
    second. The cause was a &quot;crude avoid timing attacks&quot; block
    in <code>SigninModel::checkValidSignin</code> that slept each login
    up to 0.25 / 0.5 / 1.0s of wall clock. That pad was both slow and
    redundant: the routine already runs a real bcrypt in the
    missing-user branch, so the expensive work is already constant
    whether or not the account exists. Removed the sleep and changed the
    password compare from <code>==</code> to <code>hash_equals</code>, so
    the timing-attack defence is now the constant bcrypt plus a
    constant-time compare instead of a wall-clock pad.</li>
  <li><span class="check">&#10003;</span> <strong>Password hashing runs
    inline; the per-login helper process is gone.</strong>
    <code>crawlCrypt</code> handed each bcrypt to a freshly spawned
    <code>php -r</code> helper so the cooperative loop would not block.
    A standalone probe put cost-12 bcrypt at ~0.29s on the (x86_64)
    pollett.org box &mdash; less than the ~0.09s it costs just to spawn
    the helper, plus the Apple-Silicon scheduling variance the offload
    added (one run swung an AUTH to 1.3s). On a single-process personal
    server logins rarely overlap, so the offload was paying that
    overhead on every login for a benefit never collected. Dropped the
    offload (<code>crawlCryptOffload</code> removed) and compute
    <code>crypt()</code> inline: deterministic ~0.3s, no spawn. The
    bcrypt cost stays at 12 (the PHP 8.4 / Laravel 11 default). A
    separate experiment that turned the daemon's parked-fiber busy-spin
    into a 2ms sleep was tried and reverted &mdash; on Apple Silicon the
    sleeping loop let the hash helper drift onto efficiency cores and
    ran slower, so the spin stays.</li>
  <li><span class="check">&#10003;</span> <strong>Standard-folder
    provisioning lists the mailbox once, not once per role.</strong>
    With the hash no longer dominating, in-daemon instrumentation showed
    the rest of an IMAP login was <code>ensureStandardFolders</code>,
    run on every login, at ~355ms (and several seconds on a cold cache).
    It called <code>listFolders</code> inside its per-role loop, walking
    the whole mailbox tree four times even when every canonical folder
    already existed and nothing needed consolidating. It now lists once
    and reuses the result, creating a canonical folder only when it is
    missing and entering the message-move path only when an alias folder
    (&quot;Spam&quot;, &quot;Sent Items&quot;) is actually present.</li>
  <li><span class="check">&#10003;</span> <strong>Folder listing is
    cached per user and stays correct across processes.</strong> Added a
    per-user in-memory folder list, validated against a small per-user
    <code>folder_generation.txt</code> token that
    <code>createFolder</code>, <code>deleteFolder</code>, and
    <code>renameFolder</code> rewrite (staged to a temp name and renamed
    into place) on any change in any process.
    <code>listFolders</code> reads the token first and only re-walks
    when it differs, so a folder created or deleted in the web interface
    shows up live in IMAP, while an unchanged mailbox costs one small
    read instead of a tree walk. <code>collectFolderTree</code> also
    rejects <code>.eml</code> message files with a single suffix test
    before its fuller metadata checks. Net for this run: IMAP AUTH on
    pollett.org fell from ~1.08s to ~0.35&ndash;0.44s, now floored by
    the cost-12 hash itself.</li>
  <li><span class="check">&#10003;</span> <strong>BASE_URL became a
    function so mail (and every) link names the domain in use, not
    localhost.</strong> Account-activation and password-recovery mail
    from <code>yioop.com</code> carried links reading
    <code>https://localhost/</code>. <code>BASE_URL</code> was a
    constant built once at start-up from
    <code>$_SERVER['SERVER_NAME']</code>; under the single-process atto
    server that is the one name the server bound to (often localhost)
    and never the domain a given visitor used, and a server answering to
    several domains has no single correct constant. Since the value is
    genuinely per request, it can no longer be a constant: it is now a
    function <code>C\baseUrl()</code> in <code>Config.php</code>,
    computed nowhere else. It reads the host (and scheme and port) from
    <code>$_SERVER</code>, which the web server in front of Yioop sets
    per request &mdash; atto, Apache, and nginx alike &mdash; so the
    same code is correct under any of them; the fixed path part stays
    the <code>SHORT_BASE_URL</code> constant. The bootstrap that used to
    assemble the constant was reduced to setting that path and seeding
    <code>SERVER_NAME</code> from the launch address; the scheme rule
    (Apache's <code>HTTPS</code> &quot;off&quot;, and the
    own-web-server TLS-context fallback gated on
    <code>IS_OWN_WEB_SERVER</code>) moved into the function. Every
    <code>C\BASE_URL</code> use across the tree (controllers, models,
    views, library) was changed to the call; the three
    <code>nsdefined(&quot;BASE_URL&quot;)</code> guards collapsed into
    the function, which returns <code>NAME_SERVER</code> for the
    command-line tools that previously relied on that fallback. Outside
    a request, where no host is in <code>$_SERVER</code>, the host falls
    back to the first configured served name and then the
    operating-system name from <code>gethostname</code>. Under atto the
    served name is made trustworthy at a lower level than the
    controller: the launcher passes the parsed
    <code>SECURE_DOMAINS</code> into the <code>WebSite</code> config
    (top-level, so it survives secure mode where
    <code>SERVER_CONTEXT</code> is unset), and the self-contained
    <code>WebSite</code> sets <code>$_SERVER['SERVER_NAME']</code> per
    request to the visitor's <code>Host</code> when it is one of those
    names, or the first served name otherwise &mdash; the allowlist
    check keeps the attacker-controllable <code>Host</code> header out
    of the links.</li>
  <li><span class="check">&#10003;</span> <strong>Test suite made green
    and re-run safe.</strong> Three test fixes. First,
    <code>UtilityTest</code>'s cooperative-hash case still asserted the
    old behaviour &mdash; that inside a fiber <code>crawlCrypt</code>
    offloads to a helper and suspends on its pipe &mdash; which the
    inline-hashing change had removed; it now asserts the real
    behaviour, that the call runs to completion in one step inside a
    fiber and returns the same hash as a direct call. Second, an
    intermittent failure that surfaced only after the suite had been
    run many times in a row on one work directory was root-caused to
    the data-source connection cache. The mail-model tests
    (<code>MailCloneModelTest</code>, <code>MailAccountModelTest</code>,
    <code>MailSuppressionModelTest</code>,
    <code>MailAliasModelTest</code>) each open a throwaway database
    whose file name carries only a small random tag, and the data-source
    layer keeps a process-wide cache of open handles keyed by path. A
    plain disconnect nulls the handle but does not drop it from that
    cache, so when two tests in one run happened to draw the same tag
    the second was handed the first's still-open handle &mdash; rows and
    all, since the unlinked file stays alive through the handle &mdash;
    and assertions such as &quot;two claimable jobs&quot; saw leftovers
    and failed. Each of these tests now evicts its own handle from the
    cache in <code>tearDown</code> (keyed by the connection string, so
    shared handles are left untouched). Third, the <code>UnitTest</code>
    runner called a test method and then <code>tearDown</code> without a
    finally, so a method that threw part way through skipped its
    <code>tearDown</code> and the eviction above; <code>tearDown</code>
    now runs in a finally so it always cleans up.</li>
  <li><strong>Keep member accounts and the bot off each other's
    address (pending commit).</strong> The site's bot is the sender of
    all system mail and owns the mailbox the bulk-mail job scans, so an
    account and the bot must never share an address. Two directions are
    now guarded. Account side: when an account is created or its email is
    changed (self-registration, an admin adding or editing a user, and a
    member editing their own profile), an email that is the bot's address
    on a managed mail domain is refused;
    <code>MailSiteFactory::isForbiddenAccountEmail</code> holds the rule,
    with the decision in the testable <code>accountEmailRefused</code>.
    The off-by-default <code>MAIL_REFUSE_LOCAL_DOMAIN_ACCOUNTS</code>
    extends that to every address on a managed domain, so an in-house
    install where everyone shares the domain still works by default. Bot
    side: the existing check that refused renaming the bot onto an
    existing <em>username</em> now also refuses renaming it onto an
    address an existing account already uses as its <em>email</em> (new
    <code>UserModel::getUserByEmail</code>). Covered by
    <code>MailSiteFactoryTest</code> and <code>UserModelTest</code>. Also
    reworded the login-code verify screen (title &ldquo;Sign-in&rdquo;,
    field label &ldquo;Emailed code&rdquo;).</li>
  <li><span class="check">&#10003;</span> <strong>Standardize sign-in
    wording, drop &ldquo;login&rdquo;, collapse duplicate labels.</strong>
    The sign-in view
    title is now always the single <code>signin_view_signin</code> =
    &ldquo;Sign in&rdquo;; the password button, the emailed-code request and
    verify screen titles, and the verify button all resolve to it, so four
    duplicate labels (<code>signin_view_login</code> and the emailed-code
    <code>_title</code>/<code>_enter_title</code>/<code>_verify_button</code>)
    are removed. The welcome-menu and page-options links also read
    &ldquo;Sign in&rdquo;; adjective uses such as &ldquo;sign-in code&rdquo;
    keep the hyphen. The word &ldquo;login&rdquo; is removed from the
    user-facing message texts (cookie and sign-in prompts, &ldquo;Signed
    in!!&rdquo;). The emailed-code locale keys are renamed from
    <code>*_login_code_*</code> to <code>*_email_code_*</code> with all
    <code>tl()</code> references updated, so the keys no longer carry
    &ldquo;login&rdquo;. The only emailed-code-specific labels left are the
    field label and the &ldquo;Email me a code&rdquo; button.</li>
  <li><span class="check">&#10003;</span> <strong>Rename internal
    <code>LOGIN_CODE</code> symbols to
    <code>SIGNIN_CODE</code>; restore the emailed-code link
    text.</strong> With the user-facing wording settled, the feature's
    internal names now match: the <code>LOGIN_CODE</code> table becomes
    <code>SIGNIN_CODE</code>, the <code>LOGIN_CODE_*</code> timing constants
    become <code>SIGNIN_CODE_*</code>, and the controller and model members
    (<code>signinCode</code>, <code>processSigninData</code>,
    <code>signinCodeComplete</code>, <code>setSigninCode</code> and friends,
    the <code>SIGNIN_SIGN_PREFIX</code> token tag, and the session and data
    keys) drop &ldquo;login&rdquo; too. Because the table only ever existed
    on the dev machine, the version-113 migration and the fresh schema are
    edited in place to create <code>SIGNIN_CODE</code> directly &mdash; no
    new migration, <code>DATABASE_VERSION</code> stays 113. The emailed-code
    entry link, which the wording pass had over-collapsed into plain
    &ldquo;Sign in&rdquo;, is restored to &ldquo;Sign in with emailed
    code&rdquo;. <code>SigninModelTest</code> updated and green.</li>
  <li><span class="check">&#10003;</span> <strong>Replace the fixed recovery
    quiz with a member-typed recovery question, for airgapped sites (item
    33c).</strong> The old &ldquo;email link and check questions&rdquo;
    mode generated a like/dislike quiz over six fixed, translated questions
    and stored the answers in the session. It is replaced by a single
    recovery mode, <code>QUESTIONS_RECOVERY</code>, in which each member
    types their own question and answer. Two columns are added to the
    <code>USERS</code> table &mdash; <code>RECOVERY_QUESTION</code> (kept as
    plain text so it can be shown back) and <code>RECOVERY_ANSWER</code>
    (only a one-way hash is stored, the same way a password is) &mdash; via
    a new <code>upgradeDatabaseVersion114</code> migration, so
    <code>DATABASE_VERSION</code> is now 114. <code>UserModel</code> gains
    <code>setRecoveryQuestion</code> and a constant-time
    <code>checkRecoveryAnswer</code>. Registration, the Manage-Account
    security panel, and the airgapped recover screen now show a plain
    question field and an answer field instead of the quiz dropdowns; the
    recover screen is a single page that, once the username and captcha
    clear, shows the member their own question with an answer box and the
    new-password fields together. Email-link recovery is unchanged. After
    sign-in, a member with no question set yet is reminded to add one. The
    quiz machinery is removed end to end (the
    <code>getRecoveryQuestions</code>,
    <code>selectQuestionsAnswers</code>, <code>checkRecoveryQuestions</code>
    and <code>makeRecoveryQuestions</code> methods, the
    <code>NUM_RECOVERY_QUESTIONS</code> constant, the
    <code>register_view_recovery</code> question strings, and the
    session-based answer storage), and the obsolete
    &ldquo;edit recovery questions&rdquo; locale link is dropped.
    <code>UserModelTest</code> covers the store/verify round trip and the
    empty-answer case.</li>
  <li><strong>Fix the Change-Recovery-Question save and finish the
    old-quiz removal (pending commit).</strong> The
    Manage-Account &ldquo;Change Recovery Question&rdquo; form never
    persisted: the save branch still guarded on the deleted
    <code>$recovery_answer_change</code> flag and saved a session blob
    rather than calling <code>setRecoveryQuestion</code>, so a typed
    question and answer were silently dropped &mdash; which then made
    question-based password reset fail because no answer had been stored.
    The save branch now stores the question and answer (the answer hashed,
    the same as at registration) once the confirming password checks out.
    Recovery answers are verified against the raw typed text so storage and
    verification hash the same value. The last dead remnants of the old
    quiz are removed: the <code>$recovery_answer_change</code> guard and the
    now-unused <code>RegisterController</code>, <code>LocaleModel</code> and
    <code>RegisterView</code> imports the deleted
    <code>makeRecoveryQuestions</code> had pulled in.</li>
  <li><strong>Show that a recovery answer is already on file (pending
    commit).</strong> On the Manage-Account &ldquo;Change Recovery
    Question&rdquo; form the answer box was always blank, giving no sign an
    answer had been saved. It now shows a fixed run of bullets when an
    answer is on file. The answer itself stays a one-way hash and is never
    shown or stored in the clear; the bullets are only a marker. Saving with
    the bullets left untouched rewords the question and keeps the existing
    answer (<code>setRecoveryQuestion</code> now takes a null answer to mean
    &ldquo;leave it&rdquo;); typing over the bullets sets a new answer. The
    marker lives in one place as <code>RECOVERY_ANSWER_PLACEHOLDER</code>.
    <code>UserModelTest</code> covers the question-only edit keeping the old
    answer.</li>
  <li><span class="check">&#10003;</span> <strong>HTTP/2 partial transfer
    over a real network.</strong> The wiki Media-List edit page (about
    185&nbsp;KB) intermittently failed in Firefox with
    <code>NS_ERROR_NET_PARTIAL_TRANSFER</code>: it downloaded about
    128&nbsp;KB, hung for roughly a minute, then finished or gave up;
    Safari and Chrome were fine and loopback never reproduced it. A
    Firefox log capture pinned it: Firefox enlarges a stream's window to
    about 12&nbsp;MB with a WINDOW_UPDATE the instant the stream opens,
    but the server was not reading it. Over TLS, OpenSSL decrypts a whole
    record into its own buffer while <code>stream_select</code> watches
    only the raw socket, so a single read per wakeup took the request
    headers and left the grant sitting decrypted-but-unseen until an
    unrelated wakeup (Firefox's 58-second keep-alive) finally read it.
    Three fixes: the read path now drains the decrypted buffer to empty
    each wakeup, capped at the chunk size and stopping on the first empty
    read, so the grant is read alongside the headers and the stream never
    parks; the end-of-file close now waits until nothing is left to send
    (no queued bytes, no parked send, no streaming generator), since the
    drain exposes a spurious end-of-file on a non-blocking TLS stream that
    used to tear the connection down mid-response; and the frame loop now
    dispatches every request that arrived in one read, not just the first,
    so a page plus its style sheets, scripts, and images all load instead
    of stalling at zero bytes. The investigation tracing and an earlier
    ping-on-park nudge were then removed. <code>H3Listener</code> was
    reviewed and needs none of these: QUIC datagrams are discrete, its
    read loop already drains every datagram, its drive step already
    iterates every stream, and QUIC's own close replaces TCP end-of-file.
    Covered by <code>readCreditThenKeepsFlushingPastEofTestCase</code> and
    <code>readDispatchesEveryRequestInOneReadTestCase</code>.</li>
  <li><span class="check">&#10003;</span> <strong>HTTP/2 closed-stream
    window updates no longer exhaust the credit guard.</strong> The drain
    above had a second consequence that only showed on a page streaming
    many ranges in a row, such as a playing video the browser fetches a
    chunk at a time: each range is its own short-lived stream, and a
    browser sends WINDOW_UPDATE frames as it consumes a response, so some
    arrive just after the server has finished a range and closed that
    stream. Before the drain those late frames were usually left unread;
    with the drain they are all read. The handler could not tell a
    just-closed stream from a freshly-opened one awaiting its response
    &mdash; both have no send window yet &mdash; so it stashed the late
    credit as a pre-grant that nothing ever consumed, accumulating one
    stranded entry per closed stream until it reached the
    max-concurrent-streams ceiling and the next grant was misread as a
    misbehaving peer and answered with a GOAWAY, surfacing as a 206 that
    returned no bytes and a video that stopped after about a hundred
    chunks. The server now remembers the most recently closed streams
    (bounded, oldest dropped) and ignores a WINDOW_UPDATE for one of them,
    which RFC&nbsp;7540 sec&nbsp;6.9 permits, while still holding a genuine
    pre-grant for an open stream so the larger-than-window page path above
    is untouched. Covered by
    <code>closedStreamWindowUpdateForClosedStreamIgnoredTestCase</code>
    and <code>preGrantForOpenStreamStillAppliedTestCase</code>.
    <br><em>A close-races-the-tail case on a one-shot connection (a
    connection the client closes immediately after a single
    larger-than-window response) is older than this work and still open;
    a connection kept open across requests, the browser case, never lost
    bytes in testing.</em></li>
</ol>
</body>
</html>
X