Skip to content

Commit d1428ea

Browse files
fix(billing): reserve quota before spending, measure listening time, serialize lines
Addresses review feedback on #90. Quota was non-atomic (P1) `read → +1 → put` after the provider call meant every request arriving while another was in flight read the same pre-spend count, passed the cap, and spent. Now every metered route reserves a slot BEFORE calling a provider and refunds it if the provider never ran, and read-modify-write is serialized per key. KV has no compare-and-set, so the per-key chain only covers one isolate; that collapses the dominant case (one learner's own concurrent calls) but cross-colo interleaving still needs a Durable Object — filed as follow-up. Applies to coach, coach/stream, pick-word, extract-words, tts, and notebook summaries. Realtime listening was billed by sampling, not measuring (P1) The heartbeat charged a flat five minutes based only on whether playback happened to be paused when the timer fired: playing 4:59 then pausing cost nothing, resuming just before a tick cost the full five. Elapsed unpaused time is now accumulated, banked the moment playback flips, and flushed on pause, stop, and mode change so partial intervals aren't discarded. A failed report puts the time back rather than giving it away. The backend accepts fractional minutes (usage is already stored as a float) instead of rounding every partial minute away. Subtitle lines could overlap (P1) Processing now makes background round-trips before deciding anything, so several lines could each clear `overlay.isOpen()` while the others awaited, each spend quota, then each replace the previous card. One line is processed at a time; while one runs only the newest arrival is held. Eligibility is rechecked after the awaited pick, and the card is awaited so the gate covers its whole lifetime rather than only up to mount. Stale realtime socket on a cache-key flap (P2) A key arriving while `getWsKey()` was in flight left a stray connection whose later errors could stop a healthy cached session. Sessions now carry a mode generation that async work rechecks after every await; the reconnect backoff checks it too. UI correctness (P2) - The limit sheet showed AI and Listening meters even when the *auto* bucket was what ran out, reporting balances for two features that still worked and none for the one that stopped. The exhausted meter now leads. - A failed AI-usage fetch became a real-looking 0/0 meter, so the sheet claimed the learner had used up "0" messages. Missing meters stay null and fall back to plan-neutral copy; the popup says usage is unavailable rather than drawing a bar from nothing. Docs (P2) web/.env.example still pinned 40/150/600, overriding the new defaults for anyone copying it, and backend/README.md still documented free as 480 min. Tests: reservation semantics including two concurrency cases (verified non-vacuous by removing the lock), and the listening-billing pins extended to cover measured time, flush-on-transition, failure rollback, and the mode-race guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 373cb14 commit d1428ea

23 files changed

Lines changed: 676 additions & 189 deletions

File tree

