Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/pr-86.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": minor
---

- Fixed SDK logs not being uploaded when a test run is interrupted (Ctrl-C or CI job cancellation); interrupted runs are now correctly reported with their termination reason.
29 changes: 28 additions & 1 deletion packages/browserstack-service/src/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getErrorString, stopBuildUpstream } from './util.js'
import { getErrorString, stopBuildUpstream, uploadLogs } from './util.js'
import { finalizeOrphanedRuns } from './testOps/openRunsJournal.js'
import { BStackLogger } from './bstackLogger.js'
import fs from 'node:fs'
Expand All @@ -19,6 +19,10 @@ export default class BStackCleanup {
const filePath = process.argv[index + 1]
funnelData = BStackCleanup.getFunnelDataFromFile(filePath)
}
// Snapshot before sendFunnelData — fireFunnelRequest redacts the
// credentials in place.
const funnelUser = funnelData?.userName
const funnelKey = funnelData?.accessKey

if (process.argv.includes('--observability') && funnelData) {
await this.executeObservabilityCleanup(funnelData)
Expand All @@ -27,6 +31,12 @@ export default class BStackCleanup {
if (funnelDataCleanup && funnelData) {
await this.sendFunnelData(funnelData)
}

// Rescue the SDK-log upload for runs whose launcher never reached
// onComplete (signal termination) — after events, before exit.
if (process.argv.includes('--uploadLogs')) {
await this.executeLogsUpload(funnelUser, funnelKey)
}
} catch (err) {
const error = err as string
BStackLogger.error(error)
Expand All @@ -40,6 +50,23 @@ export default class BStackCleanup {
BStackLogger.debug(`Error in sending events data ${util.format(er)}`)
}
}

static async executeLogsUpload(funnelUser?: string, funnelKey?: string) {
try {
const index = process.argv.indexOf('--uploadLogs')
const clientBuildUuid = process.argv[index + 1]
const user = funnelUser || process.env.BROWSERSTACK_USERNAME
const key = funnelKey || process.env.BROWSERSTACK_ACCESS_KEY
if (!clientBuildUuid || !user || !key) {
BStackLogger.debug('Skipping logs upload in cleanup: missing uuid or credentials')
return
}
BStackLogger.debug(`Uploading SDK logs from cleanup for ${clientBuildUuid}`)
await uploadLogs(user, key, clientBuildUuid)
} catch (e: unknown) {
BStackLogger.error('Error uploading SDK logs in cleanup: ' + getErrorString(e))
}
}
static async executeObservabilityCleanup(funnelData: FunnelData) {
if (!process.env[BROWSERSTACK_TESTHUB_JWT]) {
return
Expand Down
6 changes: 5 additions & 1 deletion packages/browserstack-service/src/cli/grpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,12 @@ export class GrpcClient {
}

const clientWorkerId = CLIUtils.getClientWorkerId()
// Forward the interrupt signal so the binary stamps
// finished_metadata on the build stop.
const killSignal = process.env.BROWSERSTACK_SDK_KILL_SIGNAL
const request = StopBinSessionRequestConstructor.create({
binSessionId: this.binSessionId
binSessionId: this.binSessionId,
...(killSignal ? { exitSignal: killSignal, exitReason: 'user_killed' } : {})
})
// Add clientWorkerId to request (proto field 500)
;(request as unknown as Record<string, unknown>).clientWorkerId = clientWorkerId
Expand Down
6 changes: 6 additions & 0 deletions packages/browserstack-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class BrowserStackConfig {
public percyBuildId?: number | null
public isPercyAutoEnabled = false
public sdkRunID: string
public killSignal?: string
public logsUploaded: boolean = false

constructor(
options: BrowserstackConfig & Options.Testrunner,
Expand Down Expand Up @@ -116,6 +118,10 @@ class BrowserStackConfig {
this.funnelDataSent = true
}

setKillSignal(signal: string) {
this.killSignal = signal
}

}

export default BrowserStackConfig
3 changes: 3 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export const TESTOPS_SCREENSHOT_ENV = 'BS_TESTOPS_ALLOW_SCREENSHOTS'

// To store build hashed id
export const BROWSERSTACK_TESTHUB_UUID = 'BROWSERSTACK_TESTHUB_UUID'
// Interrupt signal, propagated via env so the detached cleanup child and gRPC
// stop path can stamp the kill reason onto the build stop.
export const BROWSERSTACK_KILL_SIGNAL = 'BROWSERSTACK_SDK_KILL_SIGNAL'

// To store test run uuid
export const TEST_ANALYTICS_ID = 'TEST_ANALYTICS_ID'
Expand Down
41 changes: 40 additions & 1 deletion packages/browserstack-service/src/exitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,29 @@ import path from 'node:path'
import BrowserStackConfig from './config.js'
import { saveFunnelData } from './instrumentation/funnelInstrumentation.js'
import { fileURLToPath } from 'node:url'
import { BROWSERSTACK_TESTHUB_JWT } from './constants.js'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import TestOpsConfig from './testOps/testOpsConfig.js'
import { BStackLogger } from './bstackLogger.js'
import { BrowserstackCLI } from './cli/index.js'
import { BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, BROWSERSTACK_KILL_SIGNAL } from './constants.js'

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

const SIGNAL_EXIT_CODES: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGTERM: 15, SIGBREAK: 21 }
const FORCED_EXIT_GRACE_MS = 5000

function getInterruptSignals(): NodeJS.Signals[] {
const allSignals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP']
if (process.platform !== 'win32') {
allSignals.push('SIGABRT', 'SIGQUIT')
} else {
// For windows Ctrl+Break
allSignals.push('SIGBREAK')
}
return allSignals
}

export function setupExitHandlers() {
const handleCLICleanup = () => {
BStackLogger.debug('Handling CLI cleanup in exit handler')
Expand Down Expand Up @@ -54,6 +68,24 @@ export function setupExitHandlers() {
childProcess.unref()
}
})

getInterruptSignals().forEach((sig) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — signal handlers never re-exit. These process.on(sig,…) handlers only set the kill flag; attaching a listener suppresses Node's default termination. SIGTERM (the primary CI-cancellation path) and SIGHUP/SIGQUIT/SIGABRT no longer exit the process — it hangs until CI sends SIGKILL, which fires no 'exit' event, so the cleanup child never spawns and logs are not uploaded. After stamping kill metadata, drive a deterministic exit (re-raise via process.kill(process.pid, sig) or process.exit(128+n)) without preempting WDIO's own graceful SIGINT/SIGTERM shutdown.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f48766 — valid catch. The listener now arms an unref()'d 5s grace timer after stamping the signal and forces process.exit(128 + n) (SIGTERM→143) if nothing else has terminated the process. WDIO's own graceful shutdown gets first shot (a naturally exiting process is never held open — timer is unref'd); process.exit still fires the 'exit' listener, so the cleanup child spawns and the log/funnel/build-stop rescue runs. 5s stays inside typical CI kill grace (e.g. GitHub Actions ~7.5s before SIGKILL). Unit-tested with fake timers (no exit pre-grace, exit 143 post-grace).

process.on(sig, () => {
BStackLogger.debug(`${sig} received, setting kill signal`)
BrowserStackConfig.getInstance().setKillSignal(sig)
process.env[BROWSERSTACK_KILL_SIGNAL] = sig

// Listening on a signal suppresses Node's default termination. Give the
// runner's own shutdown a grace window, then force the conventional 128+n
// exit — a hung shutdown would otherwise live until CI's SIGKILL, which
// fires no 'exit' event and skips the cleanup rescue. unref() so a
// naturally exiting process is never held open.
const timer = setTimeout(() => {
process.exit(128 + (SIGNAL_EXIT_CODES[sig] || 0))
}, FORCED_EXIT_GRACE_MS)
timer.unref()
})
})
}

export function shouldCallCleanup(config: BrowserStackConfig, isCLIEnabled = false): string[] {
Expand All @@ -74,5 +106,12 @@ export function shouldCallCleanup(config: BrowserStackConfig, isCLIEnabled = fal
args.push('--performanceData')
}

// A signal-terminated run never reaches onComplete's log upload, leaving the
// build with no SDK-log object — rescue it from the detached cleanup process.
const clientBuildUuid = process.env[BROWSERSTACK_TESTHUB_UUID] || config.sdkRunID
if (!config.logsUploaded && config.userName && config.accessKey && clientBuildUuid) {
args.push('--uploadLogs', clientBuildUuid)
}

return args
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ async function fireFunnelTestEvent(eventType: string, config: BrowserStackConfig
const data = buildEventData(eventType, config, isCLIEnabled)
await fireFunnelRequest(data)
BStackLogger.debug('Funnel event success')
config.sentFunnelData()
// Only the finish event disarms the exit-time cleanup resend — marking it
// on SDKTestAttempted left killed runs with no SDKTestSuccessful at all.
if (eventType === 'SDKTestSuccessful') {
config.sentFunnelData()
}
} catch (error) {
BStackLogger.debug(`Exception in sending funnel data: ${format(error)}`)
}
Expand Down Expand Up @@ -164,6 +168,11 @@ function buildEventData(eventType: string, config: BrowserStackConfig, isCLIEnab
if (reloadHappened) {
eventProperties.finishedMetadata = { reason: 'session_reloaded' }
}
// A signal-terminated run must be query-detectable — kill wins over
// reload as the finish reason.
if (config.killSignal) {
eventProperties.finishedMetadata = { reason: 'user_killed', signal: config.killSignal }
}
}

return {
Expand Down
7 changes: 6 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,12 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
// measureWrapper is no longer needed here.
const clientBuildUuid = this._getClientBuildUuid()
const response = await uploadLogs(getBrowserStackUser(this._config), getBrowserStackKey(this._config), clientBuildUuid)
if (response) {
// Treat a truthy response carrying a non-success status as a server-side
// rejection, not a delivery — a delivered upload must not be repeated by
// the exit-time cleanup rescue; failed/skipped uploads stay eligible for it.
const delivered = !!response && !(response.status && response.status !== 'success')
if (delivered) {
this.browserStackConfig.logsUploaded = true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — logsUploaded set only on a truthy response. If the upload succeeds but returns an empty body, the flag stays false and the detached cleanup child re-uploads. Wasteful, not harmful.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f48766 — and your comment surfaced a worse inverse case: a truthy server rejection (response.status !== 'success', which uploadLogs records as a failure) also set logsUploaded = true, disabling the rescue for a build whose upload never landed. Now delivered = !!response && !(response.status && response.status !== 'success') gates the flag. The empty-body-success case can't be distinguished from a transport failure (nodeRequest returns undefined for both on this path), so it stays rescue-eligible — wasteful-but-safe as you noted. Unit-tested (rejection → flag false; success/no-status → true).

BStackLogger.info(`Upload response: ${JSON.stringify(response, null, 2)}`)
BStackLogger.logToFile(`Response - ${format(response)}`, 'debug')
}
Expand Down
1 change: 1 addition & 0 deletions packages/browserstack-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ export interface EventProperties {
isCLIEnabled?: boolean
finishedMetadata?: {
reason: string
signal?: string
}
}

Expand Down
9 changes: 8 additions & 1 deletion packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
BROWSERSTACK_LTS_SESSION_ID,
TESTOPS_SCREENSHOT_ENV,
BROWSERSTACK_TESTHUB_UUID,
BROWSERSTACK_KILL_SIGNAL,
PERF_MEASUREMENT_ENV,
RERUN_ENV,
BROWSERSTACK_TEST_PLAN_ID,
Expand Down Expand Up @@ -732,9 +733,15 @@ export const stopBuildUpstream = PerformanceTester.measureWrapper(PERFORMANCE_SD
message: 'Token/buildID is undefined, build creation might have failed'
}
}
const data = {
const data: Record<string, unknown> = {
'stop_time': (new Date()).toISOString()
}
// Stamp the kill reason onto the build record so terminated runs are
// identifiable server-side, not only via the analytics funnel.
const killSignal = process.env[BROWSERSTACK_KILL_SIGNAL]
if (killSignal) {
data.finished_metadata = [{ reason: 'user_killed', signal: killSignal }]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — finished_metadata shape. Sent here as an array [{reason,signal}], but the funnel path sends an object {reason,signal}. No existing precedent for finished_metadata on the HTTP builds/stop payload in main — confirm the server schema and align the shape, else the kill reason is silently dropped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against the server schema — the array is correct for this payload, no change made. The HTTP builds/stop endpoint takes finished_metadata as an array: the node-agent direct flow sends finished_metadata: [{reason, signal, failure_data}] (browserstack-node-agent testhubHandler.js), and the binary's own stopBuild builds finished_metadata: [...] with [] default (browserstack-binary packages/@browserstack/testhub/index.js). The object shape you saw is the EDS funnel event (finishedMetadata inside SDKTestSuccessful event props) — a different destination with its own schema. Both destinations verified end-to-end in the kill-matrix validation (BQ web_events shows the object; build record gets the array via the stop).

}

try {
const url = `${APIUtils.DATA_ENDPOINT}/api/v1/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]}/stop`
Expand Down
30 changes: 28 additions & 2 deletions packages/browserstack-service/tests/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import fs from 'node:fs'
import { describe, expect, it, vi, beforeEach, afterEach, type Mock } from 'vitest'

import BStackCleanup from '../src/cleanup.js'
import { stopBuildUpstream } from '../src/util.js'
import { stopBuildUpstream, uploadLogs } from '../src/util.js'
import { fireFunnelRequest } from '../src/instrumentation/funnelInstrumentation.js'
import { BROWSERSTACK_TESTHUB_JWT } from '../src/constants.js'
import type { FunnelData } from '../src/types.js'

vi.mock('../src/util.js', () => ({
stopBuildUpstream: vi.fn()
stopBuildUpstream: vi.fn(),
uploadLogs: vi.fn(),
getErrorString: vi.fn((e) => String(e))
}))

vi.mock('../src/instrumentation/funnelInstrumentation.js', () => ({
Expand Down Expand Up @@ -79,6 +81,30 @@ describe('BStackCleanup', () => {
})
})

describe('executeLogsUpload', () => {
it('uploads with the pre-redaction funnel creds and the --uploadLogs uuid', async () => {
const funnelData = { userName: 'real-user', accessKey: 'real-key' }
process.argv.push('--funnelData', 'funnel.json', '--uploadLogs', 'client-uuid')
vi.spyOn(BStackCleanup, 'getFunnelDataFromFile').mockReturnValue(funnelData)
// Mirror fireFunnelRequest's in-place credential redaction so the test
// proves the snapshot is taken before it runs.
;(fireFunnelRequest as unknown as Mock).mockImplementation((d: FunnelData) => {
d.userName = '[REDACTED]'
d.accessKey = '[REDACTED]'
})

await BStackCleanup.startCleanup()

expect(uploadLogs).toHaveBeenCalledWith('real-user', 'real-key', 'client-uuid')
})

it('skips upload when the build uuid or credentials are missing', async () => {
process.argv.push('--uploadLogs', '')
await BStackCleanup.executeLogsUpload('user', 'key')
expect(uploadLogs).not.toHaveBeenCalled()
})
})

describe('sendFunnelData', () => {
it('sends funnel data and removes file', async () => {
const funnelData = { key: 'value' } as unknown as FunnelData
Expand Down
53 changes: 53 additions & 0 deletions packages/browserstack-service/tests/cli/grpcClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'

import { GrpcClient } from '../../src/cli/grpcClient.js'

vi.mock('../../src/grpc/index.js', () => ({
StopBinSessionRequestConstructor: { create: (fields: Record<string, unknown>) => ({ ...fields }) }
}))

vi.mock('../../src/cli/cliUtils.js', () => ({
CLIUtils: { getClientWorkerId: vi.fn(() => '1-123') }
}))

vi.mock('../../src/cli/cliLogger.js', () => ({
BStackLogger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }
}))

vi.mock('../../src/instrumentation/performance/performance-tester.js', () => ({
default: { start: vi.fn(), end: vi.fn() }
}))

describe('GrpcClient.stopBinSession', () => {
let client: GrpcClient
let stopBinSession: ReturnType<typeof vi.fn>

beforeEach(() => {
stopBinSession = vi.fn((_req: unknown, cb: (err: unknown, res: unknown) => void) => cb(null, { done: true }))
client = new GrpcClient()
client.binSessionId = 'bin-1'
client.client = { stopBinSession } as any
})

afterEach(() => {
delete process.env.BROWSERSTACK_SDK_KILL_SIGNAL
vi.clearAllMocks()
})

it('includes exitSignal and exitReason when the kill-signal env is set', async () => {
process.env.BROWSERSTACK_SDK_KILL_SIGNAL = 'SIGTERM'
await client.stopBinSession()
expect(stopBinSession.mock.calls[0][0]).toMatchObject({
binSessionId: 'bin-1',
exitSignal: 'SIGTERM',
exitReason: 'user_killed'
})
})

it('omits exitSignal and exitReason when the kill-signal env is absent', async () => {
await client.stopBinSession()
const request = stopBinSession.mock.calls[0][0] as Record<string, unknown>
expect(request.exitSignal).toBeUndefined()
expect(request.exitReason).toBeUndefined()
})
})
Loading
Loading