diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb0130b..d597774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 51b9a50..798f1ad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/leaks/README.md b/test/leaks/README.md index 9cba2f4..988a7f3 100644 --- a/test/leaks/README.md +++ b/test/leaks/README.md @@ -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` @@ -29,6 +34,11 @@ 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 | @@ -36,18 +46,19 @@ The following environment variables can be used to tune a run: ## 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. diff --git a/test/leaks/scan-leak.test.ts b/test/leaks/scan-leak.test.ts index e428dad..9bd0740 100644 --- a/test/leaks/scan-leak.test.ts +++ b/test/leaks/scan-leak.test.ts @@ -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, @@ -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; } /** @@ -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`); @@ -71,13 +97,26 @@ function writeHeapSnapshot(label: string) { }); } +function writeDiagnostics(diagnostics: Record) { + 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++) { @@ -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); }); });