Skip to content

fix(progress): don't regress progress when an update is in flight - #60

Open
Tanish Gudise (TanishGudise) wants to merge 2 commits into
dedalus-labs:mainfrom
TanishGudise:fix/progress-in-flight-state
Open

Tanish Gudise (TanishGudise) wants to merge 2 commits into
dedalus-labs:mainfrom
TanishGudise:fix/progress-in-flight-state

Conversation

@TanishGudise

@TanishGudise Tanish Gudise (TanishGudise) commented Oct 7, 2026 •

Copy link
Copy Markdown

Description

The progress emitter can send progress backwards. This is also why test_progress_telemetry_hooks_capture_events is flaky on main (it failed 1 in 6 runs locally).

  1. The run loop takes the newest state (_latest = None), then waits (for the throttle delay, or to retry a failed send) before sending it.
  2. If the progress() block exits during that wait, close() sees _latest is None and re-queues _last_emitted for at-least-once delivery, but _last_emitted is older than the state the loop is holding.
  3. The loop sends the newer state and then the stale one. Clients see 1 → 4 → 1, which violates the MCP requirement that progress increase monotonically. The close telemetry event also reports the stale value.

advance() and the monotonicity check in set() have the same blind spot. While set(3) is in flight, advance(1) builds on _last_emitted and returns 2 instead of 4, and set(2) is accepted and sent after 3.

Fix

  • Track _in_flight: the state the emitter has taken but not yet delivered. A state moves from _latest to _in_flight to _last_emitted, and each handoff happens under the lock.
  • _newest_state_locked() checks queued, then in-flight, then delivered state. advance() uses it for its base, and the monotonicity check uses it too.
  • close() skips the at-least-once re-emit while an update is in flight, because the retry loop guarantees that update is delivered.

Behavior change: calling set() with a value below one that is still in flight now raises ValueError. Previously it quietly sent the lower value after the higher one. This enforces the monotonicity the method already documents and the MCP spec requires.

Related Issue

No issue, since issue creation is restricted on this repo. I found this through the flaky test on main.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test coverage improvement

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Additional Notes

Tests. I added 6 tests. The 4 deterministic ones use a HeldSession fake that holds each send until the test releases it, with emit_hz=0, so nothing depends on timing. All 4 fail on every run against main and pass with this change.

  • test_close_while_send_in_flight_does_not_regress
  • test_advance_builds_on_held_in_flight_send
  • test_close_during_retry_does_not_regress (the retry path)
  • test_set_below_in_flight_value_raises (the behavior change)
  • test_close_during_throttled_send_does_not_regress and test_advance_uses_in_flight_state_as_base cover the real-world throttle-delay path.

Results. tests/test_progress_flow.py passes 11/11 across 10 repeated runs. The full suite passes (896). ruff format --check is clean. ruff check reports new findings in the test file, but they're the same rule types the file already has, and the lint step already fails on main.

AI disclosure (per CONTRIBUTING.md): I used Claude to help trace the race and draft the tests. I've reviewed the change and can walk through it.

close() re-queued _last_emitted whenever _latest was empty, but the run
loop may still hold a newer state (sleeping out the throttle delay or
retrying). The stale state was then sent after the newer one, so clients
saw progress go backwards (e.g. 4 -> 1), violating the MCP requirement
that progress increase monotonically. The close telemetry event also
reported the stale value. advance() and the monotonicity check had the
same blind spot and could compute a new value from the stale base.

Track the state the emitter has taken but not yet delivered and treat it
as newer than _last_emitted. Skip the at-least-once re-emit on close when
an update is in flight, since that update will be delivered.

Fixes test_progress_telemetry_hooks_capture_events, which failed
consistently on main, and adds regression tests for both paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GonJZXszkMqG1RmHhkVaFe
@TanishGudise
Tanish Gudise (TanishGudise) marked this pull request as draft October 7, 2026 19:17
Add a HeldSession fake that holds each send until the test releases it,
so a state stays in flight without relying on sleeps. With emit_hz=0 the
new tests don't depend on timing:

- close() while a send is in flight doesn't re-send an older value
- advance() builds on the in-flight state
- close() while a failed send is being retried doesn't regress
- set() below the in-flight value raises ValueError

All four fail on every run against main. The existing throttle-delay
tests are kept, since they cover the real-world path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TanishGudise
Tanish Gudise (TanishGudise) marked this pull request as ready for review October 7, 2026 20:11

This branch has not been deployed

No deployments
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.

1 participant