From 932add5813b59a9afca46eb4de6dd1985dc12a7b Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 15 Aug 2026 10:03:32 -0600 Subject: [PATCH] refresh: serialize quota requests Let the watch own startup refreshes and coalesce additional triggers behind one active request. Bound each HTTP request, complete it once, and ignore responses from credentials replaced while a refresh is in flight. --- src/pkjs/index.js | 73 +++++++++++++++++++++++++++++++---- test/pkjs.test.js | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/pkjs/index.js b/src/pkjs/index.js index bbfcbed..d4183a7 100644 --- a/src/pkjs/index.js +++ b/src/pkjs/index.js @@ -5,6 +5,7 @@ var CONFIG_BASE_URL = "plexusBaseUrl"; var CONFIG_ADMIN_KEY = "plexusAdminKey"; var ERROR_KIND_RETRYABLE = 0; var ERROR_KIND_SETUP_REQUIRED = 1; +var REQUEST_TIMEOUT_MILLISECONDS = 20000; var clay = new Clay( createConfiguration( localStorage.getItem(CONFIG_BASE_URL) || "", @@ -17,6 +18,10 @@ var MAX_ROWS_PER_GROUP = 4; var quotaGroups = []; var currentPageIndex = 0; var providerDisplayNames = {}; +var configurationRevision = 0; +var refreshInFlight = false; +var refreshPending = false; +var pendingPageIndex; var ROW_KEYS = [ { @@ -62,17 +67,32 @@ function sendError(kind, message) { function xhrRequest(url, adminKey, callback) { var xhr = new XMLHttpRequest(); + var completed = false; + + function finish(error, responseText) { + if (completed) return; + completed = true; + callback(error, responseText); + } + xhr.onload = function () { if (xhr.status < 200 || xhr.status >= 300) { - callback(new Error("HTTP " + xhr.status), null); + finish(new Error("HTTP " + xhr.status), null); return; } - callback(null, xhr.responseText); + finish(null, xhr.responseText); }; xhr.onerror = function () { - callback(new Error("Network error"), null); + finish(new Error("Network error"), null); + }; + xhr.ontimeout = function () { + finish(new Error("Network error"), null); + }; + xhr.onabort = function () { + finish(new Error("Network error"), null); }; xhr.open("GET", url); + xhr.timeout = REQUEST_TIMEOUT_MILLISECONDS; xhr.setRequestHeader("x-admin-key", adminKey); xhr.send(); } @@ -513,14 +533,20 @@ function refreshQuotaWithDisplayNames( baseUrl, adminKey, pageIndex, - displayNames + displayNames, + revision ) { xhrRequest( baseUrl + "/v0/management/quotas", adminKey, function (error, responseText) { + if (revision !== configurationRevision) { + completeRefresh(); + return; + } if (error) { sendError(ERROR_KIND_RETRYABLE, error.message); + completeRefresh(); return; } try { @@ -529,11 +555,22 @@ function refreshQuotaWithDisplayNames( } catch (e) { sendError(ERROR_KIND_RETRYABLE, "Bad quota response"); } + completeRefresh(); } ); } -function refreshQuota(pageIndex) { +function completeRefresh() { + refreshInFlight = false; + if (!refreshPending) return; + + var pageIndex = pendingPageIndex; + refreshPending = false; + pendingPageIndex = undefined; + refreshQuota(pageIndex); +} + +function startRefresh(pageIndex) { var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL)); var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY); if (!baseUrl || !adminKey) { @@ -541,10 +578,16 @@ function refreshQuota(pageIndex) { return; } + refreshInFlight = true; + var revision = configurationRevision; xhrRequest( baseUrl + "/v0/management/providers", adminKey, function (providerError, providerResponseText) { + if (revision !== configurationRevision) { + completeRefresh(); + return; + } var displayNames = providerDisplayNames; try { if (!providerError) { @@ -554,11 +597,26 @@ function refreshQuota(pageIndex) { } catch (e) { displayNames = providerDisplayNames; } - refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames); + refreshQuotaWithDisplayNames( + baseUrl, + adminKey, + pageIndex, + displayNames, + revision + ); } ); } +function refreshQuota(pageIndex) { + if (refreshInFlight) { + refreshPending = true; + pendingPageIndex = pageIndex; + return; + } + startRefresh(pageIndex); +} + function configurationValues(fields) { var baseUrl = fields[CONFIG_BASE_URL]; var adminKey = fields[CONFIG_ADMIN_KEY]; @@ -577,7 +635,7 @@ function configurationValues(fields) { } Pebble.addEventListener("ready", function () { - refreshQuota(); + // The watch owns startup refresh so phone readiness cannot race its request. }); Pebble.addEventListener("appmessage", function (e) { @@ -603,6 +661,7 @@ Pebble.addEventListener("webviewclosed", function (e) { var values = configurationValues(clay.getSettings(e.response, false)); localStorage.setItem(CONFIG_BASE_URL, values.baseUrl); localStorage.setItem(CONFIG_ADMIN_KEY, values.adminKey); + configurationRevision += 1; clay.setSettings({ plexusBaseUrl: values.baseUrl, plexusAdminKey: values.adminKey diff --git a/test/pkjs.test.js b/test/pkjs.test.js index 64187cf..aa92a55 100644 --- a/test/pkjs.test.js +++ b/test/pkjs.test.js @@ -60,6 +60,7 @@ function loadPebbleKit(initialValues) { function FakeXMLHttpRequest() { this.headers = {}; + this.timeout = 0; requests.push(this); } @@ -76,6 +77,20 @@ function loadPebbleKit(initialValues) { this.sent = true; }; + FakeXMLHttpRequest.prototype.respond = function (status, responseText) { + this.status = status; + this.responseText = responseText; + this.onload(); + }; + + FakeXMLHttpRequest.prototype.fail = function () { + this.onerror(); + }; + + FakeXMLHttpRequest.prototype.triggerTimeout = function () { + this.ontimeout(); + }; + var context = { XMLHttpRequest: FakeXMLHttpRequest, localStorage: storage, @@ -248,6 +263,87 @@ test("network failures remain retryable", function () { ]); }); +test("phone readiness waits for the watch to request quotas", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.ready(); + + assert.deepEqual(kit.requests, []); + assert.deepEqual(kit.sentMessages, []); +}); + +test("refresh triggers coalesce behind one active request", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + + assert.equal(kit.requests.length, 1); + kit.requests[0].respond(200, "{}"); + assert.equal(kit.requests.length, 2); + kit.requests[0].fail(); + assert.equal(kit.requests.length, 2); + kit.requests[1].respond(200, "[]"); + assert.equal(kit.requests.length, 3); + assert.equal( + kit.requests[2].url, + "https://plexus.example/v0/management/providers" + ); +}); + +test("requests time out once and ignore later terminal events", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://plexus.example", + plexusAdminKey: "secret" + }); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + assert.equal(kit.requests[0].timeout, 20000); + kit.requests[0].triggerTimeout(); + assert.equal(kit.requests.length, 2); + assert.equal(kit.requests[1].timeout, 20000); + kit.requests[1].triggerTimeout(); + kit.requests[1].fail(); + + assert.deepEqual(JSON.parse(JSON.stringify(kit.sentMessages)), [ + { ERROR: "Network error", ERROR_KIND: ERROR_KIND_RETRYABLE } + ]); +}); + +test("credential replacement ignores the active refresh", function () { + var kit = loadPebbleKit({ + plexusBaseUrl: "https://old.example", + plexusAdminKey: "old-secret" + }); + var response = encodeURIComponent( + JSON.stringify({ + plexusBaseUrl: { value: "https://new.example" }, + plexusAdminKey: { value: "new-secret" } + }) + ); + + kit.listeners.appmessage({ payload: { REQUEST_QUOTA: 1, PAGE_INDEX: 0 } }); + kit.listeners.webviewclosed({ response: response }); + assert.equal(kit.requests.length, 1); + + kit.requests[0].respond(200, '{"old":{"displayName":"Old"}}'); + + assert.equal(kit.requests.length, 2); + assert.equal( + kit.requests[1].url, + "https://new.example/v0/management/providers" + ); + assert.equal(kit.requests[1].headers["x-admin-key"], "new-secret"); + assert.deepEqual(kit.sentMessages, []); +}); + test("phone-only configuration keys are not AppMessage keys", function () { var packageJson = require(path.join(__dirname, "..", "package.json")); -- knotmirror 0.0.1