Skip to content

iOS history hydration can erase a newly sent message and corrupt the active stream #309

Closed

Description

@yxlyx

Summary

A history row's ChatView is interactive while transcript hydration is still in flight. Sending during that window can have the hydration response replace the newly appended messages. The stream then continues mutating a captured array index that may now refer to a historical message or be out of bounds.

The result can be lost user input, transcript corruption, or a crash.

Reproduction

  1. Delay GET /v1/app/sessions/{id} on the gateway.
  2. Open a lightweight history row, starting hydrateIfNeeded().
  3. Before the GET finishes, enter a prompt and tap Send.
  4. Wait until send() appends the user message and runTurn() appends its assistant placeholder.
  5. Release the delayed history response so hydration replaces session.messages.
  6. Deliver the next stream event.

Root cause

  • The view .task invokes hydrateIfNeeded() (ChatView.swift:81-87), but the composer remains interactive while that awaited request is pending.
  • send() can append messages and start a turn during hydration (ChatView.swift:91-99).
  • runTurn() captures an integer assistant index and mutates that position for each event (ChatView.swift:124-139).
  • Hydration replaces the complete session.messages array (ChatView.swift:154-160).

Main-actor isolation does not prevent this logical race because both flows yield at network awaits.

Expected behavior

  • Disable sending until hydration reaches an explicit success/failure state.
  • Merge or coordinate hydration with local mutations rather than blindly replacing the array.
  • Target the streaming assistant placeholder by stable message identity, not an array index.
  • Add a delayed-hydration regression test proving that in-flight messages cannot be erased or misapplied.

Activity

  1. justrach commented on Jul 31, 2026

    @justrach
    Owner

    Fixed in v0.0.226 (PR #328).

    The in-flight turn is now addressed by stable id instead of array index. This was a real crash: hydration replaces session.messages wholesale, and a shorter hydrated array traps on messages[idx]. Verified on a real simulator - TURNSTATE-PASS asserts both that the captured index does go out of bounds after hydration and that update-by-id on a dropped turn is a safe no-op.

    Tests went 449 -> 575 on this release; every Zig fix carries a pinning test that was falsified independently (revert the production change, confirm the test goes red naming itself, restore).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions