Skip to content

Cover the reconnection paths #625 leaves unpinned - #1

Draft
mokagio wants to merge 30 commits into
tellyworth:simpl-75-recurring-intermittent-sync-failure-on-official-clientsfrom
Simperium:mokagio/simpl-75-websocket-reconnection-test-coverage
Draft

mokagio wants to merge 30 commits into
tellyworth:simpl-75-recurring-intermittent-sync-failure-on-official-clientsfrom
Simperium:mokagio/simpl-75-websocket-reconnection-test-coverage

Conversation

@mokagio

@mokagio mokagio commented Aug 28, 2026

Copy link
Copy Markdown

Stacked on Simperium#625. Adds test coverage for the reconnection behaviour that PR changes; no production code is touched.

Why

Reviewing Simperium#625 locally, three of its behaviours turned out not to be pinned by its own tests:

  • The stop: cancel narrowing has no test. Reverting that line to the old cancelPreviousPerformRequestsWithTarget:self leaves all seven of Retry websocket connections that drop before completing the handshake Simperium/simperium-ios#625's tests green — including testStopCancelsPendingReconnection, which looks like it guards it.
  • The retry assertions are openAttempts > 0. A regression that scheduled a burst of reconnections rather than one would pass. Given the change's whole risk profile is "does this now reconnect too aggressively", that is the assertion worth tightening.
  • The socket teardown after a close is only exercised indirectly, through the retry it enables, and not at all on the networkEnabled == NO branch.

Separately, start: returning early for an authenticated channel is what makes startNetworkManagers safe to re-run per bucket on every restart — the load-bearing claim in that method's new comment. It had no test.

Gotcha

CountingWebSocketInterface overrides openWebSocket without calling super, so the de-duplication inside the real openWebSocket never runs under test. That rules out a "repeated closes collapse to one reconnection" test at this level — it would be asserting on the double, not the code. testSingleCloseSchedulesExactlyOneReconnection covers what is reachable.

How to test

xcodebuild -project Simperium.xcodeproj -scheme "Simperium iOS" -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' build test

114 tests, 0 failures locally (iOS 26.x simulator).

Each new test was also checked against a targeted mutation of SPWebSocketInterface.m — broadening the stop: cancel, dropping start:'s authenticated-channel early return, dropping the socket release in didCloseWithCode:, and double-scheduling the retry. Every mutation failed exactly the test meant to catch it, and nothing else.


Opened by Claude Code (Opus 5) on behalf of @mokagio with approval.

mokagio and others added 27 commits August 28, 2026 17:12
Two things an agent gets wrong here without being told.

The test command exists only in `.travis.yml`, which is stale and no
longer runs anywhere — so the command has to be reconstructed, and the
absence of CI makes a wrong reconstruction easy to miss.

Integration is a SwiftPM `binaryTarget`, so a merge to `develop` reaches
no consumer and pointing an app at a branch silently pairs that branch's
source with an older release's binary.
Reasoning about how a change ships depends on knowing this.

`CLAUDE.md` defers to `AGENTS.md` so both toolchains read one file.

---

Generated with the help of Claude Code, https://claude.ai/code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gio Lodi <giovanni.lodi42@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The a8c CI toolkit's Buildkite plugin expects a `test` lane, and going
through fastlane gives the test step a JUnit report to annotate failures
from.

The macOS lane only builds: the `Simperium OSX` scheme has an empty test
action, so compiling is all that can guard the macOS-only sources.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs the unit tests and the macOS build on every pull request, tag, and
push to `develop` or `main`.

