oficina / slides Public

Sign in with Keymail in Slides, without an allowlist oracle open

On branch fix/keymail-signin

Paul, 2026-10-06: "I do want it to use keymail". Slides forced every sign-in to an emailed link (hidden force=1 on the form, and gateBegin set it again server-side), so paul@keymail.dev and paul@tito.io never reached Keymail.

What changed

  • The ceremony depends on the domain only, never the allowlist. Every POST /signin reaches rauth's Begin. A Keymail-classified address, permitted or not, gets the same first answer: a 200 continuation page to Keymail. Any other address gets 303 /signin?sent=1; admissionMailer drops the mail unless the address is permitted (Docs' approach). Same budgets and classification for both, so no oracle.
  • The hop to Keymail is a page, not a 303 from the form POST. connect.OAuthContinuation wraps Begin in both standalone and Home-local mode. Safari applies form-action ('self', plus the Home issuer) to a form's whole redirect chain; a meta refresh (and a "Continue to Keymail" link) from a page is outside it. The card is the door (estilo-signin-sent), with the copy in the catalog.
  • Admission after verification is the door, not bare text. rastrillo 29e4491's admit() answers 403 "This address is verified but not admitted here." with no hook. doorOnRefusal wraps Callback and Verify and turns exactly that answer into 303 /signin?err=refused (/signin/local in Home mode): the form with "That address can't sign in to Slides. Use a different address, or ask whoever runs this team to add it." Remove when rastrillo grows the hook (see the rastrillo branch) and the pin is bumped.
  • The err=keymail fallback still works. The form carries force=1 only on ?force=1 (a failed Keymail exchange), so the retry mails a link.
  • sameAnswer still rewrites ?err=rate|address|1 to sent. Only ?err=1 (a failed send) still depends on admission; returning rate/address to their own sentences is a separate, safe follow-up.
  • Package comment, gateBegin comment, config doc and AGENTS.md rewritten to say what is true now.

Tests (each shown failing on origin/main first)

  • TestKeymailAddressGoesToKeymailWhetherPermittedOrNot: permitted and non-permitted Keymail addresses get the same 200 continuation page (bodies equal after masking the authorize URL, same cookie names), no mail. Failed before: 303.
  • TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed: ?sent=1 for both, one mail to the permitted address only. Behaviour preserved, so it passes on main; shown failing against the naive fix (no admissionMailer).
  • TestKeymailFailureFallsBackToAnEmailLink: plain form has no force; ?force=1&err=keymail carries it; a forced POST mails a link with zero classification lookups. Failed before: the plain form forced email.
  • TestKeymailMemberIsSignedIn / TestKeymailRefusalIsTheDoor: full callback round trip; the refusal is 303 /signin?err=refused showing the door and form, no session. Failed before (303 from the POST); refusal test also shown failing with the interception disabled (403 bare text).
  • TestHomeLocalKeymailIsAContinuationAndRefusesAtTheDoor: same in Home mode, refusal to /signin/local?err=refused. Failed before: 303 /signin?sent=1.

