diff --git a/src/lib/alarms/csfloat_trade_pings.test.ts b/src/lib/alarms/csfloat_trade_pings.test.ts new file mode 100644 index 00000000..eda42bae --- /dev/null +++ b/src/lib/alarms/csfloat_trade_pings.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, it, vi} from 'vitest'; +import {HasPermissions} from '../bridge/handlers/has_permissions'; +import {pingTradeStatus} from './csfloat_trade_pings'; + +vi.hoisted(() => vi.stubGlobal('chrome', {storage: {local: {}}})); +vi.mock('../storage/store', () => ({gStore: {setWithStorage: vi.fn()}})); +vi.mock('../bridge/handlers/has_permissions', () => ({ + HasPermissions: {handleRequest: vi.fn().mockResolvedValue({granted: false})}, +})); + +describe('pingTradeStatus', () => { + it('shares one run between overlapping background triggers, but not page bridge calls', async () => { + await Promise.all([pingTradeStatus(), pingTradeStatus()]); + expect(HasPermissions.handleRequest).toHaveBeenCalledTimes(1); + + await Promise.all([pingTradeStatus(), pingTradeStatus('76561198000000000')]); + expect(HasPermissions.handleRequest).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 7bd4d0b6..11c1b134 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -22,7 +22,25 @@ import {FetchSlimTrades} from '../bridge/handlers/fetch_slim_trades'; export const PING_CSFLOAT_TRADE_STATUS_ALARM_NAME = 'ping_csfloat_trade_status_alarm'; -export async function pingTradeStatus(expectedSteamID?: string) { +let inFlightPing: Promise | null = null; + +/** + * Shares one run between overlapping background triggers (alarm, service worker wake-up) so they don't + * duplicate the same pings and notary proofs. Page bridge calls pass a steam ID and run on their own, since + * joining an in-flight run would ignore that steam ID and return trade data fetched before the user's action. + */ +export function pingTradeStatus(expectedSteamID?: string): Promise { + if (expectedSteamID) { + return runPingTradeStatus(expectedSteamID); + } + + inFlightPing ??= runPingTradeStatus().finally(() => { + inFlightPing = null; + }); + return inFlightPing; +} + +async function runPingTradeStatus(expectedSteamID?: string) { await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_TRADE_PING_ATTEMPT, Date.now()); const hasPermissions = await HasPermissions.handleRequest( diff --git a/src/lib/alarms/notary.test.ts b/src/lib/alarms/notary.test.ts index af02c787..da40d0b6 100644 --- a/src/lib/alarms/notary.test.ts +++ b/src/lib/alarms/notary.test.ts @@ -4,6 +4,7 @@ import {isBackgroundNotaryOfferStateEnabled, isBackgroundNotaryRollbackEnabled} vi.mock('../bridge/handlers/fetch_notary_meta', () => ({FetchNotaryMeta: {handleRequest: vi.fn()}})); vi.mock('../utils/detect', () => ({isFirefox: () => false})); +vi.stubGlobal('chrome', {offscreen: {Reason: {WORKERS: 'WORKERS'}}}); describe('background notary meta', () => { it('caches the meta for 30 minutes, but not failed fetches', async () => { diff --git a/src/lib/alarms/notary.ts b/src/lib/alarms/notary.ts index 8ae63620..1e678231 100644 --- a/src/lib/alarms/notary.ts +++ b/src/lib/alarms/notary.ts @@ -25,7 +25,8 @@ export function isBackgroundNotaryOfferStateEnabled(): Promise { } async function isBackgroundNotaryEnabled(setting: keyof NotaryMeta): Promise { - if (isFirefox()) { + // Some older Chrome versions don't define the offscreen WORKERS reason, so the offscreen document can't be created + if (isFirefox() || !chrome.offscreen?.Reason?.WORKERS) { return false; } diff --git a/src/lib/bridge/handlers/notary_prove.ts b/src/lib/bridge/handlers/notary_prove.ts index cbbd110f..7584886d 100644 --- a/src/lib/bridge/handlers/notary_prove.ts +++ b/src/lib/bridge/handlers/notary_prove.ts @@ -44,5 +44,6 @@ export const NotaryProve = MaxConcurrency( }; } ), - 2 + // The offscreen document is periodically closed after a proof responds, which kills any other in-flight proof + 1 ); diff --git a/src/offscreen/handlers/notary_prove.ts b/src/offscreen/handlers/notary_prove.ts index e65ab635..913c1349 100644 --- a/src/offscreen/handlers/notary_prove.ts +++ b/src/offscreen/handlers/notary_prove.ts @@ -8,7 +8,7 @@ import {getSteamRequestURL} from '../../lib/notary/utils'; import * as Comlink from 'comlink'; import {environment} from '../../environment'; import type {IoChannel, LoggingLevel, Method, Prover as TProver, ProverMode, Reveal} from '@csfloat/tlsn-wasm'; -import {NotarySessionClient} from './notary_session_client'; +import {NotarySessionClient, toWebSocketUrl} from './notary_session_client'; import {Remote} from 'comlink'; import {wait} from '../../lib/utils/snips'; import {WebSocketIoChannel} from '../io_channel'; @@ -146,10 +146,6 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< } ); -function toWebSocketUrl(url: string): string { - return url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'); -} - /** * Estimates the total request byte size over the wire if sent over HTTP 1.1 * diff --git a/src/offscreen/handlers/notary_session_client.ts b/src/offscreen/handlers/notary_session_client.ts index fe200bab..0f079fee 100644 --- a/src/offscreen/handlers/notary_session_client.ts +++ b/src/offscreen/handlers/notary_session_client.ts @@ -1,6 +1,11 @@ import type {VerificationResults} from './types'; import {environment} from '../../environment'; +// Older Chrome versions reject http(s) URLs in the WebSocket constructor +export function toWebSocketUrl(url: string): string { + return url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'); +} + type ClientMessage = { type: 'register'; maxRecvData: number; @@ -47,7 +52,7 @@ export class NotarySessionClient { } private static buildSessionUrl(token?: string): string { - let url = `${environment.notary.tlsn}/session`; + let url = toWebSocketUrl(`${environment.notary.tlsn}/session`); if (token) { url += `?token=${token}`; } diff --git a/src/offscreen/utils.ts b/src/offscreen/utils.ts index 9c470535..e5c73e3d 100644 --- a/src/offscreen/utils.ts +++ b/src/offscreen/utils.ts @@ -3,38 +3,36 @@ const OFFSCREEN_DOCUMENT_PATH = '/src/offscreen.html'; let creating: Promise | null; export async function openOffscreenDocument(): Promise { - const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH); - const hasExistingContext = await chrome.offscreen.hasDocument(); + // Shared so a concurrent caller can't see the document before it has registered its message listener + creating ??= createOffscreenDocument().finally(() => { + creating = null; + }); + await creating; +} - if (hasExistingContext) { +async function createOffscreenDocument(): Promise { + if (await chrome.offscreen.hasDocument()) { return; } - if (creating) { - await creating; - } else { - creating = (async () => { - const ready = new Promise((resolve) => { - const listener = (request: any, sender: chrome.runtime.MessageSender) => { - if (request?.type === 'offscreen_ready' && sender.url === offscreenUrl) { - chrome.runtime.onMessage.removeListener(listener); - resolve(); - } - }; - chrome.runtime.onMessage.addListener(listener); - }); - - await chrome.offscreen.createDocument({ - url: OFFSCREEN_DOCUMENT_PATH, - reasons: [chrome.offscreen.Reason.WORKERS], - justification: 'Workers for multi-threading', - }); - - await ready; - })(); - await creating; - creating = null; - } + const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH); + const ready = new Promise((resolve) => { + const listener = (request: any, sender: chrome.runtime.MessageSender) => { + if (request?.type === 'offscreen_ready' && sender.url === offscreenUrl) { + chrome.runtime.onMessage.removeListener(listener); + resolve(); + } + }; + chrome.runtime.onMessage.addListener(listener); + }); + + await chrome.offscreen.createDocument({ + url: OFFSCREEN_DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.WORKERS], + justification: 'Workers for multi-threading', + }); + + await ready; } export async function closeOffscreenDocument(): Promise {