Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:

- name: Run ObjC Demo (all demos)
run: |
printf 'a\n\n\n\n\n\nq\n' | ./ObjCDemo
printf 'a\n\n\n\n\n\n\nq\n' | ./ObjCDemo

swift-tests:
name: Swift Tests (macOS)
Expand Down
262 changes: 251 additions & 11 deletions ObjC/NWAsyncSocketObjC/GCDAsyncSocket.m
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ @interface GCDAsyncSocket ()

#if NW_FRAMEWORK_AVAILABLE
@property (nonatomic, assign) nw_connection_t connection;
@property (nonatomic, assign, nullable) nw_listener_t listener;
#endif

@property (nonatomic, strong) dispatch_queue_t socketQueue;
@property (nonatomic, strong) NWStreamBuffer *buffer;
@property (nonatomic, strong) NSMutableArray<NWReadRequest *> *readQueue;
@property (nonatomic, assign) BOOL isReadingContinuously;
@property (atomic, assign) BOOL isListening;

// SSE / streaming text mode
@property (nonatomic, strong, nullable) NWSSEParser *sseParser;
Expand Down Expand Up @@ -222,7 +224,11 @@ - (uint16_t)connectedPort {
- (uint16_t)localPort {
__block uint16_t port = 0;
[self performSyncOnSocketQueue:^{
port = _isConnected ? _localPort : 0;
if (_isListening) {
port = _localPort;
} else {
port = _isConnected ? _localPort : 0;
}
}];
return port;
}
Expand All @@ -237,6 +243,9 @@ - (NSString *)localHost {

- (void)dealloc {
#if NW_FRAMEWORK_AVAILABLE
if (_listener) {
nw_listener_cancel(_listener);
}
if (_connection) {
nw_connection_cancel(_connection);
}
Expand Down Expand Up @@ -270,13 +279,230 @@ - (BOOL)connectToHost:(NSString *)host onPort:(uint16_t)port error:(NSError **)e
}

- (BOOL)acceptOnPort:(uint16_t)port error:(NSError **)errPtr {
(void)port;
return [self acceptOnInterface:nil port:port error:errPtr];
}

- (BOOL)acceptOnInterface:(NSString *)interface port:(uint16_t)port error:(NSError **)errPtr {
#if NW_FRAMEWORK_AVAILABLE
if (self.isListening) {
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorAlreadyConnected
userInfo:@{NSLocalizedDescriptionKey: @"Socket is already listening."}];
}
return NO;
}

Comment on lines +285 to +295
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acceptOnInterface:port:error: only checks self.isListening to reject a second listen. During the window after self.listener is created but before the state handler sets isListening = YES, a second call can succeed and replace the listener. Also, calling accept while isConnected is true should be rejected. Consider guarding on (self.listener != NULL) || self.isListening || self.isConnected (and returning an appropriate error).

Copilot uses AI. Check for mistakes.
nw_parameters_t parameters = nw_parameters_create_secure_tcp(
NW_PARAMETERS_DISABLE_PROTOCOL,
NW_PARAMETERS_DEFAULT_CONFIGURATION
);

if (interface.length > 0) {
// Bind to a specific interface/address
NSString *portStr = [NSString stringWithFormat:@"%u", port];
nw_endpoint_t localEndpoint = nw_endpoint_create_host(interface.UTF8String, portStr.UTF8String);
nw_parameters_set_local_endpoint(parameters, localEndpoint);
}

nw_listener_t listener = nw_listener_create_with_port([NSString stringWithFormat:@"%u", port].UTF8String, parameters);
if (!listener) {
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorConnectionFailed
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create listener."}];
}
return NO;
}

self.listener = listener;

__weak typeof(self) weakSelf = self;

nw_listener_set_state_changed_handler(listener, ^(nw_listener_state_t state, nw_error_t _Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;

switch (state) {
case nw_listener_state_ready: {
strongSelf.isListening = YES;
uint16_t assignedPort = nw_listener_get_port(listener);
strongSelf.localPort = assignedPort;
break;
}
case nw_listener_state_failed: {
strongSelf.isListening = NO;
NSError *nsError = [strongSelf socketErrorWithCode:GCDAsyncSocketErrorConnectionFailed
description:@"Listener failed."
reason:@"NW listener entered failed state"
nwError:error];
[strongSelf disconnectInternalWithError:nsError];
break;
}
case nw_listener_state_cancelled: {
strongSelf.isListening = NO;
break;
}
default:
break;
}
});

nw_listener_set_new_connection_handler(listener, ^(nw_connection_t newConnection) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;

// Create a new GCDAsyncSocket for the accepted connection.
// The new socket inherits the listener's delegate – this matches
// GCDAsyncSocket from CocoaAsyncSocket. The user may reassign the
// delegate on newSocket inside socket:didAcceptNewSocket: if needed.
GCDAsyncSocket *newSocket = [[GCDAsyncSocket alloc] initWithDelegate:strongSelf.delegate
delegateQueue:strongSelf.delegateQueue
socketQueue:nil];
newSocket.connection = newConnection;

// State change handler for the accepted connection
nw_connection_set_state_changed_handler(newConnection, ^(nw_connection_state_t state, nw_error_t _Nullable error) {
[newSocket handleStateChange:state error:error];
});

nw_connection_set_queue(newConnection, newSocket.socketQueue);
nw_connection_start(newConnection);

dispatch_async(strongSelf.delegateQueue, ^{
id delegate = strongSelf.delegate;
if ([delegate respondsToSelector:@selector(socket:didAcceptNewSocket:)]) {
[delegate socket:strongSelf didAcceptNewSocket:newSocket];
}
});
});

nw_listener_set_queue(listener, self.socketQueue);
nw_listener_start(listener);

return YES;
#else
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorConnectionFailed
userInfo:@{NSLocalizedDescriptionKey: @"Network.framework is not available on this platform."}];
}
return NO;
#endif
}

- (BOOL)acceptOnUrl:(NSURL *)url error:(NSError **)errPtr {
#if NW_FRAMEWORK_AVAILABLE
if (self.isListening) {
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorAlreadyConnected
userInfo:@{NSLocalizedDescriptionKey: @"Socket is already listening."}];
}
return NO;
}

Comment on lines +394 to +404
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acceptOnUrl:error: only checks self.isListening to reject a second listen, but self.listener is assigned before isListening is flipped in the state handler. A second call during startup could incorrectly succeed and replace the listener. Also consider rejecting accept calls when isConnected is already true. Guarding on self.listener != NULL (and/or setting isListening immediately when listener creation succeeds) would make this robust.

Copilot uses AI. Check for mistakes.
if (!url.isFileURL) {
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorInvalidParameter
userInfo:@{NSLocalizedDescriptionKey: @"URL must be a file URL for Unix Domain Socket."}];
}
return NO;
}

nw_parameters_t parameters = nw_parameters_create_secure_tcp(
NW_PARAMETERS_DISABLE_PROTOCOL,
NW_PARAMETERS_DEFAULT_CONFIGURATION
);

// Remove existing socket file if present
NSString *path = url.path;
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];

// Construct a unix:// URL for the endpoint (Network.framework expects this scheme)
NSString *unixURLString = [NSString stringWithFormat:@"unix://%@", path];
nw_endpoint_t localEndpoint = nw_endpoint_create_url(unixURLString.UTF8String);
nw_parameters_set_local_endpoint(parameters, localEndpoint);

nw_listener_t listener = nw_listener_create(parameters);
if (!listener) {
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorConnectionFailed
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create Unix Domain Socket listener."}];
}
return NO;
}

self.listener = listener;

__weak typeof(self) weakSelf = self;

nw_listener_set_state_changed_handler(listener, ^(nw_listener_state_t state, nw_error_t _Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;

switch (state) {
case nw_listener_state_ready: {
strongSelf.isListening = YES;
break;
}
case nw_listener_state_failed: {
strongSelf.isListening = NO;
NSError *nsError = [strongSelf socketErrorWithCode:GCDAsyncSocketErrorConnectionFailed
description:@"Unix Domain Socket listener failed."
reason:@"NW listener entered failed state"
nwError:error];
[strongSelf disconnectInternalWithError:nsError];
break;
}
case nw_listener_state_cancelled: {
strongSelf.isListening = NO;
break;
}
default:
break;
}
});

nw_listener_set_new_connection_handler(listener, ^(nw_connection_t newConnection) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;

// See acceptOnInterface:port:error: for rationale on delegate sharing.
GCDAsyncSocket *newSocket = [[GCDAsyncSocket alloc] initWithDelegate:strongSelf.delegate
delegateQueue:strongSelf.delegateQueue
socketQueue:nil];
newSocket.connection = newConnection;

nw_connection_set_state_changed_handler(newConnection, ^(nw_connection_state_t state, nw_error_t _Nullable error) {
[newSocket handleStateChange:state error:error];
});

nw_connection_set_queue(newConnection, newSocket.socketQueue);
nw_connection_start(newConnection);

dispatch_async(strongSelf.delegateQueue, ^{
id delegate = strongSelf.delegate;
if ([delegate respondsToSelector:@selector(socket:didAcceptNewSocket:)]) {
[delegate socket:strongSelf didAcceptNewSocket:newSocket];
}
});
});

nw_listener_set_queue(listener, self.socketQueue);
nw_listener_start(listener);

return YES;
#else
if (errPtr) {
*errPtr = [NSError errorWithDomain:GCDAsyncSocketErrorDomain
code:GCDAsyncSocketErrorInvalidParameter
userInfo:@{NSLocalizedDescriptionKey: @"acceptOnPort:error: is not supported in NWAsyncSocketObjC."}];
code:GCDAsyncSocketErrorConnectionFailed
userInfo:@{NSLocalizedDescriptionKey: @"Network.framework is not available on this platform."}];
}
return NO;
#endif
}

- (BOOL)connectToHost:(NSString *)host
Expand Down Expand Up @@ -362,7 +588,18 @@ - (BOOL)connectToHost:(NSString *)host
- (void)disconnect {
__weak typeof(self) weakSelf = self;
dispatch_async(self.socketQueue, ^{
[weakSelf disconnectInternalWithError:nil];
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
#if NW_FRAMEWORK_AVAILABLE
// Stop listener if in server mode
if (strongSelf.listener) {
nw_listener_cancel(strongSelf.listener);
strongSelf.listener = nil;
strongSelf.isListening = NO;
strongSelf.localPort = 0;
}
#endif
Comment on lines +593 to +601
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disconnect cancels and nils listener (and sets isListening = NO) before calling disconnectInternalWithError:. This can make disconnectInternalWithError: hit its early-return guard for a listen-only socket, skipping state reset and socketDidDisconnect:withError:. Consider removing the pre-cancel block here and letting disconnectInternalWithError: handle listener cancellation/cleanup (or avoid clearing listener/isListening before calling into it).

Suggested change
#if NW_FRAMEWORK_AVAILABLE
// Stop listener if in server mode
if (strongSelf.listener) {
nw_listener_cancel(strongSelf.listener);
strongSelf.listener = nil;
strongSelf.isListening = NO;
strongSelf.localPort = 0;
}
#endif

Copilot uses AI. Check for mistakes.
[strongSelf disconnectInternalWithError:nil];
});
}

Expand Down Expand Up @@ -513,10 +750,8 @@ - (void)handleStateChange:(nw_connection_state_t)state error:(nw_error_t _Nullab
if (localHostStr) {
self.localHost = [NSString stringWithUTF8String:localHostStr];
}
const char *localPortStr = nw_endpoint_get_port(localEndpoint);
if (localPortStr) {
self.localPort = (uint16_t)strtoul(localPortStr, NULL, 10);
}
uint16_t localPortValue = nw_endpoint_get_port(localEndpoint);
self.localPort = localPortValue;
}
}
#endif
Expand Down Expand Up @@ -720,9 +955,9 @@ - (void)disconnectWithError:(NSError *)error {
}

- (void)disconnectInternalWithError:(NSError *)error {
if (!self.isConnected
if (!self.isConnected && !self.isListening
#if NW_FRAMEWORK_AVAILABLE
&& !self.connection
&& !self.connection && !self.listener
#endif
) {
return;
Expand All @@ -732,6 +967,11 @@ - (void)disconnectInternalWithError:(NSError *)error {
self.isReadingContinuously = NO;

#if NW_FRAMEWORK_AVAILABLE
if (self.listener) {
nw_listener_cancel(self.listener);
self.listener = nil;
self.isListening = NO;
}
if (self.connection) {
nw_connection_cancel(self.connection);
self.connection = nil;
Expand Down
12 changes: 12 additions & 0 deletions ObjC/NWAsyncSocketObjC/include/GCDAsyncSocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ typedef NS_ENUM(NSInteger, GCDAsyncSocketError) {
/// Whether the socket is currently disconnected.
@property (atomic, readonly) BOOL isDisconnected;

/// Whether the socket is currently listening for incoming connections (server mode).
@property (atomic, readonly) BOOL isListening;

Comment on lines 53 to +58
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the introduction of isListening, a socket that is listening shouldn’t generally report isDisconnected == YES. The current implementation in GCDAsyncSocket.m returns !isConnected only, which will read as disconnected while listening. Consider updating isDisconnected semantics to include !isListening (e.g. disconnected iff not connected and not listening).

Copilot uses AI. Check for mistakes.
/// Whether the socket is using a secure TLS transport.
@property (atomic, readonly) BOOL isSecure;

Expand Down Expand Up @@ -109,8 +112,17 @@ typedef NS_ENUM(NSInteger, GCDAsyncSocketError) {
error:(NSError **)errPtr;

/// Compatibility API for CocoaAsyncSocket server mode.
/// Listen for incoming TCP connections on all interfaces on the given port.
/// Pass port 0 to let the system assign an available port (query via `localPort`).
- (BOOL)acceptOnPort:(uint16_t)port error:(NSError **)errPtr;

/// Listen for incoming TCP connections on a specific interface/address and port.
/// Pass @"localhost" or @"127.0.0.1" to restrict connections to the local machine.
- (BOOL)acceptOnInterface:(nullable NSString *)interface port:(uint16_t)port error:(NSError **)errPtr;

/// Listen for incoming connections on a Unix Domain Socket at the given file URL.
- (BOOL)acceptOnUrl:(NSURL *)url error:(NSError **)errPtr;

// MARK: - Disconnect

/// Disconnect the socket gracefully.
Expand Down
5 changes: 5 additions & 0 deletions ObjC/NWAsyncSocketObjC/include/GCDAsyncSocketDelegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ NS_ASSUME_NONNULL_BEGIN
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)error;

@optional
/// Called when a listening socket accepts a new incoming connection.
/// The delegate **must** retain `newSocket` (e.g. add it to an array),
/// otherwise it will be deallocated and the connection will be dropped.
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket;

/// Called when a complete SSE event has been parsed.
- (void)socket:(GCDAsyncSocket *)sock didReceiveSSEEvent:(NWSSEEvent *)event;

Expand Down
Loading