From 0204218829602261a4588e5b73615e9cb6551b43 Mon Sep 17 00:00:00 2001 From: rounak bhatia Date: Mon, 7 Sep 2026 16:24:54 +0530 Subject: [PATCH 1/2] fix(observability): bound orphaned-run finalization POST during shutdown (SDK-7518) finalizeOrphanedRuns() posts synthetic TestRun/HookRunFinished events immediately before the build-stop call, in both the onComplete path and the detached exit-cleanup rescue. That POST had no timeout, so a hung connection could stall shutdown before stopBuildUpstream ran, leaving the build "running" until the server-side inactivity timeout (~60 min). It fires only when an orphaned run exists (a worker killed mid-test), which is why it is intermittent and shows up more with multiple products enabled. Bound it with an optional per-call timeout on batchAndPostEvents, passed by finalizeOrphanedRuns (ORPHAN_FINALIZE_POST_TIMEOUT_MS), mirroring the already- hardened stopBuildUpstream. Normal in-run event uploads pass no timeout and are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/browserstack-service/src/constants.ts | 6 ++++++ .../src/testOps/openRunsJournal.ts | 4 ++-- packages/browserstack-service/src/util.ts | 13 +++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) 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) } } } From 10ac4ded54e976f46b9b33c72413cabeb3ff4ebb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:57:13 +0000 Subject: [PATCH 2/2] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-180.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-180.md 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.