Skip to content

feat(pwa): make push notifications reliable and non-disruptive - #1173

Open
penso wants to merge 10 commits into
mainfrom
ancient-vest
Open

feat(pwa): make push notifications reliable and non-disruptive#1173
penso wants to merge 10 commits into
mainfrom
ancient-vest

Conversation

@penso

@penso penso commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes PWA push notifications reliable, private, ordered, and non-disruptive across tabs and devices.

  • Re-alerts for newer messages in the same chat without losing the earlier-message count, uses a generic privacy-safe title, strips rich formatting, and maintains an app-wide unread badge.
  • Tracks ordered per-window presence leases so only the endpoint actively viewing the exact chat is suppressed. Stale cross-tab responses cannot overwrite newer subscription intent.
  • Durably revokes remotely removed devices. Automatic presence and rotation recovery cannot silently restore them; only an explicit user Enable action can revive the endpoint.
  • Replaces unknown or expired browser capabilities with fresh subscriptions, preserves rotation chains and retry authorization, and serializes cross-tab changes with Web Locks.
  • Orders detached response delivery at both the server and service-worker boundaries with reset-safe monotonic completion order, so an older response cannot replace a newer notification.
  • Hardens delivery with validated DNS-pinned HTTPS endpoints, SSRF protection, bounded input and concurrency, disabled redirects/proxies, bounded responses, typed expiry handling, race-safe cleanup, endpoint-log redaction, and API scopes.
  • Persists subscriptions, revocations, and VAPID keys transactionally through atomic file replacement. Malformed stores disable push initialization instead of silently rotating keys or discarding subscriptions.
  • Migrates legacy notification tags, fixes badge close races, focuses clients before acknowledged in-place routing, and keeps worker updates waiting until existing clients close.
  • Restores correct voice feature gating so isolated web and lightweight CLI builds do not pull voice dependencies.
  • Expands documentation and behavioral Rust/Playwright coverage for recovery, revocation, ordering, persistence, migration, and feature configurations.

Validation

Completed

  • just lint
  • just release-preflight
  • cargo fmt --all -- --check
  • cargo test -p moltis-gateway push:: -- --nocapture - 48 passed
  • cargo test -p moltis-httpd push_routes::tests -- --nocapture - 19 passed
  • cargo test -p moltis-chat --features push-notifications channel_push::tests -- --nocapture - 14 passed
  • cargo check -p moltis-web --no-default-features
  • cargo check -p moltis-web --no-default-features --features voice
  • cargo check -p moltis --no-default-features --features lightweight
  • npx biome check src/push.ts src/pwa.ts src/sw.ts e2e/specs/pwa-push.spec.js
  • cd crates/web/ui && npm run build:all
  • cd crates/web/ui && npx tsc --noEmit
  • cd crates/web/ui && npx playwright test e2e/specs/pwa-push.spec.js - 37 passed
  • ./scripts/local-validate.sh 1173 - all local statuses published successfully; 66 targeted Playwright tests and real macOS/iOS builds passed
  • Independent final backend/frontend re-review - no actionable findings

Remaining

  • Real-device push delivery check on an installed iOS 16.4+ PWA and Android Chrome; headless CI has no push service.

Manual QA

  1. Run cargo run, open the web UI, and enable notifications in Settings > Notifications.
  2. Send a test notification and confirm the UI reports accepted, failed, expired, and timed-out delivery counts.
  3. Background the app and send two responses in one chat. Confirm the second notification alerts and includes the earlier-message count.
  4. Open the same chat in one tab and a different chat in another. Confirm only the exact visible, focused chat is suppressed and another subscribed device still receives the push.
  5. Remove a different device, then reopen or focus that device. Confirm it remains disabled until Enable is explicitly selected there.
  6. Click a notification while the app is open elsewhere. Confirm the existing window focuses and routes without a full reload; with the app closed, confirm it opens the target chat.
  7. Disable notifications in one tab while another tab is open. Confirm reconciliation and endpoint rotation do not recreate the subscription.
  8. Reset a busy session and send another response. Confirm its notification is delivered despite the cleared history.
  9. Switch offline and navigate to an uncached route. Confirm the offline fallback loads and reconnects cleanly.
  10. Install a new worker while the app is open. Confirm it waits until existing clients close before activating.

Push notifications had several defects that compounded into an unreliable
and noisy experience. The most visible: the service worker tagged every
notification with the session key but never set `renotify`, so a second
message in a chat silently replaced the first — no sound, no alert, and
the earlier message simply vanished. Notifications without a session key
all collapsed onto one shared tag.

Notification handling:
- Tag per session with `renotify`, and roll up replaced messages into an
  "…and N earlier messages" line so nothing is lost silently.
- Title from the session label instead of a fixed "Message received", and
  strip markdown from the body — fences and emphasis render literally in
  the notification shade.
- Click focuses a window already showing the target chat and routes
  in-place instead of reloading the whole document.

