Reproduce the loss without simulators, and show the fix is not sufficient

Two :interop:peer accounts against relay1, both running the retired-session fix.
Neither polls before sending, which is the simultaneous-initiation case:

    p1 send / p2 send   -> delivered:1, failed:0   (each on its own chain)
    p1 poll / p2 poll   -> both first messages arrive
    p1 send / p2 send   -> delivered:1, failed:0
    p1 poll / p2 poll   -> {"messages":[]} / {"messages":[]}

The second exchange is lost in both directions, silently, with the relay
reporting success. Same failure as the two-simulator run with every iOS variable
removed, and it reproduces in about two minutes instead of forty.

So retaining the retired session is necessary and not sufficient. The receive
side can now open the old chain; the loop still does not close. The remaining
gap looks to be on the send side — after adopting the peer's chain each client
encrypts on the adopted one while the peer still reads the one it kept, so
neither is listening where the other is talking. Resolving this properly means
both sides converging on the SAME chain by a deterministic tie-break, not each
adopting the other's.

Recorded so the earlier commit is not read as having closed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 17:08:34 -04:00
co-authored by Claude Opus 5
parent 999ab2bd26
commit e62317b6f3
+41
View File
@@ -461,3 +461,44 @@ section and a tab bar.
A DM run takes 20-40 minutes per side. The group phase has never completed
inside a sitting. **Budget hours, not minutes** — or give the pair a trimmed
screen to work against before running it.
### 🔴 REPRODUCED CHEAPLY — and the retired-session fix does NOT close it
2026-08-25, two `:interop:peer` accounts against `relay1`, no simulators, no UI.
Both had the retired-session fix compiled in.
```
p1 send p2 first-from-P1 {"delivered":1,"failed":0,"to":408}
p2 send p1 first-from-P2 {"delivered":1,"failed":0,"to":407} # neither had polled
p1 poll {"messages":[{"text":"first-from-P2",...}]} ✅
p2 poll {"messages":[{"text":"first-from-P1",...}]} ✅
p1 send p2 second-from-P1 {"delivered":1,"failed":0,"to":408}
p2 send p1 second-from-P2 {"delivered":1,"failed":0,"to":407}
p1 poll {"messages":[]} ❌
p2 poll {"messages":[]} ❌
```
**First messages cross. The second exchange is lost in both directions, silently,
with the relay reporting `delivered:1, failed:0`.**
This is the same failure seen between two simulators, with every iOS variable
removed — two Kotlin clients and a live relay. **Reproduce it in about two
minutes** rather than the 20-40 the simulator pair costs:
```
./gradlew -q :interop:peer -Pargs="p1.json create <relay> <h1>"
./gradlew -q :interop:peer -Pargs="p2.json create <relay> <h2>"
# add both ways, then send both ways WITHOUT polling, then poll, then send again
```
⚠️ **Retaining the retired session is necessary but NOT sufficient**, which the
commit that added it does not say clearly enough. The receive side can now open
the old chain; what this shows is that the loop still does not close. The
likely remaining gap is on the SEND side — after adopting the peer's chain,
each client encrypts on the adopted one while the peer is still reading the one
it kept, so neither is listening where the other is talking. Resolving
simultaneous initiation properly means both sides converging on the SAME chain
by a deterministic rule (Signal breaks the tie on the identity keys), not each
adopting the other's.
⚠️ Do not treat the ratchet-overwrite commit as having fixed this.