fix(browserstack-service): close orphaned cucumber hooks that inflate build duration (SDK-7167) - #120
fix(browserstack-service): close orphaned cucumber hooks that inflate build duration (SDK-7167)#120osho-20 wants to merge 3 commits into
Conversation
… build duration (SDK-7167)
A cucumber hook (typically AFTER_EACH) that emitted HookRunStarted but never
its HookRunFinished stayed open on the Test Observability backend until the
project's hook timeout (2h), inflating the build duration shown on the new
dashboard (customer saw 4h35m for a 2h42m build). Customer SDK debug logs
confirmed the drop is client-side: 525 hook starts vs 521 finishes triggered,
zero upload failures.
Three complementary fixes:
- Extend the teardown sweep (previously mocha-only, documented known gap) to
cucumber: hook meta is tagged kind/name/hookType/testRunId at start,
scenario meta is tagged in beforeScenario and stamped finished in
afterScenario, and sweepUnfinished now emits terminal HookRunFinished /
TestRunFinished for any started-but-unfinished cucumber entity before the
worker's event queue shuts down.
- Journal open hook runs like open test runs, so when the worker is killed
outright mid-hook (Ctrl-C / CI cancellation) the exit cleanup finalizes the
orphaned hook with a HookRunFinished (hook_run envelope) instead of only
finalizing the test run.
- Guard the cucumber hook 'after' path against a missing start record (skip
with a warning instead of emitting an unmatched finish / TypeError), and
reset in-flight step state at scenario start so an aborted step can no
longer silently drop every later AFTER_EACH hook's events.
Verified end-to-end on Automate: interrupting a run mid-After-hook with the
published 9.33.0 leaves the hook open (only the test run is finalized);
with this fix the exit cleanup finalizes both ("Finalized 2 orphaned
test/hook run(s)").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🔴 SDK PR Review gate is red. Pending:
It turns green once the latest SDK PR Review Agent run reports GTG on the current head commit. A native reviewer approval is separately required by branch protection before merge. |
1 similar comment
|
🔴 SDK PR Review gate is red. Pending:
It turns green once the latest SDK PR Review Agent run reports GTG on the current head commit. A native reviewer approval is separately required by branch protection before merge. |
|
RUN_TESTS |
|
[SDK Wdio Test] TRA build state: passed | Stability 100% — verdict: success. Passed: 85, Failed: 0, Aggregate: 85. TRA: https://observability.browserstack.com/builds/mfeml8mrmwyavljudzogob3embvcioqxixiijywg |
harshit-browserstack
left a comment
There was a problem hiding this comment.
SDK PR Review — 🔴 2 blocking issues
Scope reviewed: 6 files · Risk: Medium · 1 critical, 1 warning, 0 suggestions
Intent as understood: Fixes SDK-7167 — inflated build durations on the TRA dashboard for WebdriverIO + Cucumber. Some Cucumber AFTER_EACH hooks emit HookRunStarted but their HookRunFinished never arrives, so the backend holds the hook in-progress until its 2h timeout and the build watchdog folds that into the reported duration. Three changes: extend the mocha-only sweepUnfinished() teardown net to Cucumber, journal open Cucumber hook runs so a hard-killed worker's exit-cleanup can finalize them, and harden processCucumberHook's after path against a missing before.
🔴 Critical — the cited E2E verification exercises a different code path than the fix it's offered as proof for
packages/browserstack-service/tests/insights-handler.test.ts · describe('sweepUnfinished - cucumber')
The PR body's "End-to-end verification on Automate" runs a minimal WDIO 9 + Cucumber project and interrupts it via SIGINT while the After hook sleeps, showing "Finalized 2 orphaned test/hook run(s)" on the fixed branch vs 1 on unfixed.
But a SIGINT'd process only reaches finalizeOrphanedRuns() — the detached exit-cleanup / next-launch recovery path (cleanup.ts:78, launcher.ts:631). It can never reach sweepUnfinished() (service.ts:657), which runs only from the worker's own in-process, graceful after() completion. A hard-killed process doesn't reach its own graceful after() hook at all. These are two disjoint code paths triggered by two disjoint failure modes.
This matters because the reported symptom — hookEvents { started: 525, finished: 521 }, zero upload failures, on a build that otherwise completed normally — looks much more like the graceful-completion failure mode (sweepUnfinished's target: a hook's own after callback never firing while the worker runs to completion) than the hard-kill mode. If that read is right, the flagship E2E proof demonstrates the secondary journal-based fix, not the primary sweepUnfinished-based one the root-cause narrative centers on.
Separately, none of the 9 new unit tests simulate two sequential hook invocations sharing one hookId — every sweepUnfinished - cucumber test manually seeds a single pre-existing _tests[key] entry. That proves the sweep works for one tracked entry; it can't prove a second orphaned occurrence of the same hookId survives to be swept.
Suggested fix
- Add or re-cite an E2E build exercising the graceful path — an
After()hook whose body fails in a way that lets the framework continue without ever firing the hook'saftercallback, rather than a SIGINT — so the worker actually reachesafter()→sweepUnfinished(). Compare against an unfixed-branch build under that same scenario. - Add a unit test firing two
beforeevents for the samehookIdwith no interveningafter, asserting whether the first (now-overwritten) occurrence is still recoverable. That single test also resolves the open question below.
🟠 Warning (needs confirmation) — _tests[hookId] unconditional overwrite may silently discard an earlier orphan
packages/browserstack-service/src/insights-handler.ts · _InsightsHandler.processCucumberHook
In the before branch, this._tests[hookId] = hookMetaData has no guard against clobbering an existing still-open entry at that key. The two identity schemes this PR relies on aren't symmetric:
- Scenario key —
getUniqueIdentifierForCucumber(world)=pickle.uri + '_' + pickle.astNodeIds.join(','). Provably unique per scenario occurrence. ✅ - Hook key —
getCucumberHookUniqueId(hookType, hook)=hook.hookId, forwarded as-is from@wdio/cucumber-framework. Its per-invocation uniqueness can't be established from this repo's source.
If hook.hookId is assigned once per hook registration (Cucumber.js's conventional message-protocol behavior — a Hook's id set at Before()/After() definition time and reused by every scenario invoking it) rather than fresh per invocation, then: scenario N's AFTER_EACH orphans, scenario N+1's before for the same registered hook overwrites _tests[hookId], and N's orphan is gone before sweepUnfinished() — which runs once, at worker teardown — ever inspects it.
The reported build dropped 4 finishes out of 525 starts. If 2+ land on the same registered global hook within one worker, this fix would recover at most the last of them.
The disk-journal path (recordOpenRun/clearOpenRun, keyed by a fresh per-invocation uuid in separate files) is not affected — only the in-process sweepUnfinished() net.
Suggested fix — key _tests for BEFORE_EACH/AFTER_EACH on a composite identifier that's unique regardless of hookId's external semantics, using data already in scope:
const hookKey = `${hookId}_${InsightsHandler.currentTest.uuid}`
this._tests[hookKey] = hookMetaData(mirroring the same composite key in the after branch's lookup/guard). Alternatively, guard the overwrite itself: before replacing, check whether the existing entry is started-but-unfinished and eagerly emit its terminal HookRunFinished first, mirroring sweepUnfinished()'s own synthetic-finish logic.
Honest confidence note: the overwrite mechanism is provable by reading this repo's code. The external fact the claim hinges on — whether @wdio/cucumber-framework's hook.hookId is per-registration or per-invocation — is not verifiable from this checkout, since the framework isn't vendored here. Two independent review passes pulled the framework's public source and both read it as registration-scoped (cucumberFormatter.ts's onTestStepStarted resolves via this._hookEvent.find(h => h.id === teststep.hookId)), but that's external-library evidence. Flagging it as needs-confirmation rather than asserting it.
Per-file confidence
| File | Status | Reason |
|---|---|---|
src/insights-handler.ts |
🔴 Fix 1 | hookId keying concern — needs independent confirmation |
tests/insights-handler.test.ts |
🔴 Fix 1 | E2E verification path mismatch — objectively verifiable |
src/testOps/listener.ts |
✅ | Reviewed, clean |
src/testOps/openRunsJournal.ts |
✅ | Reviewed, clean |
src/types.ts |
✅ | Reviewed, clean |
tests/testOps/openRunsJournal.test.ts |
✅ | Reviewed, clean |
External services
No external-contract shape changes detected — only when existing event types fire. One sub-threshold observation not promoted to a finding: whether the collector accepts HookRunFinished / hook_run payloads batched under the pre-existing 'ORPHANED_TEST_RUN_FINALIZATION' label. Cheap sanity check if you want extra confidence; not blocking.
What's good
sweepUnfinished()'s framework guard stays scoped tomocha+cucumberrather than broadening, keeping blast radius tight.- The new after-path guard and the
afterScenario/uuid checks correctly avoid crashes and unmatched-finish emissions, consistent with the file's existing defensive style. - Nine new unit tests give solid breadth across the provably-safe paths (hook tagging, the guard, single-entry sweep for both hook and scenario, step-state reset), backed by real before/after Automate build links.
Open questions
- Can you confirm — from
@wdio/cucumber-framework/@cucumber/cucumberdocs, or a quick instrumented log — whetherhook.hookIdis the same across two scenarios invoking the same registeredBefore()/After(), or unique per invocation? This one fact decides whether the warning above is a real gap or a non-issue. - Can the E2E verification be re-run for the graceful-completion path (no SIGINT), to match the customer's actual symptom signature?
Posted as a recommendation, not a merge decision — the review deliberately does not submit an approve/request-changes verdict.
…ation (SDK-7167) cucumber's hookId is assigned at Before()/After() registration time, so every scenario invoking the same registered hook shares one _tests key — a still-open entry orphaned in an earlier scenario was clobbered by the next invocation before sweepUnfinished() could close it. Suffix the key with the current scenario's run uuid so each invocation is tracked independently; this also stops a dropped 'before' from matching a previous scenario's closed entry and re-emitting its uuid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🔴 SDK PR Review gate is red. Pending:
It turns green once the latest SDK PR Review Agent run reports GTG on the current head commit. A native reviewer approval is separately required by branch protection before merge. |
|
Re: review — both findings verified against primary sources; the warning is a real gap and is now fixed in 88ccf6f. 🟠 Warning —
|
Graceful-path E2E verification (no SIGINT) — closing the remaining critical item from review 4875326601Setup — minimal WDIO 9 + Cucumber project on Automate (Chrome / Windows 11): 2 scenarios sharing one registered global // node_modules/@wdio/cucumber-framework/build/index.js — wrapStep()
const afterFn = process.env.E2E_DROP_HOOK_AFTER === '1' && !isStep ? [] : config.afterHook;Unfixed (published 9.33.0) — build: https://automation.browserstack.com/builds/qbe0zamxggqm5g5cupde0q0j9cdqjarmpwwpfpuf The customer's exact fingerprint (starts > finishes, zero upload failures) on a normally-completing build — both hook runs left open server-side, to be held until the backend hook timeout folds into the build duration. Fixed (this branch @ 88ccf6f) — build: https://automation.browserstack.com/builds/s6fuyhbqi3icnnsivswvh5nfidsdc8vguqqjryfv Three things this proves at once:
O11Y backend state for both builds: terminal, |
What is this about?
Fixes incorrect (inflated) build durations on the new Test Observability dashboard for WebdriverIO + Cucumber runs ([Aya] customer report: build showed 4h 35m on the new dashboard vs 2h 42m on the old one).
Root cause: some Cucumber
AFTER_EACHhooks emitHookRunStartedbut theirHookRunFinishedis never sent. The backend holds the hook open until the project's hook timeout (2h) and the build watchdog inflates the reported duration. Customer SDK debug logs prove the drop is client-side:hookEvents { started: 525, finished: 521 }with zero upload failures — 4 finish events were never created.Three complementary fixes:
kind/name/hookType/testRunIdat start, scenario meta is tagged inbeforeScenarioand stamped finished inafterScenario, andsweepUnfinished()emits a terminalHookRunFinished/TestRunFinishedfor any started-but-unfinished entity before the worker's event queue shuts down.HookRunFinished(hook_runenvelope). Previously only orphaned test runs were finalized.afterpath skips (with a warning) when no start was recorded instead of emitting an unmatched finish / throwing aTypeError, and in-flight step state is reset at scenario start so an aborted step can no longer silently drop every laterAFTER_EACHhook's events for the rest of the worker.End-to-end verification on Automate (minimal WDIO 9 + Cucumber project, run interrupted via SIGINT while the
Afterhook sleeps):Finalized 1 orphaned test run(s)→ repro build: https://automation.browserstack.com/builds/m4ohrcqiqwnflmfrino0snwsnlfmunul9d5pv5bvFinalized 2 orphaned test/hook run(s)— hook closed → build: https://automation.browserstack.com/builds/pyyrun48cfjll4xcfxo1fgornzrovs8xrklibwhyEnd-to-end verification on Automate — graceful-completion path (no SIGINT; per review 4875326601: adapter-level fault drops the hook's
aftercallback while the worker completes normally; one registered globalAfterhook shared by 2 scenarios, same fault active in both runs; service artifact stock):hookEvents { started: 2, finished: 0 }, zero upload failures — the customer's exact fingerprint on a normally-completing build → https://automation.browserstack.com/builds/qbe0zamxggqm5g5cupde0q0j9cdqjarmpwwpfpufEmitted synthetic HookRunFinished for unfinished 3_<scenario-uuid>lines fromsweepUnfinished()(noFinalized N orphanedline — journal path not involved),hookEvents { started: 2, finished: 2 }— both orphans of the shared registration-scoped hookId recovered via the composite key → https://automation.browserstack.com/builds/s6fuyhbqi3icnnsivswvh5nfidsdc8vguqqjryfvUnit tests: 9 new tests (cucumber sweep, hook journaling/finalization, after-path guard, step-state reset). Full vitest suite shows the identical 70 pre-existing environmental failures as clean
main— zero regressions. Build + eslint clean.Related Jira task/s
Release (mandatory for every PR — required for the
ready-for-reviewlabel)Version bump: (required — tick exactly one)
Release notes type: (optional)
Release notes (customer-facing): (optional but encouraged)
Release notes (internal): (required — engineer-facing; what actually changed / why)
sweepUnfinished()teardown safety net to Cucumber: hook/scenario meta is tagged withkind+ identity at start,afterScenariostampsfinishedAt, and the sweep emits terminalHookRunFinished/TestRunFinishedfor started-but-unfinished cucumber entities (SDK-7167 — orphanedAFTER_EACHstarts held hooks open until the 2h backend hook timeout, inflating build duration on the new dashboard).listener.hookStarted/hookFinishednow record/clear the open-runs journal, andfinalizeOrphanedRuns()emitsHookRunFinished(hook_runenvelope) for journaled hook entries — so a worker killed outright mid-hook gets its hook finalized by the exit cleanup, not just its test run.afterevents with no recorded start are skipped with a warning (no unmatched finish, noTypeError);_cucumberData.stepsis reset per scenario so a stuck in-flight step can't misclassify laterAFTER_EACHhooks as step-level and silently drop their events.Checklist
PR Validations
Run Tests: Comment RUN_TESTS to trigger sanity tests.
🤖 Generated with Claude Code