Skip to content

Commit

Permalink
Add preliminary support for the OTP socket interface
Browse files Browse the repository at this point in the history
This PR adds preliminary support for the OTP socket interface, which exposes APIs for low-level socket operations, as defined in OTP 22 and later. This implementation aims to be API-compatible with the OTP implementation, though many parts of the interface have yet to be implemented.

Key features of this implementation include the following:

* A new socket driver has been added, which uses the BSD socket interface to implement the OTP socket driver
* This driver is supported on generic UNIX and ESP32 platforms. Where possible, a common codebase is used, to minimize the cost of maintenance.
* On ESP32, a separate RTOS task is run to periodically run select.  Messages are sent to and from the VM via RTOS queues

The following operations are supported:

* socket:open/3
* socket:close/1
* socket:bind/2
* socket:listen/1,2
* socket:accept/1
* socket:sockname/1
* socket:peername/1
* socket:recv/1
* socket:recvfrom/1
* socket:send/2
* socket:sendto/3
* socket:setopt/3
* socket:connect/2
* socket:shutdown/2

Currently, the TCP and UDP protocols is supported. Additional work is needed to support other data types and protocols, as well as name resolution via the OTP net:addrinfo interface.

Signed-off-by: Fred Dushin <fred@dushin.net>
  • Loading branch information
fadushin committed Sep 24, 2023
1 parent 89bb82a commit 13514ab
Show file tree
Hide file tree
Showing 27 changed files with 2,940 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ functions that default to `?ATOMVM_NVS_NS` are deprecated now).
- Added links to process_info/2
- Added lists:usort/1,2
- Added missing documentation and specifications for available nifs
- Added support for the OTP `socket` interface.

### Fixed
- Fixed issue with formatting integers with io:format() on STM32 platform
Expand Down
198 changes: 198 additions & 0 deletions doc/src/programmers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1601,3 +1601,201 @@ Once a connection is established, you can use a combination of
end.

For more information about the `gen_tcp` client interface, consults the AtomVM API documentation.

## Socket Programming

AtomVM supports a subset of the OTP [`socket`](https://www.erlang.org/doc/man/socket.html) interface, giving users more fine-grained control in socket programming.

The OTP socket APIs are relatively new (they were introduced in OTP 22 and have seen revisions in OTP 24). These APIs broadly mirror the [BSD Sockets](https://en.wikipedia.org/wiki/Berkeley_sockets) API, and should be familiar to most programmers who have had to work with low-level operating system networking interfaces. AtomVM supports a strict subset of the OTP APIs. Future versions of AtomVM may add additional coverage of these APIs.

The following types are relevant to this interface and are referenced in the remainder of this section:

-type domain() :: inet.
-type type() :: stream | dgram.
-type protocol() :: tcp | udp.
-type socket() :: any().
-type sockaddr() :: sockaddr_in().
-type sockaddr_in() :: #{
family := inet,
port := port_number(),
addr := any | loopback | in_addr()
}.
-type in_addr() :: {0..255, 0..255, 0..255, 0..255}.
-type port_number() :: 0..65535.
-type socket_option() :: {socket, reuseaddr} | {socket, linger}.

Create a socket using the `socket:open/3` function, providing a domain, type, and protocol. Currently, AtomVM supports the `inet` domain, `stream` and `dgram` types, and `tcp` and `udp` protocols.

For example:

%% erlang
{ok, Socket} = socket:open(inet, stream, tcp),

### Server-side TCP Socket Programming

To program using sockets on the server side, you can bind an opened socket to an address and port number using the `socket:bind/2` function, supplying a map that specifies the address and port number.

This map may contain the following entries:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `family` | `inet` | | The address family. (Currently, only `inet` is supported) |
| `addr` | `in_addr()` \| `any` \| `loopback` | The address to which to bind. The `any` value will bind the socket to all interfaces on the device. The `loopback` value will bind the socket to the loopback interface on the device. |
| `port` | `port_number()` | | The port to which to bind the socket. If no port is specified, the operating system will choose a port for the user. |

For example:

%% erlang
PortNumber = 8080,
ok = socket:bind(Socket, #{family => inet, addr => any, port => PortNumber}),

To listen for connections, use the `socket:listen/1` function:

%% erlang
ok = socket:listen(Socket),

Once your socket is listening on an interface and port, you can wait to accept a connection from an incoming client using the `socket:accept/1` function.

This function will block the current execution context (i.e., Erlang process) until a client establishes a TCP connection with the server:

%% erlang
{ok, ConnectedSocket} = socket:accept(Socket),

> Note. Many applications will spawn processes to listen for socket connections, so that the main execution context of your application is not blocked.
### Client-side TCP Socket Programming

To program using sockets on the client side, you can connect an opened socket to an address and port number using the `socket:connect/2` function, supplying a map that specifies the address and port number.

This map may contain the following entries:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `family` | `inet` | | The address family. (Currently, only `inet` is supported) |
| `addr` | `in_addr()` \| `loopback` | | The address to which to connect. The `loopback` value will connect the socket to the loopback interface on the device. |
| `port` | `port_num()` | | The port to which to connect the socket. |

%% erlang
ok = socket:connect(Socket, #{family => inet, addr => loopback, port => 44404})

### Sending and Receiving Data

Once you have a connected socket (either via `socket:connect/2` or `socket:accept/1`), you can send and receive data on that socket using the `socket:send/2` and `socket:recv/1` functions. Like the `socket:accept/1` function, these functions will block until data is sent to a connected peer (or until the data is written to operating system buffers) or received from a connected peer.

The `socket:send/2` function can take a binary blob of data or an io-list, containing binary data.

For example, a process that receives data and echos it back to the connected peer might be implemented as follows:

%% erlang
case socket:recv(ConnectedSocket) of
{ok, Data} ->
case socket:send(ConnectedSocket, Data) of
ok ->
io:format("All data was sent~n");
{ok, Rest} ->
io:format("Some data was sent. Remaining: ~p~n", [Rest]);
{error, Reason} ->
io:format("An error occurred sending data: ~p~n", [Reason])
end;
{error, closed} ->
io:format("Connection closed.~n");
{error, Reason} ->
io:format("An error occurred waiting on a connected socket: ~p~n", [Reason])
end.

The `socket:recv/1` function will block the current process until a packet has arrived or until the local or remote socket has been closed, or some other error occurs.

Note that the `socket:send/2` function may return `ok` if all of the data has been sent, or `{ok, Rest}`, where `Rest` is the remaining part of the data that was not sent to the operating system. If the supplied input to `socket:send/2` is an io-list, then the `Rest` will be a binary containing the rest of the data in the io-list.

### Getting Information about Connected Sockets

You can obtain information about connected sockets using the `socket:sockname/1` and `socket:peername/1` functions. Supply the connected socket as a parameter. The address and port are returned in a map structure

For example:

%% erlang
{ok, #{addr := LocalAddress, port := LocalPort}} = socket:sockname(ConnectedSocket),
{ok, #{addr := PeerAddress, port := PeerPort}} = socket:peername(ConnectedSocket),

### Closing and Shutting down Sockets

Use the `socket:close/1` function to close a connected socket:

%% erlang
ok = socket:close(ConnectedSocket)

> Note. Data that has been buffered by the operating system may not be delivered, when a socket is closed via the `close/1` operation.
For a more controlled way to close full-duplex connected sockets, use the `socket:shutdown/2` function. Provide the atom `read` if you only want to shut down the reads on the socket, `write` if you want to shut down writes on the socket, or `read_write` to shut down both reads and writes on a socket. Subsequent reads or writes on the socket will result in an `einval` error on the calls, depending on how the socket has been shut down.

For example:

%% erlang
ok = socket:shutdown(Socket, read_write)

### Setting Socket Options

You can set options on a socket using the `socket:setopt/3` function. This function takes an opened socket, a key, and a value, and returns `ok` if setting the option succeeded.

Currently, the following options are supported:

| Option Key | Option Value | Description |
|------------|--------------|-------------|
| `{socket, reuseaddr}` | `boolean()` | Sets `SO_REUSEADDR` on the socket. |
| `{socket, linger}` | `#{onoff => boolean(), linger => non_neg_integer()}` | Sets `SO_LINGER` on the socekt. |

For example:

%% erlang
ok = socket:setopt(Socket, {socket, reuseaddr}, true),
ok = socket:setopt(Socket, {socket, linger}, #{onoff => true, linger => 0}),

### UDP Socket Programming

You can use the `socket` interface to send and receive messages over the User Datagram Protocol (UDP), in addition to TCP.

To use UDP sockets, open a socket using the `dgram` type and `udp` protocol.

For example:

%% erlang
{ok, Socket} = socket:open(inet, dgram, udp)

To listen for UDP connections, use the `socket:bind/2` function, as described above.

For example:

%% erlang
PortNumber = 512,
ok = socket:bind(Socket, #{family => inet, addr => any, port => PortNumber}),

Use the `socket:recvfrom/1` function to receive UDP packets from clients on your network. When a packet arrives, this function will return the received packet, as well as the address of the client that sent the packet.

For example:

%% erlang
case socket:recvfrom(dSocket) of
{ok, {From, Packet}} ->
io:format("Received packet ~p from ~p~n", [Packet, From]);
{error, Reason} ->
io:format("Error on recvfrom: ~p~n", [Reason])
end;

> Note. The `socket:recvfrom/1` function will block the current process until a packet has arrived or until the local or remote socket has been closed, or some other error occurs.
Use the `socket:sendto/3` function to send UDP packets to a specific destination. Specify the socket, data, and destination address you would like the packet to be delivered to.

For example:

%% erlang
Dest = #{family => inet, addr => loopback, port => 512},
case socket:sendto(Socket, Data, Dest) of
ok ->
io:format("Send packet ~p to ~p.~n", [Data, Dest]);
{ok, Rest} ->
io:format("Send packet ~p to ~p. Remaining: ~p~n", [Data, Dest, Rest]);
{error, Reason} ->
io:format("An error occurred sending a packet: ~p~n", [Reason])
end

Close a UDP socket just as you would a TCP socket, as described above.
4 changes: 4 additions & 0 deletions examples/erlang/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pack_runnable(udp_server udp_server estdlib eavmlib)
pack_runnable(udp_client udp_client estdlib eavmlib)
pack_runnable(tcp_client tcp_client estdlib eavmlib)
pack_runnable(tcp_server tcp_server estdlib eavmlib)
pack_runnable(tcp_socket_client tcp_socket_client estdlib eavmlib)
pack_runnable(tcp_socket_server tcp_socket_server estdlib eavmlib)
pack_runnable(udp_socket_server udp_socket_server estdlib eavmlib)
pack_runnable(udp_socket_client udp_socket_client estdlib eavmlib)
pack_runnable(hello_world_server hello_world_server estdlib eavmlib)
pack_runnable(system_info_server system_info_server estdlib eavmlib)
pack_runnable(code_lock code_lock estdlib eavmlib)
Expand Down
52 changes: 52 additions & 0 deletions examples/erlang/tcp_socket_client.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
%
% This file is part of AtomVM.
%
% Copyright 2023 Fred Dushin <fred@dushin.net>
%
% Licensed under the Apache License, Version 2.0 (the "License");
% you may not use this file except in compliance with the License.
% You may obtain a copy of the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS,
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
% See the License for the specific language governing permissions and
% limitations under the License.
%
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
%

-module(tcp_socket_client).

-export([start/0]).

-spec start() -> no_return().
start() ->
{ok, Socket} = socket:open(inet, stream, tcp),
case socket:connect(Socket, #{family => inet, addr => loopback, port => 44404}) of
ok ->
loop(Socket);
{error, Reason} ->
io:format("An error occurred connecting to TCP server: ~p~n", [Reason])
end.

loop(ConnectedSocket) ->
io:format("Sending data...~n"),
case socket:send(ConnectedSocket, <<"ping">>) of
ok ->
io:format("Sent ping to ~p.~n", [socket:peername(ConnectedSocket)]),
case socket:recv(ConnectedSocket) of
{ok, Data} ->
io:format("Received ~p.~n", [Data]),
timer:sleep(1000),
loop(ConnectedSocket);
Error ->
io:format("Error on recv: ~p~n", [Error])
end;
{error, closed} ->
io:format("Client closed connection.~n");
{error, Reason} ->
io:format("An error occurred sending data through a connected socket: ~p~n", [Reason])
end.
72 changes: 72 additions & 0 deletions examples/erlang/tcp_socket_server.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
%
% This file is part of AtomVM.
%
% Copyright 2023 Fred Dushin <fred@dushin.net>
%
% Licensed under the Apache License, Version 2.0 (the "License");
% you may not use this file except in compliance with the License.
% You may obtain a copy of the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS,
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
% See the License for the specific language governing permissions and
% limitations under the License.
%
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
%

-module(tcp_socket_server).

-export([start/0]).

-spec start() -> no_return().
start() ->
{ok, ListeningSocket} = socket:open(inet, stream, tcp),

ok = socket:setopt(ListeningSocket, {socket, reuseaddr}, true),
ok = socket:setopt(ListeningSocket, {socket, linger}, #{onoff => true, linger => 0}),

ok = socket:bind(ListeningSocket, #{family => inet, addr => any, port => 44404}),
ok = socket:listen(ListeningSocket),
io:format("Listening on ~p.~n", [socket:sockname(ListeningSocket)]),

spawn(fun() -> accept(ListeningSocket) end),

timer:sleep(infinity).

accept(ListeningSocket) ->
io:format("Waiting to accept connection...~n"),
case socket:accept(ListeningSocket) of
{ok, ConnectedSocket} ->
io:format("Accepted connection. local: ~p peer: ~p~n", [
socket:sockname(ConnectedSocket), socket:peername(ConnectedSocket)
]),
spawn(fun() -> accept(ListeningSocket) end),
echo(ConnectedSocket);
{error, Reason} ->
io:format("An error occurred accepting connection: ~p~n", [Reason])
end.

-spec echo(ConnectedSocket :: socket:socket()) -> no_return().
echo(ConnectedSocket) ->
io:format("Waiting to receive data...~n"),
case socket:recv(ConnectedSocket) of
{ok, Data} ->
io:format("Received data ~p from ~p. Echoing back...~n", [
Data, socket:peername(ConnectedSocket)
]),
case socket:send(ConnectedSocket, Data) of
ok ->
io:format("All data was sent~n");
{ok, Rest} ->
io:format("Some data was sent. Remaining: ~p~n", [Rest]);
{error, Reason} ->
io:format("An error occurred sending data: ~p~n", [Reason])
end,
echo(ConnectedSocket);
{error, Reason} ->
io:format("An error occurred waiting on a connected socket: ~p~n", [Reason])
end.
48 changes: 48 additions & 0 deletions examples/erlang/udp_socket_client.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
%
% This file is part of AtomVM.
%
% Copyright 2023 Fred Dushin <fred@dushin.net>
%
% Licensed under the Apache License, Version 2.0 (the "License");
% you may not use this file except in compliance with the License.
% You may obtain a copy of the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS,
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
% See the License for the specific language governing permissions and
% limitations under the License.
%
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
%

-module(udp_socket_client).

-export([start/0]).

-spec start() -> no_return().
start() ->
{ok, Socket} = socket:open(inet, dgram, udp),

loop(Socket).

loop(Socket) ->
Dest = #{family => inet, addr => loopback, port => 44405},
io:format("Sending packet to ~p...~n", [Dest]),
Data = <<"Hello AtomVM!\n">>,
case socket:sendto(Socket, Data, Dest) of
ok ->
io:format("Send packet ~p to ~p.~n", [
Data, Dest
]);
{ok, Rest} ->
io:format("Send packet ~p to ~p. Remaining: ~p~n", [
Data, Dest, Rest
]);
{error, Reason} ->
io:format("An error occurred sending a packet: ~p~n", [Reason])
end,
timer:sleep(1000),
loop(Socket).
Loading

0 comments on commit 13514ab

Please sign in to comment.