Skip to content

fix(core): resolve deferred session candidate races - #65

Merged
chanderlud merged 40 commits into
masterfrom
fix/core-concurrency-races
Aug 8, 2026
Merged

fix(core): resolve deferred session candidate races#65
chanderlud merged 40 commits into
masterfrom
fix/core-concurrency-races

Conversation

@chanderlud

Copy link
Copy Markdown
Owner

Summary

Pending outgoing calls now survive viable replacement-session collisions and terminate exactly once when every deferred replacement aborts. Accept-prompt cancellation is observable only after pending call-slot ownership is released.

Root cause

Deferred terminal intent was attached to one candidate attempt instead of the predecessor call lifecycle. A successor could replace an aborted candidate while predecessor cleanup still held the stale resolution, causing either a missing CallEnded callback or premature termination of a viable replacement. Prompt cancellation also notified observers before releasing PendingIncoming ownership.

Solution

  • Carry terminal intent across replacement candidates that share the same predecessor.
  • Resolve completed candidate IDs against the registry's current entry, following successors until promotion or final abort.
  • Mark promotion before completion and release pending slot ownership before cancellation callbacks.
  • Add deterministic unit and integration regressions plus a solution record.

Tests

  • cargo fmt --manifest-path rust/Cargo.toml --all -- --check - passed after rebase.
  • cargo nextest run --manifest-path rust/Cargo.toml -p telepathy_core --test core_integration_test --features integration-testing call_lifecycle:: - 23 passed, 0 failed.
  • cargo nextest run --manifest-path rust/Cargo.toml -p telepathy_core terminal_resolution_survives_sequential_aborted_candidates stale_aborted_resolution_waits_for_viable_successor promoted_candidate_does_not_inherit_abort_outcome - 3 passed, 0 failed.

Platforms and limitations

Rust checks ran locally on Linux. System tests require manual WSL execution and were not run locally for this PR.

chanderlud and others added 30 commits August 1, 2026 07:47
A simultaneous-dial collision resolved with session_collision_kept_new
tore down the existing session even while it carried a direct-call
engagement, cancelling the callee's pending accept prompt and stranding
the caller's outgoing call (system-test failure: accept_call rejected
with "unknown accept_call request_id").

Defer the collision winner through the pending-candidate registry while
the current session is engaged with the peer, and promote it once the
engagement releases (or the predecessor finishes). An inbound winner
with a pending incoming prompt is still honored immediately so a
restarted endpoint replaces its stale predecessor. A session-stopped
Goodbye during outgoing negotiation now preserves the pending outgoing
ownership when a deferred candidate will re-drive the call instead of
terminalizing it.

Also fix SessionStatusProbe::park_connecting in the test harness:
watch::Sender::send drops the value when no receiver exists, so the
park was a silent no-op; send_replace stores unconditionally.
…waps

The previous approach deferred the collision winner while the current
session carried a call engagement, but that broke tie-break convergence:
both sides could keep their own dialers, stranding each side's Hello on
the peer's parked session (system-test regressions in
test_call_simultaneous_dial and others).

Instead, let the tie-break swap proceed and preserve the call through
it:

- When an outbound collision winner tears down a session with a pending
  accept prompt, the prompt's accept handle is parked (AcceptPromptGuard)
  instead of cancelled; the re-driven incoming negotiation on the
  replacement session adopts the same handle, so the user's prompt
  survives the swap without a cancel/re-prompt flicker. Inbound winners
  (identity restart) keep the existing cancel-and-retain semantics.
- A session-stopped Goodbye during outgoing negotiation now waits
  briefly for a replacement session or deferred candidate before being
  treated as terminal; when one materializes it owns the call's
  resolution and the pending slot is preserved.
- The hello-ack wait's transport-error arm no longer releases the
  pending slot for critical errors: the session-error path owns
  terminalization, which emits CallEnded for a genuinely lost call and
  skips it when a replacement installed. This also fixes a lost
  CallEnded when the transport error raced ahead of the peer's goodbye.

Adds outbound_collision_transfers_accept_prompt_and_completes_call
covering prompt survival, adoption, and call completion with no
re-prompt.
…g early

wait_for_stable_session_pair returned on the first poll where both ids
were stable, then asserted require_a_id_change — racing an in-flight
collision replacement, which routinely lands more than one 100ms poll
interval after the trigger (deferred-candidate promotion waits for the
predecessor session to finish). A required-but-not-yet-landed id change
is not a stable end state: keep polling until it lands, bounded by the
existing 30s deadline. Eliminates load-dependent flakes in
deferred_collision_before_prompt_preserves_original_call,
start_call_waits_for_trusted_session_attempt_and_cancellation_leaves_no_call,
and aborted_deferred_collision_terminalizes_original_call_once.
Concurrent room end_call + immediate rejoin crosses the previous room
generation's teardown Goodbye with the fresh join's Hello. The outgoing
room negotiation treated the first Goodbye as a current-room leave and
ended silently while the peer completed the leg, wedging the mesh
asymmetrically (the intermittent 60s hang in
concurrent_room_end_immediately_rejoins_without_direct_negotiation).
Outgoing room negotiations now hold goodbyes in a 500ms grace window and
complete on the peer's affirmative response.

A parked accept prompt whose caller never re-drives (hangup, crash)
leaked: the prompt stayed open and its retained pending generation held
the call slot forever. Parked transfers now expire after the caller's
offer window (HELLO_TIMEOUT), cancelling the prompt and releasing the
exact generation.

