Skip to content

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

Merged
prklm10 merged 6 commits into
masterfrom
fix/PER-10062-node26-admzip
Jul 20, 2026
Merged

Fix: Percy CLI hangs on Node.js 26 during Chromium extraction — adm-zip variant (PER-10062)#2344
prklm10 merged 6 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);
prklm10 and others added 2 commits July 17, 2026 18:44
… 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>
…6-admzip

# Conflicts:
#	packages/core/src/utils.js
#	packages/core/test/utils.test.js

@prklm10 prklm10 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review (automated) — 5 inline finding(s). Full report in the PR comment below. Verdict: Passed.

Comment on lines +132 to +135
// yauzl 3 rejects relative paths at parse time; the unzip module also
// guards against zip-slip for entries yauzl allows through
await expectAsync(unzip(archive, { dir: path.join(tmp, 'out') }))
.toBeRejectedWithError(/invalid relative path|Out of bound path/);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Stale comment + dead regex branch

The comment says "yauzl 3 rejects relative paths at parse time," but this module no longer uses yauzl at all — it's leftover from the extract-zip era. The invalid relative path alternative in the regex is dead (adm-zip never throws that string).

Suggestion: describe the zip-slip guard instead and tighten the regex to /Out of bound path/.

Reviewer: stack:code-reviewer

// healthcheck endpoint until the server is reachable. A fixed sleep proved
// flaky on slow Windows CI runners where startup can exceed a second and
// `request` only retries ECONNREFUSED 5 times at 50ms intervals.
async function waitForHealthcheck(port = 5338, deadline = Date.now() + 15000) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Poll deadline exceeds Jasmine's non-Windows timeout

The 15s default deadline is longer than jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000 set for non-Windows platforms in scripts/test-helpers.js (25s is Windows-only). A slow Linux/macOS runner needing >10s would fail with Jasmine's generic "Async function did not complete within 10000ms" instead of the informative healthcheck error this helper is meant to provide.

Suggestion: cap the default deadline below 10s on non-Windows, or raise the affected specs' own timeout to at least the poll deadline.

Reviewer: stack:code-reviewer

// Returns the file mode of an extracted entry, with defaults matching the
// previous extract-zip behavior when the archive does not provide one
function extractedMode(mode, isDir) {
return (mode || (isDir ? 0o755 : 0o644)) & 0o777;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] mode || default conflates "no mode" with "mode 0"

An entry whose archive mode is genuinely 0 silently gets the 0755/0644 default instead of 0. Harmless for Chromium archives, but this module isn't Chromium-specific.

Suggestion: add a one-line comment noting the tradeoff.

Reviewer: stack:code-reviewer

let contents = entry.getData();

if (isSymlink) {
await fs.promises.symlink(contents.toString('utf8'), dest);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Symlink creation isn't idempotent on retried extractions

fs.promises.symlink throws EEXIST if dest already exists. download() in install.js only skips extraction when the final executable exists, so a retry after a mid-extraction failure into the same revision outdir could fail permanently until .local-chromium/<revision> is deleted manually.

Suggestion: await fs.promises.rm(dest, { force: true }) before symlink, or remove the outdir on extraction failure.

Reviewer: stack:code-reviewer

while (true) {
try {
return await request(`http://localhost:${port}/percy/healthcheck`, { retries: 0, noProxy: true });
} catch (err) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Retries on any error, not just connection-refused

If the server binds but the healthcheck route itself errors, the loop masks the real error for up to 15s before surfacing it, delaying failure feedback.

Suggestion: optionally retry only connection errors (e.g. err.code === 'ECONNREFUSED') and rethrow others immediately.

Reviewer: stack:code-reviewer

@prklm10

prklm10 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2344Head: 31d304bReviewers: stack:code-reviewer

Summary

Replaces the unmaintained extract-zip (yauzl@2) with a local adm-zip-based packages/core/src/unzip.js to fix the Node 26 Chromium-extraction deadlock (PER-10062), preserving file modes, directories, and symlinks with a zip-slip guard; also de-flakes the Windows percy exec:start CI specs by polling the healthcheck endpoint instead of a fixed 1s sleep.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass Only test-fixture placeholder tokens (<<PERCY_TOKEN>>)
High Security Authentication/authorization checks present N/A No auth surface in this change
High Security Input validation and sanitization Pass Absolute-dir check, realpath, and zip-slip guard in unzip.js; CRC validation on inflate (full security lens disabled for this run — handled separately)
High Security No IDOR — resource ownership validated N/A No resource access by identifier
High Security No SQL injection (parameterized queries) N/A No database code
High Correctness Logic is correct, handles edge cases Pass Mode/symlink/dir handling verified against adm-zip source; per-file parent mkdir is archive-order independent; minor non-blocking nits noted below (#3, #4)
High Correctness Error handling is explicit, no swallowed exceptions Pass Corrupted data throws (CRC), out-of-bound entries throw, relative target dirs rejected
High Correctness No race conditions or concurrency issues Pass PR removes a race (fixed-sleep test wait → deterministic healthcheck polling)
Medium Testing New code has corresponding tests Pass 10-spec unzip.test.js with hand-crafted zip fixtures; 100% coverage on the new module
Medium Testing Error paths and edge cases tested Fail #2: 15s poll deadline exceeds the 10s Jasmine timeout on non-Windows — slow POSIX runner would fail with a generic timeout instead of the informative error
Medium Testing Existing tests still pass (no regressions) Pass All 46 CI checks green on head, including Windows
Medium Performance No N+1 queries or unbounded data fetching Pass #5 noted: adm-zip inflates synchronously (event-loop blocking) — acceptable for a one-shot install step, worth knowing
Medium Performance Long-running tasks use background jobs N/A CLI install step, inherently foreground
Medium Quality Follows existing codebase patterns Pass Matches core module style; esm loader mocks updated consistently across install specs
Medium Quality Changes are focused (single concern) Pass Extraction swap + the CI de-flake needed to land it; no scope creep
Low Quality Meaningful names, no dead code Pass Dead regex branch flagged in #1 (test-only)
Low Quality Comments explain why, not what Fail #1: stale "yauzl 3 rejects relative paths" comment in unzip.test.js — leftover from the extract-zip era; module under test no longer uses yauzl
Low Quality No unnecessary dependencies added Pass Swaps 7 transitive packages (yauzl/fd-slicer/pend/buffer-crc32/get-stream@5/pump/extract-zip) for zero-dependency adm-zip@0.6.0

Findings

  • File: packages/core/test/unit/unzip.test.js:132

  • Severity: Medium

  • Reviewer: stack:code-reviewer

  • Issue: Stale comment claims "yauzl 3 rejects relative paths at parse time," but this module no longer uses yauzl at all; the invalid relative path alternative in the assertion regex is dead (adm-zip never throws that string).

  • Suggestion: Update the comment to describe the zip-slip guard and tighten the regex to /Out of bound path/.

  • File: packages/cli-exec/test/start.test.js:19

  • Severity: Medium

  • Reviewer: stack:code-reviewer

  • Issue: waitForHealthcheck defaults to a 15s deadline, but scripts/test-helpers.js sets jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000 on non-Windows (25000 only on win32). A slow Linux/macOS runner needing >10s would fail with Jasmine's generic "Async function did not complete within 10000ms" instead of the informative healthcheck error.

  • Suggestion: Cap the default deadline below 10s on non-Windows, or raise the affected specs' own timeout to ≥ the poll deadline.

  • File: packages/core/src/unzip.js:13

  • Severity: Low

  • Reviewer: stack:code-reviewer

  • Issue: extractedMode uses mode || default, conflating "archive provides no mode" with "mode explicitly 0" — a real mode of 0 silently becomes 0755/0644. Harmless for Chromium archives, but the module is not Chromium-specific.

  • Suggestion: Add a one-line comment noting the tradeoff (or check for undefined explicitly if archives with intentional 0 modes ever matter).

  • File: packages/core/src/unzip.js:60

  • Severity: Low

  • Reviewer: stack:code-reviewer

  • Issue: fs.promises.symlink throws EEXIST if dest already exists. download() in install.js only skips extraction when the final executable exists, so a retry after a mid-extraction failure into the same revision-named outdir could fail permanently until .local-chromium/<revision> is manually deleted.

  • Suggestion: await fs.promises.rm(dest, { force: true }) before symlink, or remove outdir on extraction failure.

  • File: packages/core/src/unzip.js (module-level)

  • Severity: Low

  • Reviewer: stack:code-reviewer

  • Issue: Extraction is now fully synchronous (adm-zip readFileSync + sync inflate) — for a ~150–250MB Chromium zip this blocks the event loop for the duration, a behavioral shift from streaming extract-zip. Reasonable (and what sidesteps the Node 26 deadlock) for a one-shot install step.

  • Suggestion: Keep the sync/buffered tradeoff documented in the PR description (already covered there) so future profiling isn't surprised.

  • File: packages/cli-exec/test/start.test.js:22

  • Severity: Low

  • Reviewer: stack:code-reviewer

  • Issue: waitForHealthcheck retries on any error, not just connection-refused — if the healthcheck route itself errors, the loop masks it for up to 15s before surfacing, delaying failure feedback.

  • Suggestion: Optionally retry only on connection errors (err.code === 'ECONNREFUSED' etc.) and rethrow others immediately.


Verdict: PASS — no Critical/High findings; the extraction logic was verified against adm-zip's source, coverage is thorough, and all 46 CI checks pass. The two Medium test-hygiene findings are worth a quick follow-up but are non-blocking.

@prklm10
prklm10 merged commit c837361 into master Jul 20, 2026
47 checks passed
@prklm10
prklm10 deleted the fix/PER-10062-node26-admzip branch July 20, 2026 06:10
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