Skip to content

v10 slice 3: iOS queue store, executor, and events - #43

Open
dmurphy5 wants to merge 3 commits into
dylan/v10-2-androidfrom
dylan/v10-3-ios
Open

dmurphy5 wants to merge 3 commits into
dylan/v10-2-androidfrom
dylan/v10-3-ios

Conversation

@dmurphy5

@dmurphy5 dmurphy5 commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

This PR makes the iOS side real. It does the same job as the Android PR, with the same rules and the same status names, so the app behaves the same on both platforms.

iOS has no WorkManager. It has a background URLSession: the system sends the request in its own process and wakes the app when the request finishes, even if the app was closed. This PR builds the queue on top of that.

How a request moves through iOS

sequenceDiagram
    participant JS
    participant Queue as Library (iOS)
    participant Session as Background URLSession (system)
    participant Server

    JS->>Queue: mutate(variables)
    Queue->>Queue: write the request and its body file to disk
    Queue-->>JS: stored
    Queue->>Session: create an upload task from the file
    Session->>Server: send (the system keeps sending if the app is suspended)
    Server-->>Session: response
    alt app is running
        Session-->>Queue: task finished
    else app was closed
        Session-->>iOS: wake the app
        iOS-->>Queue: handleEventsForBackgroundURLSession
        Queue->>Queue: reconnect the finished task to its request
    end
    Queue->>Queue: write the result to the journal
    Queue-->>JS: result event (now, or at the next startup)
Loading

The rules are the same as on Android: write the request first, retry with a growing wait, park on 401 until updateHeaders(), journal before telling JS, exactly one result per request.

What is different on iOS

  1. Every upload comes from a file. The system requires it for background sessions. So JSON and multipart bodies are written to a file before the task is created.
  2. Retries use delayed tasks. There is no scheduler like WorkManager. A retry is a task with a start time in the future.
  3. Reconnecting after a relaunch. When the system wakes the app, the library matches each finished task to its request by a key it stored when it created the task. A task with no request, or a request with no task, is repaired at startup.
  4. Force quit. If the user swipes the app away, the system cancels the session's tasks. The library sees this at the next launch and retries.
  5. No global limit on parallel requests. The system limits connections per host instead.

Testing without a device

The pure logic (the store, the retry table, the status changes, the journal, the relaunch matching) is a Swift package. cd ios && swift test runs 201 tests on a Mac in about one second, including crash-in-the-middle cases. The part that talks to the real URLSession is thin and is covered by the device script.

What to look at

  1. QueueCoordinator+Reconcile.swift: what happens at relaunch.
  2. RetryClassifier.swift: the same table as Android.
  3. ios/Tests/CoordinatorRelaunchTests.swift: the relaunch cases.

Test Plan

What's required for testing (prerequisites)?

Xcode with an iOS simulator. CocoaPods for the example app. For a device run, the example app README has the script.

What are the steps to reproduce (after prerequisites)?

cd ios && swift test                                              # 201 tests, 0 failures
cd example/RNBGUExample/ios && pod install && xcodebuild -workspace RNBGUExample.xcworkspace -scheme RNBGUExample -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build

Nothing has run on a device yet.

Compatibility

OS Implemented
iOS ✅
Android ✅ see the Android PR

Checklist

  • I have tested this on a device and a simulator
  • I added the documentation in README.md
  • I updated the typed files (TS)
  • I've added Detox End-to-End Test(s)
  • I've created a snack to demonstrate the changes

🤖 Generated with Claude Code

dmurphy5 and others added 3 commits September 28, 2026 13:11
iOS implements the same contract over a background URLSession. QueueStore
generalizes the v9 chunked manifest into one durable entry per mutate(),
written with fsync before any task is created. BodyStaging writes JSON and
multipart bodies to files, copies single-file bodies, and moves chunked
sources, so every upload comes from a file as the background session
requires.

QueueCoordinator owns the entries: enqueue with the same-id rules and
E_RUNNING/E_FILE_MISSING/E_STORAGE codes, one task per attempt keyed by
(id, generation, attempt), TaskMap reconciliation at relaunch and through
handleEventsForBackgroundURLSession, the retry table with delayed tasks
for backoff, 401/403 parking with header generations, expiry, pause,
cancel, and forget-after-ack. The journal writes before onSettled emits;
onState, onProgress (throttled), and onAttempt follow the TypeScript
payload types. RequestIndex backs the synchronous getRequests. First
launch imports v9 journal entries as legacy rows.

The pure half of the module is a SwiftPM package (ios/Package.swift) so
`cd ios && swift test` runs 138 host-side tests, including crash-mid-write.
The podspec excludes the package files and test sources from the pod. The
example app builds for the simulator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reliability: reconcile applies unacked same-generation records to a live
row before the task loop, so a failed entry save after the journal write
cannot re-issue a settled request. deliveries counts only deliveries that
reached a JS listener: journaled at 0 with no listener, not emitted live.
A chunked part with a pending replay holds its slot for the grace period.
A completion that lands before reconcile still advances the attempt
ordinal. A part-file build error settles error/file only when the blob is
missing or short; otherwise it refills after backoff. The background
completion handler releases as soon as the awaited replay lands, and the
grace timer only closes the wait it opened.

Contract: vars and data arrive as JSON text; NSNull handling for data is
gone; E_INVALID covers non-http(s) URLs, bad header names or values, and
GET with a body. url and method are part of the body fingerprint. attempts
reset only on reopen. A paused entry past expiresAt settles at resume; the
expiry check runs after classification. A same-id mutate on a waiting
retry retries now. updateHeaders patches part headers. Rows carry live
bytesSent; legacy rows report 0/0. Attempt events are completed or error.

Simplification: dead fields removed (TaskMap.Meta.accept, isChunkedPart,
fileUnreadable, lifetimeMs, descriptorJSON, allDormantManifests).
Tests: 170 (was 138), including relaunch and pending-replay cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner decision, matching Android: a cancel whose journal write fails
rejects with E_STORAGE and changes nothing; the caller may call again.
The hold-and-retry path stays for settle outcomes only. A cancel of a
settled entry now forgets atomically: the id directory is set aside by
one rename, the journal files are deleted, and a failure puts the row
back and rejects. A crash between the two steps is finished or undone at
launch. 178 tests.

Co-Authored-By: Claude Fable 5.1 <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.

2 participants