From 61e29557834923dd2814db7a58c83601efa9e9a2 Mon Sep 17 00:00:00 2001 From: Jay Chak Date: Tue, 1 Sep 2026 12:04:32 +0530 Subject: [PATCH 1/3] fix: select runner image by exact alias match (#86) saveSnapshot filtered runner images by alias and then unconditionally used runner_images[0]. The list endpoint can return aliases that merely contain the requested one -- querying 'my-image' also matches 'my-image-arm64' -- and the ordering of those results is not guaranteed, so which alias 'wins' varied from run to run. That produced two failure modes: the action could update the wrong snapshot alias, or throw a spurious "isn't supported" arch/os mismatch after picking a sibling image built for a different architecture. Select the image whose alias is exactly equal to the requested alias instead of trusting the first element. When no exact match exists we now fall through to the create path, which is the correct behaviour for a genuinely new alias. Fixes #86 --- src/snapshotter/snapshotter.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/snapshotter/snapshotter.ts b/src/snapshotter/snapshotter.ts index 0511c52..b2718e6 100644 --- a/src/snapshotter/snapshotter.ts +++ b/src/snapshotter/snapshotter.ts @@ -246,7 +246,18 @@ echo "Presave complete" let nextVersionID = 0 this.logger.debug('Found the following runner images:') this.logger.debug(JSON.stringify(images, null, 2)) - if ((images.runner_images?.length ?? 0) > 0) { + + // The list endpoint can return aliases that merely contain the requested + // one (e.g. querying 'my-image' also matches 'my-image-arm64'), and the + // order of those results is not guaranteed. Pick the exact match rather + // than trusting the first element — otherwise which alias "wins" varies + // from run to run, and we can update the wrong image or fail with a + // spurious arch/os mismatch. + const existingImage = images.runner_images?.find( + image => image.alias === opts.runnerImageAlias + ) + + if (existingImage) { this.logger.info( `Snapshot alias '${opts.runnerImageAlias}' already exists` ) @@ -255,11 +266,10 @@ echo "Presave complete" opts.runnerImageAlias }' to new snapshot` ) - runnerImageID = images.runner_images?.[0].id || '' - const existingArch = images.runner_images?.[0].arch - const existingOs = images.runner_images?.[0].os - versionID = - images.runner_images?.[0].warpbuild_snapshot_image?.version_id ?? 0 + runnerImageID = existingImage.id || '' + const existingArch = existingImage.arch + const existingOs = existingImage.os + versionID = existingImage.warpbuild_snapshot_image?.version_id ?? 0 nextVersionID = versionID + 1 if (existingArch !== currArch) { throw new Error( From 20ce4e6cd1ca2f9f0cdcf5a95f4ca1ff9aee6a5c Mon Sep 17 00:00:00 2001 From: Jay Chak Date: Tue, 1 Sep 2026 12:05:38 +0530 Subject: [PATCH 2/3] chore: rebuild dist for exact alias match fix --- dist/index.js | 618 ++++++++++++++-------------------------------- dist/index.js.map | 2 +- 2 files changed, 184 insertions(+), 436 deletions(-) diff --git a/dist/index.js b/dist/index.js index 1c4996c..f65009a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6538,13 +6538,7 @@ function processHeader (request, key, val) { } else if (typeof val[i] === 'object') { throw new InvalidArgumentError(`invalid ${key} header`) } else { - // Coerce primitives (and reject unsafe coercions such as functions - // with a crafted toString/Symbol.toPrimitive). - const str = `${val[i]}` - if (!isValidHeaderValue(str)) { - throw new InvalidArgumentError(`invalid ${key} header`) - } - arr.push(str) + arr.push(`${val[i]}`) } } val = arr @@ -6555,12 +6549,7 @@ function processHeader (request, key, val) { } else if (val === null) { val = '' } else { - // Coerce primitives (and reject unsafe coercions such as functions - // with a crafted toString/Symbol.toPrimitive). val = `${val}` - if (!isValidHeaderValue(val)) { - throw new InvalidArgumentError(`invalid ${key} header`) - } } if (headerName === 'host') { @@ -7937,7 +7926,6 @@ const { RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, - InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -8803,7 +8791,7 @@ async function connectH1 (client, socket) { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]) + clearTimeout(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -8812,23 +8800,15 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST - // already pending on this idle keep-alive socket are processed before the - // next request is written (GHSA-35p6-xmwp-9g52). - // - // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse - // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll - // block for ~500ms when the event loop is otherwise idle (#5600 / #5606). - // A ref'd Immediate both keeps the pending request alive and makes poll - // return immediately — the hybrid those issues asked for. - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }) + }, 0) + socket[kIdleSocketValidationTimeout].unref?.() } /** @@ -8929,16 +8909,8 @@ function writeH1 (client, request) { } body = bodyStream.stream contentLength = bodyStream.length - } else if (util.isBlobLike(body) && request.contentType == null) { - const contentType = body.type - if (contentType) { - const contentTypeValue = `${contentType}` - if (!util.isValidHeaderValue(contentTypeValue)) { - util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header')) - return false - } - headers.push('content-type', contentTypeValue) - } + } else if (util.isBlobLike(body) && request.contentType == null && body.type) { + headers.push('content-type', body.type) } if (body && typeof body.read === 'function') { @@ -12425,28 +12397,6 @@ function calculateRetryAfterHeader (retryAfter) { return new Date(retryAfter).getTime() - current } -function validatePartialResponseContentLength (headers, range, statusCode, retryCount) { - const contentLength = headers['content-length'] - if (contentLength == null) { - return null - } - - if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { - return null - } - - const length = Number(contentLength) - const expectedLength = range.end - range.start + 1 - if (!Number.isFinite(length) || length !== expectedLength) { - return new RequestRetryError('Content-Length mismatch', statusCode, { - headers, - data: { count: retryCount } - }) - } - - return null -} - class RetryHandler { constructor (opts, handlers) { const { retryOptions, ...dispatchOpts } = opts @@ -12500,7 +12450,6 @@ class RetryHandler { this.end = null this.etag = null this.resume = null - this.headersSent = false // Handle possible onConnect duplication this.handler.onConnect(reason => { @@ -12513,20 +12462,6 @@ class RetryHandler { }) } - checkpointResponseEnd (headers, resume) { - if (this.end == null && this.opts.method !== 'HEAD') { - const contentLength = headers['content-length'] - this.end = contentLength != null ? Number(contentLength) - 1 : null - - assert( - this.end == null || Number.isFinite(this.end), - 'invalid content-length' - ) - } - - this.resume = this.end != null ? resume : null - } - onRequestSent () { if (this.handler.onRequestSent) { this.handler.onRequestSent() @@ -12616,8 +12551,6 @@ class RetryHandler { if (statusCode >= 300) { if (this.retryOpts.statusCodes.includes(statusCode) === false) { - this.headersSent = true - this.checkpointResponseEnd(headers, resume) return this.handler.onHeaders( statusCode, rawHeaders, @@ -12678,23 +12611,10 @@ class RetryHandler { return false } - const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount) - if (contentLengthError != null) { - this.abort(contentLengthError) - return false - } - const { start, size, end = size - 1 } = contentRange - if (this.start !== start || (this.end != null && this.end !== end)) { - this.abort( - new RequestRetryError('Content-Range mismatch', statusCode, { - headers, - data: { count: this.retryCount } - }) - ) - return false - } + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') this.resume = resume return true @@ -12706,7 +12626,6 @@ class RetryHandler { const range = parseRangeHeader(headers['content-range']) if (range == null) { - this.headersSent = true return this.handler.onHeaders( statusCode, rawHeaders, @@ -12715,12 +12634,6 @@ class RetryHandler { ) } - const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount) - if (contentLengthError != null) { - this.abort(contentLengthError) - return false - } - const { start, size, end = size - 1 } = range assert( start != null && Number.isFinite(start), @@ -12745,7 +12658,6 @@ class RetryHandler { ) this.resume = resume - this.headersSent = true this.etag = headers.etag != null ? headers.etag : null // Weak etags are not useful for comparison nor cache @@ -12785,7 +12697,7 @@ class RetryHandler { } onError (err) { - if (this.aborted || isDisturbed(this.opts.body) || (this.headersSent && this.resume == null)) { + if (this.aborted || isDisturbed(this.opts.body)) { return this.handler.onError(err) } @@ -16993,7 +16905,7 @@ function validateCookiePath (path) { if ( code < 0x20 || // exclude CTLs (0-31) - code > 0x7E || // exclude DEL and non-ascii + code === 0x7F || // DEL code === 0x3B // ; ) { throw new Error('Invalid cookie path') @@ -17002,80 +16914,16 @@ function validateCookiePath (path) { } /** - * ::= | - * - * ::= any one of the 52 alphabetic characters A through Z in - * upper case and a through z in lower case - * - * ::= any one of the ten digits 0 through 9r - * - * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5 - * @param {number} code - */ -function isLetterOrDigit (code) { - return ( - (code >= 0x30 && code <= 0x39) || // 0-9 - (code >= 0x41 && code <= 0x5A) || // A-Z - (code >= 0x61 && code <= 0x7A) // a-z - ) -} - -/** - * Validates a cookie domain against the "preferred name syntax". - * - * ::= | " " - * ::=