feat(gateway)!: switch handshake to req:connect (protocol v2)

This commit is contained in:
Peter Steinberger
2025-12-12 23:29:57 +00:00
parent e915ed182d
commit d5d80f4247
26 changed files with 586 additions and 955 deletions

View File

@@ -151,7 +151,7 @@ actor GatewayChannelActor {
self.task = self.session.makeWebSocketTask(url: self.url)
self.task?.resume()
do {
try await self.sendHello()
try await self.sendConnect()
} catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false
@@ -176,40 +176,50 @@ actor GatewayChannelActor {
}
}
private func sendHello() async throws {
private func sendConnect() async throws {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let clientName = InstanceIdentity.displayName
let hello = Hello(
type: "hello",
minprotocol: GATEWAY_PROTOCOL_VERSION,
maxprotocol: GATEWAY_PROTOCOL_VERSION,
client: [
"name": ClawdisProtocol.AnyCodable(clientName),
"version": ClawdisProtocol.AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ClawdisProtocol.AnyCodable(platform),
"mode": ClawdisProtocol.AnyCodable("app"),
"instanceId": ClawdisProtocol.AnyCodable(InstanceIdentity.instanceId),
],
caps: [],
auth: self.token.map { ["token": ClawdisProtocol.AnyCodable($0)] },
locale: primaryLocale,
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
let data = try JSONEncoder().encode(hello)
let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([] as [String]),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
}
let frame = RequestFrame(
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (no response)"])
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
try await self.handleHelloResponse(msg)
try await self.handleConnectResponse(msg, reqId: reqId)
}
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws {
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@@ -219,37 +229,46 @@ actor GatewayChannelActor {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (empty response)"])
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
if let ok = try? decoder.decode(HelloOk.self, from: data) {
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
self.tickIntervalMs = tick
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
guard let self else { return }
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
if let err = try? decoder.decode(HelloError.self, from: data) {
let reason = err.reason
// Log and throw a detailed error so UI can surface token/hello issues.
self.logger.error("gateway hello-error: \(reason, privacy: .public)")
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1008,
userInfo: [NSLocalizedDescriptionKey: "hello-error: \(reason)"])
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
}
guard let payload = res.payload else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
}
let payloadData = try self.encoder.encode(payload)
let ok = try decoder.decode(HelloOk.self, from: payloadData)
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
self.tickIntervalMs = tick
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
guard let self else { return }
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
private func listen() {
@@ -301,9 +320,6 @@ actor GatewayChannelActor {
}
if evt.event == "tick" { self.lastTick = Date() }
await self.pushHandler?(.event(evt))
case let .helloOk(ok):
self.lastTick = Date()
await self.pushHandler?(.snapshot(ok))
default:
break
}

View File

@@ -51,11 +51,11 @@ class GatewaySocket {
this.ws = ws;
ws.onopen = () => {
logStatus(`ws: open -> sending hello (${this.url})`);
const hello = {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
const id = randomId();
logStatus(`ws: open -> sending connect (${this.url})`);
const params = {
minProtocol: 2,
maxProtocol: 2,
client: {
name: "webchat-ui",
version: "dev",
@@ -63,8 +63,10 @@ class GatewaySocket {
mode: "webchat",
instanceId: randomId(),
},
caps: [],
};
ws.send(JSON.stringify(hello));
ws.send(JSON.stringify({ type: "req", id, method: "connect", params }));
this.pending.set(id, { resolve, reject, _handshake: true });
};
ws.onerror = (err) => {
@@ -91,14 +93,6 @@ class GatewaySocket {
} catch {
return;
}
if (msg.type === "hello-ok") {
logStatus(
`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`,
);
this.handlers.set("snapshot", msg.snapshot);
resolve(msg);
return;
}
if (msg.type === "event") {
const cb = this.handlers.get(msg.event);
if (cb) cb(msg.payload, msg);
@@ -108,8 +102,20 @@ class GatewaySocket {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(new Error(msg.error?.message || "gateway error"));
if (msg.ok) {
if (pending._handshake) {
const helloOk = msg.payload;
logStatus(
`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`,
);
this.handlers.set("snapshot", helloOk.snapshot);
pending.resolve(helloOk);
} else {
pending.resolve(msg.payload);
}
} else {
pending.reject(new Error(msg.error?.message || "gateway error"));
}
}
};
});

View File

@@ -196394,20 +196394,31 @@ var GatewaySocket = class {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.onopen = () => {
logStatus(`ws: open -> sending hello (${this.url})`);
const hello = {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
const id = randomId();
logStatus(`ws: open -> sending connect (${this.url})`);
const params = {
minProtocol: 2,
maxProtocol: 2,
client: {
name: "webchat-ui",
version: "dev",
platform: "browser",
mode: "webchat",
instanceId: randomId()
}
},
caps: []
};
ws.send(JSON.stringify(hello));
ws.send(JSON.stringify({
type: "req",
id,
method: "connect",
params
}));
this.pending.set(id, {
resolve,
reject,
_handshake: true
});
};
ws.onerror = (err) => {
logStatus(`ws: error ${formatError(err)}`);
@@ -196428,12 +196439,6 @@ var GatewaySocket = class {
} catch {
return;
}
if (msg.type === "hello-ok") {
logStatus(`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`);
this.handlers.set("snapshot", msg.snapshot);
resolve(msg);
return;
}
if (msg.type === "event") {
const cb = this.handlers.get(msg.event);
if (cb) cb(msg.payload, msg);
@@ -196443,8 +196448,18 @@ var GatewaySocket = class {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(new Error(msg.error?.message || "gateway error"));
if (msg.ok) {
if (pending._handshake) {
const helloOk = msg.payload;
logStatus(`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`);
this.handlers.set("snapshot", helloOk.snapshot);
pending.resolve(helloOk);
} else {
pending.resolve(msg.payload);
}
} else {
pending.reject(new Error(msg.error?.message || "gateway error"));
}
}
};
});

View File

@@ -1,7 +1,7 @@
// Generated by scripts/protocol-gen-swift.ts do not edit by hand
import Foundation
public let GATEWAY_PROTOCOL_VERSION = 1
public let GATEWAY_PROTOCOL_VERSION = 2
public enum ErrorCode: String, Codable {
case notLinked = "NOT_LINKED"
@@ -10,8 +10,7 @@ public enum ErrorCode: String, Codable {
case unavailable = "UNAVAILABLE"
}
public struct Hello: Codable {
public let type: String
public struct ConnectParams: Codable {
public let minprotocol: Int
public let maxprotocol: Int
public let client: [String: AnyCodable]
@@ -21,7 +20,6 @@ public struct Hello: Codable {
public let useragent: String?
public init(
type: String,
minprotocol: Int,
maxprotocol: Int,
client: [String: AnyCodable],
@@ -30,7 +28,6 @@ public struct Hello: Codable {
locale: String?,
useragent: String?
) {
self.type = type
self.minprotocol = minprotocol
self.maxprotocol = maxprotocol
self.client = client
@@ -40,7 +37,6 @@ public struct Hello: Codable {
self.useragent = useragent
}
private enum CodingKeys: String, CodingKey {
case type
case minprotocol = "minProtocol"
case maxprotocol = "maxProtocol"
case client
@@ -84,31 +80,6 @@ public struct HelloOk: Codable {
}
}
public struct HelloError: Codable {
public let type: String
public let reason: String
public let expectedprotocol: Int?
public let minclient: String?
public init(
type: String,
reason: String,
expectedprotocol: Int?,
minclient: String?
) {
self.type = type
self.reason = reason
self.expectedprotocol = expectedprotocol
self.minclient = minclient
}
private enum CodingKeys: String, CodingKey {
case type
case reason
case expectedprotocol = "expectedProtocol"
case minclient = "minClient"
}
}
public struct RequestFrame: Codable {
public let type: String
public let id: String
@@ -537,9 +508,6 @@ public struct ShutdownEvent: Codable {
}
public enum GatewayFrame: Codable {
case hello(Hello)
case helloOk(HelloOk)
case helloError(HelloError)
case req(RequestFrame)
case res(ResponseFrame)
case event(EventFrame)
@@ -553,12 +521,6 @@ public enum GatewayFrame: Codable {
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
let type = try typeContainer.decode(String.self, forKey: .type)
switch type {
case "hello":
self = .hello(try Hello(from: decoder))
case "hello-ok":
self = .helloOk(try HelloOk(from: decoder))
case "hello-error":
self = .helloError(try HelloError(from: decoder))
case "req":
self = .req(try RequestFrame(from: decoder))
case "res":
@@ -574,9 +536,6 @@ public enum GatewayFrame: Codable {
public func encode(to encoder: Encoder) throws {
switch self {
case .hello(let v): try v.encode(to: encoder)
case .helloOk(let v): try v.encode(to: encoder)
case .helloError(let v): try v.encode(to: encoder)
case .req(let v): try v.encode(to: encoder)
case .res(let v): try v.encode(to: encoder)
case .event(let v): try v.encode(to: encoder)

View File

@@ -5,6 +5,7 @@ import Testing
@Suite struct GatewayConnectionTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
@@ -40,8 +41,18 @@ import Testing
return count
}
// First send is the hello frame. Subsequent sends are request frames.
if currentSendCount == 0 { return }
// First send is the connect handshake request. Subsequent sends are request frames.
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
@@ -61,7 +72,8 @@ import Testing
if self.helloDelayMs > 0 {
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
}
return .data(Self.helloOkData())
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
@@ -75,20 +87,25 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
}
private static func helloOkData() -> Data {
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "hello-ok",
"protocol": 1,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)

View File

@@ -11,6 +11,7 @@ import Testing
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let response: FakeResponse
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
initialState: nil)
@@ -36,13 +37,26 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
_ = message
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let (delayMs, msg): (Int, URLSessionWebSocketTask.Message) = switch self.response {
case let .helloOk(delayMs):
(delayMs, .data(Self.helloOkData()))
let id = self.connectRequestID.withLock { $0 } ?? "connect"
(delayMs, .data(Self.connectOkData(id: id)))
case let .invalid(delayMs):
(delayMs, .string("not json"))
}
@@ -58,20 +72,25 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func helloOkData() -> Data {
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "hello-ok",
"protocol": 1,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)

View File

@@ -6,6 +6,7 @@ import Testing
@Suite struct GatewayChannelRequestTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let requestSendDelayMs: Int
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
@@ -37,7 +38,22 @@ import Testing
return count
}
// First send is the hello frame. Second send is the request frame.
// First send is the connect handshake. Second send is the request frame.
if currentSendCount == 0 {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
if currentSendCount == 1 {
try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
throw URLError(.cannotConnectToHost)
@@ -45,7 +61,8 @@ import Testing
}
func receive() async throws -> URLSessionWebSocketTask.Message {
.data(Self.helloOkData())
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
@@ -54,20 +71,25 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func helloOkData() -> Data {
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "hello-ok",
"protocol": 1,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)

View File

@@ -5,6 +5,7 @@ import Testing
@Suite struct GatewayChannelShutdownTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
@@ -29,11 +30,24 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
_ = message
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
.data(Self.helloOkData())
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
@@ -47,20 +61,25 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
private static func helloOkData() -> Data {
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "hello-ok",
"protocol": 1,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
@@ -106,4 +125,3 @@ import Testing
#expect(session.snapshotMakeCount() == 1)
}
}