@@ -94,6 +94,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
9494 private var connectAuth : [ String : Any ] ?
9595 private var connectDevice : [ String : Any ] ?
9696 private var sentRequestMethods : [ String ] = [ ]
97+ private var sentRequestPayloads : [ [ String : Any ] ] = [ ]
9798 private var receivePhase = 0
9899 private var pendingReceiveHandler :
99100 ( @Sendable ( Result < URLSessionWebSocketTask . Message , Error > ) -> Void ) ?
@@ -142,7 +143,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
142143 obj [ " type " ] as? String == " req " ,
143144 let method = obj [ " method " ] as? String
144145 {
145- self . lock. withLock { self . sentRequestMethods. append ( method) }
146+ self . lock. withLock {
147+ self . sentRequestMethods. append ( method)
148+ self . sentRequestPayloads. append ( obj)
149+ }
146150 guard method == " connect " , let id = obj [ " id " ] as? String else { return }
147151 let params = obj [ " params " ] as? [ String : Any ]
148152 let auth = ( params ? [ " auth " ] as? [ String : Any ] ) ?? [ : ]
@@ -167,6 +171,16 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
167171 self . lock. withLock { self . sentRequestMethods. count ( where: { $0 == method } ) }
168172 }
169173
174+ func sentRequests( method: String ) -> [ [ String : Any ] ] {
175+ self . lock. withLock {
176+ self . sentRequestPayloads. filter { $0 [ " method " ] as? String == method }
177+ }
178+ }
179+
180+ func hasPendingReceiveHandler( ) -> Bool {
181+ self . lock. withLock { self . pendingReceiveHandler != nil }
182+ }
183+
170184 func sendPing( pongReceiveHandler: @escaping @Sendable ( Error ? ) -> Void ) {
171185 pongReceiveHandler ( nil )
172186 }
@@ -215,14 +229,21 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
215229 }
216230
217231 func emitInvokeRequest( id: String , command: String ) {
232+ self . emitInvokeRequest ( id: id, command: command, paramsJSON: " {} " )
233+ }
234+
235+ func emitInvokeRequest( id: String , command: String , paramsJSON: String ? ) {
218236 let handler = self . lock. withLock { ( ) -> ( @Sendable ( Result<
219237 URLSessionWebSocketTask . Message,
220238 Error,
221239 > ) -> Void ) ? in
222240 defer { self . pendingReceiveHandler = nil }
223241 return self . pendingReceiveHandler
224242 }
225- handler ? ( . success( . data( Self . invokeRequestData ( id: id, command: command) ) ) )
243+ handler ? ( . success( . data( Self . invokeRequestData (
244+ id: id,
245+ command: command,
246+ paramsJSON: paramsJSON) ) ) )
226247 }
227248
228249 private static func connectChallengeData( nonce: String ) -> Data {
@@ -284,15 +305,15 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
284305 return ( try ? JSONSerialization . data ( withJSONObject: frame) ) ?? Data ( )
285306 }
286307
287- private static func invokeRequestData( id: String , command: String ) -> Data {
308+ private static func invokeRequestData( id: String , command: String , paramsJSON : String ? ) -> Data {
288309 let frame : [ String : Any ] = [
289310 " type " : " event " ,
290311 " event " : " node.invoke.request " ,
291312 " payload " : [
292313 " id " : id,
293314 " nodeId " : " test-node " ,
294315 " command " : command,
295- " paramsJSON " : " {} " ,
316+ " paramsJSON " : paramsJSON ?? NSNull ( ) ,
296317 ] ,
297318 ]
298319 return ( try ? JSONSerialization . data ( withJSONObject: frame) ) ?? Data ( )
@@ -620,6 +641,86 @@ struct GatewayNodeSessionTests {
620641 await gateway. disconnect ( )
621642 }
622643
644+ @Test
645+ func `node invoke requests keep receiving while system run is blocked`() async throws {
646+ let session = FakeGatewayWebSocketSession ( )
647+ let gateway = GatewayNodeSession ( )
648+ let systemRunStarted = AsyncStream< Void> . makeStream( )
649+ var startedIterator = systemRunStarted. stream. makeAsyncIterator ( )
650+ let systemRunRelease = AsyncStream< Void> . makeStream( )
651+ let options = GatewayConnectOptions (
652+ role: " node " ,
653+ scopes: [ ] ,
654+ caps: [ ] ,
655+ commands: [ " system.run " ] ,
656+ permissions: [ : ] ,
657+ clientId: " openclaw-macos " ,
658+ clientMode: " node " ,
659+ clientDisplayName: " macOS Test " ,
660+ includeDeviceIdentity: false )
661+
662+ try await gateway. connect (
663+ url: #require( URL ( string: " ws://example.invalid " ) ) ,
664+ token: nil ,
665+ bootstrapToken: nil ,
666+ password: nil ,
667+ connectOptions: options,
668+ sessionBox: WebSocketSessionBox ( session: session) ,
669+ onConnected: { } ,
670+ onDisconnected: { _ in } ,
671+ onInvoke: { request in
672+ if request. id == " system-run-blocked " {
673+ systemRunStarted. continuation. yield ( )
674+ for await _ in systemRunRelease. stream {
675+ return BridgeInvokeResponse (
676+ id: request. id,
677+ ok: false ,
678+ error: OpenClawNodeError (
679+ code: . unavailable,
680+ message: " UNSUPPORTED: system.run unavailable " ) )
681+ }
682+ }
683+ return BridgeInvokeResponse ( id: request. id, ok: true , payloadJSON: #"{"ok":true}"# )
684+ } )
685+ let task = try #require( session. latestTask ( ) )
686+
687+ task. emitInvokeRequest (
688+ id: " system-run-blocked " ,
689+ command: " system.run " ,
690+ paramsJSON: #"{"command":["/bin/echo","ok"]}"# )
691+ _ = await startedIterator. next ( )
692+ try await waitUntil ( " receive loop rearmed during system.run " ) {
693+ task. hasPendingReceiveHandler ( )
694+ }
695+ task. emitInvokeRequest ( id: " camera-after-system-run " , command: " camera.snap " )
696+
697+ try await waitUntil ( " second invoke result while system.run is blocked " ) {
698+ task. sentRequestCount ( method: " node.invoke.result " ) == 1
699+ }
700+ let earlyResults = task. sentRequests ( method: " node.invoke.result " )
701+ #expect( earlyResults. count == 1 )
702+ let earlyParams = try #require( earlyResults. first ? [ " params " ] as? [ String : Any ] )
703+ #expect( earlyParams [ " id " ] as? String == " camera-after-system-run " )
704+ #expect( earlyParams [ " ok " ] as? Bool == true )
705+
706+ systemRunRelease. continuation. yield ( )
707+ systemRunRelease. continuation. finish ( )
708+ try await waitUntil ( " blocked system.run result " ) {
709+ task. sentRequestCount ( method: " node.invoke.result " ) == 2
710+ }
711+ let finalResults = task. sentRequests ( method: " node.invoke.result " )
712+ #expect( finalResults. count == 2 )
713+ let blockedResult = try #require( finalResults. first {
714+ ( $0 [ " params " ] as? [ String : Any ] ) ? [ " id " ] as? String == " system-run-blocked "
715+ } )
716+ let blockedParams = try #require( blockedResult [ " params " ] as? [ String : Any ] )
717+ #expect( blockedParams [ " ok " ] as? Bool == false )
718+ let error = try #require( blockedParams [ " error " ] as? [ String : Any ] )
719+ #expect( error [ " code " ] as? String == OpenClawNodeErrorCode . unavailable. rawValue)
720+
721+ await gateway. disconnect ( )
722+ }
723+
623724 @Test ( . stateDirectoryIsolated)
624725 func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
625726 let tempDir = FileManager . default. temporaryDirectory
0 commit comments