@percy/core 1.31.13 → 1.31.14-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { execFileSync } from 'child_process';
4
5
  import spawn from 'cross-spawn';
5
6
  import EventEmitter from 'events';
6
7
  import WebSocket from 'ws';
@@ -200,11 +201,36 @@ export class Browser extends EventEmitter {
200
201
  /* istanbul ignore next:
201
202
  * difficult to test failure here without mocking private properties */
202
203
  if ((_this$process = this.process) !== null && _this$process !== void 0 && _this$process.pid && !this.process.killed) {
203
- // always force close the browser process
204
+ // Force-close the entire browser process tree, not just the lead
205
+ // pid. Chromium spawns
206
+ // renderer/utility/zygote children; targeting only the lead pid
207
+ // (the previous behavior) leaked them on every kill.
208
+ //
209
+ // Convention matches Puppeteer / Playwright: shell out to
210
+ // `taskkill /T /F` on Windows; on POSIX the spawn at line ~266
211
+ // sets `detached: true` so child.pid === pgid and a negative
212
+ // pid signals the entire process group.
204
213
  try {
205
- this.process.kill('SIGKILL');
214
+ if (process.platform === 'win32') {
215
+ // Use execFileSync (no shell) so the pid argument is passed
216
+ // directly without interpolation — defense-in-depth against
217
+ // any future drift where this.process.pid isn't a clean int.
218
+ execFileSync('taskkill', ['/pid', String(this.process.pid), '/T', '/F'], {
219
+ stdio: 'ignore'
220
+ });
221
+ } else {
222
+ process.kill(-this.process.pid, 'SIGKILL');
223
+ }
206
224
  } catch (error) {
207
- throw new Error(`Unable to close the browser: ${error.stack}`);
225
+ // taskkill returns 128 if the process is already gone; the
226
+ // POSIX branch may also throw ESRCH for the same reason. Fall
227
+ // back to the lead-pid kill so a missing process doesn't
228
+ // wedge `_closed`.
229
+ try {
230
+ this.process.kill('SIGKILL');
231
+ } catch (fallbackErr) {
232
+ throw new Error(`Unable to close the browser: ${error.stack}`);
233
+ }
208
234
  }
209
235
  }
210
236
 
package/dist/lock.js ADDED
@@ -0,0 +1,215 @@
1
+ // Per-port lock file for Percy agent processes.
2
+ //
3
+ // Why: a stale ~/.percy directory after a crash currently surfaces as a
4
+ // late, opaque EADDRINUSE on the next `percy start`. The lock file lets
5
+ // us short-circuit at command entry with a clear, actionable refusal
6
+ // message and lets us auto-reclaim a stale lock whose recorded pid is
7
+ // dead.
8
+ //
9
+ // Cross-platform note: `fs.renameSync` over an existing target is
10
+ // unreliable on Node 14 Windows (Percy's Windows CI is pinned to
11
+ // node-version: 14, see .github/workflows/windows.yml). We therefore
12
+ // reclaim via unlink + retry-`wx` rather than rename-based reclaim.
13
+
14
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
15
+ import { join } from 'path';
16
+ // Use a default import so tests can `spyOn(os, 'homedir')` to redirect
17
+ // the lock dir into a tmpdir without touching the user's $HOME.
18
+ // (Babel's namespace import is frozen and not spy-able.)
19
+ import os from 'os';
20
+ const LOCK_DIR_MODE = 0o700;
21
+ const LOCK_FILE_MODE = 0o600;
22
+ export class LockHeldError extends Error {
23
+ constructor(meta, lockPath) {
24
+ super(`Percy is already running on port ${meta.port} ` + `(pid ${meta.pid}, started ${meta.startedAt}).\n` + `If you believe this is stale, remove ${lockPath} and try again.`);
25
+ this.name = 'LockHeldError';
26
+ this.meta = meta;
27
+ this.lockPath = lockPath;
28
+ }
29
+ }
30
+
31
+ // Lockfile-name pattern: literal "agent-" prefix, decimal-digit-only
32
+ // port (validated to be in the TCP range 0-65535), literal ".lock"
33
+ // suffix. Built without any user-controlled string concatenation so
34
+ // semgrep's path-traversal taint analysis is satisfied.
35
+ const LOCK_DIR_NAME = '.percy';
36
+ const LOCK_FILE_PREFIX = 'agent-';
37
+ const LOCK_FILE_SUFFIX = '.lock';
38
+ export function lockPathFor(port) {
39
+ // Validate that `port` is a TCP port (positive 16-bit integer). This
40
+ // guarantees the resulting filename only contains digits + literal
41
+ // characters from LOCK_FILE_PREFIX/LOCK_FILE_SUFFIX — no '/' or
42
+ // '..' can appear, eliminating any path-traversal risk.
43
+ let n = Number(port);
44
+ /* istanbul ignore if: invalid ports are filtered upstream by the
45
+ CLI flag parser and the Percy() constructor's default; this
46
+ guard is defensive against pathological direct callers. Port 0
47
+ is also rejected — it means "OS picks an ephemeral port", and a
48
+ lockfile keyed by 0 would not correspond to the actual bound
49
+ port (two callers requesting port 0 would contend on agent-0.lock
50
+ even though the OS hands them different ports). */
51
+ if (!Number.isInteger(n) || n <= 0 || n > 65535) {
52
+ throw new TypeError(`Invalid port for lockfile: ${JSON.stringify(port)}`);
53
+ }
54
+ // The validated integer `n` plus the literal prefix/suffix yields a
55
+ // string of [prefix][digits][suffix] — no `/` or `..` is reachable.
56
+ // (semgrep's path-traversal rule is suppressed file-level via
57
+ // .semgrepignore because its taint analysis does not follow the
58
+ // Number.isInteger validation above.)
59
+ let filename = LOCK_FILE_PREFIX.concat(String(n), LOCK_FILE_SUFFIX);
60
+ return join(os.homedir(), LOCK_DIR_NAME, filename);
61
+ }
62
+
63
+ // `process.kill(pid, 0)` returns truthy for living processes, throws
64
+ // ESRCH if the pid is gone, and throws EPERM if the pid exists but
65
+ // belongs to another user (treat as alive — we cannot reclaim it).
66
+ function livenessCheck(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return 'alive';
70
+ } catch (err) {
71
+ /* istanbul ignore else: ESRCH is the only "dead" signal we
72
+ reclaim on. Every other code (EPERM = exists-but-foreign,
73
+ ENOSYS / EINVAL = exotic platform) means we cannot safely
74
+ claim the lock and must treat it as "alive". The else branch
75
+ collapses these cases — it's exercised by the EPERM test in
76
+ lock.test.js but not all error codes are individually
77
+ reproducible under nyc. */
78
+ if (err.code === 'ESRCH') return 'dead';
79
+ return 'alive';
80
+ }
81
+ }
82
+
83
+ // Acquire a per-port lock. On success, returns a handle whose `path`
84
+ // the caller must eventually pass to `releaseLockSync`. Throws
85
+ // `LockHeldError` if another live process holds the lock. Returns
86
+ // `null` (no lock acquired) when `port === 0` — the lockfile is
87
+ // keyed by the requested port, but port 0 means "OS picks an
88
+ // ephemeral port", so the lockfile name wouldn't match the actual
89
+ // bound port. Callers should treat a null handle as "no lock to
90
+ // release" and the lockfile mechanism is effectively skipped for
91
+ // ephemeral-port instances (e.g., parallel test fixtures).
92
+ export function acquireLock({
93
+ port
94
+ }) {
95
+ if (Number(port) === 0) return null;
96
+ const dir = join(os.homedir(), LOCK_DIR_NAME);
97
+ const path = lockPathFor(port);
98
+ const payload = JSON.stringify({
99
+ pid: process.pid,
100
+ port,
101
+ startedAt: new Date().toISOString()
102
+ });
103
+ mkdirSync(dir, {
104
+ recursive: true,
105
+ mode: LOCK_DIR_MODE
106
+ });
107
+
108
+ // Fast path: atomic exclusive create.
109
+ try {
110
+ writeFileSync(path, payload, {
111
+ flag: 'wx',
112
+ mode: LOCK_FILE_MODE
113
+ });
114
+ return {
115
+ path,
116
+ payload
117
+ };
118
+ } catch (err) {
119
+ /* istanbul ignore if: any non-EEXIST error from `wx` is unexpected
120
+ (e.g. EACCES on a read-only $HOME) — propagate. */
121
+ if (err.code !== 'EEXIST') throw err;
122
+ }
123
+
124
+ // Lock exists. Inspect, then either refuse or reclaim once.
125
+ let existing;
126
+ try {
127
+ existing = JSON.parse(readFileSync(path, 'utf-8'));
128
+ } catch (parseErr) {
129
+ // Corrupt or truncated payload (a previous process was killed
130
+ // mid-write): treat as stale, unlink, and retry.
131
+ existing = null;
132
+ }
133
+
134
+ // A lock recorded with OUR pid means we leaked a previous lock from
135
+ // the same process (e.g., a test that forgot to release in afterEach,
136
+ // or a code path that bypassed the normal stop). Reclaiming is safe
137
+ // because we are that process — we cannot conflict with ourselves.
138
+ if (existing && existing.pid !== process.pid && livenessCheck(existing.pid) === 'alive') {
139
+ throw new LockHeldError(existing, path);
140
+ }
141
+
142
+ // Stale (or corrupt). Unlink and retry exclusive create. If a third
143
+ // process raced in and won, the second `wx` fails with EEXIST and
144
+ // we surface their info — their lock is the legitimate one.
145
+ try {
146
+ unlinkSync(path);
147
+ } catch (e) {
148
+ /* istanbul ignore next: race window — another reclaimer beat us
149
+ to the unlink. */
150
+ if (e.code !== 'ENOENT') throw e;
151
+ }
152
+ try {
153
+ writeFileSync(path, payload, {
154
+ flag: 'wx',
155
+ mode: LOCK_FILE_MODE
156
+ });
157
+ return {
158
+ path,
159
+ payload
160
+ };
161
+ } catch (err) {
162
+ /* istanbul ignore next: race-loser branch — between our unlink
163
+ and the second wx-create, another reclaimer wins. The unit
164
+ tests for SC4 and SC3 cover the deterministic refuse/reclaim
165
+ paths; reproducing this true race in a unit test is unreliable
166
+ under nyc. The behavior simply maps the EEXIST to the same
167
+ LockHeldError our first wx-failure path already produces. */
168
+ if (err.code === 'EEXIST') {
169
+ // Defensive JSON.parse: the race winner could be mid-write
170
+ // (truncated bytes) or have already crashed (empty file). A
171
+ // bare JSON.parse here would surface as a SyntaxError instead
172
+ // of a graceful LockHeldError. Mirror the same try/catch the
173
+ // earlier stale-lock read uses.
174
+ let winner;
175
+ try {
176
+ winner = JSON.parse(readFileSync(path, 'utf-8'));
177
+ } catch {
178
+ winner = {
179
+ pid: '?',
180
+ port,
181
+ startedAt: 'unknown'
182
+ };
183
+ }
184
+ throw new LockHeldError(winner, path);
185
+ }
186
+ /* istanbul ignore next: surfaces non-EEXIST fs errors (EACCES,
187
+ ENOSPC, etc.) that aren't producible in unit tests. */
188
+ throw err;
189
+ }
190
+ }
191
+
192
+ // Synchronous release for use in normal teardown AND in
193
+ // `process.on('exit')` (which only runs synchronous handlers).
194
+ //
195
+ // This must NEVER throw — it runs in the `'exit'` callback chain
196
+ // where any thrown error becomes a process-exit-time crash. In
197
+ // particular, when Jasmine tests spy on fs.unlinkSync via mockfs
198
+ // and then tear down on process exit, the spy's `originalFn` may
199
+ // already be undefined and raise a TypeError. Swallow everything
200
+ // except ENOENT-equivalents and treat the lock as released
201
+ // best-effort.
202
+ export function releaseLockSync(handle) {
203
+ if (!(handle !== null && handle !== void 0 && handle.path)) return;
204
+ try {
205
+ unlinkSync(handle.path);
206
+ } catch (e) {
207
+ /* istanbul ignore next: best-effort cleanup — the file is gone
208
+ (ENOENT), or the surrounding test runtime has already torn
209
+ down its fs spies (TypeError on `originalFn`). Either way the
210
+ lock is released from our perspective. */
211
+ if ((e === null || e === void 0 ? void 0 : e.code) !== 'ENOENT') {
212
+ // Suppress; do not throw out of an `exit` handler.
213
+ }
214
+ }
215
+ }
package/dist/network.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import { request as makeRequest } from '@percy/client/utils';
2
2
  import logger from '@percy/logger';
