Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions components/friends.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
122 changes: 122 additions & 0 deletions leak-proof-unbounded.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/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);
})();
113 changes: 113 additions & 0 deletions leak-repro-disabled.js
Original file line number Diff line number Diff line change
@@ -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);
})();
127 changes: 127 additions & 0 deletions leak-repro-enabled.js
Original file line number Diff line number Diff line change
@@ -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);
})();
Loading
Loading