-
Notifications
You must be signed in to change notification settings - Fork 8
SDK-6983: upload SDK logs and emit kill telemetry on signal termination #86
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
base: main
Are you sure you want to change the base?
Changes from all commits
bf05710
e99828d
90d0b68
dce2503
aed5492
a781b67
9f48766
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 |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
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. LOW —
Collaborator
Author
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. Fixed in 9f48766 — and your comment surfaced a worse inverse case: a truthy server rejection ( |
||
| BStackLogger.info(`Upload response: ${JSON.stringify(response, null, 2)}`) | ||
| BStackLogger.logToFile(`Response - ${format(response)}`, 'debug') | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 }] | ||
|
Collaborator
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. MEDIUM —
Collaborator
Author
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. Checked against the server schema — the array is correct for this payload, no change made. The HTTP builds/stop endpoint takes |
||
| } | ||
|
|
||
| try { | ||
| const url = `${APIUtils.DATA_ENDPOINT}/api/v1/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]}/stop` | ||
|
|
||
| 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() | ||
| }) | ||
| }) |
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.
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 viaprocess.kill(process.pid, sig)orprocess.exit(128+n)) without preempting WDIO's own graceful SIGINT/SIGTERM shutdown.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.
Fixed in 9f48766 — valid catch. The listener now arms an
unref()'d 5s grace timer after stamping the signal and forcesprocess.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.exitstill 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).