Delivery:
- Devices report which session they are viewing (`/api/push/presence`), and
  a device that is visible and focused on a session is skipped when that
  session replies. Other devices still get notified, so the phone stays
  quiet only for the message being read on it.
- Send concurrently rather than serially, set a 6h TTL, high urgency, and a
  per-session Topic so an offline device wakes to the latest message per
  chat rather than a backlog.
- Drop endpoints on a typed WebPushError instead of matching
  `e.to_string().contains("410")`, which discarded working subscriptions
  whenever an unrelated error happened to mention that number.
- Handle `pushsubscriptionchange`: browsers rotate endpoints, and without
  this push died silently until the user toggled it off and on again. The
  re-registration carries `replaces` so the dead endpoint is retired.

Service worker robustness:
- Precache assets individually. `cache.addAll` is atomic, so one missing
  generated asset failed the whole install and the worker never activated.
- Stop calling `skipWaiting()` at install. It force-reloaded the app
  mid-conversation and left the existing update-prompt machinery dead;
  activation and reload now wait until the app is out of view.
- Add an offline fallback page that reloads itself once connectivity is back.

Two calls turned out to crash the renderer outright, which takes down the
whole app rather than just push, and cannot be contained by try/catch:
- `pushManager.getSubscription()` at boot with no reachable push backend.
  Guarded behind granted permission — a subscription cannot exist without
  it, so the check costs nothing.
- The Badging API called from inside the service worker. The worker now
  posts the count to open pages and the page applies it; a fully closed
  app therefore picks up its badge when next opened.

Also adds a "Send test notification" action, since push failures are
otherwise invisible across the browser, push service, and network; and
modernises the manifest (`id`, `launch_handler`, unlocked orientation,
Projects/Settings shortcuts).

Push helpers move to `crates/chat/src/channel_push.rs` to keep
`channels.rs` under the 1500-line limit.
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR strengthens PWA push delivery, ordering, recovery, privacy, revocation, and persistence.

  • Hashes complete session keys before deriving bounded Web Push topics, preventing shared-prefix chats from superseding one another.
  • Persists failed endpoint rotations and reconciles them when the application next starts.
  • Re-registers browser subscriptions forgotten by the server while preserving explicit revocation and revival intent.
  • Adds ordered notification delivery, exact-chat presence leases, hardened endpoint validation, transactional storage, and expanded behavioral coverage.

Confidence Score: 5/5

The PR appears safe to merge.

The three previously reported failures are resolved: topics now incorporate the complete session key through a digest, failed rotations retain recovery state for startup reconciliation, and forgotten server subscriptions trigger guarded replacement and registration.

Important Files Changed

Filename Overview
crates/gateway/src/push.rs Derives privacy-safe, bounded topics from SHA-256 session-key digests and integrates ordered, filtered, bounded push fanout.
crates/web/ui/src/sw.ts Preserves failed endpoint-rotation state, enforces notification ordering, and maintains notification and badge lifecycle behavior.
crates/web/ui/src/push.ts Adds startup subscription reconciliation, guarded 404 recovery, durable enablement intent, and cross-tab serialization.
crates/gateway/src/push_subscriptions.rs Enforces durable endpoint revocation while allowing revival only through explicitly authorized registration.
crates/chat/src/channel_push.rs Builds privacy-safe notification content and monotonic completion ordering for chat responses.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Agent response completes] --> B[Create ordered push payload]
  B --> C[Gateway filters active presence]
  C --> D[Derive hashed session topic]
  D --> E[Deliver to subscribed endpoints]
  E --> F[Service worker applies completion order]
  F --> G[Display or replace notification]
  H[Endpoint rotation or presence 404] --> I[Persist rotation intent]
  I --> J[Startup reconciliation]
  J --> K[Register current browser subscription]
  K --> C
Loading

Reviews (3): Last reviewed commit: "fix(pwa): harden notification recovery a..." | Re-trigger Greptile

Comment thread crates/gateway/src/push.rs Outdated
Comment thread crates/web/ui/src/sw.ts Outdated
Comment thread crates/web/ui/src/push.ts Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 39 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing ancient-vest (b73302b) with main (fb95aa7)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

penso added 3 commits July 27, 2026 00:54
Badging was moved into the page on the belief that the Badging API cannot be
called from a service worker. That diagnosis was wrong. Probing both
environments directly: in headed Chrome every setAppBadge/clearAppBadge call
from the worker returns normally, and only headless Chromium is broken — there
the promise never settles and never rejects, and merely invoking it wedges the
worker, which then takes the page down on its next navigation. `typeof
navigator.setAppBadge` is "function" in both, so feature detection cannot tell
them apart, and nothing throws, so try/catch is equally useless.

Badging a closed app is the whole point of the API, so restore it under two
rules. Only when the app is installed: the page reports display-mode to the
worker, which persists it in a cache kept outside the versioned one so it
survives a cache bump and the app being closed. A badge is meaningless in a
browser tab anyway, so this is a semantic gate as much as it is what keeps the
worker away from the broken path. And never awaited, never inside waitUntil, so
a platform call that hangs cannot stop a notification being shown.

