fix(langsmith): bound test bodies and retry on fresh worker to stop CI hangs - #2310
Draft
DABH wants to merge 2 commits into
Draft
fix(langsmith): bound test bodies and retry on fresh worker to stop CI hangs#2310DABH wants to merge 2 commits into
DABH wants to merge 2 commits into
Conversation
…I hangs Under CI load the dev server + worker pair can permanently fail to deliver a workflow's first workflow task: it stays SCHEDULED at attempt 1 forever (normal-queue first tasks have no schedule-to-start timeout) while a fresh poller on the same queue receives it instantly. The case promise then never settles, AVA's 120s inactivity watchdog fires, its SIGTERM is swallowed by the SDK Runtime shutdown handler, and the suite wedges until the CI job timeout (observed stuck at '28 tests, 3 failures' for 23+ minutes). Bound each withTracingWorker body attempt at 30s and retry up to twice on a fresh worker + task queue, rolling back the run collector and terminating workflows leaked by the stalled attempt. A stall now recovers in-place (or at worst fails visibly in bounded time) instead of hanging the job. Validated with 25 full-suite runs and 24 signal-child-file runs under 6x CPU load: all green, with three real stalls absorbed by the retry path (one captured in the archived test log).
CI run 31544937973 (linux-arm Node 24) showed the delivery stalls are correlated in time: one case stalled 2/3 attempts then recovered, another stalled 3/3 back-to-back and exhausted the retry budget, failing the leg. Attempt forensics rule out a too-tight bound: fresh workers reach RUNNING in <300ms even on that runner, healthy bodies finish in seconds, and each stalled attempt shows zero activity for its full 30s window. Keep the 30s per-attempt bound (a stalled first workflow task never self-recovers, so longer waits are pure waste) and raise the attempt cap to six, sampling a ~3 minute window - twice the worst observed sequence. Attempts are not capped by AVA's 120s inactivity watchdog: AVA debounces it on any stateChange carrying a testFile, including worker stdout, so the per-retry warning keeps the watchdog at bay while attempts continue; a genuine silent wedge still trips it. Validated under 6x CPU load: 8/8 full-suite runs and 16/16 signal-child file runs green (two absorbed stalls), plus 51 harness-shaped loop iterations absorbing five real stalls, each verified dispatchable by a manual poll at stall time.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(langsmith): bound test bodies and retry on fresh worker to stop CI hangs
What was happening
Integration-test jobs intermittently froze inside the
contrib/langsmithsuite with the avaprogress counter stuck (e.g.
… 28 tests, 3 failuresheartbeating every 30s) until the job's20-30m timeout killed the leg. Reproduces on main; observed on run 31517630441 attempt 1
(job 93866798620, linux-arm / Node 20) and previously on main run 30591851926.
The stuck test was always
signal-child: emits a closed SignalChildWorkflow marker nested under the workflow run. From the hung job's raw log, the failure chain is:client.workflow.execute(SignalChildWorkflow, …)never settles.shutdownSignalshandler swallows the SIGTERM and shuts the Worker down gracefully instead ofexiting, so
Worker.runUntilrejects withPromiseCompletionTimeoutError: Promise did not resolve within 0ms after Worker completed shutdown.GetWorkflowExecutionHistorylong-poll retry then surfaces an uncaughtError: Channel has been shut downwhen the connection closes.timeout. One stalled test therefore taxes every PR with a 20-30 minute hang.
Root cause
A first-workflow-task delivery stall between the ephemeral dev server and the worker, triggered
by CPU contention at worker startup:
RUNNINGwith its firstworkflow task
PENDING_WORKFLOW_TASK_STATE_SCHEDULED,attempt: 1, forever. History ends atWorkflowTaskScheduled. Normal-queue first workflow tasks have no schedule-to-start timeout,so nothing on the server ever times out or redelivers — the workflow can never make progress.
DescribeTaskQueueon the workflow task queue reportedpollers: []in thenever-polled variant (the server had not received a single workflow-queue poll from the worker
45s after the worker entered RUNNING), while a manual
PollWorkflowTaskQueuefrom a bareclient instantly (2-5 ms) received the stuck task — reproduced on every probed stall. The
task was dispatchable all along; the backlog was healthy.
RUNNINGand shutdown (no poll RPC bursts, no bridge iterations), while healthy iterations open poll
streams within milliseconds of
RUNNING.So the loss is on the worker/poll-delivery side of the pair, not in the test logic, not in the
LangSmith plugin (the workflow never even started executing), and not in a specific server
version. Ruled out empirically (300+ iteration loops per configuration, all still stalling):
CLI 1.8.2/server 1.31.2, CLI 1.7.3/1.31.2, CLI 1.6.0/server 1.30.0,
matching.enableMigration=false,matching.useNewMatcher=false, and single-partition task queues.This suite is uniquely exposed because every case boots a fresh Worker on a fresh task queue on a
fresh per-file dev server and immediately starts a workflow — dozens of cold-start races per run,
on the slowest CI legs (linux-arm/x64 Node 20 under full job load).
The exact sdk-core mechanism (why the non-sticky WFT poller can fail to issue polls from worker
startup under CPU starvation) still deserves an upstream look; this PR fixes the CI hang at the
test-harness layer, which is also where the hang-amplification lives.
The fix
withTracingWorker(used by every case in the suite) now:worker.runUntil(async () => …)so the worker is polling before the bodystarts any workflow;
attempt, rolls the run collector back to its pre-attempt snapshot, logs a warning (surfaced in
the archived test log for CI diagnosability), and retries on a fresh worker + fresh task
queue, up to 3 attempts total.
A stall now recovers in place; a triple-stall (never observed) fails visibly in bounded time
(3 x 30s < ava's 120s inactivity cap, so the watchdog/SIGTERM wedge path can no longer engage).
Reproduction and validation
Standalone repro (not committed): loop the exact signal-child scenario (fresh worker + fresh task
queue per iteration,
maxCachedWorkflows: 2, 1s sticky timeout) under 6x CPU load.Before the fix: the loop stalled fatally in 7/7 runs, within 1-90 iterations each
(~1-5% of iterations under load); every stall matched the CI signature (first WFT
SCHEDULED/attempt 1 forever; manual poll wins the task instantly).
Mitigated logic, same loop, same load: 66/66 iterations passed with 3 real stalls absorbed
by the retry (including one double-stall that recovered on the third attempt).
Real suite with the fix, 25 full-suite runs (17 of them under 6x CPU load, one with
REUSE_V8_CONTEXT=falsefor matrix parity; note the langsmith tests do not read that variable):25/25 runs, 46/46 tests each. Three of those runs absorbed a real stall — two show the
+30s wall-clock signature (53.8s / 53.2s vs the ~23s norm) and one has the retry captured in
its archived log:
24/24 loaded runs of the signal-child test file alone.
Changelog
Test-infrastructure only — no user-facing change to
@temporalio/langsmith(nothing undersrc/outside__tests__/is touched), so noCHANGELOG.mdentry; please apply theskip-changeloglabel.Update: retry budget recalibrated after first CI run (second commit)
The first CI run of this PR (31544937973) exercised the designed behavior and exposed a
calibration gap on the linux-arm Node 24 leg (the macos-arm Node 24 failure on that run was an
unrelated
packages/testintegration-updateflake — its langsmith suite passed):readonly-determinismstalled on 2/3 attempts, recovered on the third — retry working asdesigned.
signal-childstalled on 3/3 back-to-back attempts and exhausted the budget, failing fastwith
HarnessStallError(6m55s leg — fail-fast held, no hang).Attempt forensics from the archived log rule out "30s too tight for slow arm runners": each fresh
worker reached RUNNING within ~280ms of the previous attempt's shutdown, sibling tests on the leg
ran at normal speed, and every stalled attempt showed zero activity for its entire 30s window
(attempt boundaries 23:08:56.499 → 23:09:26.505 → 23:09:26.784 → 23:09:56.786 → 23:09:57.052 →
23:10:27.054; each exactly 30.0s of silence). The stalls are correlated in time — the degraded
state persists across consecutive fresh workers for a window on the order of 90s+.
Recalibration: keep the 30s per-attempt bound (an unmitigated stall never self-recovers — waiting
longer per attempt is pure waste; only a fresh worker recovers), and raise the cap to 6
attempts, sampling a ~3 minute window — double the worst observed sequence. This is safe
against ava's 120s inactivity watchdog because ava debounces that timer on any stateChange record
carrying a
testFile, which includes worker stdout/stderr chunks (ava 5.3.1lib/fork.jstagsall records;
lib/api.jsdebounces on them): the harness's per-retry warning guarantees outputevery ≤30s while attempts continue, so the watchdog only fires for a genuine silent wedge — its
backstop role, unchanged. A persistent degradation now fails loudly at ~3 minutes instead of
hanging.
Recalibration validation (6x CPU load): 8/8 full-suite runs green (one absorbed stall, captured
in log), 16/16 signal-child-file runs green (one absorbed stall), and 51 harness-shaped loop
iterations absorbing five real stalls — every one manual-poll-verified (task dispatched to a
fresh poller in 2-23ms at stall time) and every one recovered on the second attempt.
Notes / residual risk
rolled back and leaked workflows are terminated first). One test file (
test-replay-parenting)counts raw
createRuncalls via a wrapper outside the collector; a stalled-then-retried run ofthat specific case could overcount and fail visibly — a bounded, diagnosable failure rather
than a 30m hang, and only after an already-rare stall.
normal-queue polls from startup under CPU starvation; evidence above). Filing that upstream is
follow-up work; this PR removes its ability to freeze CI.