Repository navigation
feat(push): Web Push subscriptions, outbox and VAPID worker (CHE-1795) [squash] - #304
Closed
congvc-bot-x99[bot] wants to merge 16 commits into
Closed
congvc-bot-x99[bot] wants to merge 16 commits into
congvc-bot-x99[bot] wants to merge 16 commits into
Conversation
added 10 commits
October 19, 2026 17:06
Tests only, written before the implementation. The router tests name no symbol N3 adds and fail against the parent with 404s and a missing push_subscription table; the worker tests live in their own package and cannot compile until it exists.
…CHE-1795) - push_subscription / push_delivery tables (migrations 657-663); an inbox_item trigger queues one delivery per device of the recipient when the source's push switch is on, 10 s out, and re-arms it on a coalesced DM reply - GET /api/push/config and POST/DELETE /api/push/subscriptions; a subscription stores the registering session and logout ends it - pushnotify worker: leased claim, re-checks unread/switch/member/session/DM readability before each send, backoff with a cap, 404/410 deletes the subscription, payload with fixed labels and no message text - webpush-go v1.4.0 behind a Sender interface; https-only endpoints and a dialer that refuses non-public addresses
…r review (CHE-1795) Tests only, against reviewed head dff195d. The connect-time guard allows shared and special-use addresses (100.64.0.0/10 and more), and a device can change hands while a push is in flight, after which a stale 410 removes the new owner's subscription. The pushnotify test setup no longer falls back to a default database URL.
…hip race (CHE-1795) - connect-time guard: block the IANA special-purpose IPv4/IPv6 ranges, including 100.64.0.0/10, 198.18.0.0/15, 240.0.0.0/4 and the NAT64, 6to4 and Teredo ranges; IPv4 carried in IPv6 is judged as IPv4 - worker: read the delivery context, send and record the outcome in one transaction holding FOR NO KEY UPDATE on the device row, so registering the endpoint for another account, logout and pruning wait for an in-flight send and no stale 404/410/success lands on the new owner - NewWorker takes the pool; in-flight sends per pass drop from 8 to 4
… P2 (CHE-1795) Tests only, against reviewed head 235e55b. A claimant whose lease expired while queued behind a same-device send still sends, a stale outcome clears the newer claim (sent, retry, 410 prune), a contested device queues instead of deferring, a batch can outlast its lease, and registration or logout waits behind an in-flight send. Logout is now expected to mark the session ended without touching the device row.
…n wait (CHE-1795) P1 lease ownership - migration 664: push_delivery.claim_token, set on every claim; the context read, finish, retry, drop and 404/410 prune all require it, so a claimant whose lease expired while it queued reads nothing, sends nothing and writes nothing; a stale outcome cannot clear or move the newer claim - the device row is locked in its own statement before the context is read (a statement that waits for a row lock does not re-check the other rows it joined), and the context must show at least deliveryTimeout of lease left - batch of 8 instead of 32, same-device rows go through one goroutine in order, so the worst case (8 x 15 s) stays under the 3 min lease P2 latency contract - worker: one 25 s deadline per attempt from pool to commit; a delivery that cannot get the device in 2 s is deferred with its attempt refunded - registration and delete: lock wait 3 s inside a 5 s request budget, then 503 with Retry-After; logout writes a marker row (migration 665) and never waits behind a send - test helper no longer swaps a global token between concurrent requests
…down P2 (CHE-1795) Tests only, against reviewed head 7519e7c. Logout answers while a send admitted before it is still in flight, reports 200 and clears the cookie when the session end was not recorded, an ended sid can register again, a pass drops the marker of a session that sliding renewal can keep alive, the worker can read its context after the session ended, and the contested-device and handler rollbacks run with no deadline.
…(CHE-1795) P1 session lifecycle - a delivery takes a shared advisory lock on the registering session after the device row and before it reads its context; logout commits the session marker, then takes the exclusive lock (3 s drain), so when logout answers no send admitted before the marker is left to start. A send already on the wire is not recalled and is not promised away - logout answers 503 with Retry-After and keeps the cookie when the marker cannot be written; nothing is recorded when push is not configured - registration refuses an ended session (401); markers are no longer removed by age, because sliding renewal keeps a sid alive for as long as it is used P2 teardown - RollbackBounded: every rollback, in the worker (including the contested device path and its deferral write) and in the handler, runs under its own 2 s TeardownBudget outside the operation budget
…HE-1795) Tests only, against reviewed head 11cb9a6. Logout answers 200 and clears the cookie after a drain that timed out, and after a Begin, SET LOCAL, drain, deadline, cancellation or commit failure of the drain. The slow-send test no longer accepts a 200 behind a send that outlasts the drain: it expects 503 with Retry-After, the cookie kept, the marker kept, and a clean retry once the admitted worker is done.
added 3 commits
October 20, 2026 01:22
drainPushSession returns every failure (Begin, SET LOCAL, the exclusive lock including its lock timeout and deadline, cancellation, commit) and endPushSession passes it on, so Logout answers 503 with Retry-After and keeps the cookie instead of reporting success while an admitted send may still start. The marker stays recorded and a retry repeats both steps. The marker, drain and teardown budgets are documented as separate.
…7-666 (CHE-1795) main (PR #296, workflow catalog) now holds 657-666 under different names. The nine push migrations keep their order and content and move to 667-675; the concurrent-index cleanup map and the comments that name them follow. A fresh database migrates up through 675, and 675 down to 666 and up again.
added 2 commits
October 20, 2026 05:07
…CHE-1795) Tests only, against reviewed head 0ef14ef. - views: with cookie auth a refused or slow server logout must leave the session, the cache and the URL alone, say so, and allow a retry that cleans up exactly once; token mode and an expired session need no round trip - server: a logout while the VAPID configuration is incomplete or off must still record the session end, so a worker or a re-registration after the keys come back cannot use the ended sid; a missing store is a failed logout
…gnores the push configuration (CHE-1795) Browser: with cookie auth the server logout revokes the session, so the auth store gains endServerSession (rejects on failure, shared by concurrent callers) and useLogout awaits it before it cleans up or navigates. A refused, failed or slow logout leaves the session, cache and URL alone, shows an error and can be pressed again; a successful retry cleans up once. Token mode and the expired- session path need no round trip and are unchanged. The old best-effort request stays for any caller that never confirmed. Server: endPushSession no longer skips when the VAPID keys are incomplete. The marker and the drain are always recorded, so keys that come back before the saved session expiry cannot revive an ended session. A store that cannot record it is a failed logout.
Author
|
Closing as orphan: linked issue CHE-1795 is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Status
Draft. N3 server layer of CHE-1789 (Web Push subscriptions, outbox and VAPID worker). Base
mainat3ae6189563bbfe705d6baf1a0be0492969b7f510. Reviewed headdff195dc2fc2eff8eb33fccf01a8c1e2cd671a33got BLOCKINGS_FOUND with two P1 findings. Revision 2 head8fe92cb7900ff25c1eb174fb81a5e0daf9a581c7fixes both. Revision 3 head235e55b3cc7edc42c4736ceea5af77495a0145b4is that head plus a merge ofmainat0e941dafe4738a49a6da779308b6e2e2e19e29fa. Luna's review of it returned BLOCKINGS_FOUND with one P1 (lease fencing) and one P2 (latency contract). Revision 4 head7519e7c5f673349d74ffdd7f07b2910dfd4565aecorrected both. Luna's review of it returned BLOCKINGS_FOUND with an open logout P1 and a teardown-latency P2. Revision 5 head11cb9a671a0e580521a9c1701a9b0f4bcece346ccorrected both again. Commits: tests only (7986f097d), implementation (dff195dc2), regression tests only (3e78c190d), fix (8fe92cb79), merge (235e55b3c), regression tests only (21cf8f485), fix (7519e7c5f), regression tests only (5a65c2995), fix (af6aca76d), comment-only (11cb9a671).Revision 5 head
11cb9a671a0e580521a9c1701a9b0f4bcece346cwas reviewed with BLOCKINGS_FOUND (a logout that could answer 200 after a drain that did not finish). Revision 6 head0ef14efb22e409cd11ce7eb386eb181eeb7a6ab6fixed that, mergedmainat860e4e1210bb2879e4658d282d0076aeb727bef3and renumbered the migrations. Luna's review of it returned BLOCKINGS_FOUND with two P1 findings (the browser could not retry a failed logout, and logout skipped the session end when push was unconfigured). Current head0bbcdbd8a086ac5712fe859284da24e8c40d1113fixes both (Revision 7).Numbering note: migration numbers in the Revision 2 to 5 sections are the numbers at that time, 657 to 665. After
maintook 657 to 666 they are 667 to 675 in the same order (each old number plus 10).Conditions
multicadatabase by mistake. See Evidence, "Database incident".Change
push_subscription,push_delivery, their concurrent indexes, and aninbox_itemtrigger that queues one delivery per device of the recipient. The row is due 10 s out. A coalesced DM reply re-arms the same row.GET /api/push/config,POST /api/push/subscriptions,DELETE /api/push/subscriptions. A subscription stores the registering session.POST /auth/logoutends that session's subscriptions.internal/pushnotifyworker. Leased claim withSKIP LOCKED. Before each send it re-checks that the record is unread, the push switch is on, the user is a member, the session is valid, the device still belongs to the recipient and the DM is readable. Backoff with a cap of 6 claims. 404 or 410 deletes the subscription. Sent rows are kept for 1 hour.record_id,workspace_slug,kind,actor_name,label,reply_count,url. DM labels are fixed ("New reply", "Decision needed"). Issue records send the issue key and title. Every other record sends "Notification". No message text, body, ask or DM title is sent.MULTICA_VAPID_PUBLIC_KEY,MULTICA_VAPID_PRIVATE_KEYandMULTICA_VAPID_SUBJECTare all set. Then no worker starts and registration returns 503. Docs are inSELF_HOSTING_ADVANCED.md,.env.exampleanddocker-compose.selfhost.yml.Deviations from the design
createInboxItem(tx, item). Many writers create inbox items, several outside a transaction. A trigger covers every current and future writer in the item's own transaction.sidand the token expiry. Logout and expiry both end it. There is nofailure_countcolumn. Failures are bounded by the attempt cap.workspace_id. They arekeepin the workspace deletion manifest.Evidence
Dependency health,
github.com/SherClockHolmes/webpush-gov1.4.0, read 2026-10-19: MIT licence, not archived, 455 stars, last commit 2026-04-22 (ICT 2026-04-22 12:22), latest tag v1.4.0 from 2025-01-02. It needs onlygolang-jwt/jwt/v5, already a dependency, andgolang.org/x/crypto.Test-first. Commit
7986f097dadds tests only. Against its parent (3ae618956) the router tests inserver/cmd/server/push_subscription_test.goname no new symbol and fail at run time:All 11 router tests fail this way. The 20 worker tests in
server/internal/pushnotify/worker_test.gocannot compile at the parent because the package has no non-test file. That is a compile failure, not behavioral red evidence. Record the worker tests' red check as NOT-RUN.GREEN at
dff195dc2plus the manifest fix, task-owned databasemultica_multica_310:go test ./cmd/server -run 'TestPush|TestLogoutEnds': 11 of 11 pass.go test ./internal/pushnotify -race: 22 of 22 pass. This covers read before the 10 s delay (no send), coalesced replies (one row), revoked session (no send), 410 (subscription deleted), payload without message text, and push disabled without keys.go test ./cmd/server ./internal/decisiontxpass.go test ./internal/handler ./internal/service ./cmd/migrate -p 1pass after the manifest fix (TestWorkspaceDeletionManifestCoversPublicSchemafailed before it).golangci-lint run --new-from-merge-base=origin/mainon the touched packages: 0 issues.go vetclean.Database incident. After the first RED run,
DATABASE_URLwas not set in later shell calls. Migration 657 to 663 and the pushnotify, cmd/server, handler, service and cmd/migrate test runs of that window went to the sharedmulticadatabase onlocalhost:5432, not to the task database. I rolled migrations back to 656 on that database withgo run ./cmd/migrate down --to 656_dm_channel_default_offand verified that the push tables, the trigger and the function are gone. The shared database still shows whatever else those test suites leave behind. I made no other change to it. All GREEN results above were re-run on the task database.Not run: real-device push, a live push service, rendering of the diagram below.
Revision 2: P1 corrections (head
8fe92cb7900ff25c1eb174fb81a5e0daf9a581c7)Review input: x99-codex-luna, BLOCKINGS_FOUND on
dff195dc2fc2eff8eb33fccf01a8c1e2cd671a33. The earlier PASS verdicts do not cover the revised bytes. A fresh independent exact-pair review and an applicable OCR receipt are needed.P1 shared-address SSRF gap,
server/internal/pushnotify/sender.go100.64.0.0/10,198.18.0.0/15,192.0.0.0/24,240.0.0.0/4and the NAT64, 6to4 and Teredo ranges. An IPv4 address carried in IPv6 is judged as IPv4. HTTPS-only endpoints, the IP-literal andlocalhostrejection, the dial-time check against DNS rebinding and the no-redirect rule are unchanged.server/internal/pushnotify/guard_test.gouses a table of 43 refused and 16 allowed addresses, including the neighbours of each blocked range. It makes no connection and resolves no host.P1 in-flight ownership race,
server/pkg/db/queries/push.sqlandserver/internal/pushnotify/worker.goFOR NO KEY UPDATE. Registering the endpoint for another account, logout and pruning wait for an in-flight send. So no push starts after the device changes hands, and a stale 404, 410 or success cannot land on the new owner. The row id stays unchanged on transfer.NewWorkertakes the pool.server/internal/pushnotify/inflight_test.goholds a send open at the push-service boundary and moves the device meanwhile.TestDeviceCannotChangeHandsWhileAPushIsInFlightcovers transfer after context acquisition.TestStaleGoneDoesNotRemoveTheNewOwnersSubscriptioncovers a stale 410 against the new owner.TestTransferBeforeTheSendStartsSendsNothingcovers transfer before the send.Test-first evidence for Revision 2
Commit
3e78c190dhas regression tests only. Against the reviewed headdff195dc2(production code unchanged) they fail at run time on a task-owned database:GREEN at
8fe92cb79on the same kind of database:go test ./internal/pushnotify -race: 28 of 28 pass.go test ./cmd/server ./internal/decisiontx ./pkg/db/...pass.go test ./internal/handler ./internal/service -p 1pass.go test ./cmd/migratepasses.golangci-lint run --new-from-merge-base=origin/mainon the touched packages: 0 issues.go vetis clean.Database isolation for these runs: a disposable PostgreSQL container (
pgvector/pgvector:pg17) on a random127.0.0.1port, data on tmpfs, owned by this task. Every database command set its ownDATABASE_URLandPG*variables and passed a guard first. The guard checks that the port is not 5432, that the container's published port matches and that the database name matches. The shared service on 5432 was not used. Thepushnotifytest setup no longer falls back to a default URL.Revision 3: base alignment (head
235e55b3cc7edc42c4736ceea5af77495a0145b4)Source-branch integration only. This is not a merge of the PR.
mainat0e941dafe4738a49a6da779308b6e2e2e19e29fa(PR #299, Jev routing controls) was merged into the branch with a merge commit, no rebase and no force-push. The merge had no conflicts. The merged commit touches 18 files, none underserver/, so the server code of this PR is byte-identical to Revision 2.7986f097d,dff195dc2,3e78c190d,8fe92cb79and0e941dafeare all ancestors of235e55b3c. Test-first history and the Revision 2 RED evidence above stay unchanged.mainends at 656. PR feat(push): Web Push subscriptions, outbox and VAPID worker (CHE-1795) [squash] #304 holds 657 to 663. No other open PR holds 657 or above. No migration changed in Revisions 2 and 3.internal/pushnotifywith-race28 of 28,cmd/server,internal/decisiontx,pkg/db,internal/handler,internal/serviceandcmd/migratepass,go vetclean, lint 0 issues,sqlc generateleaves no diff.main(this PR does not change them), model-backed OCR, real-device push, a live push service.Revision 4: lease fencing and latency contract (head
7519e7c5f673349d74ffdd7f07b2910dfd4565ae)Review input: x99-codex-luna, BLOCKINGS_FOUND on
235e55b3cc7edc42c4736ceea5af77495a0145b4. Luna confirmed both earlier P1 findings as FIXED. One P1 and one P2 were open. Both are fixed below. The earlier verdicts and OCR receipts do not cover the new bytes.P1 lease ownership not fenced after expiry (fixed)
Luna's source reading is right: a batch of 32 rows with a 180 s lease and four send slots can spend 480 s in sender calls when all rows share a device. A claimant could outlive its lease while queued on the device lock, another worker could reclaim the row, and no statement checked the claim. I did not reproduce this at runtime beyond the regression tests below.
push_delivery.claim_token. Every claim sets a new one. The context read, finish, retry, drop and the 404/410 prune all require the token. A claimant whose lease expired reads nothing, sends nothing and writes nothing. A stale outcome cannot clear, move or complete the newer claim, retry or drop another claim, or prune the subscription.clock_timestamp, not the transaction start). So no other worker may reclaim the row while this attempt sends and writes. If the margin is gone, the attempt stops and leaves the row for reclaim.P2 unsupported 15-second registration and logout bound (fixed by replacing the contract)
Luna's finding is right:
sendTimeoutcovers only the push call. Pool acquisition, lock waits, result writes and the commit were not bounded, and requests queued on a device each restart their lock wait.New contract:
Retry-After: 5and the client retries. This is a bounded failure, not a promise that the request completes within 15 s. Account switching still works: the transfer succeeds once the device is free.push_session_end) and never touches the device row. Revision 5 below replaces the rest of this bullet: markers are no longer removed after the token lifetime, and logout now also drains admitted sends.Test-first evidence for Revision 4
Commit
21cf8f485has tests only. Production code is the reviewed head235e55b3cc7edc42c4736ceea5af77495a0145b4. The runs below are on a task-owned database; exit codes were captured per command.The first RED run of the two registration tests used a test helper that swapped a global token between concurrent requests. That made the delete request run as the wrong user. I fixed the helper in the fix commit and re-ran both tests against the reviewed head with the fixed helper. The output above is from that second run.
GREEN at
7519e7c5f, task-owned database,-racewhere stated:go test ./internal/pushnotify -race: exit 0, 33 of 33 pass, including both earlier P1 regressions and the SSRF table.go test ./cmd/server ./internal/decisiontx ./pkg/db/...: exit 0.go test ./internal/handler ./internal/service ./cmd/migrate -p 1: exit 0.go vet: exit 0.golangci-lint --new-from-merge-base=origin/main: exit 0, 0 issues.sqlc generate: exit 0, no further diff.mainends at 656. No other open PR holds 657 or above. This PR now holds 657 to 665.Database isolation: a disposable
pgvector/pgvector:pg17container on a random 127.0.0.1 port, data on tmpfs, owned by this task. Every database command set its ownDATABASE_URLandPG*variables and passed a guard first (port is not 5432, published port matches, database name matches). The shared 5432 service was not used.Not run: model-backed OCR, real-device push, a live push service, rendering of the diagram on GitHub. External CI is not awaited here.
Revision 5: logout lifecycle and teardown bounds (head
11cb9a671a0e580521a9c1701a9b0f4bcece346c)Review input: x99-codex-luna, BLOCKINGS_FOUND on
7519e7c5f673349d74ffdd7f07b2910dfd4565ae. Luna confirmed the SSRF, account-transfer and claim-fencing fixes as FIXED. A logout P1 and a teardown P2 were open. The logout trade-off was not accepted. Both findings are fixed below. The earlier verdicts and OCR receipts do not cover the new bytes.P1 logout and session lifecycle (fixed, four gaps)
DrainPushSession). So when logout answers, every send admitted before the marker is over or still running past the drain, and no other send is left to start. The drain waits at most 3 s (lock_timeout); a longer send is left to finish, because the marker is already committed and the send was admitted before it. A push already on the wire cannot be recalled and this PR does not promise that. Preventing a new send is the guarantee, not a trade-off.Retry-Afterand keeps the cookie when the marker cannot be written, so the client can retry. Superseded in Revision 7: the session end is now recorded whatever the push configuration is.sidalive for as long as it is used, so no lifetime bounds an ended session. The cost is one small row per logout with push configured. Cleanup, if it is ever needed, has to be keyed to something other than time.sid, so account switching and new logins work. Unrelated authentication, including token renewal, is unchanged.P2 teardown paths (fixed)
Every rollback now runs under its own
TeardownBudgetof 2 s (pushnotify.RollbackBounded), outside the operation budget: the handler's deferred rollback, the worker's deferred rollback, the contested-device rollback and the write that defers the delivery. A rollback that cannot finish in time closes the connection, which the pool replaces. Contract, stated without using the sender timeout as a total: a push request fails or completes within its 5 s budget plus at most 2 s of teardown; a worker attempt has a 25 s budget plus at most 2 s of teardown.Test-first evidence for Revision 5
Commit
5a65c2995has tests only. Production code is the reviewed head7519e7c5f673349d74ffdd7f07b2910dfd4565ae. Task-owned database; each package run alone with its own exit code (all exit 1):Two new tests passed against the reviewed head and are regression guards, not RED:
TestCancelledAttemptRollsBackBoundedAndReleasesItsConnectionandTestLogoutAnswersWithinTheDrainBudgetWhenASendIsSlow.The race between the context read and the marker commit is tested through the session lock: the test plays logout by holding the exclusive lock, so the worker cannot reach its context read, commits the marker, and lets go. The test therefore names the lock key (
hashtextextended(session_id, 0)). A test cannot pause a worker between its context read andSend, because that needs a hook in production code.GREEN at
11cb9a671, task-owned database, each command with its own exit code:go test ./internal/pushnotify -race: exit 0, 37 of 37.go test ./cmd/server ./internal/decisiontx ./pkg/db/...: exit 0.go test ./internal/handler ./internal/service ./cmd/migrate -p 1: exit 0.go test ./internal/auth ./internal/middleware: exit 0.go test -raceon the two new handler tests: exit 0.go vet: exit 0.golangci-lint --new-from-merge-base=origin/main: exit 0, 0 issues.sqlc generate: exit 0, no further diff.mainends at 656 and no other open PR holds 657 or above.Source graph, read from a fresh full (not shallow) clone of the GitHub repository into a task directory, then deleted:
refs/pull/304/headis11cb9a671a0e580521a9c1701a9b0f4bcece346c;mainis0e941dafe4738a49a6da779308b6e2e2e19e29fa, the merge base. All earlier heads (7986f097d,dff195dc2,3e78c190d,8fe92cb79,235e55b3c,21cf8f485,7519e7c5f) and both new commits are ancestors. There are 10 commits and 1 merge commit between base and head, and 48 changed paths. The GitHub compare API reportsahead_by10,behind_by0, 48 files, status ahead.Database isolation: a disposable
pgvector/pgvector:pg17container on a random 127.0.0.1 port, data on tmpfs, owned by this task. Every database command set its ownDATABASE_URLandPG*variables and passed a guard first (port is not 5432, published port matches, database name matches). The shared 5432 service was not used.Not run: model-backed OCR, real-device push, a live push service, diagram rendering on GitHub. External CI is not awaited here.
Revision 6: logout fails closed; migrations renumbered (head
0ef14efb22e409cd11ce7eb386eb181eeb7a6ab6)Review input: x99-codex-luna, BLOCKINGS_FOUND on
11cb9a671a0e580521a9c1701a9b0f4bcece346c. Luna confirmed the claim-fencing, SSRF, account-transfer and teardown fixes. One logout P1 was open. The earlier verdicts and OCR receipts do not cover the new bytes.P1 logout success after an unfinished drain (fixed)
Luna's source reading is right.
drainPushSessionlogged its Begin, SET LOCAL, drain and commit errors and returned nothing. SoendPushSessionreturned success andLogoutcleared the cookie and answered 200 even when the drain timed out. A worker that was admitted before the marker could then still start its send after that success. The slow-send test of Revision 5 even opened its sender gate only after logout had answered 200.drainPushSessionreturns every failure (Begin, the SET LOCAL, the exclusive session lock including its lock timeout and the deadline, cancellation, the commit).endPushSessionpasses it on.Logoutanswers 503 withRetry-After: 5and keeps the cookie. The marker stays recorded and a retry repeats both steps, so retry is idempotent. A logout that answers 200 has finished the drain, which means no delivery admitted before the marker is left to start.Migration numbers
mainmerged PR #296 and now holds 657 to 666 under other names. The nine push migrations keep their order and content and are renumbered to 667 to 675. The concurrent-index cleanup map and the comments that name them follow.mainwas merged into the branch with a merge commit, no rebase and no force-push, and the merge had no text conflicts. No other open PR holds 667 or above.Test-first evidence for Revision 6
Commit
1eeb76fd7has tests only. Production code is the reviewed head11cb9a671a0e580521a9c1701a9b0f4bcece346c. Task-owned database, each package alone, both exit 1:The cmd/server test holds an admitted worker (the sender gate at
Sendentry, with the shared session lock held) beyond the drain deadline. It checks 503,Retry-After, the cookie kept, the marker kept, and that releasing the worker lets a retry answer 200 and clear the cookie. Gate-at-Send-entry stands for a worker paused between admission and the start of its send. The handler test injects a fault at each of the seven drain steps through a wrapping transaction starter.GREEN, each command with its own exit code, on the merged and renumbered head:
go buildexit 0;internal/pushnotifywith-raceexit 0, 37 of 37;cmd/server,decisiontx,pkg/db,internal/auth,internal/middlewareexit 0;internal/handler,internal/service,cmd/migrateexit 0 (the handler focused run also passed with-race);go vetexit 0; lint exit 0 with 0 issues;sqlc generateexit 0 with no diff. A fresh database migrated up through 675, and 675 down to 666 and up again, exit 0.Isolation: a disposable
pgvector/pgvector:pg17container on a random 127.0.0.1 port, data on tmpfs, owned by this task. Every database command set its ownDATABASE_URLandPG*variables and passed a guard first (port is not 5432, published port matches, database name matches). The shared 5432 service was not used.Not run: model-backed OCR, real-device push, a live push service, diagram rendering on GitHub. External CI is not awaited here.
Revision 7: browser logout retry and session end with push disabled (head
0bbcdbd8a086ac5712fe859284da24e8c40d1113)Review input: x99-codex-luna, BLOCKINGS_FOUND on
0ef14efb22e409cd11ce7eb386eb181eeb7a6ab6. Luna confirmed the claim fencing, SSRF, account-transfer and teardown fixes and the server-side fail-closed logout. Two P1 findings were open. The earlier verdicts and OCR receipts do not cover the new bytes.P1 browser retry after a failed logout (fixed at the shared root)
Luna's trace is right. The server answers 503 and
ApiClient.logoutrejects, butcreateAuthStore.logoutdiscarded the failure and cleared local auth, anduseLogoutthen cleaned up and navigated to the login page. With cookie auth the browser had no usable retry path.Callers traced:
useLogoutserves seven views (the sidebar menu, the no-access page, the interactive-login banner, the invitations page, the invite page, the onboarding logout button and the workspace step). The desktop navigation adapter and the daemon re-authentication call the store'slogout()directly, in token mode only. Mobile has its own store and no push.endServerSession(). In cookie mode it calls the server logout and rejects when that fails. Concurrent callers share one request. In token mode it resolves at once.logout()stays the local teardown. A caller that never confirmed still gets the old best-effort request.useLogoutawaitsendServerSession()first. On failure it cleans nothing, does not navigate, keeps the session and shows an error toast (new stringauth.errors.logout_failedin all five locales). On success it runs the same sequence as before: erase client data, local teardown, navigate to the login page. A second press while a request is in flight shares it. The returned promise resolves to whether the logout completed and never rejects, so the sevenonClick={logout}callers need no change.sessionExpired()without calling the server, so a dead credential does not depend on a successful round trip. The server's logout route has no authentication, so a dead cookie still logs out.P1 session end skipped when push is unconfigured (fixed)
Luna's source reading is right.
endPushSessionreturned early when any VAPID value was missing, so no marker was written, although subscriptions registered while push was configured survive a change of keys. If the keys came back before the saved session expiry, the worker could send to the ended session, and the endedsidcould register again.Retry-After, not a skipped step.sid.Test-first evidence for Revision 7
Commit
467f2062chas tests only. Production code is the reviewed head0ef14efb22e409cd11ce7eb386eb181eeb7a6ab6. Runtime failures, each command with its own exit code (all exit 1):Passing at the reviewed head as controls: token mode logs out locally without the server, an expired session tears down without the server, the worker-off case with complete keys, and a new login registering after another session ended. The browser tests run the real auth store and the real hook; only the network, the navigation and the toast are replaced. The old hook test and the banner test were adapted to the new async contract in the fix commit. The disabled-push test creates the subscription with complete keys, removes the keys only for the logout, restores them before the saved expiry, and then checks that a worker pass sends nothing and that the ended
sidcannot register.GREEN at
0bbcdbd8a086ac5712fe859284da24e8c40d1113, each command with its own exit code:vitestinpackages/core: exit 0, 185 files, 2407 tests (includes the new store tests).tsc --noEmitin core, views and desktop: exit 0.eslinton the changed core and views directories: exit 0.vitestinpackages/views: the logout, banner, locale parity and all other files pass, 6530 of 6531 tests. One test,table-view-editing"confirms a status change that would start an agent run", times out at 5 s in three full runs (that file takes 26 s under load against 5.8 s alone). It passes alone three times and with the auth tests, and a full run on the tests-only commit passed it once. It does not touch logout. I treat it as a load-dependent timeout and do not claim a pass for the full views suite.tsc --noEmitinapps/webfails only on a missing generated module (@/.source, the docs source) because I installed without scripts. That is unrelated and NOT-RUN for web.go build,internal/pushnotifywith-race(37 of 37),cmd/server,decisiontx,pkg/db,internal/auth,internal/middleware,internal/handler,internal/service,cmd/migrateall exit 0; the focused handler logout tests with-raceexit 0;go vetexit 0; lint exit 0 with 0 issues;sqlc generateexit 0 with no diff.mainis still860e4e1210bb2879e4658d282d0076aeb727bef3, and no other open PR holds 667 or above.Isolation: a disposable
pgvector/pgvector:pg17container on a random 127.0.0.1 port, data on tmpfs, owned by this task. Every database command set its ownDATABASE_URLandPG*variables and passed a guard first (port is not 5432, published port matches, database name matches). The shared 5432 service was not used. Frontend checks use no database.Not run: model-backed OCR, real-device push, a live push service, a browser end-to-end run, mobile checks, web typecheck, diagram rendering on GitHub. External CI is not awaited here.
Refs CHE-1795
Changelog: none