catalyst-core 1.0.0 → 1.0.1-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/otel.js +34 -1
  31. package/dist/server/expressServer.js +1 -0
  32. package/dist/server/renderer/handler.jsx +23 -0
  33. package/dist/vite/vite.config.client.js +1 -1
  34. package/dist/vite/vite.config.js +0 -4
  35. package/dist/web-router/components/MetaTag.jsx +6 -13
  36. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  37. package/mcp_v2/conversion-tasks.json +10 -9
  38. package/mcp_v2/knowledge-base.json +34 -4
  39. package/mcp_v2/test/config.test.ts +52 -0
  40. package/mcp_v2/tools/config.d.ts +18 -0
  41. package/mcp_v2/tools/config.js +40 -0
  42. package/mcp_v2/tools/conversion.js +2 -2
  43. package/package.json +6 -10
  44. package/test/hooks/aiProviders.test.ts +33 -0
  45. package/test/hooks/useSafeNavigate.test.ts +21 -0
  46. package/test/native/aiPluginComposition.test.js +161 -0
  47. package/test/server/handler.test.ts +27 -0
@@ -102,10 +102,10 @@ public class FrameworkServerUtils {
102
102
 
103
103
  // Server state
104
104
  private var listener: NWListener?
105
- private var serverPort: UInt16 = 0
106
- private var sessionId: String = ""
107
- private var isServerRunning: Bool = false
108
- private var isHTTPS: Bool = false
105
+ var serverPort: UInt16 = 0
106
+ var sessionId: String = ""
107
+ var isServerRunning: Bool = false
108
+ var isHTTPS: Bool = false
109
109
 
110
110
  // Connection management
111
111
  private var activeConnections: Set<ConnectionWrapper> = []
@@ -114,11 +114,11 @@ public class FrameworkServerUtils {
114
114
  private let connectionTimeoutSeconds: TimeInterval = CatalystConstants.NetworkServer.connectionTimeout
115
115
 
116
116
  // CORS configuration - store the base URL from WebView
117
- private var allowedOrigin: String = "*"
117
+ var allowedOrigin: String = "*"
118
118
 
119
119
  // File management
120
- private var servedFiles: [String: ServedFile] = [:]
121
- private let fileQueue = DispatchQueue(label: "framework.server.files", attributes: .concurrent)
120
+ var servedFiles: [String: ServedFile] = [:]
121
+ let fileQueue = DispatchQueue(label: "framework.server.files", attributes: .concurrent)
122
122
 
123
123
  // Cache directory
124
124
  private var cacheDirectory: URL?
@@ -126,6 +126,21 @@ public class FrameworkServerUtils {
126
126
  // Cleanup timer
127
127
  private var cleanupTimer: Timer?
128
128
 
129
+ // Native AI — supplier is set by the AI engine (catalyst-ai) before POST /ai/stream is called.
130
+ // Kotlin equivalent: nativeAiSupplier / nativeSystemPrompt in FrameworkServerUtils.kt.
131
+ var nativeAiSupplier: NativeAISupplier?
132
+ var nativeSystemPrompt: String = ""
133
+ let aiLock = NSLock()
134
+
135
+ // Requests whose headers/body arrive across several reads, keyed by connection.
136
+ var pendingRequests: [ObjectIdentifier: Data] = [:]
137
+ // In-flight AI stream tasks, cancelled when the client goes away.
138
+ var aiStreamTasks: [ObjectIdentifier: Task<Void, Never>] = [:]
139
+ let requestStateLock = NSLock()
140
+
141
+ static let maxHeaderBytes = 16 * 1024
142
+ static let maxBodyBytes = 1024 * 1024
143
+
129
144
  private init() {}
130
145
 
131
146
  // MARK: - Public API
@@ -332,20 +347,35 @@ public class FrameworkServerUtils {
332
347
  return isServerRunning
333
348
  }
334
349
 
350
+ /**
351
+ * Number of client connections currently tracked by the server.
352
+ * Internal so tests can wait on connection lifecycle events instead of sleeping.
353
+ */
354
+ func activeConnectionCount() -> Int {
355
+ return connectionQueue.sync { activeConnections.count }
356
+ }
357
+
335
358
  /**
336
359
  * Get server port
337
360
  */
338
- func getServerPort() -> UInt16 {
361
+ public func getServerPort() -> UInt16 {
339
362
  return serverPort
340
363
  }
341
364
 
342
365
  /**
343
366
  * Get current session ID
344
367
  */
345
- func getSessionId() -> String {
368
+ public func getSessionId() -> String {
346
369
  return sessionId
347
370
  }
348
371
 
372
+ /// Stream and generate connections outlive the default per-connection timeout (slow on-device decoding).
373
+ func cancelConnectionTimeout(for connection: NWConnection) {
374
+ connectionQueue.sync {
375
+ activeConnections.first { $0.connection === connection }?.cancelTimeout()
376
+ }
377
+ }
378
+
349
379
  // MARK: - Private Implementation
350
380
 
351
381
  /**
@@ -529,28 +559,35 @@ public class FrameworkServerUtils {
529
559
  let port = NWEndpoint.Port(integerLiteral: serverPort)
530
560
  // parameters.requiredLocalEndpoint = .hostPort(host: host, port: port)
531
561
 
532
- listener = try NWListener(using: parameters, on: port)
562
+ let newListener = try NWListener(using: parameters, on: port)
563
+ listener = newListener
533
564
 
534
- listener?.newConnectionHandler = { [weak self] connection in
565
+ newListener.newConnectionHandler = { [weak self] connection in
535
566
  self?.handleNewConnection(connection)
536
567
  }
537
568
 
538
- listener?.stateUpdateHandler = { [weak self] state in
569
+ newListener.stateUpdateHandler = { [weak self, weak newListener] state in
570
+ // NWListener delivers state changes asynchronously, so a cancelled
571
+ // or failed listener can report after stopServer() -> startServer()
572
+ // has already installed a replacement. Only the current listener
573
+ // may flip isServerRunning; a stale callback would otherwise mark a
574
+ // healthy new server as stopped.
575
+ let isCurrent = { self?.listener === newListener }
539
576
  switch state {
540
577
  case .ready:
541
578
  logger.debug("NW server ready on port \(self?.serverPort ?? 0)")
542
579
  case .failed(let error):
543
580
  logger.error("NW server failed: \(error.localizedDescription)")
544
- self?.isServerRunning = false
581
+ if isCurrent() { self?.isServerRunning = false }
545
582
  case .cancelled:
546
583
  logger.debug("NW server cancelled")
547
- self?.isServerRunning = false
584
+ if isCurrent() { self?.isServerRunning = false }
548
585
  default:
549
586
  break
550
587
  }
551
588
  }
552
589
 
553
- listener?.start(queue: .global(qos: .utility))
590
+ newListener.start(queue: .global(qos: .utility))
554
591
  }
555
592
 
556
593
  private func handleNewConnection(_ connection: NWConnection) {
@@ -566,102 +603,73 @@ public class FrameworkServerUtils {
566
603
  let wrapper = ConnectionWrapper(connection: connection, timeout: self.connectionTimeoutSeconds)
567
604
  self.activeConnections.insert(wrapper)
568
605
 
569
- connection.stateUpdateHandler = { [weak self, weak wrapper] state in
570
- switch state {
571
- case .ready:
572
- self?.receiveHTTPRequest(on: connection)
573
- case .failed(let error):
574
- logger.debug("Connection failed: \(error.localizedDescription)")
575
- if let wrapper = wrapper {
576
- self?.removeConnection(wrapper)
577
- }
578
- connection.cancel()
579
- case .cancelled:
580
- logger.debug("Connection cancelled")
581
- if let wrapper = wrapper {
582
- self?.removeConnection(wrapper)
583
- }
584
- default:
585
- break
586
- }
606
+ connection.stateUpdateHandler = { [weak self] state in
607
+ self?.handleConnectionStateChange(state, on: connection)
587
608
  }
588
609
 
589
610
  connection.start(queue: .global(qos: .utility))
590
611
  }
591
612
  }
592
613
 
593
- private func removeConnection(_ wrapper: ConnectionWrapper) {
594
- connectionQueue.sync {
595
- self.activeConnections.remove(wrapper)
596
- wrapper.cancelTimeout()
614
+ /// Reacts to a client connection's state change. Internal (not private) so
615
+ /// tests can drive the failed/cancelled paths directly: which one Network.framework
616
+ /// reports for a dropped client is timing-dependent, so real sockets cannot
617
+ /// exercise them reliably.
618
+ func handleConnectionStateChange(_ state: NWConnection.State, on connection: NWConnection) {
619
+ switch state {
620
+ case .ready:
621
+ receiveHTTPRequest(on: connection)
622
+ case .failed(let error):
623
+ logger.debug("Connection failed: \(error.localizedDescription)")
624
+ cancelAIStream(for: connection)
625
+ discardPendingRequest(for: connection)
626
+ removeTrackedConnection(for: connection)
627
+ connection.cancel()
628
+ case .cancelled:
629
+ logger.debug("Connection cancelled")
630
+ cancelAIStream(for: connection)
631
+ discardPendingRequest(for: connection)
632
+ removeTrackedConnection(for: connection)
633
+ default:
634
+ break
597
635
  }
598
636
  }
599
637
 
600
- private func receiveHTTPRequest(on connection: NWConnection) {
601
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in
602
-
603
- if let error = error {
604
- logger.error("Error receiving request: \(error.localizedDescription)")
605
- connection.cancel()
606
- return
607
- }
608
-
609
- guard let data = data, let requestString = String(data: data, encoding: .utf8) else {
610
- self?.sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
611
- return
612
- }
613
-
614
- self?.processHTTPRequest(requestString, on: connection)
615
-
616
- if isComplete {
617
- connection.cancel()
618
- }
619
- }
638
+ /// Internal for the same reason as handleConnectionStateChange.
639
+ func handleReceiveError(_ error: NWError, on connection: NWConnection) {
640
+ logger.error("Error receiving request: \(error.localizedDescription)")
641
+ connection.cancel()
620
642
  }
621
643
 
622
- private func processHTTPRequest(_ requestString: String, on connection: NWConnection) {
623
- let lines = requestString.components(separatedBy: "\r\n")
624
- guard let requestLine = lines.first else {
625
- sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
626
- return
644
+ /// Internal for the same reason as handleConnectionStateChange.
645
+ func handleSendCompletion(_ error: NWError?, on connection: NWConnection) {
646
+ if let error = error {
647
+ logger.debug("Error sending response: \(error.localizedDescription)")
627
648
  }
649
+ connection.cancel()
650
+ }
628
651
 
629
- let components = requestLine.components(separatedBy: " ")
630
- guard components.count >= 2, components[0] == "GET" else {
631
- sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
632
- return
652
+ private func removeTrackedConnection(for connection: NWConnection) {
653
+ let wrapper = connectionQueue.sync { activeConnections.first { $0.connection === connection } }
654
+ if let wrapper = wrapper {
655
+ removeConnection(wrapper)
633
656
  }
657
+ }
634
658
 
635
- let path = components[1]
636
-
637
- // Handle status endpoint
638
- if path == "/framework-\(self.sessionId)/status" {
639
- fileQueue.sync {
640
- let statusResponse = """
641
- {
642
- "status": "running",
643
- "sessionId": "\(self.sessionId)",
644
- "port": \(self.serverPort),
645
- "servedFiles": \(self.servedFiles.count)
646
- }
647
- """
648
- sendHTTPResponse(on: connection, statusCode: 200, body: statusResponse, contentType: "application/json")
649
- }
650
- return
659
+ private func removeConnection(_ wrapper: ConnectionWrapper) {
660
+ connectionQueue.sync {
661
+ self.activeConnections.remove(wrapper)
662
+ wrapper.cancelTimeout()
651
663
  }
664
+ }
652
665
 
653
- // Handle file requests
654
- if path.hasPrefix("/framework-\(self.sessionId)/file-") {
655
- let fileId = String(path.dropFirst("/framework-\(self.sessionId)/file-".count))
656
- serveFile(fileId: fileId, on: connection)
657
- return
666
+ func receiveHTTPRequest(on connection: NWConnection) {
667
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, isComplete, error in
668
+ self?.handleReceivedData(data, isComplete: isComplete, error: error, on: connection)
658
669
  }
659
-
660
- // Invalid route
661
- sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.fileNotFound, body: "Not Found")
662
670
  }
663
671
 
664
- private func serveFile(fileId: String, on connection: NWConnection) {
672
+ func serveFile(fileId: String, on connection: NWConnection) {
665
673
  fileQueue.sync {
666
674
  guard let servedFile = self.servedFiles[fileId] else {
667
675
  logger.warning("File not found for fileId: \(fileId)")
@@ -706,7 +714,7 @@ public class FrameworkServerUtils {
706
714
 
707
715
  // Add CORS headers
708
716
  response += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
709
- response += "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
717
+ response += "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
710
718
  response += "Access-Control-Allow-Headers: *\r\n"
711
719
 
712
720
  // Add headers
@@ -771,18 +779,18 @@ public class FrameworkServerUtils {
771
779
  })
772
780
  }
773
781
 
774
- private func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: String, contentType: String = "text/plain") {
782
+ func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: String, contentType: String = "text/plain") {
775
783
  let bodyData = body.data(using: .utf8) ?? Data()
776
784
  sendHTTPResponse(on: connection, statusCode: statusCode, body: bodyData, headers: ["Content-Type": contentType])
777
785
  }
778
786
 
779
- private func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: Data, headers: [String: String] = [:]) {
787
+ func sendHTTPResponse(on connection: NWConnection, statusCode: Int, body: Data, headers: [String: String] = [:]) {
780
788
  let statusText = HTTPURLResponse.localizedString(forStatusCode: statusCode)
781
789
  var response = "HTTP/1.1 \(statusCode) \(statusText)\r\n"
782
790
 
783
791
  // Add CORS headers
784
792
  response += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
785
- response += "Access-Control-Allow-Methods: GET, OPTIONS\r\n"
793
+ response += "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
786
794
  response += "Access-Control-Allow-Headers: *\r\n"
787
795
 
788
796
  // Add default headers
@@ -803,11 +811,8 @@ public class FrameworkServerUtils {
803
811
  }
804
812
  let responseData = responseHeaderData + body
805
813
 
806
- connection.send(content: responseData, completion: .contentProcessed { error in
807
- if let error = error {
808
- logger.debug("Error sending response: \(error.localizedDescription)")
809
- }
810
- connection.cancel()
814
+ connection.send(content: responseData, completion: .contentProcessed { [weak self] error in
815
+ self?.handleSendCompletion(error, on: connection)
811
816
  })
812
817
  }
813
818
 
@@ -0,0 +1,76 @@
1
+ //
2
+ // NativeAI.swift
3
+ // iosnativeWebView
4
+ //
5
+ // Types shared between the framework server's native-AI routes and the AI
6
+ // engine that catalyst-ai plugs in. Mirrors the Kotlin contract in
7
+ // FrameworkServerUtils.kt: an engine hands the server a "supplier" that
8
+ // turns (prompt, genConfig, conversationId) into (conversationId, token stream).
9
+ // catalyst-core has no knowledge of any inference library.
10
+ //
11
+
12
+ import Foundation
13
+
14
+ /// Result of one native-AI request: the active conversation id plus the token stream.
15
+ /// Kotlin equivalent: Pair<String, Flow<String>>.
16
+ public typealias NativeAIStream = (conversationId: String, tokens: AsyncThrowingStream<String, Error>)
17
+
18
+ /// Kotlin equivalent: suspend (String, JSONObject, String?) -> Pair<String, Flow<String>>.
19
+ public typealias NativeAISupplier = (
20
+ _ prompt: String,
21
+ _ genConfig: [String: Any],
22
+ _ conversationId: String?
23
+ ) async throws -> NativeAIStream
24
+
25
+ /// Error codes come from packages/catalyst-core/src/errors/registry.js (AI-xxx) so the
26
+ /// JS side surfaces the same code on iOS and Android.
27
+ public enum NativeAIErrorCode {
28
+ public static let invalidRequestBody = "AI-003"
29
+ public static let streamNotReady = "AI-005"
30
+ public static let requestFailed = "AI-006"
31
+ }
32
+
33
+ /// An error an engine/server can raise with the registry code that should reach JS.
34
+ public struct NativeAIError: LocalizedError {
35
+ public let message: String
36
+ public let code: String
37
+
38
+ public init(message: String, code: String = NativeAIErrorCode.requestFailed) {
39
+ self.message = message
40
+ self.code = code
41
+ }
42
+
43
+ public var errorDescription: String? { message }
44
+ }
45
+
46
+ /// Server-Sent Events framing. Frame shapes match Android's /ai/stream exactly:
47
+ /// {"conversationId"}, {"token"}, {"done":true}, {"error"} (+ "code" on iOS).
48
+ enum NativeAISSE {
49
+ static func frame(_ payload: [String: Any]) -> Data {
50
+ // Payloads are built from Strings/Bools only, so serialization cannot fail;
51
+ // the fallback keeps the stream well-formed regardless.
52
+ let json = (try? JSONSerialization.data(withJSONObject: payload, options: [.withoutEscapingSlashes]))
53
+ ?? Data("{\"error\":\"serialization failed\"}".utf8)
54
+ return Data("data: ".utf8) + json + Data("\n\n".utf8)
55
+ }
56
+
57
+ static func conversationId(_ id: String) -> Data { frame(["conversationId": id]) }
58
+ static func token(_ text: String) -> Data { frame(["token": text]) }
59
+ static func done() -> Data { frame(["done": true]) }
60
+ static func error(_ message: String, code: String) -> Data { frame(["error": message, "code": code]) }
61
+ }
62
+
63
+ /// Parsed body of POST /ai/stream and /ai/generate: { prompt, genConfig, conversationId }.
64
+ struct NativeAIRequest {
65
+ let prompt: String
66
+ let genConfig: [String: Any]
67
+ let conversationId: String?
68
+
69
+ init(body: Data) {
70
+ let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:]
71
+ prompt = object["prompt"] as? String ?? ""
72
+ genConfig = object["genConfig"] as? [String: Any] ?? [:]
73
+ let id = (object["conversationId"] as? String) ?? ""
74
+ conversationId = id.isEmpty ? nil : id
75
+ }
76
+ }
@@ -0,0 +1,133 @@
1
+ import XCTest
2
+ import Foundation
3
+ import Network
4
+ @testable import CatalystCoreLogic
5
+
6
+ /**
7
+ * Deterministic tests for FrameworkServerUtils' connection error paths.
8
+ *
9
+ * Whether Network.framework reports a dropped client as a `.failed` connection
10
+ * state, a receive error, or a send error is timing-dependent, so driving them
11
+ * with real sockets made the CoreLogic line-coverage number vary from run to
12
+ * run and turned the coverage-regression gate into a coin flip. These tests
13
+ * call the extracted handlers directly with synthetic errors so every branch
14
+ * runs every time.
15
+ */
16
+ final class FrameworkServerUtilsConnectionHandlerTests: XCTestCase {
17
+
18
+ private var frameworkServer: FrameworkServerUtils!
19
+
20
+ /// A connection to a closed loopback port (discard, 9). It never carries data;
21
+ /// it only needs to be startable so that cancel() is observable as `.cancelled`.
22
+ private func makeIdleConnection() -> NWConnection {
23
+ NWConnection(host: "127.0.0.1", port: 9, using: .tcp)
24
+ }
25
+
26
+ /// Starts a connection, runs `action` on it, and asserts it ends up `.cancelled`.
27
+ private func assertCancelled(
28
+ by action: (NWConnection) -> Void,
29
+ file: StaticString = #filePath,
30
+ line: UInt = #line
31
+ ) {
32
+ let connection = makeIdleConnection()
33
+ let cancelled = expectation(description: "connection cancelled")
34
+ connection.stateUpdateHandler = { state in
35
+ if case .cancelled = state { cancelled.fulfill() }
36
+ }
37
+ connection.start(queue: .global(qos: .utility))
38
+
39
+ action(connection)
40
+
41
+ wait(for: [cancelled], timeout: 5)
42
+ }
43
+
44
+ override func setUp() {
45
+ super.setUp()
46
+ frameworkServer = FrameworkServerUtils.shared
47
+ }
48
+
49
+ override func tearDown() {
50
+ if frameworkServer.isRunning() {
51
+ frameworkServer.stopServer()
52
+ }
53
+ frameworkServer = nil
54
+ super.tearDown()
55
+ }
56
+
57
+ func testConnectionStateFailed_CancelsConnection() {
58
+ assertCancelled { connection in
59
+ frameworkServer.handleConnectionStateChange(.failed(.posix(.ECONNRESET)), on: connection)
60
+ }
61
+ }
62
+
63
+ func testConnectionStateCancelled_DoesNotThrowForUntrackedConnection() {
64
+ let connection = makeIdleConnection()
65
+
66
+ frameworkServer.handleConnectionStateChange(.cancelled, on: connection)
67
+
68
+ XCTAssertEqual(frameworkServer.activeConnectionCount(), 0)
69
+ }
70
+
71
+ func testConnectionStateWaiting_IsIgnored() {
72
+ let connection = makeIdleConnection()
73
+
74
+ frameworkServer.handleConnectionStateChange(.waiting(.posix(.ENETDOWN)), on: connection)
75
+
76
+ XCTAssertNotEqual(connection.state, .cancelled)
77
+ connection.cancel()
78
+ }
79
+
80
+ func testReceiveError_CancelsConnection() {
81
+ assertCancelled { connection in
82
+ frameworkServer.handleReceiveError(.posix(.ECONNRESET), on: connection)
83
+ }
84
+ }
85
+
86
+ func testReceivedData_WithError_CancelsConnection() {
87
+ assertCancelled { connection in
88
+ frameworkServer.handleReceivedData(nil, isComplete: false, error: .posix(.ECONNRESET), on: connection)
89
+ }
90
+ }
91
+
92
+ func testSendCompletion_WithError_CancelsConnection() {
93
+ assertCancelled { connection in
94
+ frameworkServer.handleSendCompletion(.posix(.EPIPE), on: connection)
95
+ }
96
+ }
97
+
98
+ func testSendCompletion_WithoutError_CancelsConnection() {
99
+ assertCancelled { connection in
100
+ frameworkServer.handleSendCompletion(nil, on: connection)
101
+ }
102
+ }
103
+
104
+ // MARK: - Port selection
105
+
106
+ /// findAvailablePort() must skip a port that is already bound. Which ports
107
+ /// are busy on a CI runner is environmental, so the skip path only ran
108
+ /// sometimes; occupying the first port in the range makes it run every time.
109
+ func testStartServer_SkipsOccupiedPort() throws {
110
+ let firstPort = FrameworkServerUtils.FRAMEWORK_PORT_RANGE_START
111
+
112
+ let blocker = socket(AF_INET, SOCK_STREAM, 0)
113
+ try XCTSkipIf(blocker == -1, "could not create blocker socket")
114
+ defer { close(blocker) }
115
+
116
+ var addr = sockaddr_in()
117
+ addr.sin_family = sa_family_t(AF_INET)
118
+ addr.sin_port = firstPort.bigEndian
119
+ addr.sin_addr.s_addr = INADDR_ANY
120
+ let bound = withUnsafePointer(to: &addr) {
121
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
122
+ Darwin.bind(blocker, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
123
+ }
124
+ }
125
+ // bind() failing means another process already holds the first port,
126
+ // which exercises the same skip path, so the assertions below still hold.
127
+ _ = bound
128
+
129
+ XCTAssertTrue(frameworkServer.startServer())
130
+ XCTAssertNotEqual(frameworkServer.getServerPort(), firstPort)
131
+ XCTAssertGreaterThan(frameworkServer.getServerPort(), firstPort)
132
+ }
133
+ }