Also fixes three recovery gaps found in review, each of which left push
permanently dead with nothing to signal it:

- Topics collided across chats. The Web Push Topic header is capped at 32
  characters, and truncating the encoded session key made any two keys sharing a
  long prefix — `telegram:bot123:chat…`, or nested project/session keys — collapse
  onto one another, so one chat's pending notification superseded another's while
  the device was offline. Hash the key first so the whole of it stays
  significant.

- Endpoint rotation could fail silently. `fetch` resolves for 4xx, and the
  response status was never checked, so a rejected re-registration looked like
  success and the browser was left holding an endpoint the server never stored.
  Check it, and record the failure for the page to repair.

- Stale subscriptions never reconciled. `/api/push/presence` answers 404 for an
  endpoint the server does not know, but the client ignored it and its cached
  payload then suppressed every retry. Worse, reconciliation only ran from the
  settings page, so a forgotten subscription stayed broken until someone happened
  to open Settings. Handle the 404 by re-registering (rate limited so a server
  that keeps rejecting cannot drive a loop), and reconcile at app startup.

Adds unit coverage for the new push routes, which had almost none: device-name
and client-IP parsing, unconfigured-service behaviour, subscribe/unsubscribe,
rotation via `replaces`, and presence accept/reject.
The flag the previous commit added to mark a failed endpoint rotation was
never read: initPushState() reconciles the browser's subscription against
the server on every page load regardless, so a rotation the server rejected
is already repaired there. Keeping it meant write-only state plus the same
cache key spelled out in both the worker and the page, free to drift.

The part that mattered — checking the re-registration response instead of
treating any resolved fetch as success — stays.
Foreground suppression asked whether any visible client URL *contained*
`/chats/<session>`. `/chats/main-2` contains `/chats/main`, so a user reading
one chat suppressed notifications for a different one they could not see —
the same prefix-collision the Topic header had.

Compares the parsed pathname exactly instead.
@penso

penso commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

penso added 6 commits July 28, 2026 00:29
The session name in the chat toolbar sits in a flex row with `min-w-0` and
no overflow containment, so a long name spills out of its box to the left,
underneath `#projectCombo`. That combo is `position: relative` and therefore
paints above the static name: the name looks and reports as visible, enabled
and stable, yet every click lands on the project combo instead and renaming
is impossible.

It only bites once a project exists, because `#projectCombo` is hidden while
the project list is empty — which is why it went unnoticed, and why the
`channel-bound session can be renamed` e2e test only failed in a full-suite
run, after an earlier spec had created a project.

Contains the name in its own box and truncates it, which is what `min-w-0`
already implied was intended.

Two e2e fixes come with it, both for failures that had nothing to do with
the code under test:

- The e2e gateway now runs exec on the host. The first sandboxed exec
  provisions a container with the full default package set; on Linux a
  pre-built image absorbs that, but the Apple Container backend has no
  pre-built image and installs ~150 packages after the container starts.
  No test timeout survives it, so any spec whose agent calls exec failed on
  macOS while passing in CI. Commands come from the specs' own mock
  providers, and sandbox behaviour is covered by the sandbox specs, which
  use their own gateways.

- The date-bucket test now stubs the session list endpoint. It injects
  sessions into the client store to control their timestamps, but a
  background `/api/sessions` refresh replaces the list wholesale
  (`mergeSessionListPage` with `append=false`) and the server page is capped
  at 40 by recency — so once earlier specs had created enough sessions, the
  injected fixtures were evicted and could never return.
Push delivery and subscription recovery still had unsafe endpoint handling, unbounded or racy lifecycle paths, and service-worker behavior that could lose alerts or app state. Harden endpoint validation and delivery deadlines, serialize cross-tab subscription state, aggregate ordered presence, and keep worker updates and click routing non-disruptive.\n\nAdd focused Rust and Playwright coverage for the corrected security, concurrency, badge, rotation, offline, and routing behavior.
Targeted local validation filters in-source tests by filename. Match the file to the Rust module name so the three push concurrency regressions are selected instead of producing a no-tests error.
The web crate exercises gateway session types that unconditionally use moltis-voice, but isolated web tests built gateway without that optional dependency. Enable the required gateway feature so targeted asset tests compile outside the full CLI feature set.
Remote device removal could be undone by automatic reconciliation, detached sends could finish out of order, and subscription persistence could leave corrupt or partially applied state. Worker rotation and cross-tab intent races also allowed stale responses to disable newer subscriptions.

Persist explicit revocations, rotate unknown capabilities, order responses at both server and worker boundaries, make push-store updates transactional and atomic, redact endpoint logs, migrate legacy notifications, and restore correct voice feature gating. Add focused Rust and Playwright regressions for each path.
@penso

penso commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant