Skip to content

TD-2668: judge test progress by ping count, not elapsed time - #174

Merged
wmech-tiugo merged 8 commits into
masterfrom
feature/TD-2600-watchdog-liveness
Oct 19, 2026
Merged

wmech-tiugo merged 8 commits into
masterfrom
feature/TD-2600-watchdog-liveness

Conversation

@wmech-tiugo

@wmech-tiugo wmech-tiugo commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

The server judged a remote browser by a single 55 second budget answering two questions at once: has the browser stopped responding, and has it finished a test recently. Results are batched and a reload posts nothing, so a browser that was merely slow looked exactly like a dead driver and the lane was killed.

This splits the two. Silence from the browser is still caught after 55 seconds. Lack of progress is now measured by counting the posts the runner makes rather than by a configurable timeout, because the failure this exists to catch is a connection slowing down, and a constant does not slow down with it.

One budget answering two questions

session.updated was bumped by exactly three routes: /tests/start, /tests/results and /tests/done. /tests/alive only set session.alive, and in auto mode the runner never sent it, because the keep-alive timer was guarded by if (harness.mode === 'manual').

The watchdog only ever ran in auto mode: awaitDone requires sticky sessions and manual sets stickyFirstSession: false. So the one mode with a watchdog had no evidence of life other than posted test results, and Reporter flushed those at most every 30 seconds, and only when a test completed.

Why counting pings rather than seconds

A wall-clock budget cannot be set correctly here. The failure it catches is a remote grid slowing down, and five minutes is at once too long for a genuinely stuck run and too short for a bad day on the grid. Two reviewers said the numbers were wrong, independently, three weeks apart.

Progress is now judged by counting posts. The runner posts on a fixed interval whether or not a test has finished. The server treats every post as a ping and only a post carrying results as progress, so a link that halves in speed halves the ping rate and widens the tolerance by the same factor. It also cannot misfire on this.timeout(0) or this.retries(n), because those keep pinging.

session.updated and session.done had to move inside the results.length > 0 guard. Without that the heartbeat feeds the very clock it exists to let us read.

The reload window nobody was watching

/tests/init called no controller method at all, so the window where a chunk or retry reload re-fetches the test bundle counted as silence against the liveness clock. No JS runs during it, which is exactly when the browser can least afford to look idle.

The runner already posts the session id to that route, so recording activity there costs one line.

Correcting the original diagnosis

The first version of this description blamed chunk-boundary reloads. That was wrong for the builds it cited. Build 41's kills landed at tests 836, 1719, 1374, 1128 and 1147 against --chunk=1000, so none was at a boundary. The mechanism was slow execution inside a chunk, which is what a progress budget covers and what the reload story does not.

The earlier claim that the effective budget was "closer to 25 seconds than 55" was also too strong. The flush is evaluated only when a test completes, so the real range is 25 to 55 seconds, and 25 is the worst case for a single test rather than the budget.

What this does not fix

Merging this does not make builds like 24-25 September green on its own. tinymce-premium sets bedrock's overallTimeout to 30 minutes in Gruntfile.js. Measured over 89 lanes the p50 bedrock run is 13.9 minutes and p90 is 19.1, so 39% of lanes already sit above the point where a 2x slowdown exceeds that ceiling.

Under a uniform 2x slowdown the share of green builds goes from 0% today to roughly 5% with this change, and to roughly 57% if that timeout moves to 40 minutes. That clock starts after the tunnel and the remote session, so 40 still fits under the 50 minute Tests stage. It is one line in another repository and should be raised separately.

Nothing reaches CI until someone cuts a bedrock release either. npm still serves 17.0.1 from 28 August, and #173 has been merged and unreleased since 9 September.

Verified

yarn test green: lint, build of all four projects, and 177 tests passing (26 + 3 + 93 + 55).

The new test covers the client half of the contract, that the heartbeat posts even when the batch is empty. The server half, that an empty post counts as a ping but not as progress, has no test: Controller.create arms a session cleanup setInterval that is never cleared, so any test of that module hangs mocha. That is pre-existing and worth its own ticket.

Not exercised against a real remote grid. The behaviour worth confirming there is that a slow lane finishes slowly instead of being killed.

What the reviewer should check

The ping threshold. Six pings at a 20 second heartbeat is about two minutes of proven liveness without a finished test before a run is called stuck. It only applies while the browser is still responding.

The heartbeat against the liveness budget. Server-visible gaps are now the interval plus one round trip, against the unchanged 55 second liveness timeout, so a round trip above roughly 35 seconds would trip it. This coupling did not exist before and is the part I am least sure of.

The failure message. Our CI classifier keys on these strings. The liveness message is deliberately unchanged. The new progress message needs a companion rule in jenkins-build-analytics, and without one it is caught silently by that rule's Unexpected Bedrock Error alternative and counted as a dropped session.

…atchdog

The server used a single 55 second budget for both "the browser has stopped
responding" and "the browser has not finished a test", so a remote browser that
was merely slow was killed as if its driver had died.

- Controller now tracks liveness (session.alive) separately from progress
  (session.updated). Silence from the browser is still caught after 55 seconds;
  a browser that is responding but slow gets the new --progressTimeout, which
  defaults to 5 minutes.
- The runner sends keep-alive pings in auto mode as well as manual. Results are
  batched and a page reload posts nothing at all, so without them the server has
  no evidence of liveness other than test results.
- /tests/alive now uses the throttled maybeSendKeepAlive, so the extra pings do
  not issue a webdriver command every few seconds.
@wmech-tiugo
wmech-tiugo requested a review from a team as a code owner September 25, 2026 16:03
@wmech-tiugo
wmech-tiugo requested review from TheSpyder and tiny-mcg and a balanced review from Copilot October 7, 2026 04:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Invalid progress-timeout values can silently disable the new watchdog.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Separates browser liveness from test-progress monitoring to avoid terminating responsive but slow test sessions.

Changes:

  • Adds configurable --progressTimeout.
  • Tracks liveness and test progress independently.
  • Enables throttled keep-alive pings in auto mode.
File Description
CHANGELOG.md Documents watchdog and keep-alive changes.
modules/​runner/​src/​main/​ts/​runner/​Runner.ts Enables keep-alive pings in all modes.
modules/​server/​src/​main/​ts/​bedrock/​cli/​ClOptions.ts Defines the progress-timeout option.
modules/​server/​src/​main/​ts/​bedrock/​cli/​Clis.ts Registers the new CLI option.
modules/​server/​src/​main/​ts/​bedrock/​core/​Settings.ts Adds progress timeout to settings.
modules/​server/​src/​main/​ts/​bedrock/​server/​Apis.ts Propagates the timeout and throttles keep-alives.
modules/​server/​src/​main/​ts/​bedrock/​server/​Controller.ts Implements separate watchdog clocks.
modules/​server/​src/​main/​ts/​bedrock/​server/​Serve.ts Passes the setting into server APIs.
modules/​server/​src/​test/​ts/​ClisTest.ts Updates expected CLI settings.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@wmech-tiugo

Copy link
Copy Markdown
Contributor Author

@TheSpyder three things you were right about, one you were not, and I am taking your design.

Right. The "25 second budget" in the description was too strong. The flush is only evaluated when a test completes, so the real gap between posts is 30s plus whatever test is running, a 25 to 55s range. 25s is the worst-case floor for one test, not the budget. You pushed back on that on 28 Sep and you were correct.

Right. There is a second wall. Not the one you named. The per-lane Jenkins timeout is timeout(..., activity: true), which resets on every line of console output, and bedrock's HUD writes continuously, so it never fires on a slow-but-talking run. The wall is bedrock's own overallTimeout, 30 minutes, set in tinymce-premium/Gruntfile.js:18. The only absolute ceilings above a lane are the 50 minute Tests stage and the 100 minute pipeline.

