diff --git a/.changeset/pr-180.md b/.changeset/pr-180.md new file mode 100644 index 0000000..48c6536 --- /dev/null +++ b/.changeset/pr-180.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed builds occasionally showing as "running" for hours after the run finished, caused by an unbounded network request during shutdown. diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index c64bfa8..aca7554 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -256,6 +256,12 @@ export const STOP_BUILD_ATTEMPT_TIMEOUT_MS = 10000 export const STOP_BUILD_TOTAL_BUDGET_MS = 30000 export const STOP_BUILD_BACKOFF_BASE_MS = 1000 +// SDK-7518: finalizeOrphanedRuns posts synthetic TestRunFinished events during shutdown, +// immediately before the build-stop call. Bound that POST so a hung connection cannot stall +// shutdown before the build is stopped (which leaves the build "running" until the server-side +// inactivity timeout). Sibling to STOP_BUILD_ATTEMPT_TIMEOUT_MS on the same shutdown path. +export const ORPHAN_FINALIZE_POST_TIMEOUT_MS = 10000 + // API Endpoint constants export const UPDATED_CLI_ENDPOINT = 'sdk/v1/update_cli' diff --git a/packages/browserstack-service/src/testOps/openRunsJournal.ts b/packages/browserstack-service/src/testOps/openRunsJournal.ts index 9d2f62c..923a4e2 100644 --- a/packages/browserstack-service/src/testOps/openRunsJournal.ts +++ b/packages/browserstack-service/src/testOps/openRunsJournal.ts @@ -3,7 +3,7 @@ import fs from 'node:fs' import type { TestData, UploadType } from '../types.js' import { batchAndPostEvents } from '../util.js' -import { DATA_BATCH_ENDPOINT } from '../constants.js' +import { DATA_BATCH_ENDPOINT, ORPHAN_FINALIZE_POST_TIMEOUT_MS } from '../constants.js' import { BStackLogger } from '../bstackLogger.js' /** @@ -94,7 +94,7 @@ export async function finalizeOrphanedRuns(): Promise { } return { event_type: 'TestRunFinished', test_run: finishedRun } }) - await batchAndPostEvents(DATA_BATCH_ENDPOINT, 'ORPHANED_TEST_RUN_FINALIZATION', events) + await batchAndPostEvents(DATA_BATCH_ENDPOINT, 'ORPHANED_TEST_RUN_FINALIZATION', events, ORPHAN_FINALIZE_POST_TIMEOUT_MS) BStackLogger.info(`Finalized ${events.length} orphaned test/hook run(s) left behind by an interrupted run`) return events.length } catch (e) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 8c381e9..e6bbb0c 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1327,7 +1327,7 @@ export function shouldAddServiceVersion(config: Options.Testrunner, testObservab return true } -export async function batchAndPostEvents (eventUrl: string, kind: string, data: UploadType[]) { +export async function batchAndPostEvents (eventUrl: string, kind: string, data: UploadType[], timeoutMs?: number) { if (!process.env[TESTOPS_BUILD_COMPLETED_ENV]) { throw new Error('Build not completed yet') } @@ -1337,6 +1337,12 @@ export async function batchAndPostEvents (eventUrl: string, kind: string, data: throw new Error('Missing authentication Token') } + // SDK-7518: an optional per-call timeout. When set (by shutdown-path callers such as + // finalizeOrphanedRuns), bound the fetch with an AbortController so a hung connection cannot + // stall shutdown before the build-stop call. Left unset by normal in-run callers, whose + // behaviour is unchanged. + const controller = timeoutMs ? new AbortController() : undefined + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined try { const url = `${APIUtils.DATA_ENDPOINT}/${eventUrl}` const response = await fetch(url, { @@ -1345,7 +1351,8 @@ export async function batchAndPostEvents (eventUrl: string, kind: string, data: ...DEFAULT_REQUEST_CONFIG.headers, 'Authorization': `Bearer ${jwtToken}` }, - body: JSON.stringify(data) + body: JSON.stringify(data), + signal: controller?.signal }) // read as text first: error responses (401/5xx) and empty bodies are not JSON, and a blind // response.json() surfaced them as a misleading "Unexpected end of JSON input" @@ -1357,6 +1364,8 @@ export async function batchAndPostEvents (eventUrl: string, kind: string, data: } catch (error) { BStackLogger.debug(`[${kind}] EXCEPTION IN ${kind} REQUEST TO TEST REPORTING AND ANALYTICS : ${error}`) throw new Error('Exception in request ' + error) + } finally { + if (timeoutId) { clearTimeout(timeoutId) } } }