A message list may look fine in the iOS Simulator on a cloud Mac, yet miss a status update—or process the same notification twice—when a real network connection briefly drops. SSE (Server-Sent Events), which streams text events from server to client, is especially prone to this problem: a successful HTTP request does not mean the client has finished processing the last event. The local-only test server below turns interruptions and resumption into repeatable checks.
Define the replay contract first
An SSE event consists of field lines and ends with a blank line. The id is the cursor assigned by the server. On reconnection, the client sends the ID of the last processed event in the Last-Event-ID request header. The server then returns subsequent events.
“Read an ID” is not the same as “processed an event.” A connection can drop between id: 2 and the following data line. If the client saves ID 2 at that point, reconnection starts at 3 and event 2 is lost for good. Wait for the terminating blank line, complete the application work, and only then commit the cursor. If processing writes to a database, deduplicate by event ID as well: an interruption can also occur between committing the result and saving the cursor.
The goal is not to prove that the connection never drops. It is to prove that the set of events remains consistent across a drop and that each ID produces an application effect at most once.
Start a controllable local event source
Create sse_fixture.py on the cloud Mac. The script binds only to 127.0.0.1, so the test port is not exposed externally. With cut=1, it ends the response after sending one event. On the next request, the server reads the request header and continues with the following event.
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit
EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlsplit(self.path)
if url.path != "/events":
self.send_error(404)
return
try:
last = int(self.headers.get("Last-Event-ID", "0"))
except ValueError:
self.send_error(400)
return
cut = parse_qs(url.query).get("cut") == ["1"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
for event_id, value in EVENTS:
if event_id <= last:
continue
payload = f"id: {event_id}\ndata: {value}\n\n".encode()
try:
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
if cut:
break
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
Run python3 sse_fixture.py, then open another terminal to inspect two responses:
curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'
The first response should contain only ID 1; the second should contain IDs 2 and 3, in order. This clean ending tests “request again after the stream ends.” Network resets, timeouts, and partial events in production require separate fault injection. One clean ending does not cover every disconnection scenario.
Commit complete events on iOS
A test client can read line by line using URLSession.bytes(for:). The following snippet requires a deployment target that supports that API. It handles one connection; the caller schedules retries. It delivers an event only when a blank line arrives and discards any incomplete fragment at the end.
import Foundation
struct StreamEvent {
let id: String
let data: String
}
func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
var request = URLRequest(url: url)
if let lastID {
request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
var result: [StreamEvent] = []
var eventID: String?
var data: [String] = []
for try await line in bytes.lines {
if line.isEmpty {
if let eventID, !data.isEmpty {
result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
}
eventID = nil
data = []
} else if line.hasPrefix("id:") {
eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
data.append(String(line.dropFirst(5)).dropFirst(
line.dropFirst(5).first == " " ? 1 : 0
).description)
}
}
return result
}
This minimal example preserves multiline data and ignores heartbeat lines and other fields. A production parser must also handle events without IDs, empty IDs, and retry hints according to the protocol. Returning an array makes test results easy to inspect, but it is unsuitable for a production stream that runs indefinitely: process events as they arrive rather than accumulating them in memory.
Run two disconnection checks in the simulator
First, use curl on the Mac to confirm the service is reachable. Then request http://127.0.0.1:8765/events?cut=1 from the iOS Simulator. The simulator can usually reach local services on its host Mac. On a physical device, 127.0.0.1 refers to the device itself, so the same address will not work. If the app blocks plain HTTP by default, configure a narrowly scoped local-network exception for debug builds only; do not carry a broad exception into the release configuration.
In the first round, record event 1 and set the cursor to 1 only after processing succeeds. In the second, remove cut=1, send Last-Event-ID: 1, and confirm that the IDs received are exactly 2 and 3. Next, deliberately fail the first attempt at application processing: the cursor must retain its previous value. When the event arrives again, deduplicate by ID or retry safely. Finally, cancel the reading task and confirm that the reconnection timer is canceled too; otherwise, background requests may continue after the user leaves the screen.
| Check | Pass condition |
|---|---|
| Complete event | Application processing starts only after the terminating blank line arrives |
| Resume after disconnect | Events after the committed ID arrive in order |
| Duplicate delivery | The same ID does not produce a second application effect |
| Leaving the screen | The reading task and any pending retry both stop |
Attribute failures to the right layer
If the second round starts from the beginning, first check whether the request header actually contains the committed ID, then whether the server supports filtering by cursor. Do not immediately blame the simulator. If only the last event is missing, check whether the parser mistakes “end of stream” for an event boundary. If reconnection produces duplicate notifications, check application-level deduplication rather than merely increasing the retry interval.
Also distinguish “connection succeeded” from “event delivered successfully.” A server that disconnects after sending the bytes cannot prove that the client wrote its local state. A client that saves the cursor cannot assume that the remote application operation is safe to retry. Record the event ID, processing result, and committed cursor together. When you next change the parser or retry logic, you can compare each result instead of judging by eye from the message list.
Frequently asked questions
When should an app persist Last-Event-ID?
After it has received and successfully processed the complete event. Persisting an ID as soon as its line arrives can skip unprocessed data after a disconnect.
Can the iOS Simulator reach a server bound to 127.0.0.1?
The simulator can generally reach a local service on its host Mac. Confirm the port with curl first; a physical device cannot use the same loopback address.
Ready to put it to the test?
Rent a dedicated Mac mini and run your build
Choose from two M4 dedicated Mac models, then select a rental term and location to suit your needs. Check real-time availability in the console.