Skip to content

Commit ed65cba

Browse files
authored
Merge pull request #5097 from cardstack/worktree-flaky-lint-memory-test
Measure retained heap in lint memory test to remove flakiness
2 parents 08f02e6 + 7102eeb commit ed65cba

2 files changed

Lines changed: 101 additions & 16 deletions

File tree

packages/realm-server/tests/helpers/prettier-test-utils.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,57 @@
11
// Test utilities for prettier formatting tests
22
import { performance } from 'perf_hooks';
3+
import * as v8 from 'v8';
4+
import * as vm from 'vm';
5+
6+
// Resolved once and cached: the test runner does not start Node with
7+
// `--expose-gc`, so `globalThis.gc` is normally absent and we synthesize the
8+
// function through the V8 flag API. Resolving lazily and caching means we
9+
// create at most one throwaway VM context for the whole suite rather than one
10+
// per `forceGc` call (a per-call context would itself allocate and perturb the
11+
// very heap reading the helper exists to make trustworthy). `null` records a
12+
// completed resolution that produced no usable function.
13+
let resolvedGc: (() => void) | null | undefined;
14+
15+
function resolveGc(): (() => void) | null {
16+
if (resolvedGc !== undefined) {
17+
return resolvedGc;
18+
}
19+
let gc = (globalThis as { gc?: () => void }).gc;
20+
if (typeof gc !== 'function') {
21+
try {
22+
v8.setFlagsFromString('--expose-gc');
23+
// Evaluating `gc` throws ReferenceError if the flag didn't take effect
24+
// (e.g. an unsupported V8 build); treat that as "no GC available".
25+
gc = vm.runInNewContext('gc') as () => void;
26+
} catch {
27+
gc = undefined;
28+
} finally {
29+
v8.setFlagsFromString('--no-expose-gc');
30+
}
31+
}
32+
resolvedGc = typeof gc === 'function' ? gc : null;
33+
return resolvedGc;
34+
}
35+
36+
/**
37+
* Forces a full garbage collection so that a subsequent
38+
* `process.memoryUsage().heapUsed` reading reflects retained memory rather
39+
* than uncollected transient garbage. Returns true if a collection was
40+
* actually performed, false if no GC function could be resolved — callers can
41+
* branch on the result to decide how much to trust the measurement.
42+
*
43+
* Two passes: the first promotes/collects the young generation, the second
44+
* collects what the first pass made unreachable, so the reading settles.
45+
*/
46+
export function forceGc(): boolean {
47+
const gc = resolveGc();
48+
if (!gc) {
49+
return false;
50+
}
51+
gc();
52+
gc();
53+
return true;
54+
}
355

456
interface FormattingTestCase {
557
name: string;

packages/realm-server/tests/realm-endpoints/lint-test.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createConcurrentTestData,
99
createErrorTestCases,
1010
createPerformanceAssertion,
11+
forceGc,
1112
} from '../helpers/prettier-test-utils';
1213
import '@cardstack/runtime-common/helpers/code-equality-assertion';
1314

@@ -341,27 +342,43 @@ export class MyCard extends CardDef {
341342
});
342343

343344
test('memory usage during lint operations', async function (assert) {
344-
const initialMemory = process.memoryUsage().heapUsed;
345-
346345
const testSource = `import { CardDef } from 'https://cardstack.com/base/card-api';
347346
export class MyCard extends CardDef {
348347
@field name = contains(StringField);
349348
}`;
350349

350+
const lintOnce = () =>
351+
request
352+
.post('/_lint')
353+
.set(
354+
'Authorization',
355+
`Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`,
356+
)
357+
.set('X-HTTP-Method-Override', 'QUERY')
358+
.set('Accept', 'application/json')
359+
.send(testSource);
360+
361+
// Warm up first so one-time, legitimately-retained initialization (eslint
362+
// rule definitions, prettier plugins, module caches) is established before
363+
// the baseline reading and isn't misattributed to the measured loop.
364+
const warmup = await lintOnce();
365+
assert.strictEqual(
366+
warmup.status,
367+
200,
368+
'warm-up lint request should succeed so the baseline is a steady-state lint path',
369+
);
370+
371+
// Force a collection before each reading so the delta reflects retained
372+
// memory, not transient garbage that GC simply hasn't reclaimed yet.
373+
// Without this the reading is dominated by collectible allocations from
374+
// ten concurrent lint requests, which is noise, not a leak signal.
375+
const gcForced = forceGc();
376+
const initialMemory = process.memoryUsage().heapUsed;
377+
351378
// Run multiple lint operations to test memory usage
352379
const operations = [];
353380
for (let i = 0; i < 10; i++) {
354-
operations.push(
355-
request
356-
.post('/_lint')
357-
.set(
358-
'Authorization',
359-
`Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`,
360-
)
361-
.set('X-HTTP-Method-Override', 'QUERY')
362-
.set('Accept', 'application/json')
363-
.send(testSource),
364-
);
381+
operations.push(lintOnce());
365382
}
366383

367384
const results = await Promise.all(operations);
@@ -375,13 +392,29 @@ export class MyCard extends CardDef {
375392
);
376393
});
377394

395+
forceGc();
378396
const finalMemory = process.memoryUsage().heapUsed;
379397
const memoryIncrease = finalMemory - initialMemory;
398+
const memoryIncreaseMb = memoryIncrease / 1024 / 1024;
399+
400+
// Always record the retained-growth number (and whether GC actually ran)
401+
// so a CI failure — or a passing run drifting toward the bound — can be
402+
// told apart from measurement noise without another CI cycle.
403+
console.log(
404+
`[lint-memory-test] initial=${(initialMemory / 1024 / 1024).toFixed(2)}MB ` +
405+
`final=${(finalMemory / 1024 / 1024).toFixed(2)}MB ` +
406+
`retainedGrowth=${memoryIncreaseMb.toFixed(2)}MB gcForced=${gcForced}`,
407+
);
380408

381-
// Memory increase should be reasonable (less than 45MB for lint operations)
409+
// The bound only means "retained memory" when a collection actually ran;
410+
// with warm caches and a forced GC ten idempotent lint operations retain
411+
// almost nothing, so 20MB is a generous leak guard. If GC could not be
412+
// forced the delta still includes transient garbage, so fall back to the
413+
// looser historical bound rather than flaking against the tight one.
414+
const thresholdMb = gcForced ? 20 : 45;
382415
assert.ok(
383-
memoryIncrease < 45 * 1024 * 1024,
384-
`Memory increase should be under 45MB, got ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`,
416+
memoryIncrease < thresholdMb * 1024 * 1024,
417+
`Memory increase should be under ${thresholdMb}MB, got ${memoryIncreaseMb.toFixed(2)}MB (gcForced=${gcForced})`,
385418
);
386419
});
387420

0 commit comments

Comments
 (0)