Skip to content

Commit 442821a

Browse files
fix(browserstack-service): flush build-stop event on process kill (SDK-6050)
When the WDIO process is killed mid-run (Ctrl+C / SIGINT / SIGTERM), the service previously relied on a synchronous process.on('exit') handler that cannot await the async build-stop call. As a result, TestHub never received a terminal event for the build and waited out its inactivity timeout, leaving the build in "Unknown Test Status" / TEST_TIMEOUT_WITH_BUILD_SUCCESS for hours. This change mirrors browserstack-node-agent's intExitHandler / TestHubHandler.stop(signal) pattern: - Register async handlers for SIGINT, SIGTERM, SIGHUP, SIGABRT, SIGQUIT (and SIGBREAK on Windows). Each awaits the existing build-stop call with a bounded 10-second grace, then exits with the POSIX code (128 + signum). Idempotent: first signal wins; subsequent signals are ignored. - Snapshot and remove pre-existing listeners for those signals at install time, so create-wdio's synchronous process.exit hook and async-exit-hook (registered transitively by @wdio/cli) cannot kill our async cleanup mid-await. - Thread the signal through BrowserstackCLI.stop(signal) and GrpcClient.stopBinSession(signal) so the StopBinSessionRequest carries { exitSignal, exitReason: 'user_killed', exitCode: 1 }. The binary's onStop reads these and forwards them to the TestHub stop API as finished_metadata: [{ reason: 'user_killed', signal }] -- matching the shape node-sdk and java-agent already send. - For Direct (non-CLI) mode, stopBuildUpstream(signal) adds the same finished_metadata payload directly to the PUT body. Verified end-to-end with a 6-cell matrix (Direct + CLI x normal/SIGINT/ SIGTERM). Kill cells now emit the stop tokens, return POSIX exit codes (130 for SIGINT, 143 for SIGTERM), and TestHub receives the build-stop PUT with finished_metadata.{reason: 'user_killed', signal} as confirmed by binary CLI logs.
1 parent 876fb8f commit 442821a

4 files changed

Lines changed: 182 additions & 10 deletions

File tree

packages/wdio-browserstack-service/src/cli/grpcClient.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,17 @@ export class GrpcClient {
244244

245245
/**
246246
* Stop the bin session
247+
* @param {string} [signal] - Optional POSIX signal name. When set, populates
248+
* `exitSignal`, `exitReason='user_killed'`, `exitCode=1` on the StopBinSessionRequest
249+
* so the binary's testhub `onStop` (`browserstack-binary/packages/@browserstack/testhub/index.js:63-89`)
250+
* reads them and pushes `finished_metadata: [{reason, signal}]` to the TestHub stop API.
251+
* Mirrors browserstack-node-agent `src/bin/v2/grpcClient.js stopBinSession()` (SDK-6050).
247252
* @returns {Promise<void>}
248253
* @private
249254
*/
250-
async stopBinSession() {
255+
async stopBinSession(signal: NodeJS.Signals | null = null) {
251256
PerformanceTester.start(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_CLI_ON_STOP)
252-
this.logger.debug('Stopping bin session')
257+
this.logger.debug(`Stopping bin session${signal ? ` (signal=${signal})` : ''}`)
253258

254259
try {
255260
if (!this.binSessionId) {
@@ -261,12 +266,18 @@ export class GrpcClient {
261266
}
262267

263268
const clientWorkerId = CLIUtils.getClientWorkerId()
264-
const request = StopBinSessionRequestConstructor.create({
269+
const requestPayload: Record<string, unknown> = {
265270
binSessionId: this.binSessionId
266-
})
271+
}
272+
if (signal) {
273+
requestPayload.exitSignal = signal
274+
requestPayload.exitReason = 'user_killed'
275+
requestPayload.exitCode = 1
276+
}
277+
const request = StopBinSessionRequestConstructor.create(requestPayload)
267278
// Add clientWorkerId to request (proto field 500)
268279
;(request as unknown as Record<string, unknown>).clientWorkerId = clientWorkerId
269-
this.logger.debug(`StopBinSession with clientWorkerId: ${clientWorkerId}`)
280+
this.logger.debug(`StopBinSession with clientWorkerId: ${clientWorkerId}${signal ? ` exitSignal=${signal} exitReason=user_killed` : ''}`)
270281

271282
// Get response from gRPC call
272283
const stopBinSessionPromise = promisify(this.client!.stopBinSession).bind(this.client!)

packages/wdio-browserstack-service/src/cli/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,14 +327,18 @@ export class BrowserstackCLI {
327327

328328
/**
329329
* Stop the CLI
330+
* @param {string} [signal] - Optional POSIX signal name (e.g. 'SIGINT') when stop is
331+
* triggered by a process signal. Threaded into the gRPC StopBinSessionRequest so the
332+
* binary can mark TestHub `finished_metadata.{reason:'user_killed', signal}` —
333+
* mirrors browserstack-node-agent's `intExitHandler`/`stopBinSession` (see SDK-6050).
330334
* @returns {Promise<void>}
331335
*/
332-
async stop() {
336+
async stop(signal: NodeJS.Signals | null = null) {
333337
PerformanceTester.start(PerformanceEvents.SDK_CLI_ON_STOP)
334-
this.logger.debug('stop: CLI stop triggered')
338+
this.logger.debug(`stop: CLI stop triggered${signal ? ` (signal=${signal})` : ''}`)
335339
try {
336340
if (this.isMainConnected) {
337-
const response = await GrpcClient.getInstance().stopBinSession()
341+
const response = await GrpcClient.getInstance().stopBinSession(signal)
338342
BStackLogger.debug(`stop: stopBinSession response=${JSON.stringify(response)}`)
339343
}
340344

packages/wdio-browserstack-service/src/exitHandler.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,149 @@ import PerformanceTester from './instrumentation/performance/performance-tester.
88
import TestOpsConfig from './testOps/testOpsConfig.js'
99
import { BStackLogger } from './bstackLogger.js'
1010
import { BrowserstackCLI } from './cli/index.js'
11+
import { stopBuildUpstream } from './util.js'
1112

1213
const __filename = fileURLToPath(import.meta.url)
1314
const __dirname = path.dirname(__filename)
1415

16+
// SDK-6050: bounded grace period for async signal-driven build-stop cleanup
17+
const SIGNAL_CLEANUP_GRACE_MS = 10_000
18+
19+
// Idempotency guard — once cleanup starts, ignore subsequent signals (R5/R6).
20+
// Module-scope so multiple service instances / multiple signals converge here.
21+
let signalCleanupInFlight = false
22+
let signalHandlersInstalled = false
23+
24+
/**
25+
* SDK-6050: async signal handler that mirrors the graceful `onComplete()` build-stop
26+
* (`launcher.ts:586-590`) so killing the WDIO process mid-run still emits a stop event
27+
* to TestHub. Without this, only the synchronous `process.on('exit')` runs — which
28+
* cannot await `BrowserstackCLI.stop()` or `stopBuildUpstream()`.
29+
*
30+
* Behavior:
31+
* - SIGINT, SIGTERM, SIGHUP, SIGABRT, SIGQUIT (+ SIGBREAK on Windows).
32+
* - First signal triggers cleanup; subsequent signals are ignored.
33+
* - Cleanup runs with a {@link SIGNAL_CLEANUP_GRACE_MS} timeout — process exits anyway after.
34+
* - After cleanup (or timeout), exit code 128+signum so callers can distinguish.
35+
*/
36+
function setupSignalHandlers() {
37+
if (signalHandlersInstalled) {
38+
return
39+
}
40+
signalHandlersInstalled = true
41+
42+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGABRT', 'SIGQUIT']
43+
if (process.platform === 'win32') {
44+
signals.push('SIGBREAK')
45+
}
46+
47+
// Signal-number mapping for exit-code (POSIX convention: 128 + signum).
48+
const signalToNum: Record<string, number> = {
49+
SIGINT: 2,
50+
SIGTERM: 15,
51+
SIGHUP: 1,
52+
SIGABRT: 6,
53+
SIGQUIT: 3,
54+
SIGBREAK: 21,
55+
}
56+
57+
const handler = async (signal: NodeJS.Signals) => {
58+
if (signalCleanupInFlight) {
59+
BStackLogger.debug(`Signal ${signal} received again; cleanup already in flight, ignoring`)
60+
return
61+
}
62+
signalCleanupInFlight = true
63+
64+
// Defense-in-depth: re-prune any listeners that may have been added
65+
// after install. Note that Node snapshots the listener array at the
66+
// start of emit() — removals here do NOT prevent already-snapshotted
67+
// listeners from firing in THIS emit cycle. That's why the primary
68+
// prune happens at install time (see setupSignalHandlers below).
69+
try {
70+
const listeners = process.listeners(signal).slice()
71+
for (const l of listeners) {
72+
if (l !== currentHandlerWrappers.get(signal)) {
73+
process.removeListener(signal, l as (...args: unknown[]) => void)
74+
}
75+
}
76+
} catch {
77+
// best-effort; never let listener-manipulation errors block cleanup
78+
}
79+
80+
BStackLogger.debug(`Signal ${signal} received; starting async build-stop cleanup (grace ${SIGNAL_CLEANUP_GRACE_MS}ms)`)
81+
82+
const exitCode = 128 + (signalToNum[signal] ?? 0)
83+
84+
const cleanup = (async () => {
85+
try {
86+
const isCLIEnabled = BrowserstackCLI.getInstance().isRunning()
87+
BStackLogger.debug(`Sending stop launch event (signal=${signal}, mode=${isCLIEnabled ? 'cli' : 'direct'})`)
88+
await (isCLIEnabled ? BrowserstackCLI.getInstance().stop(signal) : stopBuildUpstream(signal))
89+
BrowserStackConfig.getInstance().testObservability.buildStopped = true
90+
BStackLogger.debug(`Build-stop completed for signal ${signal}`)
91+
} catch (err) {
92+
BStackLogger.debug(`Build-stop on signal ${signal} failed: ${err}`)
93+
}
94+
})()
95+
96+
const timeout = new Promise<void>((resolve) => setTimeout(() => {
97+
BStackLogger.debug(`Signal cleanup grace period (${SIGNAL_CLEANUP_GRACE_MS}ms) elapsed for ${signal}; exiting`)
98+
resolve()
99+
}, SIGNAL_CLEANUP_GRACE_MS))
100+
101+
await Promise.race([cleanup, timeout])
102+
103+
// After cleanup (or timeout), exit so Node doesn't continue indefinitely.
104+
process.exit(exitCode)
105+
}
106+
107+
// Track which wrapper we registered per-signal, so the listener-pruning
108+
// above can identify and preserve ours while removing competing handlers.
109+
const currentHandlerWrappers = new Map<NodeJS.Signals, (...args: unknown[]) => void>()
110+
111+
for (const sig of signals) {
112+
const wrapper = () => {
113+
// Fire-and-forget; node ignores the returned promise on signal handlers anyway.
114+
handler(sig).catch((err) => {
115+
BStackLogger.debug(`Unhandled error in signal cleanup for ${sig}: ${err}`)
116+
process.exit(128 + (signalToNum[sig] ?? 0))
117+
})
118+
}
119+
currentHandlerWrappers.set(sig, wrapper)
120+
121+
// Critical: snapshot AND remove existing listeners for THIS signal at
122+
// install time. Once an emit() begins, Node iterates a frozen snapshot
123+
// of the listener array — removals during emit have NO effect on the
124+
// current emit cycle. Competing handlers (e.g. create-wdio's top-level
125+
// `process.on('SIGINT', () => process.exit(1))` at
126+
// packages/create-wdio/src/utils.ts:29, and `async-exit-hook`'s SIGINT
127+
// hook registered transitively by @wdio/cli) call `process.exit()`
128+
// synchronously, killing our async cleanup mid-await.
129+
//
130+
// setupExitHandlers() is invoked from the BrowserstackLauncherService
131+
// constructor (launcher.ts:88), which runs AFTER @wdio/cli has loaded
132+
// create-wdio and async-exit-hook — so by this point their handlers
133+
// are already registered and we can prune them cleanly.
134+
try {
135+
const existing = process.listeners(sig).slice()
136+
for (const l of existing) {
137+
process.removeListener(sig, l as (...args: unknown[]) => void)
138+
}
139+
if (existing.length > 0) {
140+
BStackLogger.debug(`Removed ${existing.length} pre-existing ${sig} listener(s) at install time`)
141+
}
142+
} catch {
143+
// best-effort; never let listener-manipulation errors block install
144+
}
145+
146+
// Now register ours. prependListener (rather than on) is belt-and-braces
147+
// in case a later library still manages to slip a listener in front.
148+
process.prependListener(sig, wrapper)
149+
}
150+
}
151+
15152
export function setupExitHandlers() {
153+
setupSignalHandlers()
16154
const handleCLICleanup = () => {
17155
BStackLogger.debug('Handling CLI cleanup in exit handler')
18156
try {

packages/wdio-browserstack-service/src/util.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,15 @@ export const getA11yResultsSummary = PerformanceTester.measureWrapper(PERFORMANC
708708
}
709709
})
710710

711-
export const stopBuildUpstream = PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.TESTHUB_EVENTS.STOP, o11yErrorHandler(async function stopBuildUpstream() {
711+
/**
712+
* Stop the TestHub build (Direct flow).
713+
* @param {string} [signal] - Optional POSIX signal name (e.g. 'SIGINT'). When set, includes
714+
* `finished_at` and `finished_metadata: [{reason: 'user_killed', signal, failure_data: ''}]`
715+
* in the PUT payload so the TestHub dashboard renders "Stopped/Aborted" instead of "Unknown".
716+
* Mirrors browserstack-node-agent `src/helpers/testhub/testhubHandler.js TestHubHandler.stop(signal)`
717+
* (SDK-6050).
718+
*/
719+
export const stopBuildUpstream = PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.TESTHUB_EVENTS.STOP, o11yErrorHandler(async function stopBuildUpstream(signal: NodeJS.Signals | null = null) {
712720
const stopBuildUsage = UsageStats.getInstance().stopBuildUsage
713721
stopBuildUsage.triggered()
714722
if (!process.env[TESTOPS_BUILD_COMPLETED_ENV]) {
@@ -727,9 +735,20 @@ export const stopBuildUpstream = PerformanceTester.measureWrapper(PERFORMANCE_SD
727735
message: 'Token/buildID is undefined, build creation might have failed'
728736
}
729737
}
730-
const data = {
738+
const data: Record<string, unknown> = {
731739
'stop_time': (new Date()).toISOString()
732740
}
741+
if (signal) {
742+
// SDK-6050: signal-aware build stop. Server reads `finished_metadata[0]` and
743+
// marks the build "Stopped" instead of the "Unknown / inactivity timeout" state.
744+
data['finished_at'] = data['stop_time']
745+
data['finished_metadata'] = [{
746+
reason: 'user_killed',
747+
signal,
748+
failure_data: ''
749+
}]
750+
BStackLogger.debug(`[STOP_BUILD] signal=${signal} reason=user_killed`)
751+
}
733752

734753
try {
735754
const url = `${APIUtils.DATA_ENDPOINT}/api/v1/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]}/stop`

0 commit comments

Comments
 (0)