Skip to content

Fix: Percy CLI hangs on Node.js 26 during Chromium extraction — adm-zip variant (PER-10062)#2344

Open
prklm10 wants to merge 5 commits into
masterfrom
fix/PER-10062-node26-admzip
Open

Fix: Percy CLI hangs on Node.js 26 during Chromium extraction — adm-zip variant (PER-10062)#2344
prklm10 wants to merge 5 commits into
masterfrom
fix/PER-10062-node26-admzip

Conversation

@prklm10

@prklm10 prklm10 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes PER-10062. Alternative implementation to #2343 — same root cause and test contract, different extraction library. Pick one.

Root cause

Identical to #2343: extract-zip@2.0.1yauzl@2 relies on undefined stream destroy() behavior that Node.js 26 tightened, so openReadStream() callbacks never fire on deflate entries and Chromium extraction hangs forever (thejoshwolfe/yauzl#176). percy exec blocks before spawning the wrapped command → no tests run, builds finalize no_snapshots.

Fix

Replaces extract-zip with a local packages/core/src/unzip.js built on adm-zip@^0.6.0, which inflates synchronously and is unaffected by the Node 26 stream change.

Why not adm-zip's own extractAllTo: verified on Node 26 that it writes symlink entries as regular files containing the target path — this breaks the Chromium.app bundle on macOS (framework symlinks) — and mishandles windows-style directory attributes. The wrapper iterates getEntries() and restores file modes, directories, and symlinks itself, with a zip-slip guard (reachable here, unlike with yauzl 3 which pre-validates entry names).

Trade-offs vs #2343 (yauzl@3)

  • adm-zip is sync/buffered: each entry is inflated fully in memory (largest Chromium entry is a few hundred MB) vs yauzl's streaming. Fine for install-time use; worth knowing on 4 GB CI containers.
  • adm-zip has no transitive deps; yauzl@3 pulls only pend.
  • getData() CRC-validates every entry (corrupted data throws instead of writing bad files).

Testing

🤖 Generated with Claude Code

prklm10 and others added 3 commits July 17, 2026 15:47
…rage break)

Master's Test @percy/core job has been red since a4b6b8a — global 100%
coverage fails on src/utils.js:103, the canonicalHost catch for
colon-containing hosts that are not valid IPv6. No spec fed such a host
through the metadata gates. Adds one isMetadataIP spec ('abc:def') that
deterministically exercises the fallback on Node 14/20/26.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a callers)

Second half of the master coverage break: the falsy-host guard in
canonicalHost is unreachable — matchMetadataHost early-returns on falsy
hosts before calling it and METADATA_IPS maps hardcoded literals. Kept
as a defensive no-op with an ignore pragma per repo convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (PER-10062)

Chromium archive extraction hangs forever on Node.js 26 because
extract-zip@2.0.1 depends on yauzl@2, which relies on undefined stream
destroy() behavior that Node 26 tightened — openReadStream callbacks
never fire on deflate entries (thejoshwolfe/yauzl#176). adm-zip inflates
synchronously and is unaffected.

adm-zip's own extractAllTo is not used: verified on Node 26 it writes
symlink entries as regular files containing the target path (breaking
the Chromium.app bundle) and mishandles windows-style directory
attributes. The local unzip module iterates getEntries() and restores
modes, directories, and symlinks itself, with a zip-slip guard.

Verified: 10 unzip specs (deflate, symlinks, exec modes, windows dirs,
corrupted data, zip-slip) pass on Node 14 and Node 26; 100% coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@prklm10
prklm10 requested a review from a team as a code owner July 17, 2026 10:20
Comment thread packages/core/src/unzip.js Fixed
Comment thread packages/core/src/unzip.js Fixed
Comment thread packages/core/src/unzip.js Fixed
CodeQL js/zipslip does not model split(sep).includes('..') as a barrier;
path.relative(dir, dest).startsWith('..') is its recognized
RelativePathStartsWithSanitizer shape. Same rejection semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
// `dir` is the trusted install target from install.js, not user input,
// and entry names are validated by the traversal guard below
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
let dest = path.join(dir, entry.entryName);
// `dir` is the trusted install target from install.js, not user input,
// and entry names are validated by the traversal guard below
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
let dest = path.join(dir, entry.entryName);
… specs

The exec:start specs waited a fixed 1s after start() before pinging, but
request() only retries ECONNREFUSED 5 times at 50ms intervals, so on slow
Windows runners where the server takes longer to bind the specs fail with
EEXIT: 1 (flaking on master too, not specific to this branch). Replace the
sleep with a healthcheck poll bounded at 15s (within the 25s Windows jasmine
timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants