Skip to content
Merged
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
64 changes: 58 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,66 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Run constructor-level heap regression harness
run: pnpm test:heap-leak
- name: Upload heap regression artifacts
if: always()
- name: Upload heap diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: heap-regression-${{ github.run_id }}
path: |
heap-diff.json
heap-before.heapsnapshot
heap-after.heapsnapshot
path: heap-diff.json
if-no-files-found: warn
retention-days: 14

stream-leak-regression:
name: Streaming scan leak regression (nightly)
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run streaming scan leak harness
run: pnpm test:leak
- name: Upload stream leak diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: stream-leak-regression-${{ github.run_id }}
path: scan-leak-diagnostics.json
if-no-files-found: warn
retention-days: 14

leak-injection:
name: Leak detection gate (nightly)
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prove the streaming leak gate catches a deliberate leak
id: stream-gate
continue-on-error: true
run: SCAN_LEAK_INJECT=1 pnpm test:leak
- name: Prove the constructor leak gate catches a deliberate leak
id: heap-gate
continue-on-error: true
run: HEAP_LEAK_INJECT=1 pnpm test:heap-leak
- name: Fail when an injected leak goes undetected
if: steps.stream-gate.outcome == 'success' || steps.heap-gate.outcome == 'success'
run: |
echo "An injected leak was not detected by its regression gate."
echo "stream-gate outcome: ${{ steps.stream-gate.outcome }}"
echo "heap-gate outcome: ${{ steps.heap-gate.outcome }}"
exit 1
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"format:check": "prettier --check .",
"prepare": "husky",
"test:fuzz": "FC_RUNS=100000 vitest run test/chains/stellar/properties.test.ts",
"test:leak": "vitest run test/leaks/scan-leak.test.ts",
"test:leak": "node --expose-gc ./node_modules/vitest/vitest.mjs run test/leaks/scan-leak.test.ts --pool=threads",
"test:heap-leak": "node --expose-gc ./node_modules/vitest/vitest.mjs run test/leaks/heap-snapshot.test.ts --pool=threads",
"size": "size-limit",
"api:check": "api-extractor run --config api-extractor.json && api-extractor run --config api-extractor-evm.json && api-extractor run --config api-extractor-stellar.json && api-extractor run --config api-extractor-solana.json && api-extractor run --config api-extractor-ckb.json && api-extractor run --config api-extractor-vault.json",
Expand Down
25 changes: 18 additions & 7 deletions test/leaks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

The SDK has two complementary leak checks:

- `scan-leak.test.ts` tracks the slope of `heapUsed` over repeated scans.
- `scan-leak.test.ts` tracks the slope of `heapUsed` over repeated scans and fails when it exceeds a configured MB-per-step threshold.
- `heap-snapshot.test.ts` compares retained object counts by constructor between deterministic before/after V8 heap snapshots.

The constructor-level harness is intended to catch slow retention bugs such as leaked `WeakRef` instances, buffers, or pooled objects that can hide inside a stable aggregate heap slope.

## Running locally

The harness requires explicit garbage collection so the before/after snapshots are comparable:
Both harnesses require explicit garbage collection so their measurements are stable:

```bash
pnpm test:leak
pnpm test:heap-leak
```

This script launches Vitest through Node with `--expose-gc` and uses the thread pool so the test worker retains explicit GC access. It writes these files in the repository root:
Each script launches Vitest through Node with `--expose-gc` and uses the thread pool so the test worker retains explicit GC access.

`scan-leak` writes `scan-leak-diagnostics.json` with the recorded samples, the fitted slope, and the active threshold. It also writes `heap-final.heapsnapshot` when the gate fails so the retained graph can be inspected.

`heap-snapshot` writes these files in the repository root:

- `heap-before.heapsnapshot`
- `heap-after.heapsnapshot`
Expand All @@ -29,25 +34,31 @@ The following environment variables can be used to tune a run:

| Variable | Default | Meaning |
| ----------------------------------- | ------: | --------------------------------------------------------------------------------- |
| `SCAN_LEAK_ANNOUNCEMENTS` | `2000` | Announcements generated for the scan workload |
| `SCAN_LEAK_ITERATIONS` | `300` | Number of measured scan iterations |
| `SCAN_LEAK_SAMPLE_INTERVAL` | `25` | Iterations between heap samples |
| `SCAN_LEAK_THRESHOLD_MB` | `0.2` | Maximum allowed regression slope in MB per sample step |
| `SCAN_LEAK_INJECT` | unset | Set to `1` to deliberately retain `LeakSentinel` objects and prove the gate fails |
| `HEAP_ANNOUNCEMENTS` | `1000` | Announcements generated for the scan workload |
| `HEAP_SCANS` | `100` | Number of measured scan iterations |
| `HEAP_CONSTRUCTOR_GROWTH_THRESHOLD` | `25` | Maximum retained-object growth allowed for any constructor |
| `HEAP_LEAK_INJECT` | unset | Set to `1` to deliberately retain `LeakSentinel` objects and prove the gate fails |

## Reproducibility

Run `pnpm test:heap-leak` twice from the same commit with the same Node version and configuration. The harness forces GC before each snapshot and reports constructor counts using the V8 snapshot schema rather than process-level heap estimates. Small runtime bookkeeping differences are tolerated by the configured growth threshold; application-level retained growth above that threshold fails the test.
Run either harness twice from the same commit with the same Node version and configuration. Both force GC before measuring: `scan-leak` fits a least-squares line to sampled `heapUsed`, and `heap-snapshot` compares constructor counts using the V8 snapshot schema rather than process-level heap estimates. Small runtime bookkeeping differences are tolerated by the configured thresholds; application-level retained growth above a threshold fails the test.

## Regression proof

To verify that the harness detects a known leak:
To verify that each harness detects a known leak:

```bash
SCAN_LEAK_INJECT=1 pnpm test:leak
HEAP_LEAK_INJECT=1 pnpm test:heap-leak
```

That run intentionally keeps one `LeakSentinel` per scan alive. The constructor count should exceed the threshold and the test should fail. Do not enable this variable in normal CI.
Those runs intentionally keep one `LeakSentinel` alive per iteration or scan. The measured slope or constructor count should exceed the threshold and the test should fail. Do not enable these variables in normal CI.

## CI

The regular per-PR test job does not run the heap snapshot harness because heap snapshots are intentionally heavier and environment-sensitive. The scheduled nightly job runs `pnpm test:heap-leak` on Node 22 and uploads `heap-diff.json` together with the before/after snapshots as a GitHub Actions artifact, even when the regression gate fails.
The regular per-PR test job does not run either harness because heap snapshots are intentionally heavier and environment-sensitive. Scheduled nightly jobs run `pnpm test:leak` and `pnpm test:heap-leak` on Node 22 and upload only the compact diagnostics (`scan-leak-diagnostics.json` and `heap-diff.json`) when a gate fails. A separate nightly job injects a deliberate leak into each harness and fails when either gate reports success.
73 changes: 62 additions & 11 deletions test/leaks/scan-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import {
SCHEME_ID,
} from '../../src/chains/stellar';

class LeakSentinel {
readonly payload = new Uint8Array(64 * 1024);
}

const injectedLeak: LeakSentinel[] = [];

function envInt(name: string, fallback: number) {
const value = Number.parseInt(process.env[name] ?? '', 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}

function envFloat(name: string, fallback: number) {
const value = Number.parseFloat(process.env[name] ?? '');
return Number.isFinite(value) && value > 0 ? value : fallback;
}

function generateAnnouncements(
count: number,
spendingPubKey: Uint8Array,
Expand All @@ -31,8 +47,10 @@ function generateAnnouncements(
});
}

function heapMB() {
return process.memoryUsage().heapUsed / 1024 / 1024;
function memoryMB() {
const m = process.memoryUsage();
const bytes = m.heapUsed + m.external + Number(m.arrayBuffers ?? 0);
return bytes / 1024 / 1024;
}

/**
Expand All @@ -59,6 +77,14 @@ function regressionSlope(y: number[]) {
return slope;
}

function forceGc() {
if (typeof global.gc !== 'function') {
throw new Error('Leak regression harness requires Node to run with --expose-gc');
}
global.gc();
global.gc();
}

function writeHeapSnapshot(label: string) {
const snapshotStream = v8.getHeapSnapshot();
const file = path.join(process.cwd(), `heap-${label}.heapsnapshot`);
Expand All @@ -71,13 +97,26 @@ function writeHeapSnapshot(label: string) {
});
}

function writeDiagnostics(diagnostics: Record<string, unknown>) {
fs.writeFileSync(
path.join(process.cwd(), 'scan-leak-diagnostics.json'),
`${JSON.stringify(diagnostics, null, 2)}\n`,
);
}

describe('scanAnnouncements - CI leak detection', () => {
it('detects memory leaks via regression + heap snapshots', async () => {
const announcementsCount = envInt('SCAN_LEAK_ANNOUNCEMENTS', 50);
const iterations = envInt('SCAN_LEAK_ITERATIONS', 200);
const sampleInterval = envInt('SCAN_LEAK_SAMPLE_INTERVAL', 20);
const threshold = envFloat('SCAN_LEAK_THRESHOLD_MB', 0.2);
const injectLeak = process.env.SCAN_LEAK_INJECT === '1';

const signature = new Uint8Array(64);
crypto.getRandomValues(signature);

const keys = deriveStealthKeys(signature);
const data = generateAnnouncements(10_000, keys.spendingPubKey, keys.viewingPubKey);
const data = generateAnnouncements(announcementsCount, keys.spendingPubKey, keys.viewingPubKey);

// warmup
for (let i = 0; i < 20; i++) {
Expand All @@ -86,30 +125,42 @@ describe('scanAnnouncements - CI leak detection', () => {

const samples: number[] = [];

const iterations = 10_000;
const sampleInterval = 250;

for (let i = 0; i < iterations; i++) {
scanAnnouncements(data, keys.viewingKey, keys.spendingPubKey, keys.spendingScalar);
if (injectLeak) injectedLeak.push(new LeakSentinel());

if (i % sampleInterval === 0) {
global.gc?.();
samples.push(heapMB());
forceGc();
samples.push(memoryMB());
}
}

const slope = regressionSlope(samples);
const failed = slope >= threshold;

console.log('Heap samples:', samples);
console.log('Leak slope (MB/step):', slope);

// snapshot artifacts for CI debugging
await writeHeapSnapshot('final');
// Compact diagnostic that CI uploads on failure. The full snapshot is large
// and only written when the gate trips, so success runs stay cheap.
writeDiagnostics({
announcementsCount,
iterations,
sampleInterval,
threshold,
slope,
injectLeak,
samples,
});

if (failed) {
await writeHeapSnapshot('final');
}

/**
* CI GATE:
* > 0.2 MB per sample step is suspicious for long-running agents
*/
expect(slope).toBeLessThan(0.2);
expect(slope).toBeLessThan(threshold);
});
});
Loading