Relationships
#1938 serve still panics on successful OAuth device-flow completion after #1899 fix (200 branch only)
Opened by sntxrr · 9/1/2026
Summary
swamp serve still panics the instant an OAuth device-flow login succeeds. This
is the crash reported in #1899, re-verified on a build that contains #1899's
shipped fix. The panic is unchanged: a Rust-level abort in Deno's HTTP layer,
exit code 139, no JS stack, no graceful shutdown.
Filing separately rather than rippling on #1899 because the shipped mitigation addresses a different failure class than the one occurring, and one of the two hypotheses offered in #1899 is now disconfirmed. The information below supersedes the "Notes toward a cause" section of that report.
Environment
- swamp
20260901.180419.0-sha.eb8f3c77(contains the #1899 and #1900 fixes — verified in fetched source:src/serve/unhandled_rejection_guard.tspresent and installed atsrc/cli/commands/serve.ts:1397;Login.tsxhas theAbortController+inFlightguard) swamp serve --auth-mode oauth --oauth-provider https://<INTERNAL_HOST> --dashboard --host 0.0.0.0- Login initiated from the
--dashboardweb UI, approved at the upstream IdP - Self-hosted authorization server (an OIDC shim in front of Authentik)
- Linux/amd64, Docker,
restart: unless-stopped
The panic
system │ Minted OAuth server token "oauth-<REDACTED>" for "user:<USERNAME>"
system │ Stored OAuth access token for "oauth-<REDACTED>"
system │ OAuth device flow completed for "user:<USERNAME>"
thread 'main' (1) panicked at ext/http/service.rs:794:5:
HTTP state error: Expected to be last strong reference
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceContainer exits 139 (SIGSEGV) and is restarted by the restart policy.
Timestamps put the abort 17 microseconds after the final success log line:
2026-09-01T18:54:32.817831285Z Stored OAuth access token
2026-09-01T18:54:32.817849150Z OAuth device flow completed
2026-09-01T18:54:32.817866013Z HTTP state error: Expected to be last strong referenceThe token is minted and persisted. The process dies before the 200 reaches the client, so the login can never complete.
Reproduction
Deterministic — 3 of 3 attempts, across two builds
(20260828.202201.0-sha.2a5bcb89 and 20260901.180419.0-sha.eb8f3c77), days
apart, on two different container instances.
swamp serve --auth-mode oauth --oauth-provider <non-swamp-club server> --dashboard- Open
/dashboard, click login - Approve the device code at the IdP
- Serve aborts within microseconds of the successful token exchange
The differential: only the 200 branch aborts
This is the useful narrowing. POST /auth/device/token returns from a single
handler through a single two-line helper
(jsonResponse, src/serve/device_auth_handler.ts:101). Every status but 200
is fine.
| Response | Path | Result |
|---|---|---|
202 {"status":"pending"} |
~10 polls over the 50 s the login was open | no panic |
400 {"error":"Missing or invalid deviceCode"} |
POST {} |
no panic (verified) |
502 {"error":"Upstream provider returned an unexpected error"} |
POST a bogus deviceCode |
no panic (verified) |
200 {token, principal} |
successful exchange | panics, every time |
The 400 and 502 rows were probed directly against the running instance after the
crash; the server stayed up and RestartCount did not move. So this is not the
route, not the handler, and not the Response construction helper — it is
specific to the success branch.
What is unique to the success branch
src/serve/device_auth_handler.ts:227-255. Relative to the 202 path, the 200
path additionally performs:
deps.mintServerToken(...)—:228, writes token data via the datastore (DatastoreSyncServicewhen configured)deps.storeAccessToken(...)—:238, which callsVaultService.fromRepository(...)andvaultService.put(...)(:434-446). On this deployment the vault provider makes an outbound HTTPS call, so this is real async I/O inside the request handler.- returns a larger body containing
principal.collectives/principal.groups
Any of those could be leaving a live reference on the request's HTTP record when
the handler resolves. Expected to be last strong reference is Deno asserting
Rc::strong_count == 1 while recycling the record, so the shape to look for is
something retaining the request/response past the handler's return.
Why the shipped #1899 mitigations do not apply
Both are worth keeping, but neither can affect this crash:
installUnhandledRejectionGuard()cannot catch it. It registersunhandledrejectionanderrorlisteners onglobalThis. This is a Rustpanic!that aborts the process — it never becomes a JS event, so no listener runs andpreventDefault()is never reached. Good hardening for extension code; structurally unable to catch a runtime abort.The client-side poll guard did not prevent it.
Login.tsxnow has both theAbortControllerand theinFlightskip, and the crash reproduced anyway. That disconfirms the overlapping-request hypothesis I offered in #1899 — please disregard it. Concurrent polls are not the trigger.
The embedded Deno version is not pinned
Relevant because the panic is in Deno, not swamp. scripts/download_deno.ts:136
derives the embedded runtime version from whatever deno is on the build
runner, and the workflows request a floating deno-version: v2.8.x
(.github/workflows/publish-testing.yml:35, publish-client.yml:35). So the
runtime patch level in any given release is not recorded in the repo and not
reproducible from it. Pinning it exactly would make this class of bug bisectable,
and would tell you whether the abort tracks a specific Deno patch.
I could not extract the version from the shipped binary to report it here.
Suggested reproduction harness (no IdP required)
handleDeviceAuth takes all of its I/O through the injectable DeviceAuthDeps
interface (src/serve/device_auth_handler.ts:56-98), and
src/serve/device_auth_handler_test.ts already stubs it. A real Deno.serve
listener wrapping handleDeviceAuth with stubbed pollForToken / getUserInfo
/ mintServerToken / storeAccessToken, hit by a single POST, should reproduce
without any OAuth provider. Suggested variables to bisect, in order:
- stub
storeAccessTokenas a no-op vs. one performing an outboundfetch - stub
mintServerTokenwith and without a datastore write - return the full
{token, principal}body vs. a bare{token}
If a no-op-deps 200 does not panic, the trigger is in the async work, not the response — which would point at something in the vault or datastore path retaining the request.
Impact
- OAuth dashboard login cannot be completed at all. Succeeding is what kills it, so there is no way through by retrying.
- Process-level abort takes down everything the instance owns. On this deployment that includes the workflow scheduler: 8 cron triggers are interrupted and re-registered on restart, roughly 40 s of downtime per attempt.
- A token is minted and written to the vault on every failed attempt, so each one leaves an orphaned credential behind.
Note
#1900's fix is working and made this diagnosable — the dashboard now reports "Unexpected response from server" (the JSON-parse branch, hit because the proxy returns an HTML error page once serve is dead) instead of spinning forever. Thank you for that one.
Closed
No activity in this phase yet.