В симуляторе iOS на облачном Mac список сообщений выглядит нормально, но в реальной сети после краткого обрыва соединения приложение пропускает обновление статуса или дважды обрабатывает одно уведомление. С SSE (Server-Sent Events — потоком текстовых событий от сервера к клиенту) такое случается особенно легко: успешный HTTP-запрос ещё не означает, что клиент обработал последнее событие. Ниже мы используем тестовый сервер, доступный только на самом Mac, чтобы превратить обрыв и возобновление потока в воспроизводимые проверки.
Сначала определите правила возобновления потока
Событие SSE состоит из строк с полями и завершается пустой строкой. Поле id задаёт серверный курсор события. При повторном подключении клиент передаёт ID последнего обработанного события в заголовке Last-Event-ID, а сервер возвращает события после него.
«Прочитать ID» и «обработать событие» — не одно и то же. Соединение может оборваться ровно между id: 2 и следующей строкой data. Если в этот момент сохранить ID 2, повторное подключение начнётся с события 3, а событие 2 будет потеряно. Сначала нужно получить завершающую пустую строку, затем выполнить бизнес-обработку и только после этого сохранить курсор. Если обработка записывает данные в базу, её также следует дедуплицировать по ID события: обрыв возможен и между записью результата и сохранением курсора.
Цель проверки — не доказать, что соединение никогда не оборвётся, а убедиться, что набор событий до и после обрыва остаётся полным и каждый ID вызывает бизнес-эффект не более одного раза.
Запустите управляемый локальный источник событий
Создайте на облачном Mac файл sse_fixture.py. Скрипт привязывается только к 127.0.0.1 и не открывает тестовый порт для внешних подключений. Параметр cut=1 завершает ответ после одного события; при следующем запросе сервер читает заголовок и продолжает со следующего события.
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()
Запустите python3 sse_fixture.py, затем в другом терминале проверьте два ответа:
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'
В первом ответе должен быть только ID 1, во втором — последовательно ID 2 и 3. Здесь штатное завершение ответа проверяет повторный запрос после окончания потока. Сброс соединения, тайм-аут и обрыв посреди события в реальной сети нужно моделировать отдельно: одно штатное завершение не охватывает все сценарии потери связи.
Сохраняйте курсор в iOS только после полного события
Тестовый клиент может читать поток построчно через URLSession.bytes(for:). Для следующего фрагмента целевая версия платформы должна поддерживать этот интерфейс. Фрагмент обслуживает только одно подключение; повторные попытки организует вызывающий код. Событие передаётся на обработку лишь после пустой строки, а неполный фрагмент в конце потока отбрасывается.
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
}
Этот минимальный пример сохраняет многострочные data, но игнорирует строки heartbeat и другие поля. Полноценный парсер должен также обрабатывать события без ID, пустой ID и подсказки о времени повторного подключения согласно протоколу. Для удобства проверки пример возвращает массив, но для бесконечного рабочего потока такой подход не подходит: события в долгоживущем соединении нужно обрабатывать по мере поступления, а не бесконечно накапливать в памяти.
Проведите две проверки обрыва в симуляторе
Сначала убедитесь с помощью curl, что сервис доступен на Mac, затем отправьте из симулятора iOS запрос на http://127.0.0.1:8765/events?cut=1. Симулятор обычно может обращаться к локальному сервису на Mac, где он запущен. На физическом устройстве 127.0.0.1 указывает на само устройство, поэтому тот же адрес использовать нельзя. Если приложение по умолчанию запрещает незашифрованный HTTP, добавьте узко ограниченное исключение для локальной сети только в отладочную конфигурацию; не переносите широкое исключение в релизную.
В первом проходе зафиксируйте событие 1 и присвойте курсору значение 1 только после успешной обработки. Во втором уберите cut=1, передайте Last-Event-ID: 1 и проверьте, что получены ровно ID 2 и 3. Затем намеренно вызовите сбой первой бизнес-операции: курсор должен остаться прежним. Когда событие придёт повторно, дедуплицируйте его по ID или безопасно повторите обработку. Наконец, отмените задачу чтения и убедитесь, что таймер повторного подключения тоже отменён. Иначе после закрытия экрана могут остаться фоновые запросы.
| Что проверить | Условие успешной проверки |
|---|---|
| Полное событие | Бизнес-обработка начинается только после завершающей пустой строки |
| Возобновление после обрыва | События после сохранённого ID приходят по порядку |
| Повторная доставка | Один и тот же ID не вызывает бизнес-эффект второй раз |
| Закрытие экрана | Задача чтения и запланированная повторная попытка останавливаются вместе |
Определите, на каком уровне возникла ошибка
Если при втором подключении сервер снова отдаёт поток с начала, сначала проверьте, действительно ли запрос содержит заголовок с сохранённым ID, а затем — умеет ли сервер выбирать события по курсору. Не стоит сразу винить симулятор. Если теряется только последнее событие, проверьте, не принимает ли парсер окончание потока за границу события. Если повторное подключение приводит к дублированию уведомлений, проверьте дедупликацию в бизнес-логике, а не просто увеличивайте интервал между попытками.
Важно также различать успешное соединение и успешную доставку события. То, что сервер отправил байты и закрыл соединение, не доказывает, что клиент записал локальное состояние. Сохранение курсора клиентом, в свою очередь, не означает, что удалённую бизнес-операцию можно безопасно повторить. Записывайте ID события, результат обработки и курсор после сохранения в один тестовый журнал. Тогда после изменений в парсере или логике повторных попыток результаты можно будет сравнить по пунктам, а не судить о них на глаз по списку сообщений.
Часто задаваемые вопросы
Когда сохранять Last-Event-ID?
После получения полного события и успешной обработки его данных. Сохранение ID сразу после строки id может привести к пропуску события при обрыве.
Доступен ли адрес 127.0.0.1 из симулятора iOS?
Обычно симулятор видит локальный сервер на том же Mac. Сначала проверьте порт через curl; для физического устройства потребуется другой адрес.
Хотите проверить на практике?
Арендуйте выделенный Mac mini и сразу запускайте сборки
Выберите одну из двух конфигураций физических Mac на M4, срок аренды и локацию. Доступность проверяйте в консоли в реальном времени.