Classification and Keymail are stubbed in-process by internal/slides/keymailtest (NXDOMAIN resolver + DefaultTransport fixture, the same globals Docs' authkeymailtest overrides), installed by TestMain in both test packages, so no POST /signin in the suite makes a real DNS or HTTPS call.

make ci green locally (GOTOOLCHAIN=go1.26.5). The full gate's wasm step can't run on this machine (wasm32 target missing for the pinned toolchain); no Rust or JS was touched.

VERDICT: CHANGES-REQUIRED

Reviewer: Astra (GPT-6-Astra via Codex), posting through the configured agent account claude~paul@keymail.dev. Whole-branch review of fix/keymail-signin at a61f0d274d2131dfbb06259d5d90a48c3e787459 against fetched origin/main 728851566dc4ae8e7f635b287c311e305b696bbc (also the merge base). No branch edits, commits, pushes, or merges.

  1. P1 — Pre-verification allowlist timing oracle remains. internal/slides/auth.go:208–212 (admissionMailer.Send). A denied address returns immediately, whereas a permitted address waits for the real mail sender. Pinned signin.Flow.sendLink calls this synchronously, and neither sameAnswer nor OAuthContinuation sends the HTTP response until Begin returns. An unauthenticated caller can submit force=1 for any candidate, including Keymail addresses, bypass DNS/classification variance, and distinguish admission through SMTP latency. Matching status, headers, cookies, and body does not hide this. A scratch HTTP-handler probe with a controlled 250 ms sender measured alternating denied/allowed requests at 0.351 ms / 252.195 ms / 0.345 ms / 252.194 ms, all returning 303 /signin?sent=1; the no-timing-oracle assertion failed. This preserves an existing weakness of main's synchronous permitted-email path, rather than introducing it, but it fails this review's explicit timing requirement and the branch's stated no-oracle guarantee. Fix: remove admission-dependent delivery work from the response path. Enqueue equivalent bounded work for all syntactically valid email attempts, then check admission and deliver only permitted mail in the worker; keep enqueue/error responses admission-independent, retain rate limits, and add a blocking/slow-sender regression proving the response does not wait selectively. A fixed sleep is not sufficient against slow or failed SMTP.

The rest of the security trace:

  • Unforced Keymail path: no allowlist branch before verification. Both candidates classify, receive the same continuation, and spend the same budgets. Additional probes compared all nonrandom response headers and cookie attributes and exercised seven attempts per address: both get 200 for attempts 1–5 and the masked rate-limit 303 thereafter. Pending-cookie values are randomized encrypted state and contain the submitted address; byte equality is not expected. Mail fallback is the exception described in finding 1.
  • Destination validation: newBegin does invoke the pinned connect.OAuthContinuation/keymailDestination check. Through the app wrapper I confirmed rejection of HTTP, a different path, duplicate clientid, fragments, userinfo, wrong scope, and non-S256 challenges, with 502 and no pending-cookie leak. Valid destinations require HTTPS, /oauth/authorize, app-bound clientid and redirecturi, identify scope, state, and S256 challenge. The page contains the localized Slides door, a meta refresh and a Continue to Keymail link, with no-store and no-referrer. The check does not authenticate or allowlist the hostname: it intentionally permits the federation host chosen by the submitted domain. It is not proof that an arbitrary DNS-selected host is keymail.dev or trustworthy; this is the existing federation trust model, not a free-form returnto redirect. The form still has CSRF protection.
  • Refusal wrapper: requires BOTH 403 and the exact trimmed rauth refusal sentence. Direct probes preserved CSRF-style failures, address-mismatch failures, other statuses, and cookies. A real mismatched Keymail callback remained a bare 403 rather than being mislabeled as admission refusal. Outer CSRF and LocalGuard errors are outside this wrapper. ?err=refused itself is NOT authenticated: anyone can construct a URL showing the generic refusal sentence. It discloses no address, performs no allowlist lookup, grants no access, and is not evidence that verification occurred; I regard this as a presentation limitation rather than another merge blocker.
  • Home: an additional permitted Keymail recovery round trip verified FreshLocal, replacement/revocation of the previous Home session, navigation through LocalComplete back to /account/home, access to the link screen without creating another user, and offboarding after removal from the allowlist. The existing real Home exchange/link and revocation tests also passed in make ci. FreshLocal still refuses Keymail without a fresh auth_time.
  • Stub isolation: keymailtest.Install is called only from the two _test.go TestMain functions, restores the globals on exit, and has no init side effect. go list -deps ./cmd/slides excludes keymailtest; it does not enter the production binary.
  • Fallback/rates: an actual invalid-code exchange reached /signin?force=1&err=keymail and rendered the hidden force field; the branch's forced-mail test passed. Home's connect.Routes.Signin routes err/force query results to LocalSignin. Both ordinary and forced attempts remain behind the pinned IP/address budgets. Production edge traffic shares the connection-IP budget, as documented by rauth.

Validation on the unmodified reviewed head:

  • GOTOOLCHAIN=go1.26.5 make ci — PASS (vet, formatting, Go tests, migrations, credits).
  • GOTOOLCHAIN=go1.26.5 RASTRILLO_CHROME="$HOME/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" go test -tags browser ./internal/slidestest -count=1 — PASS, 126.159 s. Chromium validates the browser suite; this was not a Safari execution.
  • amadan ci status oficina/slides -branch fix/keymail-signin — exit 0, CI passed at a61f0d2.
  • Four independent scratch mutations, each restored after execution, all turned a distinct new regression test red at the intended assertion: force every Begin to email → TestKeymailAddressGoesToKeymailWhetherPermittedOrNot; bypass admissionMailer → TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed; disable refusal interception → TestKeymailRefusalIsTheDoor; omit fallback force → TestKeymailFailureFallsBackToAnEmailLink.
  • Additional scratch probes passed for Home recovery/revocation, destination checks, exact refusal interception, Keymail header/cookie/rate parity, actual exchange fallback, and mismatched-address refusal. The timing probe intentionally failed and establishes finding 1.

All mutation/probe edits were confined to detached worktrees under /tmp. The existing checkout and branch were left untouched; scratch worktrees are being removed after posting. Review evidence remains in /tmp/slides-astra-*.log on this machine.

I've addressed Astra's P1 (the timing oracle) in the new head commit.

  • What changed. - rauth's Mailer is now mailQueue (internal/slides/mailqueue.go). Send queues the link and returns at once. - One background worker checks the allowlist, then sends with its own timeout. Failures are logged, never surfaced. - The queue is bounded at 64. If it is full, the link is dropped with a log line and the request is not blocked. app.Close drains it, within 30 s. - admissionMailer is gone. Nothing branches on admission before the response.
  • Same work for every address. POST /signin now does identical synchronous work for any address: rauth's per-IP and per-address budgets, its link row, and the enqueue. That holds for forced posts too. - A failed send no longer comes back as ?err=1 for permitted addresses only. That outcome was the last one that depended on admission.
  • Test. TestSigninNeverWaitsForTheMailer blocks the mailer on a channel and sends four POSTs: permitted, denied, and both again with force=1. Each must answer 303 ?sent=1 within 2 s while the mailer is still blocked. After release, exactly the two permitted links must go out. - On a61f0d2 it fails: "POST map[address:[paul@example.test]] waited on the mailer".
  • Test harness. Mail now goes out after the response, so the harness flushes the queue (App.FlushMail) before it reads the fake mailer.

make ci is green locally (GOTOOLCHAIN=go1.26.5). Waiting on CI.

VERDICT: CHANGES-REQUIRED

Reviewer: Astra (GPT-6-Astra via Codex), posting as the configured agent account. Follow-up review of a61f0d2..833936502b927aab44fb77a831ca064ceca990cc after fetching origin.

  1. P1 — Queue overflow still exposes admission-dependent timing, and can block requests. internal/slides/mailqueue.go:67–79. The select drops immediately, but its default branch calls the synchronous logger while holding q.mu. Queue occupancy depends on admission: a permitted candidate occupies the worker during SMTP; denied candidates drain immediately. In a controlled HTTP probe, I forced a candidate, followed it with 64 distinct denied addresses, then timed another denied address. With 250 ms SMTP and a log writer taking 75 ms on the overflow line, three repetitions measured denied/permitted candidate pairs of 0.102/75.416 ms, 0.122/77.143 ms, and 0.120/76.452 ms. Every response was 303 /signin?sent=1. This probe used five connection IPs to stay within the real IP/address budgets; it requires enough aggregate budget to reach saturation and a slow log sink, and is not a measurement of the deployed proxy. A separate blocked-writer probe held overflow Send indefinitely until the logger was released; other enqueues also wait on its mutex. Move overflow reporting off the request path using bounded, nonblocking accounting/reporting, and add a saturation regression. Do not replace it with an unbounded goroutine per drop.
  1. P2 — A mail-worker panic now terminates the whole process. internal/slides/mailqueue.go:84–101. Neither run nor deliver recovers. A controlled Sender panic killed the test process with its stack in mailQueue.deliver/run; it is outside net/http's request recovery. This is a failure-containment regression from synchronous delivery, not a claim that an ordinary SMTP error panics. Recover around each job (including admission), account for pending work with defer, log the failure in the worker, and continue with subsequent jobs. Test that a panicking job does not prevent the next delivery or shutdown.
  1. P2 — The shutdown deadline abandons waiting, not the worker. internal/slides/mailqueue.go:98–99,110–123. After 30 seconds close returns without canceling the active background context or discarding pending jobs. My context-aware sender probe measured 30.002 s to return, found the worker alive and its send context uncanceled, then observed the second queued send start after Close returned. Production SMTP does honor the new one-minute deadline, but every subsequent queued job gets another independent minute; the advertised 30-second shutdown bound does not bound worker lifetime. Give the queue an owned cancellation lifecycle: drain until the deadline, cancel the active send, account for/drop remaining jobs, and join the worker before returning. Also close every successfully constructed test app: invoking TestDevelopmentStoresTheResolvedConfig and the three TestEnsureUser* tests left four new mailQueue goroutines alive after their cleanup.

Validation: GOTOOLCHAIN=go1.26.5 make ci PASS; Playwright Chromium browser suite PASS (126.313 s). The ordinary 250 ms SMTP timing matrix passed for permitted/denied addresses, Keymail/plain domains, and force on/off; link-row counts and five-attempt address-budget cutoffs matched. TestSigninNeverWaitsForTheMailer passed on this head and failed at its intended assertion when I mutated Send back to synchronous delivery. That mutation was restored. Scratch probes and logs are retained as /tmp/slides-astra-8339365-*; the detached scratch worktree was removed. No existing checkout was edited or moved, and nothing was committed, pushed, or merged.

Astra's round 2 is fixed in 158489d. Changes are in internal/slides/mailqueue.go, with tests in mailqueuetest.go and slidestest/signintiming_test.go.

P1: overflow.

  • Send no longer does any I/O. It doesn't send, check admission, or log.
  • A dropped link increments an atomic counter (dropped). The worker logs the count (reportDrops) before its next message and when it stops.
  • Every address's link is queued, permitted or not. Only the worker asks the allowlist, so how full the queue is can't depend on admission.
  • Tests: - TestMailQueueSendNeverBlocksWhenFull: the worker is stuck and the log handler blocks, and twice the queue's depth of Sends still returns within 2 s. - TestMailQueueQueuesEveryAddress: refused addresses fill the queue the same way. - TestAFullMailQueueAnswersLikeAnEmptyOne: over HTTP, 80 POSTs past a stuck mailer from distinct addresses and IPs each come back byte-identical to the first answer, in under 1 s.

P2: a panic in the worker.

  • deliver recovers per message, logs, and keeps draining.
  • Test: TestMailQueueSurvivesAPanic. The first send panics and the second is still delivered.

P2: close and leaks.

  • Sends now run under the queue's own context.
  • close drains for up to drainTimeout (30 s). After that it cancels the send in flight and abandons whatever is still queued, logging the count. It then waits a 5 s grace for the worker to exit; a mailer that ignores cancellation is logged.
  • app.Close closes the queue, and every test app is closed in cleanup.
  • Tests: - TestMailQueueCloseIsBoundedAndStopsTheWorker: the relay never answers but honours ctx, and close is given a 50 ms deadline. Close returns quickly, the worker has exited, and the goroutine count is back to baseline. - TestAppCloseStopsTheMailWorker.

How I showed each test failing first. The new tests use the new newMailQueue signature, so they don't compile against 8339365. Instead I put each defect back into the new code:

  • A log line in Send makes SendNeverBlocksWhenFull fail: "Send blocked with the queue full".
  • Removing the recover makes SurvivesAPanic fail: the process exits with "panic: mailer exploded".
  • Running sends on context.Background() makes CloseIsBounded fail: "close took 5.05s".
  • Dropping a.mail.close() from app.Close makes AppCloseStops fail.

TestMailQueueQueuesEveryAddress and the full-queue HTTP test check behaviour 8339365 already had.

make ci is green, and go test -race passes for the queue tests (×3).

VERDICT: CHANGES-REQUIRED

Reviewer: Astra (GPT-6-Astra via Codex), posting as the configured agent account. Third pass, fetched origin and reviewed 8339365..7ef08e7f973733bc18fb654ca0a3def26c207f40 in detached worktrees under /tmp.

  1. P2 — Shutdown still has an unbounded logging path; the previous shutdown finding is only partly fixed. internal/slides/mailqueue.go:158–159,185–189. When a context-aware sender returns its cancellation error, deliver synchronously logs it. A stalled log sink traps the worker there, before pending jobs are abandoned/accounted for. After the five-second grace, close itself calls the same synchronous logger and can block indefinitely too. My probe uses an ordinary context-honoring sender, a blocked slog writer, and a 20 ms drain deadline: after another 5.2 seconds both Close and the worker were still blocked; they finished only after I released the writer. Thus an uncooperative mailer is NOT the only way the worker outlives its deadline, and Close is not bounded. Keep stalled logging out of the shutdown/worker completion path, and add a regression combining cancellation with a stalled log handler.

The cleanup portion of that same previous finding is also still outstanding: TestDevelopmentStoresTheResolvedConfig and all three TestEnsureUser* tests still construct successful apps without closing them (config_test.go:41, auth_test.go:89,138,171). Re-running those tests as subtests and counting mailQueue.run stacks after their cleanup again measured 0 workers before, 4 after. Register Close for each successfully constructed app. The new AppClose test proves that calling Close works; it does not fix these callers.

The overflow and panic findings are fixed on this head. Production Send has no I/O, uses an atomic drop counter, and enqueues denied addresses too. Actual occupancy can still depend on the worker's admission-dependent service time; eliminating the observable overflow work is what removes the demonstrated oracle. recover() is present at line 147, unlike d461035. The sender-panic test passes, a separate admission-panic probe passes and continues to the next job, and replacing recover with any(nil) kills the test process as expected. With a responsive logger, cancellation stops the active send, abandons the queued job with a log, and joins the worker.

Mutation evidence, each independently restored: synchronous overflow logging makes SendNeverBlocksWhenFull fail; context.Background makes CloseIsBounded fail at 5.054 s; removing app.Close's queue close makes AppCloseStopsTheMailWorker fail; admission filtering in Send makes QueuesEveryAddress fail. The full-queue HTTP test fails at POST 65 with a 1.1 s overflow delay, but stays green when the actual old synchronous-overflow-log defect is restored, because its logger discards output. That particular test is not a regression detector for the original logging bug; the dedicated blocked-handler queue test is. All original targeted tests passed again after restoration.

Timing: repeated the 250 ms SMTP matrix for plain and Keymail domains, force absent/present, permitted/denied, and empty/full queues, with a 75 ms overflow log sink. Three repeat runs (9 observations per matrix cell/address class) passed: permitted/denied medians ranged 0.099–0.447 ms. The original saturation attack now measured denied/permitted candidate pairs of 0.077/0.260, 0.099/0.100, and 0.084/0.114 ms, all 303 /signin?sent=1. An initial empty-queue denied request had a 76.473 ms outlier and tripped my 50 ms probe threshold; the repeated matrix passed without reproducing it. This establishes removal of the demonstrated SMTP/overflow-log signal, not a claim of mathematically constant execution time.

Validation: GOTOOLCHAIN=go1.26.5 make ci PASS; go test -race ./internal/slides -count=1 PASS (162.551 s); amadan CI reports PASS at 7ef08e7. The full browser suite with Playwright Chromium 1234 FAILED (286.124 s): TestBrowserDeleteToEmptyThenNewSlide, browser_test.go:703, timed out waiting for the stage slide ID to equal location.hash.slice(1), with status Saved. A targeted three-run retry passed twice and failed once at the same assertion. The same targeted test passed three times on 8339365 in a separate detached worktree. This comparison does not establish causation for an intermittent failure; the requested browser gate remains unresolved, and I am not assigning it a new code finding in this diff.

No branch changes, commits, pushes, or merges. Both scratch worktrees removed after testing; probe sources, mutation logs and gate output are retained under /tmp/slides-astra-7ef08e7-*.

Round 3 is in fbe994f.

P2: close no longer hangs when the log sink stalls.

  • Only a dedicated log pump writes to the logger now. The worker and close pass their lines to logf, which never blocks: it uses a buffered channel of 32, and counts lines it had to drop (lostLogs). The pump reports that count later.
  • When close gives up on a worker that ignores cancellation, it sets the atomic stuck and logs through logf.
  • The pump stops when the worker stops. A stalled sink can now only hold the pump's one in-flight write.
  • TestMailQueueCloseIsBoundedWithAStalledLogger covers this. Every write to the log sink hangs, the relay never answers but honours cancellation, and there are twice the queue's depth of sends, so drops are logged too. The test checks that close returns within 2 s and the worker has exited, then releases the sink and checks the goroutine count is back to baseline. - On 7ef08e7 it fails: "close hung on a stalled log sink".

Leaked test workers.

  • Four test apps leaked: three discarded newApp results in authtest.go and one in configtest.go. They now close in t.Cleanup.
  • Both test packages' TestMain now fail the run if any (*mailQueue).run or (*mailQueue).pump goroutine is still alive afterwards. This is a stack scan, the same approach as goleak. - Before the cleanups it failed: "8 sign-in mail queue goroutines still running".

The refusal can't be spoofed any more.

  • doorOnRefusal now renders the door inside the refusal's own 403. It marks the request through its context (refusedHere) and calls the sign-in page handler.
  • ?err=refused no longer selects the sentence; it falls through to the generic "couldn't sign you in".
  • The already-signed-in skip is bypassed for a refusal, so it can't turn into a redirect carrying a 403.
  • TestKeymailRefusalIsTheDoor and the Home-mode test now require a 403 with the door and the form, and check that /signin?err=refused (or /signin/local?err=refused) shows no refusal sentence. Both failed on 7ef08e7.

make ci passes, and -race passes on the queue, refusal and Keymail tests.

VERDICT: CHANGES-REQUIRED

Reviewer: Astra (GPT-6-Astra via Codex), posting as the configured agent account. Fourth pass: fetched origin and reviewed 7ef08e7..fbe994fe6dcef9ea700c30975ad557a7714fc24d in detached /tmp worktrees.

  1. P2 — A stalled logger still leaks a queue-owned goroutine; the fix moves the blockage to the pump. internal/slides/mailqueue.go:171–178,181–187,249–273. The pump calls q.logger.Error synchronously. Closing q.logs does not interrupt an in-flight logger call, and Close waits only for q.done (the worker), with no cancellation or join for the pump. My probe uses a context-honoring sender, a 20 ms drain deadline, and a stalled log writer: Close returns in 22.052 ms, the worker exits, but the queue goroutine count goes from 0 to 1 and remains there while the sink is stalled. That pump cannot exit until the external sink recovers; each app created and closed against such a sink can leave another pump behind. Thus Close itself is now bounded, but the goroutine baseline is not restored by shutdown. TestMailQueueCloseIsBoundedWithAStalledLogger calls free() before checking the baseline, so its green result proves recovery after the sink is manually released, not cleanup under the stated stalled-sink condition. Give logging a bounded, interruptible lifecycle and verify baseline restoration while the sink remains stalled. Simply waiting for this pump would reintroduce the shutdown hang.

Validation on the unmodified head: GOTOOLCHAIN=go1.26.5 make ci PASS; GOTOOLCHAIN=go1.26.5 go test -race ./internal/slides/... PASS (slides: 142.159 s); browser suite with RASTRILLO_CHROME pointing to Playwright Chromium 1234 PASS (129.788 s). The previous browser failure did not recur in this run.

The four app cleanups now restore the worker count from 0 to 0. Independent scratch mutations all failed at the intended checks: restoring synchronous cancellation-error logging; removing the four cleanups (TestMain reports 8 survivors); removing the slidestest harness cleanup (its TestMain reports 2); disabling the refusal context in each package; restoring query-driven refusal in each package; and preventing pump exit after sink recovery. Restored targeted tests passed.

The refusal is a 403 door in standalone and Home mode. Spoof URLs show no refusal sentence. Direct wrapper probes preserve CSRF-style errors, address mismatch, other statuses, headers and cookies; actual cross-origin POST and mismatched-Keymail callback both remain their original bare 403s. Queue saturation, sender/admission panic recovery, cancellation/abandonment, continuation and fallback checks passed.

Timing rerun: 250 ms SMTP, 75 ms overflow logging, permitted/denied addresses, plain/Keymail domains, force absent/present, empty/full queues. Cell/address medians were 0.080–0.755 ms; the original saturation attack measured 0.087/0.084 ms for denied/permitted candidates, both 303 /signin?sent=1. Link-row counts and five-attempt address-budget cutoffs matched. No recurrence of the demonstrated SMTP/overflow-log oracle in this run.

Both scratch worktrees have been removed. No existing checkout was edited or moved; no commits, pushes or merges. Probe sources, mutation results and gate logs remain under /tmp/slides-astra-fbe994f-*.

Round 4 is fixed in f5d839a.

Astra is right: when the sink is stalled, the log pump stays alive after Close. Go has no way to interrupt a Write that is already blocked inside an io.Writer, so I haven't tried to engineer around it. The code and the test now state that limit instead.

  • Comments. The comments on pump and close now say it plainly. A write blocked inside the logger can only finish or die with the process. Shutdown deliberately doesn't wait for it, and nothing else waits on the pump either: not Send, not the worker, not close.
  • Test shape. TestMailQueueCloseIsBoundedWithAStalledLogger now checks the following while the sink is still stalled: - close returned within its bound; - the mail worker has exited (q.done); - goroutines are at most baseline+1, and mailWorkers() (run+pump stacks) is at most 1. That one is the pump, blocked in its write.

It then releases the sink, and checks that the pump exits and the goroutine count is back at baseline.

  • Mutation checks. The test fails under both of these mutations, each time with "close hung on a stalled log sink": - close waits for the pump to drain; - the worker logs through the logger directly instead of logf, so the stalled sink holds the worker.

So the finding is resolved as follows. Close and the worker are bounded and never wait on the sink. The one goroutine a stalled sink can hold is the pump's in-flight write, which is documented, asserted, and gone once the sink moves.

make ci is green. Waiting on CI.

Merging f5d839a. Astra's four rounds found the timing oracle, overflow logging, worker panics, a bounded Close, the refusal-page spoof and test-worker leaks, all fixed at fbe994f. The last finding (the log pump stays blocked in a stalled Write after Close) is a Go limit: a blocked io.Writer can't be interrupted. f5d839a documents it, and the test now asserts the real guarantee with the sink still stalled: Close returns within its bound, the worker exits, and at most the pump remains. The coordinating session checked the delta, which is comments and test only. Paul wants Keymail sign-in and is blocked on Slides.