1import { Controller } from "@hotwired/stimulus"; 2import { applyGlobalTriggerSummary } from "controllers/global_activity_hub/global_count"; 3 4// Shorter than the 30s interval: a stalled fetch would otherwise pin `inFlight` and stop the header 5// trigger and sidebar badge updating on every page for the life of the page (CodeRabbit, #2544). 6const REQUEST_TIMEOUT_MS = 10000; 7 8// Ticks skipped after consecutive failures, doubling to this ceiling. This poller matters most for it: 9// it runs on EVERY authenticated page, and in production a 500 renders JSON for a JSON request 10// (ApplicationController's `rescue_from StandardError`), so it clears the content-type check and lands 11// on the `!response.ok` return â retried at fixed cadence for as long as the outage lasts, from every 12// open tab at once, since they all armed their timers at page load. The jitter tick breaks that 13// lockstep; the doubling stops each tab hammering a service that is already down (ENG-6288 review). 14const MAX_BACKOFF_TICKS = 8; 15 16// Keeps the header Activity Hub trigger and the sidebar unread badge live on every page (ENG-6288), 17// so a published alert lights them up â and a finished operation stops the spinner â without the 18// customer refreshing. Mounted on the trigger itself in the layout, which the layout only renders for 19// accounts the hub is enabled for, so the poll is scoped to exactly those sessions. 20// 21// Stands down on the Activity Hub page: the inbox poller there fetches the same summary alongside the 22// card list and applies it through the same helper, so running both would just double the requests. 23export default class extends Controller { 24 static values = { 25 summaryUrl: String, 26 intervalMs: { type: Number, default: 30000 }, 27 }; 28 29 connect() { 30 this.pollHandle = null; 31 this.inFlight = false; 32 this.abortController = null; 33 this.consecutiveFailures = 0; 34 this.skipTicks = 0; 35 this.stopped = document.querySelector('[data-controller~="global-activity-hub--inbox-poller"]') !== null; 36 this.boundVisibilityChange = this.handleVisibilityChange.bind(this); 37 document.addEventListener("visibilitychange", this.boundVisibilityChange); 38 this.startPolling(); 39 } 40 41 disconnect() { 42 document.removeEventListener("visibilitychange", this.boundVisibilityChange); 43 this.stopPolling(); 44 this.abortController?.abort(); 45 } 46 47 handleVisibilityChange() { 48 if (document.visibilityState === "visible") this.startPolling(); 49 else this.stopPolling(); 50 } 51 52 startPolling() { 53 if (this.stopped || this.pollHandle) return; 54 if (document.visibilityState !== "visible") return; 55 this.pollHandle = setInterval(() => this.refresh(), this.intervalMsValue); 56 } 57 58 stopPolling() { 59 if (!this.pollHandle) return; 60 clearInterval(this.pollHandle); 61 this.pollHandle = null; 62 } 63 64 // Terminal: the session ended, or a protection layer rejected the request ahead of Rails â the 65 // Rack::Attack IP blocklist is registered per-IP with no path scoping and answers 403 everywhere. 66 // A trigger sits on every page, so a poller that kept firing on a signed-out tab would be the 67 // app's most persistent request. 68 stopPermanently() { 69 this.stopped = true; 70 this.stopPolling(); 71 } 72 73 // Spent in whole ticks rather than by rescheduling the interval â see the inbox poller for the 74 // reasoning; both pollers share the shape. 75 // 76 // @return [Boolean] whether this tick is being skipped to back off from repeated failures. 77 backoffHoldsTick() { 78 if (this.skipTicks <= 0) return false; 79 80 this.skipTicks -= 1; 81 return true; 82 } 83 84 noteFailure() { 85 this.consecutiveFailures += 1; 86 const doubling = Math.min(2 ** (this.consecutiveFailures - 1), MAX_BACKOFF_TICKS); 87 this.skipTicks = doubling + Math.floor(Math.random() * 2); 88 } 89 90 noteSuccess() { 91 this.consecutiveFailures = 0; 92 this.skipTicks = 0; 93 } 94 95 async refresh() { 96 // One request at a time, same as the inbox poller: a response slower than the 30s interval could 97 // otherwise be applied after a newer one and revert the header and sidebar counts (ENG-6288 review). 98 if (this.inFlight) return; 99 if (this.backoffHoldsTick()) return; 100 101 this.inFlight = true; 102 this.abortController = new AbortController(); 103 const deadline = setTimeout(() => this.abortController?.abort(), REQUEST_TIMEOUT_MS); 104 try { 105 const response = await fetch(this.summaryUrlValue, { 106 headers: { Accept: "application/json" }, 107 credentials: "same-origin", 108 signal: this.abortController.signal, 109 }); 110 if (response.status === 401 || response.status === 403) return this.stopPermanently(); 111 if (!response.ok) return this.noteFailure(); 112 // A redirect to the sign-in page answers 200 with HTML, so status alone cannot tell us the 113 // session is gone â the content type can. 114 if (!(response.headers.get("content-type") || "").includes("application/json")) { 115 return this.stopPermanently(); 116 } 117 118 applyGlobalTriggerSummary(await response.json()); 119 this.noteSuccess(); 120 } catch (_error) { 121 // Network blip, or our own abort on timeout/disconnect â the timer stays armed and retries. 122 this.noteFailure(); 123 } finally { 124 clearTimeout(deadline); 125 this.abortController = null; 126 this.inFlight = false;
127 } 128 } 129}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.