Skip to content

test: measure streaming working set via forced GC (Node 26 fix)#160

Merged
gmoon merged 3 commits into
masterfrom
claude/s3proxy-node-24-26-uaszcj
Jul 15, 2026
Merged

test: measure streaming working set via forced GC (Node 26 fix)#160
gmoon merged 3 commits into
masterfrom
claude/s3proxy-node-24-26-uaszcj

Conversation

@gmoon

@gmoon gmoon commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Background

The Node CI matrix was expanded to [22, 24, 26] in #159. That run went red on Node 26 only: test/streaming-memory.test.ts failed with expected 72089600 to be less than 52428800. Node 24 built and tested cleanly (its job was auto-cancelled by fail-fast once 26 failed). This PR fixes the failure so all three legs pass.

Root cause

The streaming test asserts s3proxy streams rather than buffering the full body. It measured process RSS, which is a GC-lag-driven high-water mark that V8 does not return to the OS after collection — so the ceiling drifted between Node versions and tripped on Node 26 (~69 MB peak vs. the 50 MB bound) even though streaming works correctly.

Investigating also surfaced a latent config bug: the intended --expose-gc (via vitest.config.ts forks.execArgv) never took effect. Pool options must live under poolOptions, and even there vitest v4 did not forward the flag to worker forks — so global.gc was always undefined and the test's baseline global.gc() call was a silent no-op. The test was measuring uncollected garbage the whole time.

Change

Rework the test to measure the actual retained working set instead of an RSS high-water mark:

  • Acquire GC programmatically via v8.setFlagsFromString('--expose-gc') + vm.runInNewContext('gc'), independent of launch flags. Falls back to skipping the strict bound if unavailable, so it never false-fails.
  • Sample arrayBuffers, not RSS — the payload lives in external Buffer memory, and arrayBuffers drops the instant freed buffers are collected. Forcing a full GC every ~1 MB of chunks makes the peak reflect what s3proxy actually holds (~2 MB under backpressure), stable across Node versions.
  • Tighten the bound from 50 MB to 8 MB. It sits ~4× above the streaming working set and far below a buffering regression (which would keep the body referenced and climb toward the 100 MB payload despite forced GC).
  • Drop the dead forks.execArgv entry from vitest.config.ts.

The assertion is now stronger (8 MB vs. 50 MB) and version-stable, because the GC cadence is forced rather than left to the runtime.

Verification

Locally on Node 22: observed peak 2.06 MB streaming a 100 MB body. lint, type-check, build, and test:unit all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F3RYg3wG5UhVTCx58596iW


Generated by Claude Code

claude added 3 commits July 15, 2026 02:47
The streaming-memory test asserts s3proxy doesn't buffer the full body.
It measured process RSS, which is a GC-lag-driven high-water mark that V8
does not return to the OS after collection — so the ceiling drifted between
Node versions and failed on Node 26 (~69MB vs the 50MB bound) despite
streaming working correctly.

Rework the test to measure the actual retained working set instead:

- Force a full GC every ~1MB of chunks and sample external Buffer memory
  (arrayBuffers), which drops the instant freed buffers are collected. This
  reflects what s3proxy holds (~2MB under backpressure), not transient
  garbage, and is stable across Node versions. A buffering regression keeps
  the body referenced, so forced GC can't reclaim it and the delta climbs
  toward the 100MB payload. Bound tightened from 80MB to 8MB accordingly.
- Acquire the GC trigger programmatically via v8.setFlagsFromString rather
  than relying on --expose-gc. The prior vitest.config.ts `forks.execArgv`
  entry never took effect (pool options must live under poolOptions, and
  even there vitest did not forward the flag to worker forks), so global.gc
  was always undefined and the original test's GC call was a silent no-op.
  Drop the dead config entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3RYg3wG5UhVTCx58596iW
The audit, smoke, validation, performance, and package-verification jobs,
plus the release and manual-release workflows, pinned Node 22. Move them to
Node 24 (Active LTS) so the fixed build/publish runners track the current
LTS. The test matrix still includes 22 to keep the >=22.13.0 engines floor
exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3RYg3wG5UhVTCx58596iW
Package verification exercises version-sensitive behavior (ESM resolution,
install/import) and needs no AWS credentials, so run it across [22, 24, 26]
rather than a single version. The other single-pinned jobs (audit, smoke,
validation, performance) stay on Node 24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3RYg3wG5UhVTCx58596iW
@gmoon
gmoon merged commit 904bfb5 into master Jul 15, 2026
12 checks passed
@gmoon
gmoon deleted the claude/s3proxy-node-24-26-uaszcj branch July 15, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants