From eca082bac9bdd2c4f9dc49977575e34b95ff601b Mon Sep 17 00:00:00 2001 From: Vitalii Zurian Date: Mon, 17 Aug 2026 10:06:40 +0200 Subject: [PATCH 1/3] Make rich presence localization optional --- README.md | 8 ++++++++ components/friends.js | 5 +++++ resources/default_options.js | 2 ++ 3 files changed, 15 insertions(+) diff --git a/README.md b/README.md index 8ffaf77c..9f3e9f35 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,14 @@ Added in 4.0.0. Defaults to `english`. +### localizeRichPresence + +Set this to `false` to disable localization of `rich_presence_string` in [`user`](#user) event data and in +`requestRichPresence`. When disabled, `SteamUser` won't request rich presence localization tokens from Steam and +localized strings will be empty. + +Defaults to `true`. + ### webCompatibilityMode If you're having trouble connecting to Steam (e.g. through a firewall or a proxy), set this to `true`. When in web diff --git a/components/friends.js b/components/friends.js index 41d13878..883be5b9 100644 --- a/components/friends.js +++ b/components/friends.js @@ -919,6 +919,11 @@ class SteamUserFriends extends SteamUserFamilySharing { */ _getRPLocalizedString(appid, tokens, language) { return new Promise(async (resolve, reject) => { + if (!this.options.localizeRichPresence) { + // Localization is disabled + return resolve(''); + } + if (!tokens.steam_display) { // Nothing to do here return reject(); diff --git a/resources/default_options.js b/resources/default_options.js index bdd51483..f54ea03f 100644 --- a/resources/default_options.js +++ b/resources/default_options.js @@ -18,6 +18,7 @@ const EMachineIDType = require('./EMachineIDType.js'); * @property {string|null} [socksProxy=null] * @property {EConnectionProtocol} [protocol] * @property {string} [language='english'] + * @property {boolean} [localizeRichPresence=true] * @property {boolean} [webCompatibilityMode=false] * @property {boolean} [saveAppTickets=true] * @property {boolean} [renewRefreshTokens=false] @@ -37,6 +38,7 @@ module.exports = { socksProxy: null, protocol: EConnectionProtocol.Auto, language: 'english', + localizeRichPresence: true, webCompatibilityMode: false, saveAppTickets: true, renewRefreshTokens: false From d51db2d23a54a9faa8d31af55c385f95cc4414c4 Mon Sep 17 00:00:00 2001 From: Vitalii Zurian Date: Mon, 17 Aug 2026 10:10:51 +0200 Subject: [PATCH 2/3] reproduce throwaway scirpts --- leak-proof-unbounded.js | 120 +++++++++++++++++++++++++++++++++++++ leak-repro-disabled.js | 113 +++++++++++++++++++++++++++++++++++ leak-repro-enabled.js | 127 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 leak-proof-unbounded.js create mode 100644 leak-repro-disabled.js create mode 100644 leak-repro-enabled.js diff --git a/leak-proof-unbounded.js b/leak-proof-unbounded.js new file mode 100644 index 00000000..eb52d696 --- /dev/null +++ b/leak-proof-unbounded.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Proof that the rich-presence localization cache leak is UNBOUNDED and RETAINED + * (i.e. not just GC churn that would be collected). + * + * The doubt this answers: "165 MB might be within what GC will reclaim." + * + * Method: + * 1. Every measurement is taken AFTER two forced global.gc() passes, so each number + * is the LIVE set -- memory GC looked at and could not free. If it were + * collectible garbage, it would already be gone at measurement time. + * 2. We grow the number of distinct cache keys in steps and record the post-GC live + * heap at each step. A leak shows MONOTONIC growth with a ~CONSTANT cost per + * entry (no plateau). A bounded cache would flatten (per-entry cost -> 0). + * 3. Finally we drop the cache reference (this._richPresenceLocalization = {}) and + * GC again. Memory returns to baseline -- proving the growth was pinned solely by + * the cache holding references (a real leak), not by anything GC could reclaim on + * its own while the cache stays alive. + * + * Fully isolated: never connects to Steam; the network call is stubbed. + * + * Run: node leak-proof-unbounded.js + * (Re-execs itself with --expose-gc so measurements are deterministic.) + */ + +// Re-exec with --expose-gc so we can force GC and measure the live set. +if (typeof global.gc !== 'function') { + const {spawnSync} = require('child_process'); + const res = spawnSync(process.execPath, ['--expose-gc', __filename, ...process.argv.slice(2)], {stdio: 'inherit'}); + process.exit(res.status === null ? 1 : res.status); +} + +const assert = require('assert'); +const path = require('path'); +const SteamUser = require(path.join(__dirname, 'index.js')); + +const STEPS = [10000, 20000, 40000, 80000]; // cumulative distinct cache keys +const TOKENS_PER_TITLE = 10; // localization tokens per app + +function liveHeapBytes() { + // Two passes: the first frees young garbage, the second lets anything promoted by + // the first pass be collected too. What remains is the reachable/live set. + global.gc(); + global.gc(); + return process.memoryUsage().heapUsed; +} + +const user = new SteamUser({dataDirectory: null, localizeRichPresence: true}); +user._sendUnified = (method, body, callback) => { + let tokens = [{name: '#status', value: `Playing app ${body.appid}`}]; + for (let i = 0; i < TOKENS_PER_TITLE; i++) { + tokens.push({name: `#token_${i}`, value: `In-game state ${i} on map de_${i} - score ${body.appid % 16}`}); + } + callback({appid: body.appid, token_lists: [{language: body.language, tokens}]}); +}; + +(async () => { + const baseline = liveHeapBytes(); + + let populated = 0; + const rows = []; + for (const target of STEPS) { + for (; populated < target; populated++) { + // appid = populated+1 -> every key distinct -> every call inserts a new entry + try { + await user._getRPLocalizedString(populated + 1, {steam_display: '#status'}, 'english'); + } catch (ex) {} + } + + const live = liveHeapBytes(); + const entries = Object.keys(user._richPresenceLocalization).length; + const retainedMB = (live - baseline) / (1024 * 1024); + const bytesPerEntry = (live - baseline) / entries; + rows.push({entries, retainedMB, bytesPerEntry}); + } + + console.log('=== live heap AFTER forced GC, as distinct cache keys grow ==='); + console.log('entries\tretained(MB)\tbytes/entry'); + for (const r of rows) { + console.log(`${r.entries}\t${r.retainedMB.toFixed(1)}\t\t${Math.round(r.bytesPerEntry)}`); + } + + // (a) Monotonic growth: more keys -> strictly more retained live memory. + for (let i = 1; i < rows.length; i++) { + assert.ok(rows[i].retainedMB > rows[i - 1].retainedMB, + `retained memory did not grow from ${rows[i - 1].entries} to ${rows[i].entries} entries`); + } + + // (b) No plateau: cost-per-entry stays roughly constant across a 8x range of load. + // A bounded cache would show bytes/entry collapsing toward zero as entries grow. + const first = rows[0].bytesPerEntry; + const last = rows[rows.length - 1].bytesPerEntry; + const ratio = last / first; + console.log(`\nbytes/entry first=${Math.round(first)} last=${Math.round(last)} ratio=${ratio.toFixed(2)} (≈1 => linear, unbounded)`); + assert.ok(ratio > 0.7 && ratio < 1.3, + `per-entry cost is not constant (ratio ${ratio.toFixed(2)}); expected linear growth`); + + // (c) Reachability proof: the ONLY thing pinning this memory is the cache. Drop the + // reference, GC, and the live set falls back to baseline. This is the direct + // rebuttal to "GC would reclaim it" -- GC reclaims it the instant, and only when, + // the cache stops referencing it. + const beforeDrop = liveHeapBytes(); + user._richPresenceLocalization = {}; + const afterDrop = liveHeapBytes(); + const reclaimedMB = (beforeDrop - afterDrop) / (1024 * 1024); + const residualMB = (afterDrop - baseline) / (1024 * 1024); + console.log(`\nreclaimed after dropping cache: ${reclaimedMB.toFixed(1)} MB`); + console.log(`residual above baseline : ${residualMB.toFixed(1)} MB`); + + assert.ok(reclaimedMB > rows[rows.length - 1].retainedMB * 0.8, + `dropping the cache did not reclaim the retained memory (${reclaimedMB.toFixed(1)} MB)`); + assert.ok(residualMB < 5, + `heap did not return to baseline after dropping cache (residual ${residualMB.toFixed(1)} MB)`); + + console.log('\nPASS: retained live memory grows linearly with distinct keys (unbounded),'); + console.log(' survives forced GC, and is freed only by releasing the cache reference.'); + process.exit(0); +})(); diff --git a/leak-repro-disabled.js b/leak-repro-disabled.js new file mode 100644 index 00000000..b50009bc --- /dev/null +++ b/leak-repro-disabled.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Memory-leak reproduction: localizeRichPresence DISABLED (new option). + * + * Same fleet workload as leak-repro-enabled.js (a single process serving hundreds of + * Steam clients, each localizing a catalog of titles), but with the new + * `localizeRichPresence: false` option. _getRPLocalizedString short-circuits before + * touching the network or the per-instance _richPresenceLocalization cache, so + * nothing accumulates regardless of how many clients or titles are involved: caches + * stay empty and heap usage stays flat. + * + * Fully isolated: never connects to Steam. The network method used by the + * localization path (`_sendUnified`) is stubbed AND asserted to never be called. + * + * Run: node leak-repro-disabled.js + * (It re-execs itself with --expose-gc so heap measurements are deterministic.) + */ + +// Re-exec with --expose-gc so we can force GC between measurements. +if (typeof global.gc !== 'function') { + const {spawnSync} = require('child_process'); + const res = spawnSync(process.execPath, ['--expose-gc', __filename, ...process.argv.slice(2)], {stdio: 'inherit'}); + process.exit(res.status === null ? 1 : res.status); +} + +const assert = require('assert'); +const path = require('path'); +const SteamUser = require(path.join(__dirname, 'index.js')); + +const SERVED_CLIENTS = 300; // same workload as the "enabled" repro +const DISTINCT_TITLES = 100; +const TOKENS_PER_TITLE = 25; +const MAX_GROWTH_MB = 2; // allow only small allocator noise; no retained growth + +const CATALOG = []; +for (let i = 0; i < DISTINCT_TITLES; i++) { + CATALOG.push(1000 + i); +} + +function measureHeapBytes() { + global.gc(); + global.gc(); + return process.memoryUsage().heapUsed; +} + +let totalNetworkCalls = 0; + +function makeClient() { + // dataDirectory: null keeps everything in memory (no disk storage engine). + const user = new SteamUser({dataDirectory: null, localizeRichPresence: false}); + + // Same stub as the "enabled" repro, but here it must NEVER be reached because + // localization is disabled. Every call is counted and asserted to stay at zero. + user._sendUnified = (method, body, callback) => { + totalNetworkCalls++; + let tokens = [{name: '#status', value: `Playing app ${body.appid}`}]; + for (let i = 0; i < TOKENS_PER_TITLE; i++) { + tokens.push({name: `#token_${i}`, value: `In-game state ${i} on map de_${i} - score ${body.appid % 16}`}); + } + callback({appid: body.appid, token_lists: [{language: body.language, tokens}]}); + }; + + return user; +} + +(async () => { + const clients = []; + for (let c = 0; c < SERVED_CLIENTS; c++) { + clients.push(makeClient()); + } + + const heapBefore = measureHeapBytes(); + + for (const user of clients) { + for (const appid of CATALOG) { + try { + await user._getRPLocalizedString(appid, {steam_display: '#status'}, 'english'); + } catch (ex) { + // Not expected in this repro, but never let a rejection mask the result. + } + } + } + + const heapAfter = measureHeapBytes(); + const grewMB = (heapAfter - heapBefore) / (1024 * 1024); + + let totalEntries = 0; + for (const user of clients) { + totalEntries += Object.keys(user._richPresenceLocalization).length; + } + + console.log('=== localizeRichPresence: false (no leak expected) ==='); + console.log(`served clients : ${SERVED_CLIENTS}`); + console.log(`distinct titles / client : ${DISTINCT_TITLES}`); + console.log(`network calls made : ${totalNetworkCalls}`); + console.log(`cache entries retained : ${totalEntries}`); + console.log(`heapUsed growth : ${grewMB.toFixed(1)} MB`); + + // Localization is disabled: the network is never hit and nothing is cached, + // no matter how many clients or titles are processed. + assert.strictEqual(totalNetworkCalls, 0, + `expected 0 network calls, got ${totalNetworkCalls}`); + assert.strictEqual(totalEntries, 0, + `expected 0 retained cache entries, got ${totalEntries}`); + // Heap stays flat aside from allocator noise. + assert.ok(grewMB < MAX_GROWTH_MB, + `expected heap growth under ${MAX_GROWTH_MB} MB, grew ${grewMB.toFixed(1)} MB`); + + console.log('\nPASS: no cache growth and no leak with localizeRichPresence disabled.'); + process.exit(0); +})(); diff --git a/leak-repro-enabled.js b/leak-repro-enabled.js new file mode 100644 index 00000000..102c01d9 --- /dev/null +++ b/leak-repro-enabled.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Memory-leak reproduction: localizeRichPresence ENABLED (current/default behavior). + * + * Models the real deployment that hit this: ONE process serving a FLEET of Steam + * clients. The rich-presence localization cache lives on each SteamUser instance + * (this._richPresenceLocalization, populated by getAppRichPresenceLocalization in + * components/friends.js). It is keyed by `${appID}_${language}` and entries are only + * ever overwritten, never evicted -- a title a friend played once stays cached for + * the life of the process. + * + * Two facts combine into the leak: + * 1. Per served client, distinct titles accumulate over the process lifetime + * (bounded roughly by the games a friend list plays -- on the order of a user's + * own library, ~100 titles). + * 2. The cache is NOT shared between instances. Popular games (CS2, Dota, ...) are + * played across many friend lists, yet each of the hundreds/thousands of served + * clients keeps its OWN copy of the same localization tokens. + * + * So retained memory scales with (served clients x distinct titles), and in a fleet + * of hundreds of clients that is large even though no single client sees many games. + * + * Fully isolated: never connects to Steam. The only network method reached by the + * localization path (`_sendUnified`) is stubbed to return fabricated tokens. It + * returns FRESH token objects on every call, because in production every fetch + * deserializes its own buffer -- instances do not share token memory. + * + * Run: node leak-repro-enabled.js + * (It re-execs itself with --expose-gc so heap measurements are deterministic.) + */ + +// Re-exec with --expose-gc so we can force GC between measurements. +if (typeof global.gc !== 'function') { + const {spawnSync} = require('child_process'); + const res = spawnSync(process.execPath, ['--expose-gc', __filename, ...process.argv.slice(2)], {stdio: 'inherit'}); + process.exit(res.status === null ? 1 : res.status); +} + +const assert = require('assert'); +const path = require('path'); +const SteamUser = require(path.join(__dirname, 'index.js')); + +const SERVED_CLIENTS = 300; // Steam accounts served by ONE process (prod runs hundreds/thousands) +const DISTINCT_TITLES = 100; // distinct games a client localizes over its lifetime (~ a user's library) +const TOKENS_PER_TITLE = 25; // localization tokens Steam returns per game (realistic) +const MIN_LEAK_MB = 20; // retained memory we expect to clearly exceed + +// A shared catalog of popular appIDs. Deliberately small and overlapping across all +// clients -- yet, because the cache is per-instance, it still gets duplicated +// SERVED_CLIENTS times. This is the point. +const CATALOG = []; +for (let i = 0; i < DISTINCT_TITLES; i++) { + CATALOG.push(1000 + i); +} + +function measureHeapBytes() { + global.gc(); + global.gc(); + return process.memoryUsage().heapUsed; +} + +function makeClient() { + // dataDirectory: null keeps everything in memory (no disk storage engine). + const user = new SteamUser({dataDirectory: null, localizeRichPresence: true}); + + // Stub the ONLY network call reached by the localization path. Builds FRESH + // strings/objects per call so nothing is shared between instances -- mirroring a + // real fetch that deserializes its own buffer each time. + user._sendUnified = (method, body, callback) => { + assert.strictEqual(method, 'Community.GetAppRichPresenceLocalization#1'); + let tokens = [{name: '#status', value: `Playing app ${body.appid}`}]; + for (let i = 0; i < TOKENS_PER_TITLE; i++) { + tokens.push({name: `#token_${i}`, value: `In-game state ${i} on map de_${i} - score ${body.appid % 16}`}); + } + callback({appid: body.appid, token_lists: [{language: body.language, tokens}]}); + }; + + return user; +} + +(async () => { + // Stand up the fleet with empty caches, then measure the growth that localization + // alone adds -- isolating the leak from the (expected, bounded) cost of the instances. + const clients = []; + for (let c = 0; c < SERVED_CLIENTS; c++) { + clients.push(makeClient()); + } + + const heapBefore = measureHeapBytes(); + + for (const user of clients) { + for (const appid of CATALOG) { + try { + await user._getRPLocalizedString(appid, {steam_display: '#status'}, 'english'); + } catch (ex) { + // Not expected in this repro, but never let a rejection mask the result. + } + } + } + + const heapAfter = measureHeapBytes(); + const grewMB = (heapAfter - heapBefore) / (1024 * 1024); + + let totalEntries = 0; + for (const user of clients) { + totalEntries += Object.keys(user._richPresenceLocalization).length; + } + const expectedEntries = SERVED_CLIENTS * DISTINCT_TITLES; + + console.log('=== localizeRichPresence: true (leak expected) ==='); + console.log(`served clients : ${SERVED_CLIENTS}`); + console.log(`distinct titles / client : ${DISTINCT_TITLES}`); + console.log(`cache entries retained : ${totalEntries} (= clients x titles, duplicated per instance)`); + console.log(`heapUsed growth : ${grewMB.toFixed(1)} MB`); + + // One retained entry per (client, title): scales with the fleet, never evicted. + assert.strictEqual(totalEntries, expectedEntries, + `expected ${expectedEntries} retained cache entries, got ${totalEntries}`); + // And that retention shows up as real, unbounded heap growth. + assert.ok(grewMB > MIN_LEAK_MB, + `expected heap to grow by more than ${MIN_LEAK_MB} MB, grew ${grewMB.toFixed(1)} MB`); + + console.log('\nPASS: localization cache grows with (served clients x titles) and is never freed.'); + process.exit(0); +})(); From f8badb552f9f3afe240c18f605093f6ee3d44410 Mon Sep 17 00:00:00 2001 From: Vitalii Zurian Date: Mon, 17 Aug 2026 10:17:30 +0200 Subject: [PATCH 3/3] Lint fixes --- leak-proof-unbounded.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/leak-proof-unbounded.js b/leak-proof-unbounded.js index eb52d696..a16edd09 100644 --- a/leak-proof-unbounded.js +++ b/leak-proof-unbounded.js @@ -66,7 +66,9 @@ user._sendUnified = (method, body, callback) => { // appid = populated+1 -> every key distinct -> every call inserts a new entry try { await user._getRPLocalizedString(populated + 1, {steam_display: '#status'}, 'english'); - } catch (ex) {} + } catch (ex) { + // + } } const live = liveHeapBytes();