-
Notifications
You must be signed in to change notification settings - Fork 408
fix(test-optimization): retry payload delivery failures #10069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,41 +1,38 @@ | ||
| 'use strict' | ||
|
|
||
| const crypto = require('crypto') | ||
| const { test, expect } = require('@playwright/test') | ||
|
|
||
| const uuid = crypto.randomBytes(16).toString('hex') | ||
| .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5') | ||
|
|
||
| test.describe('dynamic name suite', () => { | ||
| test(`can do stuff at ${Date.now()}`, () => { | ||
| // Playwright loads this file during discovery and again in workers, so test names must match across processes. | ||
| test('can do stuff at 1750000000000', () => { | ||
| expect(1 + 2).toBe(3) | ||
| }) | ||
|
|
||
| test(`connects to localhost:${3000 + Math.floor(Math.random() * 60000)}`, () => { | ||
| test('connects to localhost:54321', () => { | ||
| expect(2 + 3).toBe(5) | ||
| }) | ||
|
|
||
| test(`user session ${uuid}`, () => { | ||
| test('user session 12345678-1234-1234-1234-123456789abc', () => { | ||
| expect(3 + 4).toBe(7) | ||
| }) | ||
|
|
||
| test(`created at ${new Date().toISOString()}`, () => { | ||
| test('created at 2026-08-31T12:34:56.789Z', () => { | ||
| expect(4 + 5).toBe(9) | ||
| }) | ||
|
|
||
| test(`event on ${new Date().toISOString().split('T')[0]}`, () => { | ||
| test('event on 2026-08-31', () => { | ||
| expect(5 + 6).toBe(11) | ||
| }) | ||
|
|
||
| test(`probability ${Math.random()}`, () => { | ||
| test('probability 0.1234567890', () => { | ||
| expect(6 + 7).toBe(13) | ||
| }) | ||
|
|
||
| test(`server at 127.0.0.1:${3000 + Math.floor(Math.random() * 60000)}`, () => { | ||
| test('server at 127.0.0.1:54322', () => { | ||
| expect(7 + 8).toBe(15) | ||
| }) | ||
|
|
||
| test(`bound to 0.0.0.0:${3000 + Math.floor(Math.random() * 60000)}`, () => { | ||
| test('bound to 0.0.0.0:54323', () => { | ||
| expect(8 + 9).toBe(17) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ const CiVisibilityExporter = require('../ci-visibility-exporter') | |
| const request = require('../request') | ||
| const { fetchAgentInfo } = require('../../../agent/info') | ||
| const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants') | ||
| const { FINAL_FLUSH_TIMEOUT } = require('../../final-flush') | ||
|
|
||
| // Product-specific discovery: newest advertised version, skip v3 (citestcycle), gzip if >= v4. | ||
| // Shared `evp_proxy` discovery is an explicit path allowlist and does not cover this contract. | ||
|
|
@@ -47,7 +48,16 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter { | |
| } = config | ||
|
|
||
| const initializationController = new AbortController() | ||
| const initializationOptions = { signal: initializationController.signal } | ||
| const initializationOptions = { | ||
| deadline: Date.now() + FINAL_FLUSH_TIMEOUT, | ||
| signal: initializationController.signal, | ||
| // Test runners await agent discovery before starting. A detached retry can let Node exit | ||
| // while that promise is still pending because promises alone do not keep the event loop alive. | ||
| keepProcessAlive: true, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the local Agent is down or unreachable, each fast Useful? React with 👍 / 👎. |
||
| // Loading a test framework can block the event loop past the payload creation-time timeout. | ||
| // The transport timeout still bounds each attempt, and the deadline bounds all retries. | ||
| timeoutFromCreation: false, | ||
| } | ||
| this._initializationRequest = { | ||
| controller: initializationController, | ||
| options: initializationOptions, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,6 +180,8 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { | |
| const timeout = options.timeout || 2000 | ||
| const payloadSize = reservedPayloadSize ?? getPayloadSize(data) | ||
| let retryTimer | ||
| let attemptTimer | ||
| let attemptTimerImmediate | ||
| let attemptController | ||
| let settled = false | ||
| let lastError | ||
|
|
@@ -197,6 +199,8 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { | |
| if (settled) return | ||
| settled = true | ||
| clearTimeout(retryTimer) | ||
| clearTimeout(attemptTimer) | ||
| clearImmediate(attemptTimerImmediate) | ||
| signal?.removeEventListener('abort', onAbort) | ||
| bufferedBytes -= payloadSize | ||
| callback(error, result, statusCode, headers) | ||
|
|
@@ -244,40 +248,66 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { | |
| if (!commonRequest.writable) { | ||
| waitingForBackpressure = true | ||
| retryTimer = setTimeout(attempt, Math.min(BACKPRESSURE_RETRY_MS, remaining), attemptIndex) | ||
| retryTimer.unref?.() | ||
| if (!options.keepProcessAlive) retryTimer.unref?.() | ||
| return | ||
| } | ||
| waitingForBackpressure = false | ||
|
|
||
| const attemptDeadline = deadline | ||
| const attemptOptions = { | ||
| ...options, | ||
| deferTimeoutAbort: true, | ||
| headers: options.headers ? { ...options.headers } : undefined, | ||
| retry: false, | ||
| } | ||
| if (deadline !== undefined) attemptOptions.timeout = Math.max(1, Math.min(timeout, remaining)) | ||
|
|
||
| const controller = new AbortController() | ||
| const attemptTimeout = attemptOptions.timeout || timeout | ||
| let attemptTimedOut = false | ||
|
|
||
| attemptController = controller | ||
| attemptOptions.signal = controller.signal | ||
| if (options.timeoutFromCreation !== false) { | ||
| attemptTimer = setTimeout(() => { | ||
| // Let a response that became ready while the event loop was blocked win before aborting it. | ||
| attemptTimerImmediate = setImmediate(() => { | ||
| if (settled || attemptController !== controller) return | ||
| attemptTimedOut = true | ||
| controller.abort(createRequestTimeoutError()) | ||
| }) | ||
| if (!options.keepProcessAlive) attemptTimerImmediate.unref?.() | ||
| }, attemptTimeout) | ||
| if (!options.keepProcessAlive) attemptTimer.unref?.() | ||
| } | ||
|
|
||
| commonRequest(data, attemptOptions, (error, result, statusCode, headers) => { | ||
| clearTimeout(attemptTimer) | ||
| clearImmediate(attemptTimerImmediate) | ||
| if (attemptController === controller) attemptController = undefined | ||
| if (settled) return | ||
| if (!error) { | ||
| complete(null, result, statusCode, headers) | ||
| return | ||
| } | ||
|
|
||
| lastError = error | ||
| const requestError = attemptTimedOut ? createRequestTimeoutError() : error | ||
| lastError = requestError | ||
|
|
||
| const responseStatus = statusCode ?? error.status | ||
| const isRetriableError = isRetriableNetworkError(error) || isRetriableHttpStatusCode(responseStatus) | ||
| const deadlineExtended = options.deadline !== undefined && | ||
| (attemptDeadline === undefined || options.deadline > attemptDeadline) | ||
| const reachedAttemptLimit = attemptIndex >= getMaxAttempts(attemptOptions) && !deadlineExtended | ||
| if (options.retry === false || !isRetriableError || reachedAttemptLimit) { | ||
| complete(error, result, statusCode, headers) | ||
| const isUnknownNetworkError = responseStatus === undefined && error.code === undefined | ||
| const isRetriableError = | ||
| attemptTimedOut || isRetriableNetworkError(error) || isUnknownNetworkError || | ||
| isRetriableHttpStatusCode(responseStatus) | ||
| const retryUntilDeadline = options.deadline !== undefined && options.retryUntilDeadline !== false | ||
| const reachedAttemptLimit = !retryUntilDeadline && attemptIndex >= getMaxAttempts(attemptOptions) | ||
| const reachedUnknownNetworkAttemptLimit = isUnknownNetworkError && attemptIndex >= 2 | ||
|
Comment on lines
+301
to
+303
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a final-flush request receives Useful? React with 👍 / 👎. |
||
| if ( | ||
| options.retry === false || | ||
| !isRetriableError || | ||
| reachedAttemptLimit || | ||
| reachedUnknownNetworkAttemptLimit | ||
| ) { | ||
| complete(requestError, result, statusCode, headers) | ||
| return | ||
| } | ||
|
|
||
|
|
@@ -288,11 +318,11 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { | |
| if (options.deadline !== undefined) { | ||
| const retryRemaining = options.deadline - Date.now() | ||
| if (resetDelay >= retryRemaining) { | ||
| complete(error, result, statusCode, headers) | ||
| complete(requestError, result, statusCode, headers) | ||
| return | ||
| } | ||
| } else if (resetDelay > RATE_LIMIT_MAX_WAIT_MS) { | ||
| complete(error, result, statusCode, headers) | ||
| complete(requestError, result, statusCode, headers) | ||
| return | ||
| } | ||
| retryDelay = resetDelay | ||
|
|
@@ -303,15 +333,15 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { | |
| if (options.deadline !== undefined && retryDelay === undefined) { | ||
| const retryRemaining = options.deadline - Date.now() | ||
| if (retryRemaining <= 0) { | ||
| complete(error, result, statusCode, headers) | ||
| complete(requestError, result, statusCode, headers) | ||
| return | ||
| } | ||
| const retryAttemptTimeout = timeout < retryRemaining ? timeout : Math.ceil(retryRemaining / 2) | ||
| delay = Math.min(delay, Math.max(0, retryRemaining - retryAttemptTimeout)) | ||
| } | ||
|
|
||
| retryTimer = setTimeout(attempt, delay, attemptIndex + 1) | ||
| retryTimer.unref?.() | ||
| if (!options.keepProcessAlive) retryTimer.unref?.() | ||
| }) | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the Agent is unavailable while a test framework blocks the event loop for more than 60 seconds, the queued connection error is processed against this already-expired deadline and the request cannot retry even if the Agent is now ready. The exporter then permanently selects the legacy writer and loses EVP features for the session.
CiVisibilityExporter.flush()already updates this options object with a fresh shutdown deadline, so setting it during construction reintroduces a startup-relative cutoff that should remain deferred until finalization.Useful? React with 👍 / 👎.