My own error, worse than yours. This description blames chunk-boundary reloads. Build 41's kills landed at tests 836, 1719, 1374, 1128 and 1147 against --chunk=1000. None of those is a boundary. The mechanism was slow execution inside a chunk, which is what a progress budget covers and what the reload story does not. I am rewriting the description.

Measured, 89 lanes of tinymce-premium/main, bedrock run time isolated from pod start and tunnel setup: p50 13.9 min, p90 19.1, p99 24.8. 39% sit above the 15 minute break-even for a 2x slowdown, and the worst healthy lane has 5 minutes of headroom against the 30 minute limit with no degradation at all. Builds green under a uniform 2x slowdown, with the factor assumed: 0% today, about 5% with this PR, about 57% with overallTimeout at 40 min. That clock starts at awaitDone, after the tunnel and the remote session, so 40 plus about 4 of overhead still fits under the 50 minute stage.

So I am taking your design. Progress judged in ping count, not wall time. --progressTimeout goes away entirely, which also dissolves the Copilot finding instead of patching it, and the heartbeat rides the results ping rather than a second channel. A constant could never have been right here: the incident was a tunnel slowdown, and only a ping-relative measure widens by the same factor the link slows by.

One thing your version needs that is not obvious from the outside. Controller.ts:154-158 sets session.updated and session.done = false before the results.length > 0 guard, so a zero-increment ping implemented exactly as described would feed the very clock it is supposed to starve and silently disable the progress watchdog. That line has to move first, which is why the server-side split in this PR survives into your design rather than being replaced by it.

Separately, two things nobody has flagged. /tests/init calls no controller method at all, so the whole bundle re-fetch after a reload is invisible to both clocks; the runner already posts {session} there, so it is a one-line fix. And DriverMaster does not serialize anything despite its comment, because use clears inUse when f() returns its promise rather than when the command settles. Pre-existing, untouched here, but worth knowing before anyone argues about lock contention.

New commits shortly. The release is still the binding constraint: nothing here or in #173 reaches CI until someone cuts one.

The progress half of the watchdog used `--progressTimeout`, a configurable
wall-clock budget. That number cannot be set correctly, because the failure it
exists to catch is a connection slowing down, and a constant does not slow down
with it. Five minutes was too long for a stuck run and too short for a bad day
on the grid, and two reviewers said so independently.

Progress is now judged by counting the posts the runner makes. Every post is a
ping, only a post carrying results is progress, so a link that halves in speed
halves the ping rate and widens the tolerance by the same factor.

`session.updated` and `session.done` move inside the `results.length > 0`
guard. Without that the heartbeat would feed the very clock it exists to let us
read, and the progress watchdog would be silently dead.

`--progressTimeout` is removed along with its settings and CLI plumbing, so
this change adds no user-facing option.
The runner only posted when a test finished, and results were batched for up to
30 seconds, so a browser working through one slow test looked exactly like a
browser that had died. The server had nothing else to judge it by.

It now posts on a fixed interval whether or not anything has finished, and an
empty batch still goes out: that empty post is what tells a slow browser from a
gone one. The timer reschedules only once the previous post settles, so a slow
connection stretches the gap rather than stacking requests on top of one that
has not come back. The previous `setInterval` had no such protection, and
because the request carries no timeout it could never abort either.

This replaces the auto-mode keep-alive rather than adding to it. That channel
goes back to manual mode only, so there is one channel on the wire instead of
two, at roughly three requests a minute.
`/tests/init` called no controller method, so the window where a chunk or retry
reload re-fetches the test bundle counted as silence against the liveness
clock. No JS runs during it, which is exactly when the browser can least afford
to look idle.

The runner already posts the session id to that route, so recording activity
there costs one line.
@wmech-tiugo wmech-tiugo changed the title TD-2600: separate browser liveness from test progress in the server watchdog TD-2668: judge test progress by ping count, not elapsed time Oct 12, 2026
@wmech-tiugo
wmech-tiugo merged commit 2c1e4ba into master Oct 19, 2026
4 checks passed
@wmech-tiugo
wmech-tiugo deleted the feature/TD-2600-watchdog-liveness branch October 19, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants