From dd9fe70cbf2d639ea3ee771a8fa8d452797ecd3b Mon Sep 17 00:00:00 2001 From: Step7750 Date: Fri, 16 Oct 2026 17:39:59 -0600 Subject: [PATCH 1/3] fix: stop overlapping background notary proofs in the extension - Share one pingTradeStatus run between the alarm, service worker wake-up, and page bridge - Run one notary proof at a time so closing the offscreen document can't kill another proof - Share the offscreen creation promise so callers wait for its message listener - Use wss:// for the notary session socket, older Chrome rejects https:// - Skip background proving when the offscreen WORKERS reason is unsupported --- src/lib/alarms/csfloat_trade_pings.test.ts | 19 +++++++ src/lib/alarms/csfloat_trade_pings.ts | 15 +++++- src/lib/alarms/notary.test.ts | 1 + src/lib/alarms/notary.ts | 3 +- src/lib/bridge/handlers/notary_prove.ts | 3 +- src/offscreen/handlers/notary_prove.ts | 6 +-- .../handlers/notary_session_client.ts | 7 ++- src/offscreen/utils.ts | 54 +++++++++---------- 8 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 src/lib/alarms/csfloat_trade_pings.test.ts 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..7b91c271 --- /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 triggers', async () => { + await Promise.all([pingTradeStatus(), pingTradeStatus('76561198000000000')]); + expect(HasPermissions.handleRequest).toHaveBeenCalledTimes(1); + + await pingTradeStatus(); + expect(HasPermissions.handleRequest).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 7bd4d0b6..3177a368 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -22,7 +22,20 @@ 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 triggers (alarm, service worker wake-up, page bridge), otherwise + * each would build the same notary proofs concurrently and collide in the offscreen document. + */ +export function pingTradeStatus(expectedSteamID?: string): Promise { + inFlightPing ??= runPingTradeStatus(expectedSteamID).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..9749e8a6 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()) { + // The offscreen WORKERS reason needs a newer Chrome than our minimum version, so older ones can't prove + 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 { From 2c6ac0b208d0fa1f7756fa1ce69f098602158e06 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Fri, 16 Oct 2026 17:42:10 -0600 Subject: [PATCH 2/3] docs: describe the missing WORKERS offscreen reason as observed --- src/lib/alarms/notary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/alarms/notary.ts b/src/lib/alarms/notary.ts index 9749e8a6..1e678231 100644 --- a/src/lib/alarms/notary.ts +++ b/src/lib/alarms/notary.ts @@ -25,7 +25,7 @@ export function isBackgroundNotaryOfferStateEnabled(): Promise { } async function isBackgroundNotaryEnabled(setting: keyof NotaryMeta): Promise { - // The offscreen WORKERS reason needs a newer Chrome than our minimum version, so older ones can't prove + // 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; } From b0a15a4b449baa776c46c0cc9ad59d5be9725f5e Mon Sep 17 00:00:00 2001 From: Step7750 Date: Mon, 19 Oct 2026 15:27:40 -0600 Subject: [PATCH 3/3] fix: run page bridge trade pings on their own --- src/lib/alarms/csfloat_trade_pings.test.ts | 8 ++++---- src/lib/alarms/csfloat_trade_pings.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.test.ts b/src/lib/alarms/csfloat_trade_pings.test.ts index 7b91c271..eda42bae 100644 --- a/src/lib/alarms/csfloat_trade_pings.test.ts +++ b/src/lib/alarms/csfloat_trade_pings.test.ts @@ -9,11 +9,11 @@ vi.mock('../bridge/handlers/has_permissions', () => ({ })); describe('pingTradeStatus', () => { - it('shares one run between overlapping triggers', async () => { - await Promise.all([pingTradeStatus(), pingTradeStatus('76561198000000000')]); + 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 pingTradeStatus(); - expect(HasPermissions.handleRequest).toHaveBeenCalledTimes(2); + 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 3177a368..11c1b134 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -25,11 +25,16 @@ export const PING_CSFLOAT_TRADE_STATUS_ALARM_NAME = 'ping_csfloat_trade_status_a let inFlightPing: Promise | null = null; /** - * Shares one run between overlapping triggers (alarm, service worker wake-up, page bridge), otherwise - * each would build the same notary proofs concurrently and collide in the offscreen document. + * 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 { - inFlightPing ??= runPingTradeStatus(expectedSteamID).finally(() => { + if (expectedSteamID) { + return runPingTradeStatus(expectedSteamID); + } + + inFlightPing ??= runPingTradeStatus().finally(() => { inFlightPing = null; }); return inFlightPing;