Skip to content

Commit 55d398e

Browse files
committed
recover interrupted checks and harden popup check flow
- recover stale `checkInProgress` state after service worker restart/interruption - mark interrupted checks as failed with a clear message instead of leaving popup stuck on "Checking now..." - refresh popup state in a safe `try/finally` flow when `Check now` is triggered - add tests for interrupted runtime-state recovery This fixes the case where the popup could remain blocked after a background check was interrupted or the message flow between popup and service worker was broken.
1 parent 40d93a2 commit 55d398e

5 files changed

Lines changed: 87 additions & 9 deletions

File tree

src/lib/promotions.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,28 @@ import {
3434
} from "./history.js";
3535
import { createPromotionNotifications } from "./notifications.js";
3636
import { fetchPromotionsFromProviders } from "./providers/index.js";
37-
import { getRuntimeState, setRuntimeState } from "./runtime-state.js";
37+
import { buildRecoveredRuntimeState, getRuntimeState, setRuntimeState } from "./runtime-state.js";
3838
import { getSettings } from "./settings.js";
3939
import { readKey, writeLocal } from "./storage.js";
4040
import { createHash, isQuietHoursActive } from "./utils.js";
4141

4242
let activeCheckPromise = null;
4343

44+
export async function recoverInterruptedCheckState(nowTs = Date.now()) {
45+
if (activeCheckPromise) {
46+
return getRuntimeState();
47+
}
48+
49+
const runtimeState = await getRuntimeState();
50+
if (!runtimeState.checkInProgress) {
51+
return runtimeState;
52+
}
53+
54+
const recoveredState = buildRecoveredRuntimeState(runtimeState, nowTs);
55+
await writeLocal({ [STORAGE_KEYS.runtimeState]: recoveredState });
56+
return recoveredState;
57+
}
58+
4459
function getPromotionMatchKey(promotion) {
4560
const stableId = typeof promotion?.stableId === "string" ? promotion.stableId : "";
4661
const promoType = typeof promotion?.promoType === "string" ? promotion.promoType : "";

src/lib/runtime-state.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { STORAGE_KEYS } from "./constants.js";
22
import { readKey, writeLocal } from "./storage.js";
33
import { clamp, safeNumber } from "./utils.js";
44

5+
export const INTERRUPTED_CHECK_MESSAGE = "Previous check was interrupted. Run Check now again.";
6+
57
export const DEFAULT_RUNTIME_STATE = Object.freeze({
68
checkInProgress: false,
79
lastCheckStartedAt: 0,
@@ -41,6 +43,21 @@ export function sanitizeRuntimeState(raw = {}) {
4143
};
4244
}
4345

46+
export function buildRecoveredRuntimeState(raw = {}, nowTs = Date.now()) {
47+
const runtimeState = sanitizeRuntimeState(raw);
48+
if (!runtimeState.checkInProgress) {
49+
return runtimeState;
50+
}
51+
52+
return sanitizeRuntimeState({
53+
...runtimeState,
54+
checkInProgress: false,
55+
lastCheckFinishedAt: Math.max(runtimeState.lastCheckFinishedAt, safeNumber(nowTs, 0)),
56+
lastCheckOutcome: "error",
57+
lastErrorMessage: runtimeState.lastErrorMessage || INTERRUPTED_CHECK_MESSAGE
58+
});
59+
}
60+
4461
export async function getRuntimeState() {
4562
const stored = await readKey(STORAGE_KEYS.runtimeState, DEFAULT_RUNTIME_STATE);
4663
return sanitizeRuntimeState(stored);

src/service-worker.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
dismissPromotionById,
88
getIgnoredPromotions,
99
markAllPromotionsRead,
10+
recoverInterruptedCheckState,
1011
restorePromotionById,
1112
runPromotionCheck
1213
} from "./lib/promotions.js";
@@ -23,7 +24,7 @@ import {
2324
async function bootstrap() {
2425
await ensureSchemaVersion();
2526
const settings = await getSettings();
26-
const runtimeState = await getRuntimeState();
27+
const runtimeState = await recoverInterruptedCheckState();
2728

2829
const [historyEntries, latestPromotions] = await Promise.all([
2930
getHistoryEntries(),
@@ -77,7 +78,7 @@ async function runAndReschedule(trigger) {
7778
async function getPopupData() {
7879
const [settings, runtimeState, latestPromotions] = await Promise.all([
7980
getSettings(),
80-
getRuntimeState(),
81+
recoverInterruptedCheckState(),
8182
getLatestPromotionEntries()
8283
]);
8384
return {
@@ -91,7 +92,7 @@ async function getPopupData() {
9192
async function getOptionsData() {
9293
const [settings, runtimeState, ignoredPromotions] = await Promise.all([
9394
getSettings(),
94-
getRuntimeState(),
95+
recoverInterruptedCheckState(),
9596
getIgnoredPromotions()
9697
]);
9798
return {
@@ -105,7 +106,7 @@ async function getOptionsData() {
105106
async function getHistoryData() {
106107
const [settings, runtimeState, historyEntries, latestPromotions, ignoredPromotions] = await Promise.all([
107108
getSettings(),
108-
getRuntimeState(),
109+
recoverInterruptedCheckState(),
109110
getHistoryEntries(),
110111
getLatestPromotionEntries(),
111112
getIgnoredPromotions()

src/ui/popup.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,21 @@ async function refresh(temporaryWarning = "") {
158158
checkNowButton.addEventListener("click", async () => {
159159
checkNowButton.disabled = true;
160160
statusText.textContent = "Checking now...";
161-
const response = await sendMessage("CHECK_NOW");
162-
if (!response?.ok) {
163-
renderWarning(response?.error || "Manual check failed.");
161+
try {
162+
const response = await sendMessage("CHECK_NOW");
163+
if (!response?.ok) {
164+
renderWarning(response?.error || "Manual check failed.");
165+
}
166+
} catch (error) {
167+
renderWarning(error instanceof Error ? error.message : "Manual check failed.");
168+
} finally {
169+
try {
170+
await refresh();
171+
} catch (error) {
172+
checkNowButton.disabled = false;
173+
renderWarning(error instanceof Error ? error.message : String(error));
174+
}
164175
}
165-
await refresh();
166176
});
167177

168178
optionsLink.addEventListener("click", async (event) => {

tests/runtime-state.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { buildRecoveredRuntimeState, INTERRUPTED_CHECK_MESSAGE } from "../src/lib/runtime-state.js";
5+
6+
test("buildRecoveredRuntimeState clears a stale in-progress check", () => {
7+
const recovered = buildRecoveredRuntimeState({
8+
checkInProgress: true,
9+
lastCheckStartedAt: 100,
10+
lastCheckFinishedAt: 0,
11+
lastCheckOutcome: "running",
12+
lastErrorMessage: "",
13+
unreadCount: 3
14+
}, 250);
15+
16+
assert.equal(recovered.checkInProgress, false);
17+
assert.equal(recovered.lastCheckOutcome, "error");
18+
assert.equal(recovered.lastCheckFinishedAt, 250);
19+
assert.equal(recovered.lastErrorMessage, INTERRUPTED_CHECK_MESSAGE);
20+
assert.equal(recovered.unreadCount, 3);
21+
});
22+
23+
test("buildRecoveredRuntimeState keeps settled checks unchanged", () => {
24+
const recovered = buildRecoveredRuntimeState({
25+
checkInProgress: false,
26+
lastCheckFinishedAt: 100,
27+
lastCheckOutcome: "success",
28+
lastErrorMessage: ""
29+
}, 250);
30+
31+
assert.equal(recovered.checkInProgress, false);
32+
assert.equal(recovered.lastCheckOutcome, "success");
33+
assert.equal(recovered.lastCheckFinishedAt, 100);
34+
assert.equal(recovered.lastErrorMessage, "");
35+
});

0 commit comments

Comments
 (0)