Tests: deterministic recreation of the stale-goodbye sequence via a
parked room admission (fails pre-fix), reset/expiry coverage for parked
accept prompts, transfer-registry unit tests, and a log-capture harness.
Also fixes a race in
active_room_retains_same_identity_candidate_until_predecessor_finishes,
which gated promotion on the connection-level Connected status instead
of the session-map id.
Use the target-conditional sleep/sleep_until imports (tokio on native,
wasmtimer on wasm) for the room-goodbye grace deadline and the parked
accept transfer reaper; tokio::time::sleep_until's Instant type does not
match wasmtimer's on wasm.
…ompt restart flows

Adds an expect_absent step (non-consuming, scans the actor event log) and
injects identity_key_b64 so YAML scenarios can use restart_actor.

- session_simultaneous_dial_then_call: start_call during the session
  glare; the callee's accept prompt must survive collision resolution so
  accept_call never fails with 'unknown accept_call request_id', and no
  second prompt is raised (the original system-test failure geometry)
- caller_cancel_during_glare_then_room: caller hangs up mid-glare-prompt;
  prompt cancellation must be visible only after the pending slot is
  released, so an immediate room join succeeds
- call_prompt_survives_session_restart: caller restarts mid-prompt;
  exactly one cancel, then a fresh prompt completes the call
System testing (seeded sweeps) exposed two gaps in collision-churn call
flows:

- An incoming direct-call negotiation never re-checked session currency
  after the accept prompt resolved, so an accepted call could complete
  its HelloAck on a session the manager had already replaced, stranding
  the caller until its HelloAck timeout. The still-current check that
  already covered rooms now covers direct calls, aborting the stale
  negotiation so the caller ends promptly and recovery works.
- Adopted (transferred) accept prompts had no expiry: when the caller
  died after adoption onto a session that stayed up, the prompt waited
  forever. An incoming offer now expires with the caller's own
  negotiation timeout (HELLO_TIMEOUT), cancelling the prompt and
  releasing the pending slot.

Deterministic recreations in call_lifecycle (both fail pre-fix), plus
hardening of three pre-existing load-sensitive tests: drop the racy
first-poll in start_call_waits_for_trusted_session_attempt (the
unreachable peer can fail all retries within milliseconds), gate
candidate-promotion asserts on the session-map id instead of the
connection-level Connected status, and allow a legitimate sandwiched
RoomLeave (peer's teardown goodbye beating the local end_call to the
controller) in the rejoin sequence while keeping the post-rejoin window
strict.
The still-current abort sent an explicit session-stopped goodbye; under
sustained collision churn the caller's grace path treats each such
goodbye as superseded-by-replacement and silently re-drives, so the next
stale abort fed the next goodbye and the caller never terminalized
(system-test call_hello_ack_timeout livelock). The stale session's
connection teardown already informs the caller through its
critical-error path, so the abort now only releases the pending
generation.
Session churn can cancel a raised prompt before the scenario's accept
lands; the YAML DSL cannot express retry, so the restart coverage moves
to a Python test that accepts the first prompt still open. The original
prompt's cancellation after caller restart remains strictly asserted.
The outgoing negotiation's hello-timeout, peer-terminal (EndedWith), and
setup_call error branches emitted CallEnded to the frontend before
releasing the pending slot, so an observer reacting to CallEnded with an
immediate join_room could hit CallAlreadyActive (system-test
call_timeout_then_room_join). All three now release before notifying.

Also widens the hello_ack_timeout scenario window (collision re-drives
extend the caller's offer legitimately) and drops an incorrect
cancel-then-idle ordering assertion in the parked-prompt reset coverage
(the reset drain cancels parked transfers before the slot barrier by
design).
The session-churn environment on wan makes prompt/accept races dominate
the behavior under test; the race logic itself is covered
deterministically by the core integration suite. Matches the existing
precedent of running timing-sensitive scenarios on the clean profile
only.
…ation

A call Hello arriving on a deferred same-identity candidate connection
has no reader: the candidate parks until the predecessor session
finishes, so the caller waits out its full HelloAck timeout even though
the callee holds an active call with the same identity. This is the
mechanism behind the system-test failure in
session_simultaneous_dial_then_call (caller 'did not respond' while the
callee Connected). Marked ignore until the parked candidate learns to
answer.
Bug-track docs for the four fixes landed in this branch's CI iteration:
the stale room-teardown goodbye grace, accept-prompt expiry/leak fixes,
the no-goodbye stale-negotiation abort (livelock), and
release-before-notify terminal ordering; plus a conventions doc on
asserting authoritative state instead of observable proxies. Seeds
CONCEPTS.md with the session/call/room collision vocabulary.
…cabulary

The rename commit overwrote the existing identity-switching and session
establishment entries; this restores them alongside the new collision,
call, and room terms.
…vel core logs

pytest-rerunfailures was already a dependency; every collected test now
carries flaky(reruns=2, only_rerun=[...]) limited to environment startup
signatures (relay readiness timeout, pkarr publish timeout), so hostile
network profiles losing a setup handshake retry instead of failing the
run, while real assertion failures still fail immediately.

The system-test workflow now runs the CLI with telepathy_core=debug, so
failure artifacts capture negotiation/collision detail that info-level
logs omit.
…cle logs

Adds .agents/skills/system-test-artifact-analysis: artifact layout,
tracing/stdout event vocabulary, analysis flows, and pitfalls (monitor
re-emission vs real churn, ghost sessions, relayed death-detection
latency, scenario DSL ordering).

Adds two info-level diagnostics so future artifacts pinpoint negotiation
stalls: outgoing_negotiation_waiting_hello_ack (after the Hello write)
and session_candidate_resolution_wait (before the deferred-candidate
resolution loop).
…y-races

# Conflicts:
#	.github/workflows/system-tests.yml
Add ignored real-path regressions for issues #91 and #92.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
chanderlud and others added 10 commits August 8, 2026 14:17
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@chanderlud
chanderlud merged commit defb4a2 into master Aug 8, 2026
25 of 27 checks passed
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