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.
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
- package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
- package/dist/native/bridge/hooks/aiProviders.js +27 -0
- package/dist/native/bridge/hooks/useAI.js +9 -8
- package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
- package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
- package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
- package/dist/native/buildAppIos.js +8 -2
- package/dist/native/buildIos/plugins.js +3 -1
- package/dist/native/internalPluginUtils.js +78 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
- package/dist/native/pluginComposerIos.js +5 -1
- package/dist/otel.js +34 -1
- package/dist/server/expressServer.js +1 -0
- package/dist/server/renderer/handler.jsx +23 -0
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +0 -4
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/mcp_v2/conversion-tasks.json +10 -9
- package/mcp_v2/knowledge-base.json +34 -4
- package/mcp_v2/test/config.test.ts +52 -0
- package/mcp_v2/tools/config.d.ts +18 -0
- package/mcp_v2/tools/config.js +40 -0
- package/mcp_v2/tools/conversion.js +2 -2
- package/package.json +6 -10
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- package/test/native/aiPluginComposition.test.js +161 -0
- 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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
+
var allowedOrigin: String = "*"
|
|
118
118
|
|
|
119
119
|
// File management
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
562
|
+
let newListener = try NWListener(using: parameters, on: port)
|
|
563
|
+
listener = newListener
|
|
533
564
|
|
|
534
|
-
|
|
565
|
+
newListener.newConnectionHandler = { [weak self] connection in
|
|
535
566
|
self?.handleNewConnection(connection)
|
|
536
567
|
}
|
|
537
568
|
|
|
538
|
-
|
|
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
|
-
|
|
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
|
|
570
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|