The pipeline needs the matching `simperium-ios` pipeline definition in
the `buildkite-ci` repo to exist before it can run.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It pinned Xcode 12 and has not run since the project left Travis.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The constraint came over from a sibling repo's `Gemfile`; 2.238 is what
the lock already resolves to.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The path was written out by hand in the Fastfile, the pipeline's
`artifact_paths` and the annotation call, so three places had to agree.
It now comes from `TEST_RESULTS_DIR` in `shared-pipeline-vars`; the lane
keeps a default so a local `fastlane ios test` still works.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:rubygems:` stays a name: it is a Buildkite custom emoji with no Unicode
character behind it.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mac agents run the step's command in a VM, and `hostmgr generate
buildkite-job` forwards only `BUILDKITE_*` variables into it, so the
pipeline's `env` block set `TEST_RESULTS_DIR` on the host job and the
script saw nothing — `unbound variable` under `set -u`.
`artifact_paths` was unaffected: interpolation happens at upload, on the
host, and bakes the value into `BUILDKITE_ARTIFACT_PATHS`.

`shared-pipeline-vars` stays the single definition; the script now
sources it directly, and the `env` entry is gone since it never crossed
the boundary.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`shared-pipeline-vars` is only loaded at pipeline-upload time, so sourcing it
from the job script was working around the wrong thing.
Exporting the already-interpolated value in the step's `command` keeps the
directory defined in one place without the job re-reading that file.

Per @AliSoftware's review on #627.

---

Generated with the help of Claude Code, https://claude.ai/code

Co-Authored-By: Claude Code Opus 5 <noreply@anthropic.com>
The CI script reads `report.junit` by path, so the report's type and name are
part of the contract between the lane and the step rather than a fastlane
default to inherit.

Per Copilot's review on #627.

---

Generated with the help of Claude Code, https://claude.ai/code

Co-Authored-By: Claude Code Opus 5 <noreply@anthropic.com>
Pipeline-upload interpolation bakes the path into the command, so the job does not need an env-var re-export or an explanation of why `env:` cannot carry it onto the mac queue.

Suggested as an alternative to the export dance in @AliSoftware's review on #627.

---

Generated with the help of Grok, https://x.ai

Co-Authored-By: Grok 4.6 <noreply@x.ai>
The script is the one that reads the report, so it names the file and tells the lane what to write.

---

Generated with the help of Grok, https://x.ai

Co-Authored-By: Grok 4.6 <noreply@x.ai>
House style prefers `arg: arg` over the omitted-value shorthand.

---

Generated with the help of Grok, https://x.ai

Co-Authored-By: Grok 4.6 <noreply@x.ai>
`set -u` already makes `$1` fatal.

---

Generated with the help of Grok, https://x.ai

Co-Authored-By: Grok 4.6 <noreply@x.ai>
The script argument and `artifact_paths` already live in the same pipeline step, so an interpolated env var is leftover indirection.

---

Generated with the help of Grok, https://x.ai

Co-Authored-By: Grok 4.6 <noreply@x.ai>
A socket close that arrives before the connection finishes opening is
treated as an intentional close: no retry is scheduled, the socket is
nil'd, and networkManagersStarted still reads YES, so no automatic path
ever rebuilds the connection. Sync silently stops until the app is
relaunched or reachability drops completely.

These tests document the expected behavior and currently fail:

- testSocketClosedBeforeFinishingHandshakeSchedulesReconnection
- testStartNetworkManagersRestartsBucketsEvenWhenAlreadyFlaggedAsStarted

See SIMPL-75.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013N7ff8Y5ytsSLQ225boWwG
Fixes a permanent sync wedge (SIMPL-75):

- SPWebSocketInterface treated a close arriving before webSocketDidOpen
  as intentional and never retried, even though intentional closes nil
  the delegate first and can't reach that handler. Any delivered close
  now schedules a retry, guarded by networkEnabled like didFailWithError.

- startNetworkManagers early-returned whenever networkManagersStarted
  was set, but the flag only records that start ran once — not that a
  socket exists. After the close above, every automatic restart path
  (reachability regained, OSX wake) became a no-op, so sync stayed dead
  until an app relaunch or a full reachability drop. It now re-runs
  start: for each bucket, which is idempotent for healthy connections.

- stop: cancelled every pending perform request on the interface rather
  than just its own reconnection retry; the cancel is now scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013N7ff8Y5ytsSLQ225boWwG
#625 changes three behaviours and pins two of them.
The narrowing of `stop:`'s `cancelPreviousPerformRequestsWithTarget:`
ships with no test that notices its removal: reverting that line to the
old broad cancel leaves all seven of the PR's tests green.
The socket teardown that runs after a close is only exercised through
the retry it enables, and the retry assertions are `openAttempts > 0`,
so a regression that scheduled a burst of reconnections would pass.

`start:` returning early for an authenticated channel is the reason
`startNetworkManagers` can now re-run for every bucket on each restart,
so that early return is load-bearing and gets a test of its own.

Each test was checked against a targeted mutation of
`SPWebSocketInterface.m` and fails only under the mutation it is meant
to catch.

---

Generated with the help of Claude Code, https://claude.ai/code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mokagio
mokagio force-pushed the mokagio/simpl-75-websocket-reconnection-test-coverage branch from fe75fa2 to 67c7fd1 Compare September 2, 2026 05:20
mokagio and others added 2 commits September 2, 2026 15:54
The test file's `SPReconnectionDelay = 2.5` was not the reconnection
delay — the delay is 2, and 2.5 was the test's wait budget.
Two of the tests assert that no reconnection happened once the wait
elapses, so a production delay raised past 2.5 would have left them
passing without ever giving a retry the chance to fire.

`SPWebSocketHeartbeatInterval` and `SPWebSocketTimeoutInterval` are the
existing convention for naming these intervals.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`-webSocket:didCloseWithCode:` now treats every close that reaches it as
unexpected and schedules a reconnection, so an intentional stop must not
let one through.
It did not, but only because `SPRWebSocket`'s `close` is asynchronous —
the ordering here made the guarantee accidental, and contradicted the
comment on the delegate method asserting it.

`-[SPWebSocket handleTimeout:]` already detaches first.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`-dealloc` tore the network down, which reaches
`-[SPWebSocketChannel stop]` and its main-thread `NSAssert`.
Nothing guarantees which thread the last release lands on: `SPLogger`
reads its weak delegate on its own queue, so a log message still in
flight can hold the final reference to a `Simperium` and free it there.

The teardown now takes the buckets as an argument so `-dealloc` can hand
them to the main queue, which it could not do while the work needed
`self`.

This was already failing this branch's build on CI.

---

Generated with the help of Claude Code, https://claude.com/claude-code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants