Search code examples
tcpelixirpacket

How to send a message through TCP protocol to a TCP server using gen_tcp in Elixir?


I have 2 elixir applications.

In one of them I create a TCP server which listens on the port 8080 for packets. I know that it is defined correctly, because I connected to it with telnet and everything was working fine.

The problem appears when I try to connect to this TCP server from my other Elixir application.

That's how I connect to it

host = 'localhost'
{:ok, socket} = :gen_tcp.connect(host, 8080, [])

Not sure about the options that I should specify tho.

When trying to connect to it I get in the logs of the application with TCP server:

00:21:11.235 [error] Task #PID<0.226.0> started from MessageBroker.Controller terminating
** (MatchError) no match of right hand side value: {:error, :closed}
    (message_broker 0.1.0) lib/message_broker/network/controller.ex:110: MessageBroker.Controller.read_line/1
    (message_broker 0.1.0) lib/message_broker/network/controller.ex:101: MessageBroker.Controller.serve/1
    (elixir 1.12.3) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
    (stdlib 3.12) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Function: #Function<0.126110026/0 in MessageBroker.Controller.loop_acceptor/1>
    Args: []

At the line with

{:ok, data} = :gen_tcp.recv(socket, 0)

Any ideas, suggestions?


Solution

  • (MatchError) no match of right hand side value: {:error, :closed}

    At the line with

    {:ok, data} = :gen_tcp.recv(socket, 0)
    

    The error message is saying that the function call :gen_tcp.recv(socket, 0) returned {:error, :closed} and that {:ok, data} does not match {:error, :closed}. If you call recv() on a socket that was closed by the other side, then recv() returns {:error, :closed}, and the other side will not be sending anymore data to that socket.

    Not sure about the options that I should specify tho.

    Yeah, those are pretty important. The basic tenet is that when you send data to a socket, you have no idea how many chunks the data will be split into. But, the client and server have to be able to know when to stop trying to read data because the end of the data has been reached. To solve the "indeterminate number of chunks problem", the client and server have to agree on some signal to mark the end of the data:

    1. A newline?
    2. The sender closes the socket?
    3. Use an agreed upon number of bytes at the start of the data to specify the length of the data?

    For #3, send() and recv() will automatically handle packaging and unpackaging the data for you if you simply tell gen_tcp how many bytes are necessary to specify the length of a message, for instance you can specify the option {:packet, 2}. If you do that, send() will automatically calculate the length of the data, then add 2 bytes to the front of the data containing the length of the data. Likewise, recv() will automatically read the first 2 bytes from the socket, then read the integer contained in those 2 bytes, say 64,999, then recv() will wait until it has read an additional 64,999 bytes from the socket, then it will return the whole 64,999 bytes of data.

    How to send a message through TCP protocol to a TCP server using gen_tcp in Elixir?

    Here is an example of #1, where a newline is used to mark the end of the data:

    TCP Server:

    defmodule App1 do
    
      def start_tcp_server do
        port = 8080
    
        {:ok, listen_socket} = :gen_tcp.listen(
                        port,
                        [
                          {:mode, :binary},   #Received data is delivered as a string (v. a list)
    
                          {:active, :false},  #Data sent to the socket will not be
                                              #placed in the process mailbox, so you can't 
                                              #use a receive block to read it.  Instead 
                                              #you must call recv() to read data directly
                                              #from the socket.
    
                          {:packet, :line},   #recv() will read from the socket until 
                                              #a newline is encountered, then return.
                                              #If a newline is not read from the socket,
                                              #recv() will hang until it reads a newline
                                              #from the socket.
    
                          {:reuseaddr, true}  #Allows you to immediately restart the server
                                              #with the same port, rather than waiting
                                              #for the system to clean up and free up
                                              #the port.
                        ]
        )
    
        IO.puts "Listening on port #{port}...."
        listen_loop(listen_socket)
      end
    
      defp listen_loop(listen_socket) do
        {:ok, client_socket} = :gen_tcp.accept(listen_socket) #client_socket is created with the same options as listen_socket
        handle_client(client_socket)
        listen_loop(listen_socket)
      end
    
      defp handle_client(client_socket) do
        case :gen_tcp.recv(client_socket, 0) do    #Do not specify the number
                                                   #of bytes to read, instead write 0
                                                   #to indicate that the :packet option
                                                   #will take care of how many bytes to read.
          {:ok, line}  ->
            #Echo back what was received:
            IO.write("Server echoing back: #{line}")
            :gen_tcp.send(client_socket, "Server received: #{line}") #line has a "\n" on the end
    
          {:error, :closed} ->
            {:ok, :client_disconnected}
        end
      end
                    
    
    end
    

    TCP client:

    defmodule App2 do
    
      def send_data do
        host = :localhost
        port = 8080
    
        {:ok, socket} = :gen_tcp.connect(host, port,
                        [
                          {:active, :false}, #Data sent to the socket will not be put in the process mailbox.
                          {:mode, :binary},  
                          {:packet, :line}   #Must be same as server.
                        ]
        )
    
        :ok = :gen_tcp.send(socket, "Hi server!\n")
    
        case :gen_tcp.recv(socket, 0) do
          {:ok, line} -> 
            IO.puts ~s(Client got: "#{String.trim line}")
            :ok = :gen_tcp.close(socket)
    
          {:error, :closed} -> IO.puts("Server closed socket.")
        end
    
      end
    
    end
    

    Output in server window:

    iex(1)> App1.start_tcp_server
    Listening on port 8080....
    Server echoing back: Hi server!
    

    Output in client window:

    ex(1)> App2.send_data
    Client got: "Server received: Hi server!"
    :ok
    

    If you want to use {:error, :closed} to mark the end of the data, then you need to:

    1. Specify the option {:packet, :raw} for both the client and the server socket.

    2. After sending the data, close the socket by calling :gen_tcp.shutdown/2, which will ensure that all the data has been sent before closing the socket.

    3. Loop over the recv(), saving each chunk returned by recv(), until the recv() returns {:error, :closed}, marking the end of the data, then your loop can return all the chunks it read. For instance:

         defp get_data(socket, chunks) do
           case :gen_tcp.recv(socket, 0) do  #reads a chunk of indeterminate length from the socket
             {:ok, chunk} -> 
               get_data(socket, [chunk | chunks])
             {:error, :closed} -> 
               {:ok, Enum.reverse(chunks) }
           end
         end
      

    You would call that function like this:

    {:ok, data} = get_data(client_socket, [])
    

    For more details on the gen_tcp options, see the following answer

    Erlang client-server example using gen_tcp is not receiving anything