backend/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@ Pro ($8/mo), and Max ($16/mo) tiers. It does three small jobs:
99
the Worker enforces per-tier caps without calling Clerk per request. Billing
1010
itself (Dodo → Clerk metadata) is handled in the web app, not here.
1111
2. **Metering** — counts listening minutes per user per calendar month in
12-
Workers KV and enforces the per-tier cap (`CAP_MINUTES` free = 480/8 h,
12+
Workers KV and enforces the per-tier cap (`CAP_MINUTES` free = 600/10 h,
1313
`PRO_CAP_MINUTES` = 1200/20 h, `MAX_CAP_MINUTES` = 3600/60 h; see `plan.ts`).
14+
A session is billed by exactly one path: the shared-cache path charges the
15+
real audio duration of each chunk it transcribes, while the realtime path —
16+
where audio goes straight to OpenAI and never reaches this Worker — reports
17+
measured unpaused playback to `POST /v1/usage/heartbeat` (fractional minutes
18+
accepted, clamped to 10 per report). Running both double-charged the same
19+
playback. `GET /v1/usage` returns the current listening balance.
1420
3. **Shared transcript cache** — users share a per-episode transcript cache.
1521
Cache hits return stored segments with no audio upload; cache misses are
1622
transcribed server-side once via Whisper and stored in Workers KV.

backend/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ export default {
129129
if (!auth.ok) return auth.response;
130130

131131
const body = (await req.json().catch(() => ({}))) as { minutes?: number };
132-
const minutes = Math.max(0, Math.min(10, Math.round(Number(body.minutes) || 0)));
132+
// Fractional minutes are accepted (usage is stored as a float): the
133+
// client measures real unpaused playback, and rounding here threw away
134+
// every partial minute — or rounded a 31-second stretch up to a whole
135+
// one. Still clamped to [0, 10] per report so a bad client can't
136+
// charge an arbitrary amount in one call.
137+
const minutes = Math.max(0, Math.min(10, Number(body.minutes) || 0));
133138
const cap = capMinutesForPlan(env, effectivePlanFromProfile(auth.profile));
134139
let used: number;
135140
try {

extension/background.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,12 @@
404404

405405
// src/lib/usage-client.ts
406406
function meter(used, limit) {
407-
const u = Math.max(0, Math.floor(Number(used) || 0));
408-
const l = Math.max(0, Math.floor(Number(limit) || 0));
409-
return { used: u, limit: l, left: Math.max(0, l - u) };
407+
const u = Number(used);
408+
const l = Number(limit);
409+
if (!Number.isFinite(u) || !Number.isFinite(l)) return null;
410+
const usedN = Math.max(0, Math.floor(u));
411+
const limitN = Math.max(0, Math.floor(l));
412+
return { used: usedN, limit: limitN, left: Math.max(0, limitN - usedN) };
410413
}
411414
async function fetchUsage() {
412415
const token = await getSyncToken();
@@ -424,8 +427,10 @@
424427
return {
425428
plan: raw.plan || listen.plan || "free",
426429
unlimited: !!raw.unlimited,
427-
ai: meter(raw.ai?.used, raw.ai?.limit),
428-
auto: meter(raw.auto?.used, raw.auto?.limit),
430+
// Each half is independent: the AI endpoint can fail while the listening
431+
// one answers (and vice versa). Whatever is missing stays null.
432+
ai: aiData ? meter(raw.ai?.used, raw.ai?.limit) : null,
433+
auto: aiData ? meter(raw.auto?.used, raw.auto?.limit) : null,
429434
listening: listenData ? meter(listen.usedMinutes, listen.capMinutes) : null,
430435
tiers: raw.tiers || null
431436
};

extension/content.js

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,11 +2602,14 @@
26022602
card.appendChild(title);
26032603
card.appendChild(body);
26042604
if (usage) {
2605-
const meters = [
2606-
["AI messages", usage.ai.limit > 0 ? usage.ai : null, "calls"],
2607-
["Listening Mode", usage.listening, "minutes"]
2608-
];
2609-
for (const [label, m, unit] of meters) {
2605+
const all = {
2606+
ai: ["AI messages", usage.ai, "calls"],
2607+
auto: ["Word picking & audio", usage.auto, "calls"],
2608+
listening: ["Listening Mode", usage.listening, "minutes"]
2609+
};
2610+
const order = [kind, ...["ai", "auto", "listening"].filter((k) => k !== kind)];
2611+
for (const k of order) {
2612+
const [label, m, unit] = all[k];
26102613
if (m && m.limit > 0) card.appendChild(buildMeter(label, m.used, m.limit, unit));
26112614
}
26122615
}
@@ -3246,6 +3249,8 @@
32463249
let cacheKey2 = "";
32473250
let listeningActive = false;
32483251
let hourlyCapNotified = false;
3252+
let lineInFlight = false;
3253+
let queuedLine = null;
32493254
let cachePollTimer = null;
32503255
let lastCacheCueKey = "";
32513256
let playbackRelayTimer = null;
@@ -3433,6 +3438,21 @@
34333438
}
34343439
async function onLine(text, context) {
34353440
if (pipelineDisabled) return;
3441+
if (lineInFlight) {
3442+
queuedLine = { text, context };
3443+
return;
3444+
}
3445+
lineInFlight = true;
3446+
try {
3447+
await processLine(text, context);
3448+
} finally {
3449+
lineInFlight = false;
3450+
}
3451+
const next = queuedLine;
3452+
queuedLine = null;
3453+
if (next) await onLine(next.text, next.context);
3454+
}
3455+
async function processLine(text, context) {
34363456
settings = await getSettings();
34373457
if (settings.pauseMode === "off") return;
34383458
const siteKey = adapter ? adapter.name : "generic";
@@ -3493,6 +3513,10 @@
34933513
log("no target word in:", normalized);
34943514
return;
34953515
}
3516+
if (isOpen()) {
3517+
log("skipped line (card opened while picking):", normalized.slice(0, 40));
3518+
return;
3519+
}
34963520
const stats = await getStats();
34973521
const now = Date.now();
34983522
const cardTimestamps = stats.cardTimestamps || [];
@@ -3519,7 +3543,7 @@
35193543
}
35203544
}
35213545
log("showing card for:", target.token.base);
3522-
void handleCard(target, normalized, tokens, context).catch((err) => {
3546+
await handleCard(target, normalized, tokens, context).catch((err) => {
35233547
warn("handleCard failed:", err);
35243548
dismissAgent();
35253549
});

extension/offscreen/offscreen.js

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,59 @@
2929
this.code = code;
3030
}
3131
};
32+
var MIN_FLUSH_MS = 3e4;
33+
var HEARTBEAT_INTERVAL_MS = 5 * 60 * 1e3;
34+
function accrueListening(session, now = Date.now()) {
35+
if (session.billedFromMs == null) return;
36+
const delta = now - session.billedFromMs;
37+
if (delta > 0) session.pendingBillMs += delta;
38+
session.billedFromMs = now;
39+
}
40+
function setListeningClock(session, playing) {
41+
if (session.auth.kind !== "cloud" || session.useCache) {
42+
session.billedFromMs = null;
43+
return;
44+
}
45+
if (playing) {
46+
if (session.billedFromMs == null) session.billedFromMs = Date.now();
47+
return;
48+
}
49+
accrueListening(session);
50+
session.billedFromMs = null;
51+
}
52+
async function flushListening(session, final = false) {
53+
if (session.auth.kind !== "cloud") return;
54+
accrueListening(session);
55+
if (session.pendingBillMs <= 0) return;
56+
if (!final && session.pendingBillMs < MIN_FLUSH_MS) return;
57+
const { backendUrl, syncToken } = session.auth;
58+
const minutes = Math.min(10, session.pendingBillMs / 6e4);
59+
const sentMs = minutes * 6e4;
60+
session.pendingBillMs = Math.max(0, session.pendingBillMs - sentMs);
61+
try {
62+
const res = await fetch(backendUrl + "/v1/usage/heartbeat", {
63+
method: "POST",
64+
headers: { "Content-Type": "application/json", Authorization: "Bearer " + syncToken },
65+
body: JSON.stringify({ minutes })
66+
});
67+
if (res.status === 429) {
68+
olog("monthly cap reached \u2014 stopping");
69+
report(session.tabId, "quota-exceeded", "monthly listening hours used up");
70+
stop(session.tabId);
71+
}
72+
} catch (err) {
73+
session.pendingBillMs += sentMs;
74+
olog("heartbeat failed (will retry):", String(err));
75+
}
76+
}
3277
function startHeartbeat(session) {
3378
if (session.auth.kind !== "cloud") return;
3479
if (session.useCache) return;
3580
if (session.heartbeat) return;
36-
const { backendUrl, syncToken } = session.auth;
37-
session.heartbeat = setInterval(async () => {
38-
if (!session.active || session.playbackPaused) return;
39-
try {
40-
const res = await fetch(backendUrl + "/v1/usage/heartbeat", {
41-
method: "POST",
42-
headers: { "Content-Type": "application/json", Authorization: "Bearer " + syncToken },
43-
body: JSON.stringify({ minutes: 5 })
44-
});
45-
if (res.status === 429) {
46-
olog("monthly cap reached \u2014 stopping");
47-
report(session.tabId, "quota-exceeded", "monthly listening hours used up");
48-
stop(session.tabId);
49-
}
50-
} catch (err) {
51-
olog("heartbeat failed (will retry):", String(err));
52-
}
53-
}, 5 * 60 * 1e3);
81+
setListeningClock(session, session.active && !session.playbackPaused);
82+
session.heartbeat = setInterval(() => {
83+
void flushListening(session);
84+
}, HEARTBEAT_INTERVAL_MS);
5485
}
5586
function closeRealtimeSocket(session) {
5687
const ws = session.ws;
@@ -67,11 +98,14 @@
6798
}
6899
}
69100
function applyCacheMode(session) {
101+
session.modeGeneration += 1;
70102
if (session.useCache) {
71103
if (session.heartbeat) {
72104
clearInterval(session.heartbeat);
73105
session.heartbeat = null;
74106
}
107+
void flushListening(session, true);
108+
session.billedFromMs = null;
75109
closeRealtimeSocket(session);
76110
if (!session.chunkTimer) {
77111
session.chunkTimer = setInterval(() => {
@@ -106,8 +140,10 @@
106140
}
107141
function onPlaybackUpdate(session, time, paused) {
108142
const prev = session.playbackTime;
143+
const wasPaused = session.playbackPaused;
109144
session.playbackTime = time;
110145
session.playbackPaused = paused;
146+
if (paused !== wasPaused) setListeningClock(session, !paused);
111147
if (Math.abs(time - prev) > SEEK_THRESHOLD_SEC) {
112148
olog("playback seek detected", prev, "\u2192", time, "\u2014 resetting audio buffer");
113149
resetAudioBuffer(session, time);
@@ -285,7 +321,10 @@
285321
chunkStarted: false,
286322
transcribing: false,
287323
chunkTimer: null,
288-
useCache
324+
useCache,
325+
billedFromMs: null,
326+
pendingBillMs: 0,
327+
modeGeneration: 0
289328
};
290329
sessions[tabId] = session;
291330
proc.onaudioprocess = (e) => {
@@ -315,7 +354,13 @@
315354
applyCacheMode(session);
316355
}
317356
async function connectWS(session) {
357+
const generation = session.modeGeneration;
358+
const stale = () => !session.active || session.useCache || session.modeGeneration !== generation;
318359
const wsKey = await getWsKey(session);
360+
if (stale()) {
361+
olog("dropping realtime connect \u2014 session switched modes while authorizing");
362+
return;
363+
}
319364
let ws;
320365
try {
321366
ws = new WebSocket(RT_URL, ["realtime", "openai-insecure-api-key." + wsKey]);
@@ -376,7 +421,7 @@
376421
if (ev.code !== 4001 && session.reconnects < 3) {
377422
session.reconnects += 1;
378423
setTimeout(() => {
379-
if (session.active) {
424+
if (session.active && !session.useCache) {
380425
connectWS(session).catch((err) => {
381426
const code = err instanceof CodedError ? err.code : "capture-failed";
382427
report(session.tabId, code, String(err && err.message || err));
@@ -394,6 +439,8 @@
394439
const session = sessions[tabId];
395440
if (!session) return;
396441
session.active = false;
442+
setListeningClock(session, false);
443+
void flushListening(session, true);
397444
if (session.heartbeat) clearInterval(session.heartbeat);
398445
if (session.chunkTimer) clearInterval(session.chunkTimer);
399446
if (session.useCache && session.pcmBuffer.length && !session.playbackPaused) {

extension/popup/popup.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,9 @@
413413
const fmt = (n) => unit === "minutes" ? `${Number.isInteger(n / 60) ? n / 60 : (n / 60).toFixed(1)}h` : n.toLocaleString();
414414
return `<div class="av-meter"><div class="av-meter-row"><span>${esc(label)}</span><span class="av-meter-val">${esc(fmt(Math.min(used, limit)))} / ${esc(fmt(limit))}</span></div><div class="av-meter-track"><div class="${cls}" style="width:${pct}%"></div></div></div>`;
415415
}
416+
function meterLow(m) {
417+
return !!m && m.limit > 0 && m.used / m.limit >= 0.8;
418+
}
416419
async function renderUsage() {
417420
const el = byId("usage");
418421
const token = await getSyncToken();
@@ -431,9 +434,10 @@
431434
return;
432435
}
433436
const planName = usage.plan === "max" ? "Max" : usage.plan === "pro" ? "Pro" : "Free";
434-
const meters = usage.unlimited ? `<p class="av-usage-note">No caps on this account.</p>` : meterMarkup("AI messages", usage.ai.used, usage.ai.limit, "calls") + (usage.listening ? meterMarkup("Listening Mode", usage.listening.used, usage.listening.limit, "minutes") : "");
435-
const aiLow = !usage.unlimited && usage.ai.limit > 0 && usage.ai.used / usage.ai.limit >= 0.8;
436-
const listenLow = !usage.unlimited && !!usage.listening && usage.listening.limit > 0 && usage.listening.used / usage.listening.limit >= 0.8;
437+
const bars = usage.unlimited ? "" : (usage.ai ? meterMarkup("AI messages", usage.ai.used, usage.ai.limit, "calls") : "") + (usage.listening ? meterMarkup("Listening Mode", usage.listening.used, usage.listening.limit, "minutes") : "");
438+
const meters = usage.unlimited ? `<p class="av-usage-note">No caps on this account.</p>` : bars || `<p class="av-usage-note">Usage is unavailable right now.</p>`;
439+
const aiLow = !usage.unlimited && meterLow(usage.ai);
440+
const listenLow = !usage.unlimited && meterLow(usage.listening);
437441
const offer = usage.plan === "free" ? usage.tiers?.pro : usage.plan === "pro" ? usage.tiers?.max : null;
438442
const cta = (aiLow || listenLow) && offer?.checkoutUrl ? `<button id="usage-upgrade" class="av-btn av-btn-primary av-btn-block av-usage-cta" type="button">Upgrade to ${esc(offer.name)} \u2014 ${esc(offer.priceLabel)}</button>` : "";
439443
el.innerHTML = `<div class="av-usage-head"><span class="av-usage-title">This month</span><span class="av-usage-plan">${esc(planName)}</span></div>` + meters + cta;

src/entries/content.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ declare global {
4949
let listeningActive = false;
5050
/** Rate-limits the "hourly card cap" notice to once per rolling window. */
5151
let hourlyCapNotified = false;
52+
/** One subtitle line processed at a time; see onLine. */
53+
let lineInFlight = false;
54+
let queuedLine: { text: string; context?: LineContext } | null = null;
5255
let cachePollTimer: ReturnType<typeof setInterval> | null = null;
5356
let lastCacheCueKey = "";
5457
let playbackRelayTimer: ReturnType<typeof setInterval> | null = null;
@@ -269,8 +272,35 @@ declare global {
269272
return candidate;
270273
}
271274

275+
/**
276+
* Subtitle lines arrive faster than a line takes to process, and processing
277+
* now makes background round-trips (word extraction, word picking) before it
278+
* decides anything. Left unserialized, several lines could each clear the
279+
* `overlay.isOpen()` check while the others were awaiting, each spend quota,
280+
* and then each replace the previous card — `presentWord()` dismisses whatever
281+
* is up. So: one line in flight at a time, and while one is running only the
282+
* newest arrival is held. Older queued lines are dropped on purpose; their
283+
* moment on screen has passed, and showing a card for them would be wrong
284+
* even if it were free.
285+
*/
272286
async function onLine(text: string, context?: LineContext): Promise<void> {
273287
if (pipelineDisabled) return;
288+
if (lineInFlight) {
289+
queuedLine = { text, context };
290+
return;
291+
}
292+
lineInFlight = true;
293+
try {
294+
await processLine(text, context);
295+
} finally {
296+
lineInFlight = false;
297+
}
298+
const next = queuedLine;
299+
queuedLine = null;
300+
if (next) await onLine(next.text, next.context);
301+
}
302+
303+
async function processLine(text: string, context?: LineContext): Promise<void> {
274304

275305
settings = await storage.getSettings();
276306

@@ -340,6 +370,14 @@ declare global {
340370
);
341371
if (!target) { log("no target word in:", normalized); return; }
342372

373+
// pickTargetSmart just awaited a network round-trip; a card may have opened
374+
// in the meantime (a review can be triggered from the panel). Re-check
375+
// rather than trusting the read from before the await.
376+
if (overlay.isOpen()) {
377+
log("skipped line (card opened while picking):", normalized.slice(0, 40));
378+
return;
379+
}
380+
343381
const stats = await storage.getStats();
344382
const now = Date.now();
345383
const cardTimestamps = stats.cardTimestamps || [];
@@ -371,7 +409,11 @@ declare global {
371409
}
372410
log("showing card for:", target.token.base);
373411

374-
void handleCard(target, normalized, tokens, context).catch((err) => {
412+
// Awaited, so the in-flight gate covers the card's whole lifetime — not just
413+
// up to the moment it mounts. Previously this was fire-and-forget, leaving a
414+
// window where the next line was already picking a word before the card had
415+
// rendered and `overlay.isOpen()` could see it.
416+
await handleCard(target, normalized, tokens, context).catch((err) => {
375417
warn("handleCard failed:", err);
376418
overlay.dismissAgent();
377419
});

0 commit comments

Comments
 (0)