Skip to content

fix(triggers): stop-event, change-baseline, hung-poll and skip-log defects - #738

Merged
sroussey merged 5 commits into
claude/libs-issues-triage-prs-mh6x2o-237from
claude/optimistic-goldberg-gzrguj-triggers
Aug 10, 2026
Merged

fix(triggers): stop-event, change-baseline, hung-poll and skip-log defects#738
sroussey merged 5 commits into
claude/libs-issues-triage-prs-mh6x2o-237from
claude/optimistic-goldberg-gzrguj-triggers

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Stacks onto #679 (claude/libs-issues-triage-prs-mh6x2o-237), which introduces @workglow/triggers. This targets that feature branch, not main, so it can be merged into #679 before it lands.

Three MEDIUM defects found reviewing #679, plus two LOW chores. Because the package has not shipped, the API/semantics changes here land alongside its first published version — no consumer can observe the intermediate state, and no deprecation is ever needed.

The defects

1. stop emitted for a generation superseded by a restart

BaseTrigger.releaseWhenIdle guarded the _run clear with an identity check but emitted stop unconditionally on the next line.

Failure scenario. A caller does trigger.stop() without awaiting it (or aborts a caller signal) and then trigger.start(...) — the ordinary "reconfigure and restart" shape. The old generation's handler is still in flight, so the drain completes later; when it does, it emits stop even though the new generation owns the trigger and is actively firing. An observer latched on stop concludes the trigger is dead and stops watching a live trigger; trigger.running reads true inside its own stop listener.

The emit now sits inside the same identity guard, after the clear, so a real stop still reports running === false. A superseded generation stays silent and the newer one reports its own stop. Every path reaching releaseWhenIdle was traced: the only behaviour change is the restart-mid-drain case.

2. firesOnChange loses a change when the handler throws

PollingTrigger.runTick assigned state.previous = result before awaiting the handler. A handler rejection propagates up to runTickChain, which reports it — leaving the advanced baseline in place.

Failure scenario. A change feed polls a source, sees a -> b, and the handler's sink is down for one call. b is never offered again: the next poll observes b, compares against a baseline that already reads b, and stays quiet. The update is lost silently, with nothing distinguishing "handled" from "dropped", and it is unrecoverable — errorBackoff counts poll failures only and never sees a handler rejection.

The baseline is now restored when delivery throws, making delivery at-least-once. Retries are paced by the poll period (no extra ticks, and the overlap policy still gates them) and always carry the freshest result — the restored baseline is the last value the handler accepted, so if the source moves b -> c while the sink is down, the next fire delivers c, not a queue of bs. The restore is skipped when a later tick already advanced the baseline (reachable under overlap: "concurrent"), which would otherwise resurrect a value that has since been delivered successfully.

3. A hung poll wedges the trigger, and every dropped tick warns

Two halves of the same incident.

Failure scenario. poll does a fetch against a black-holed host. It never settles and never throws, so: the tick stays in flight forever; under the default overlap: "skip" every later tick is dropped; errorBackoff never engages (a hang is not a throw); and nothing at all reaches the error event. The trigger simply goes quiet. Meanwhile each dropped tick writes its own identical warning — at a 1s period that is 3,600 lines an hour, indefinitely, burying whatever else the process had to say. stop() never resolves either, because it awaits the pending tick.

  • pollTimeoutMs (new option) bounds one poll. Exceeding it aborts the signal handed to poll and fails the tick with a new TriggerPollTimeoutError, which counts as a poll failure — reported on error, and it drives errorBackoff like any other. The deadline is raced against the poll rather than merely signalled: a poll that ignores its signal never settles, so aborting alone would leave the tick in flight. Releasing the tick is also what un-wedges the skip branch and bounds stop(). The default stays undefined (no deadline), so a legitimately slow poll is not broken on upgrade — pinned by a characterization test.
  • Skip logging is collapsed to the first skip of a contiguous run, plus one summary carrying the count when the run ends (or when the trigger is stopped mid-run). Net volume: one warning for an isolated skip, unchanged from before; two for a run of any length. The skip event still fires once per dropped tick — it is a programmatic signal with existing coverage, and collapsing it would be a semantic change.

Chores

  • packages/triggers was left at 0.3.37 while every other workspace is 0.3.38. Dependents use workspace:*, so no lockfile change.
  • The README "Driving a workflow" example imported the package for its side effect only and then used IntervalTrigger / PollingTrigger — copying it produced two ReferenceErrors. Named imports patch Workflow.prototype just the same.

Testing

Every regression test was written first and confirmed failing against the unfixed code:

Test Symptom before the fix
a restart during stop() does not emit stop while the trigger is running runningAtStop is [true]
a failed handler does not consume the change payloads is ["a", "b"], missing the redelivery
rejects an invalid pollTimeoutMs no validation, nothing thrown
pollTimeoutMs fails a hung poll and the loop keeps going zero errors, zero payloads — wedged
the deadline aborts the signal handed to the poll signal never aborted
a timed-out poll drives errorBackoff consecutiveFailures stays 0
a contiguous run of skips logs once, then a count when it ends 4 warnings instead of 1
stopping mid-skip still reports the skip count no summary warning

Three further tests are guards that pass before and after, and would fail against a naive version of each fix: a failed handler does not resurrect a value a later tick already delivered (the concurrent baseline guard), a hung poll wedges the trigger when no timeout is configured (pins the opt-in default), and an isolated skip logs once and adds no summary (pins that the ordinary intermittent case does not double its log volume).

No existing test body was modified.

  • bun scripts/test.ts trigger vitest — 5 files, 147 passed (up from 135), run against a real build:packages output rather than source stubs
  • bun run test:vitest:unit (full sweep) — 438 files passed / 2 skipped, 5194 passed / 47 skipped, 0 failed
  • bun run build:types, bun run --filter @workglow/triggers lint, bun run format — all clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS


Generated by Claude Code

claude added 5 commits August 10, 2026 08:40
`releaseWhenIdle` guarded the `_run` clear with an identity check but emitted
`stop` unconditionally. A `stop()` that was not awaited followed by a `start()`
therefore reported the trigger as stopped while the new generation was actively
firing, so an observer latching on `stop` stops watching a live trigger.

The emit now sits inside the same identity guard, after the clear, so a `stop`
listener reading `trigger.running` still sees `false` on a real stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
`runTick` advanced `state.previous` before invoking the handler, so a handler
rejection consumed the change: the update it failed on was never offered again
and the loss was silent, with nothing distinguishing "handled" from "dropped".

The baseline is now restored when delivery throws, making a `firesOnChange`
poller at-least-once. The retry always carries the freshest poll result — there
is no backlog — and is paced by the poll period, so nothing accumulates. The
restore is skipped when a later tick already advanced the baseline (reachable
under `overlap: "concurrent"`), which would otherwise re-deliver a value that
has since been handled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
…ging

A poll with no deadline can hang forever — a `fetch` against a black-holed host
is the usual way. The tick stays in flight, so under the default `overlap:
"skip"` every later tick is dropped and the trigger goes silent with nothing on
`error`; a hang is not a throw, so `errorBackoff` never engages either.

`pollTimeoutMs` bounds one poll. Exceeding it aborts the signal handed to
`poll` and fails the tick with a `TriggerPollTimeoutError`, which counts as a
poll failure, so it is reported on `error` and drives `errorBackoff` like any
other. The deadline is raced against the poll rather than merely signalled: a
poll that ignores its signal never settles, and aborting alone would leave the
tick in flight. Releasing the tick is also what un-wedges the skip branch and
bounds how long `stop()` waits on a hung poll.

The default stays `undefined` — no deadline — so a legitimately slow poll is
not broken on upgrade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Every dropped tick wrote its own warning, so a handler wedged against a 1s
period produced 3,600 identical lines an hour, indefinitely — the log fills up
with the same sentence and buries whatever else the process had to say.

The log is now collapsed to the first skip of a contiguous run, with a single
summary carrying the count once the run ends (or when the trigger is stopped
mid-run). Net volume is one warning for an isolated skip, unchanged from
before, and two for a run of any length.

The `skip` EVENT still fires once per dropped tick: it is a programmatic signal
a consumer counts, and collapsing it would be a semantic change. The streak is
per-run, like every other counter here, so a restart resets it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
The package was left at 0.3.37 while every other workspace is 0.3.38.
Dependents use `workspace:*`, so no lockfile change is needed.

The "Driving a workflow" example imported the package for its side effect only,
then used `IntervalTrigger` and `PollingTrigger` — copying it produced two
ReferenceErrors. Named imports patch `Workflow.prototype` just the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 63.51% 29787 / 46900
🔵 Statements 63.38% 30876 / 48715
🔵 Functions 63.72% 5653 / 8871
🔵 Branches 52.82% 14913 / 28233
File CoverageNo changed files found.
Generated in workflow #2969 for commit a120fde by the Vitest Coverage Report Action

@sroussey
sroussey merged commit 77c65a5 into claude/libs-issues-triage-prs-mh6x2o-237 Aug 10, 2026
10 checks passed
@sroussey
sroussey deleted the claude/optimistic-goldberg-gzrguj-triggers branch August 10, 2026 16:01
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.

2 participants