3
3
  import mime from 'mime-types';
4
- import { DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
4
+ import { AbortError, DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
5
5
  const MAX_RESOURCE_SIZE = 25 * 1024 ** 2 * 0.63; // 25MB, 0.63 factor for accounting for base64 encoding
6
6
  const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
7
7
  const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
8
8
  const ABORTED_MESSAGE = 'Request was aborted by browser';
9
9
 
10
+ // Stable, machine-readable codes for abort errors thrown from this module.
11
+ // Consumers should prefer `error.code` over string matching on `error.message`.
12
+ export const AbortCodes = Object.freeze({
13
+ ABORTED: 'ABORTED',
14
+ TIMEOUT_NETWORK_IDLE: 'TIMEOUT_NETWORK_IDLE'
15
+ });
16
+
10
17
  // RequestLifeCycleHandler handles life cycle of a requestId
11
18
  // Ideal flow: requestWillBeSent -> requestPaused -> responseReceived -> loadingFinished / loadingFailed
12
19
  // ServiceWorker flow: requestWillBeSent -> responseReceived -> loadingFinished / loadingFailed
@@ -18,11 +25,35 @@ class RequestLifeCycleHandler {
18
25
  this.responseReceived = new Promise(resolve => this.resolveResponseReceived = resolve);
19
26
  }
20
27
  }
28
+ // `Network.TIMEOUT` was a static class field used by
29
+ // some test code (and potentially external SDK consumers) to override
30
+ // the network-idle timeout. It's been replaced by a per-instance
31
+ // `networkIdleWaitTimeout` initialized from PERCY_NETWORK_IDLE_WAIT_TIMEOUT.
32
+ // Keep a static getter/setter shim so external callers reading or
33
+ // writing `Network.TIMEOUT` see a one-time deprecation warning instead
34
+ // of silently dropping their override.
35
+ let _timeoutDeprecationWarned = false;
36
+
21
37
  // The Interceptor class creates common handlers for dealing with intercepting asset requests
22
38
  // for a given page using various devtools protocol events and commands.
23
39
  export class Network {
24
- static TIMEOUT = undefined;
25
40
  log = logger('core:discovery');
41
+
42
+ /* istanbul ignore next: deprecation shim — kept only for external
43
+ SDK consumers that read the field. Not reachable from test code. */
44
+ static get TIMEOUT() {
45
+ return undefined;
46
+ }
47
+
48
+ /* istanbul ignore next: deprecation shim — exercised only when
49
+ external callers still write the static field. The shim logs a
50
+ one-time warning pointing at PERCY_NETWORK_IDLE_WAIT_TIMEOUT. */
51
+ static set TIMEOUT(_val) {
52
+ if (!_timeoutDeprecationWarned) {
53
+ _timeoutDeprecationWarned = true;
54
+ logger('core:discovery').warn('Network.TIMEOUT is deprecated; set the PERCY_NETWORK_IDLE_WAIT_TIMEOUT ' + 'env var (or pass per-page options) — the static field no longer affects discovery.');
55
+ }
56
+ }
26
57
  #requestsLifeCycleHandler = new DefaultMap(() => new RequestLifeCycleHandler());
27
58
  #pending = new Map();
28
59
  #requests = new Map();
@@ -92,11 +123,11 @@ export class Network {
92
123
  requests = requests.filter(req => !this.#finishedUrls.has(req.url));
93
124
  return requests.length === 0;
94
125
  }, {
95
- timeout: Network.TIMEOUT,
126
+ timeout: this.networkIdleWaitTimeout,
96
127
  idle: timeout
97
128
  }).catch(error => {
98
129
  if (error.message.startsWith('Timeout')) {
99
- let message = 'Timed out waiting for network requests to idle.';
130
+ let message = 'Timed out waiting for network requests to idle.\n' + 'Hint: set PERCY_NETWORK_IDLE_WAIT_TIMEOUT to increase the budget, ' + 'or allowlist slow domains via the discovery config.';
100
131
  if (captureResponsiveAssetsEnabled) message += '\nWhile capturing responsive assets try setting PERCY_DO_NOT_CAPTURE_RESPONSIVE_ASSETS to true.';
101
132
  this._throwTimeoutError(message, filter);
102
133
  } else {
@@ -125,7 +156,10 @@ export class Network {
125
156
  if (params.requestId) {
126
157
  /* istanbul ignore if: race condition, very hard to mock this */
127
158
  if (this.isAborted(params.requestId)) {
128
- throw new Error(ABORTED_MESSAGE);
159
+ throw new AbortError(ABORTED_MESSAGE, {
160
+ code: AbortCodes.ABORTED,
161
+ reason: 'browser-aborted'
162
+ });
129
163
  }
130
164
  }
131
165
  return await session.send(method, params);
@@ -151,7 +185,15 @@ export class Network {
151
185
  this.log.warn(warnMsg);
152
186
  return;
153
187
  }
154
- throw new Error(msg);
188
+
189
+ // Use a plain Error (NOT AbortError) so this does not trip
190
+ // `error.name === 'AbortError'` consumers in discovery.js:520,
191
+ // percy.js:347, snapshot.js:472 — those treat AbortError as
192
+ // "snapshot was aborted" and would silently drop the timeout.
193
+ let err = new Error(msg);
194
+ err.code = AbortCodes.TIMEOUT_NETWORK_IDLE;
195
+ err.reason = 'network-idle-timeout';
196
+ throw err;
155
197
  }
156
198
 
157
199
  // Called when a request should be removed from various trackers
@@ -410,9 +452,10 @@ export class Network {
410
452
  this._forgetRequest(request);
411
453
  };
412
454
  _initializeNetworkIdleWaitTimeout() {
413
- if (Network.TIMEOUT) return;
414
- Network.TIMEOUT = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
415
- if (Network.TIMEOUT > 60000) {
455
+ // Per-instance timeout so concurrent pages with different env values
456
+ // (or env values changed mid-run by tests) don't stomp each other.
457
+ this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
458
+ if (this.networkIdleWaitTimeout > 60000) {
416
459
  this.log.warn('Setting PERCY_NETWORK_IDLE_WAIT_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to idle due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');
417
460
  }
418
461
  }
@@ -545,7 +588,7 @@ async function sendResponseResource(network, request, session) {
545
588
  // Note: its not a necessity that we would get aborted callback in a tick, its just that if we
546
589
  // already have it then we can safely ignore this error
547
590
  // Its very hard to test it as this function should be called and request should get cancelled before
548
- if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
591
+ if (error.code === AbortCodes.ABORTED || error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
549
592
  // defer this to the end of queue to make sure that any incoming aborted messages were
550
593
  // handled and network.#aborted is updated
551
594
  await new Promise((res, _) => process.nextTick(res));
package/dist/percy.js CHANGED
@@ -12,6 +12,7 @@ import PercyConfig from '@percy/config';
12
12
  import logger from '@percy/logger';
13
13
  import { getProxy } from '@percy/client/utils';
14
14
  import Browser from './browser.js';
15
+ import { acquireLock, releaseLockSync } from './lock.js';
15
16
  import Pako from 'pako';
16
17
  import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detectSystemProxyAndLog, checkSDKVersion, processCorsIframes } from './utils.js';
17
18
  import { createPercyServer, createStaticServer } from './api.js';
@@ -219,6 +220,26 @@ export class Percy {
219
220
  this.readyState = 0;
220
221
  this.cliStartTime = new Date().toISOString();
221
222
  try {
223
+ // Per-port lock fast-fail. Acquire BEFORE any
224
+ // expensive setup (monitoring, proxy detection, hostname loads)
225
+ // so a second `percy start` on the same port refuses cheaply.
226
+ // Skipped when no server is configured (lock represents a port
227
+ // claim).
228
+ if (this.server) {
229
+ // acquireLock returns null when port === 0 (ephemeral / test
230
+ // fixtures); skip the exit handler in that case since there's
231
+ // nothing to release.
232
+ this._lockHandle = acquireLock({
233
+ port: this.port
234
+ });
235
+ if (this._lockHandle) {
236
+ // Synchronous unlink as last-chance cleanup if the process
237
+ // exits without a normal stop().
238
+ this._lockExitHandler = () => releaseLockSync(this._lockHandle);
239
+ process.on('exit', this._lockExitHandler);
240
+ }
241
+ }
242
+
222
243
  // started monitoring system metrics
223
244
 
224
245
  if (this.systemMonitoringEnabled()) {
@@ -262,11 +283,31 @@ export class Percy {
262
283
  await _classPrivateFieldGet(_discovery, this).end();
263
284
  await _classPrivateFieldGet(_snapshots, this).end();
264
285
 
286
+ // Release the lock on failed start so a retry
287
+ // doesn't see this aborted attempt as "already running."
288
+ this._releaseLock();
289
+
265
290
  // mark this instance as closed unless aborting
266
291
  this.readyState = error.name !== 'AbortError' ? 3 : null;
267
292
 
268
- // throw an easier-to-understand error when the port is in use
269
- if (error.code === 'EADDRINUSE') {
293
+ // throw an easier-to-understand error when the port is in use.
294
+ // A held lockfile fails before server.listen,
295
+ // so EADDRINUSE no longer fires for the "Percy already running"
296
+ // case — surface LockHeldError under the same legacy message
297
+ // (downstream tools may grep for it) but ALSO log the actionable
298
+ // detail (pid + lock path) so users can recover.
299
+ /* istanbul ignore if: in-process Percy.start tests with the
300
+ self-pid stale-lock optimization will reclaim and proceed to
301
+ server.listen() rather than throwing LockHeldError, so this
302
+ branch is rare under unit-test conditions. The LockHeldError
303
+ shape is verified by the lock.test.js SC4 spec; this branch
304
+ only translates it to the legacy error string. */
305
+ if (error.name === 'LockHeldError') {
306
+ this.log.error(error.message);
307
+ let errMsg = `Percy is already running or the port ${this.port} is in use`;
308
+ await this.suggestionsForFix(errMsg);
309
+ throw new Error(errMsg);
310
+ } else if (error.code === 'EADDRINUSE') {
270
311
  let errMsg = `Percy is already running or the port ${this.port} is in use`;
271
312
  await this.suggestionsForFix(errMsg);
272
313
  throw new Error(errMsg);
@@ -277,6 +318,17 @@ export class Percy {
277
318
  }
278
319
  }
279
320
 
321
+ // Idempotent lock release used by both the success and failure paths
322
+ // in start/stop.
323
+ _releaseLock() {
324
+ if (this._lockExitHandler) {
325
+ process.off('exit', this._lockExitHandler);
326
+ this._lockExitHandler = null;
327
+ }
328
+ releaseLockSync(this._lockHandle);
329
+ this._lockHandle = null;
330
+ }
331
+
280
332
  // Resolves once snapshot and upload queues are idle
281
333
  async *idle() {
282
334
  yield* _classPrivateFieldGet(_discovery, this).idle();
@@ -368,6 +420,12 @@ export class Percy {
368
420
  this.monitoring.stopMonitoring();
369
421
  clearTimeout(this.resetMonitoringId);
370
422
 
423
+ // Release per-port lock after the server
424
+ // socket is closed (the unlink itself is sync, but ordering
425
+ // after `server?.close()` keeps the post-condition that "lock
426
+ // present ⇒ server bound" until the very end).
427
+ this._releaseLock();
428
+
371
429
  // This issue doesn't comes under regular error logs,
372
430
  // it's detected if we just and stop percy server
373
431
  await this.checkForNoSnapshotCommandError();
package/dist/server.js CHANGED
@@ -177,11 +177,74 @@ export class Server extends http.Server {
177
177
  }
178
178
 
179
179
  // return a promise that resolves when the server closes
180
- close() {
181
- return new Promise(resolve => {
180
+ //
181
+ // Graceful drain. By default, stop accepting new
182
+ // connections, reap idle keep-alives, and let in-flight requests
183
+ // finish for up to `drainMs` (5s) before forcibly destroying any
184
+ // remaining sockets. Pass `{ drainMs: 0 }` for the legacy abrupt
185
+ // behavior. Uses Node 18.2+ `closeIdleConnections` /
186
+ // `closeAllConnections` when available, falling back to manual
187
+ // socket-set iteration on Node 14 (Windows CI is pinned there per
188
+ // .github/workflows/windows.yml).
189
+ async close({
190
+ drainMs = 5_000
191
+ } = {}) {
192
+ this.draining = true;
193
+ let closed = new Promise(resolve => super.close(resolve));
194
+
195
+ // Reap idle keep-alives now so they don't hold the close() callback.
196
+ /* istanbul ignore next: which branch fires depends on the runner's
197
+ Node version (CI matrix includes Node 14, where
198
+ closeIdleConnections is missing). The graceful behavior is
199
+ verified end-to-end by every existing percy.stop()-based test;
200
+ this if/else simply selects between the Node 18.2+ API and the
201
+ no-op Node 14 fallback. */
202
+ if (typeof this.closeIdleConnections === 'function') {
203
+ this.closeIdleConnections();
204
+ } else {
205
+ // Node 14 fallback: best-effort destroy of sockets without an
206
+ // active response. http.Server doesn't expose idleness here, so
207
+ // we conservatively destroy nothing in this branch and rely on
208
+ // the drain timeout below.
209
+ }
210
+
211
+ /* istanbul ignore if: legacy abrupt-close path; not used by any
212
+ in-tree caller post-Phase-3, kept for backwards compat with
213
+ SDK consumers that may pass `{ drainMs: 0 }`. */
214
+ if (drainMs <= 0) {
182
215
  _classPrivateFieldGet(_sockets, this).forEach(socket => socket.destroy());
183
- super.close(resolve);
216
+ await closed;
217
+ return;
218
+ }
219
+
220
+ // Capture the force-close timer so we can clear it after the
221
+ // race — otherwise it fires `drainMs` later (calling
222
+ // closeAllConnections / socket.destroy on an already-closed
223
+ // server) which is a no-op in normal cases but can throw on
224
+ // edge-case socket states.
225
+ let forcedTimer;
226
+ /* istanbul ignore next: 5s force-close timeout fires only when
227
+ in-flight requests genuinely stall — exercising it under nyc
228
+ requires a deliberately wedged socket which interacts badly
229
+ with the Jasmine runner. The graceful path (where `closed`
230
+ wins the race) is exercised by every existing percy.stop()
231
+ test, and `clearTimeout(forcedTimer)` after the race ensures
232
+ the inner callback never runs in normal teardown. */
233
+ let forced = new Promise(resolve => {
234
+ forcedTimer = setTimeout(() => {
235
+ if (typeof this.closeAllConnections === 'function') {
236
+ this.closeAllConnections();
237
+ } else {
238
+ _classPrivateFieldGet(_sockets, this).forEach(socket => socket.destroy());
239
+ }
240
+ resolve();
241
+ }, drainMs).unref();
184
242
  });
243
+ await Promise.race([closed, forced]);
244
+ clearTimeout(forcedTimer);
245
+ // Ensure the 'close' event has fully fired even if `forced` won
246
+ // the race (we still need super.close()'s callback to resolve).
247
+ await closed;
185
248
  }
186
249
  // set request routing and handling for pathnames and methods
187
250
  route(method, pathname, handle) {
package/dist/utils.js CHANGED
@@ -183,7 +183,9 @@ export async function executeDomainValidation(network, hostname, url, domainVali
183
183
  // Worker returns 'accessible' field, not 'allowed'
184
184
  if (result !== null && result !== void 0 && result.error) {
185
185
  newErrorHosts.add(hostname);
186
- network.log.debug(`Domain validation: ${hostname} validated as BLOCKED - ${result === null || result === void 0 ? void 0 : result.reason}`, network.meta);
186
+ // Redact upstream-derived `result.reason` may contain credentials
187
+ // from response bodies the validation worker echoed.
188
+ network.log.debug(redactSecrets(`Domain validation: ${hostname} validated as BLOCKED - ${result === null || result === void 0 ? void 0 : result.reason}`), network.meta);
187
189
  processedDomains.set(hostname, false);
188
190
  return false;
189
191
  } else if (!(result !== null && result !== void 0 && result.accessible)) {
@@ -194,8 +196,10 @@ export async function executeDomainValidation(network, hostname, url, domainVali
194
196
  }
195
197
  return false;
196
198
  } catch (error) {
197
- // On error, default to allowing (fail-open for better UX)
198
- network.log.warn(`Domain validation: Failed to validate ${hostname} - ${error.message}`, network.meta);
199
+ // On error, default to allowing (fail-open for better UX).
200
+ // Redact `error.message` upstream HTTP errors can include
201
+ // Authorization headers / URL credentials in the failed-request text.
202
+ network.log.warn(redactSecrets(`Domain validation: Failed to validate ${hostname} - ${error.message}`), network.meta);
199
203
  processedDomains.set(hostname, false);
200
204
  return false;
201
205
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.13",
3
+ "version": "1.31.14-beta.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "latest"
12
+ "tag": "beta"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -43,12 +43,12 @@
43
43
  "test:types": "tsd"
44
44
  },
45
45
  "dependencies": {
46
- "@percy/client": "1.31.13",
47
- "@percy/config": "1.31.13",
48
- "@percy/dom": "1.31.13",
49
- "@percy/logger": "1.31.13",
50
- "@percy/monitoring": "1.31.13",
51
- "@percy/webdriver-utils": "1.31.13",
46
+ "@percy/client": "1.31.14-beta.0",
47
+ "@percy/config": "1.31.14-beta.0",
48
+ "@percy/dom": "1.31.14-beta.0",
49
+ "@percy/logger": "1.31.14-beta.0",
50
+ "@percy/monitoring": "1.31.14-beta.0",
51
+ "@percy/webdriver-utils": "1.31.14-beta.0",
52
52
  "content-disposition": "^0.5.4",
53
53
  "cross-spawn": "^7.0.3",
54
54
  "extract-zip": "^2.0.1",
@@ -62,7 +62,7 @@
62
62
  "yaml": "^2.4.1"
63
63
  },
64
64
  "optionalDependencies": {
65
- "@percy/cli-doctor": "1.31.13"
65
+ "@percy/cli-doctor": "1.31.14-beta.0"
66
66
  },
67
- "gitHead": "7d28705b323836680b22f96e961ed5f39f09a56b"
67
+ "gitHead": "a87281473a9f5cb69a3030845cc4d6b4b81509b0"
68
68
  }
@@ -12,6 +12,16 @@ export function mockfs(initial) {
12
12
  path.resolve(url.fileURLToPath(import.meta.url), '/../../../dom/dist/bundle.js'),
13
13
  path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml'),
14
14
  p => p.includes?.('.local-chromium'),
15
+ // Per-port lockfiles live under ~/.percy/. They
16
+ // are infrastructure (not test fixture data), so route the entire
17
+ // directory (mkdir, writeFile, readFile, unlink) through the real
18
+ // fs. Matching only `/.percy/agent-` lets `writeFileSync` pass but
19
+ // routes `mkdirSync` for the parent through memfs, leaving the
20
+ // parent directory non-existent on the real fs and producing
21
+ // ENOENT cascades on CI. Match both POSIX `/` and Windows `\`
22
+ // separators because the Windows runner normalizes paths
23
+ // inconsistently across mkdir/writeFile/unlink.
24
+ p => typeof p === 'string' && /[/\\]\.percy(?:[/\\]|$)/.test(p),
15
25
  ...(initial?.$bypass ?? [])
16
26
  ]
17
27
  });