@@ -34,6 +34,10 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
3434 /// libssh2 is not thread-safe per session, so every call must be serialized.
3535 private let sessionQueue : DispatchQueue
3636
37+ /// Concurrent queue for relay I/O (poll, send, recv — no libssh2 calls).
38+ /// Individual libssh2 calls within each relay are dispatched to `sessionQueue`.
39+ private let relayQueue : DispatchQueue
40+
3741 /// Dedicated queue for the accept loop (poll + accept only, no libssh2 calls).
3842 private let acceptQueue : DispatchQueue
3943
@@ -62,6 +66,11 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
6266 label: " com.TablePro.ssh.session. \( connectionId. uuidString) " ,
6367 qos: . utility
6468 )
69+ self . relayQueue = DispatchQueue (
70+ label: " com.TablePro.ssh.relay. \( connectionId. uuidString) " ,
71+ qos: . utility,
72+ attributes: . concurrent
73+ )
6574 self . acceptQueue = DispatchQueue (
6675 label: " com.TablePro.ssh.accept. \( connectionId. uuidString) " ,
6776 qos: . utility
@@ -98,29 +107,25 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
98107 break
99108 }
100109
101- // Open channel and spawn relay on the session queue
102- // to serialize libssh2 calls
103- let semaphore = DispatchSemaphore ( value: 0 )
104- self . sessionQueue. async {
105- let channel = self . openDirectTcpipChannel (
110+ // Open channel on sessionQueue (serialized libssh2 call),
111+ // then hand off relay to relayQueue (concurrent I/O).
112+ let channel : OpaquePointer ? = self . sessionQueue. sync {
113+ self . openDirectTcpipChannel (
106114 remoteHost: remoteHost,
107115 remotePort: remotePort
108116 )
117+ }
109118
110- guard let channel else {
111- Self . logger. error ( " Failed to open direct-tcpip channel " )
112- Darwin . close ( clientFD)
113- semaphore. signal ( )
114- return
115- }
116-
117- Self . logger. debug (
118- " Client connected, relaying to \( remoteHost) : \( remotePort) "
119- )
120- self . spawnRelay ( clientFD: clientFD, channel: channel)
121- semaphore. signal ( )
119+ guard let channel else {
120+ Self . logger. error ( " Failed to open direct-tcpip channel " )
121+ Darwin . close ( clientFD)
122+ continue
122123 }
123- semaphore. wait ( )
124+
125+ Self . logger. debug (
126+ " Client connected, relaying to \( remoteHost) : \( remotePort) "
127+ )
128+ self . spawnRelay ( clientFD: clientFD, channel: channel)
124129 }
125130
126131 Self . logger. info ( " Forwarding loop ended for port \( self . localPort) " )
@@ -306,7 +311,8 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
306311 }
307312
308313 /// Bidirectional relay between a client socket and an SSH channel.
309- /// The relay loop runs on `sessionQueue` to serialize all libssh2 calls.
314+ /// The relay loop runs on `relayQueue` (concurrent). Individual libssh2 calls
315+ /// are dispatched to `sessionQueue` (serial) for thread safety.
310316 private func spawnRelay( clientFD: Int32 , channel: OpaquePointer ) {
311317 let task = Task . detached { [ weak self] in
312318 guard let self else {
@@ -315,7 +321,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
315321 }
316322
317323 await withCheckedContinuation { ( continuation: CheckedContinuation < Void , Never > ) in
318- self . sessionQueue . async { [ weak self] in
324+ self . relayQueue . async { [ weak self] in
319325 defer { continuation. resume ( ) }
320326 guard let self else {
321327 Darwin . close ( clientFD)
@@ -332,15 +338,17 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
332338 }
333339 }
334340
335- /// Blocking relay loop. Must only be called on `sessionQueue`.
341+ /// Blocking relay loop. Runs on `relayQueue`; libssh2 calls go through `sessionQueue`.
336342 private func runRelay( clientFD: Int32 , channel: OpaquePointer ) {
337343 let buffer = UnsafeMutablePointer< CChar> . allocate( capacity: Self . relayBufferSize)
338344 defer {
339345 buffer. deallocate ( )
340346 Darwin . close ( clientFD)
341347 if self . isRunning {
342- libssh2_channel_close ( channel)
343- libssh2_channel_free ( channel)
348+ sessionQueue. sync {
349+ libssh2_channel_close ( channel)
350+ libssh2_channel_free ( channel)
351+ }
344352 }
345353 }
346354
@@ -353,52 +361,29 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
353361 let pollResult = poll ( & pollFDs, 2 , 100 ) // 100ms timeout
354362 if pollResult < 0 { break }
355363
356- // Only read from SSH channel when the SSH socket has data ready
357- if pollFDs [ 1 ] . revents & Int16 ( POLLIN) != 0 {
358- let channelRead = tablepro_libssh2_channel_read (
359- channel, buffer, Self . relayBufferSize
360- )
361- if channelRead > 0 {
362- var totalSent = 0
363- while totalSent < Int ( channelRead) {
364- let sent = send (
365- clientFD,
366- buffer. advanced ( by: totalSent) ,
367- Int ( channelRead) - totalSent,
368- 0
369- )
370- if sent <= 0 { return }
371- totalSent += sent
372- }
373- } else if channelRead == 0 || libssh2_channel_eof ( channel) != 0 {
374- return
375- } else if channelRead != Int ( LIBSSH2_ERROR_EAGAIN) {
376- return
364+ // Read from SSH channel when the SSH socket has data or on timeout
365+ // (libssh2 may have internally buffered data)
366+ if pollFDs [ 1 ] . revents & Int16 ( POLLIN) != 0 || pollResult == 0 {
367+ let readResult : Int = sessionQueue. sync {
368+ Int ( tablepro_libssh2_channel_read ( channel, buffer, Self . relayBufferSize) )
377369 }
378- }
379-
380- // Also attempt a non-blocking channel read when poll timed out,
381- // because libssh2 may have buffered data internally
382- if pollResult == 0 {
383- let channelRead = tablepro_libssh2_channel_read (
384- channel, buffer, Self . relayBufferSize
385- )
386- if channelRead > 0 {
370+ if readResult > 0 {
387371 var totalSent = 0
388- while totalSent < Int ( channelRead ) {
372+ while totalSent < readResult {
389373 let sent = send (
390374 clientFD,
391375 buffer. advanced ( by: totalSent) ,
392- Int ( channelRead ) - totalSent,
376+ readResult - totalSent,
393377 0
394378 )
395379 if sent <= 0 { return }
396380 totalSent += sent
397381 }
398- } else if channelRead == 0 || libssh2_channel_eof ( channel) != 0 {
382+ } else if readResult == 0 || sessionQueue. sync ( { libssh2_channel_eof ( channel) } ) != 0 {
383+ return
384+ } else if readResult != Int ( LIBSSH2_ERROR_EAGAIN) {
399385 return
400386 }
401- // Ignore EAGAIN on timeout read — no data buffered
402387 }
403388
404389 // Read from client -> write to SSH channel
@@ -408,13 +393,15 @@ internal final class LibSSH2Tunnel: @unchecked Sendable {
408393
409394 var totalWritten = 0
410395 while totalWritten < Int ( clientRead) {
411- let written = tablepro_libssh2_channel_write (
412- channel,
413- buffer. advanced ( by: totalWritten) ,
414- Int ( clientRead) - totalWritten
415- )
396+ let written : Int = sessionQueue. sync {
397+ Int ( tablepro_libssh2_channel_write (
398+ channel,
399+ buffer. advanced ( by: totalWritten) ,
400+ Int ( clientRead) - totalWritten
401+ ) )
402+ }
416403 if written > 0 {
417- totalWritten += Int ( written)
404+ totalWritten += written
418405 } else if written == Int ( LIBSSH2_ERROR_EAGAIN) {
419406 _ = self . waitForSocket (
420407 session: self . session,
0 commit comments