Networking: TCP and UDP
A Java socket server is a ServerSocket, a loop around accept(), an executor to run each connection, a flag to tell the
loop to stop, and a close() that has to run on every path, including the one where the handler throws. Go trades the
executor for a goroutine and the close() for a defer; Node trades both for callbacks. In each, the shape of the server is
something you assemble from parts, and the parts that go wrong are the ones you wrote last: the stop and the close.
EK9 makes three decisions, and the rest of this chapter is those decisions at work. The server is a resource a try owns,
so the close is generated and you cannot write it. The code that serves a connection is a function, a TCPHandler that is
given two streams. And every network setting, for a client or a server, travels in one record, NetworkProperties. The
chapter writes a TCP echo, a TCP server and client in two processes, a UDP echo, a UDP server that is a pipeline, and then the
addresses those programs talk to. Every server here binds the loopback address only (localOnly: true) and uses a port from
the 4445-4460 range; every output block was captured from a real run.
| The job | Java | Go | EK9 |
|---|---|---|---|
| Run the accept loop | your loop around accept() |
your loop around Accept() |
accept(handler) runs it |
| Serve one connection | a Runnable on an executor |
a goroutine | a TCPHandler function |
| Stop from inside a handler | a flag, then close from another thread | cancel a context, close the listener | stop() |
| Release the socket | try-with-resources, by hand |
defer |
the owning try, generated |
A server and its own client
The quickest way to see a TCP server work needs no second terminal. acceptUntilComplete serves connections while a second
function, the ready handler, runs on its own thread once the server is listening; when the ready handler ends, the server
is stopped.
#!ek9 defines module book.network.echo defines function echo() is TCPHandler -> input as StringInput output as StringOutput while input? output.println(`echo:${input.next()}`) defines program EchoInProcess() stdout <- Stdout() props <- NetworkProperties(port: 0, backlog: 2, maxConcurrent: 2, localOnly: true) try -> tcpServer <- TCP(props) client <- (stdout, props) is TCPReadyHandler as function loopback <- Networking().loopbackV4() try -> connection <- TCP(NetworkProperties(loopback, props.port)).connect() if connection? connection.output().println("hello") stdout.println(`client received [${connection.input().next()}]`) server.stop() if tcpServer.acceptUntilComplete(echo, client) stdout.println(`served until the client finished, server still set: ${tcpServer?}`) stdout.println("the try has closed the server")
client received [echo:hello]
served until the client finished, server still set: true
the try has closed the server
Port 0 asks the operating system to choose one, and the runtime writes the choice back into props, which is how the
client, built from that same record, learns where to connect. echo is a named function that implements TCPHandler: it is
handed the connection's StringInput and StringOutput and reads while the input still has something. The ready handler is
a dynamic function that captures stdout and props, the capture list in parentheses (Dynamic Functions and Classes
covers what that makes it); its server parameter comes from TCPReadyHandler, and it calls server.stop() itself when it
has finished. The last line of the output is the point of the try: the server was stopped by the client's side, closed by
the block that owns it, and nothing in the program says close.
Beyond being short, this form is the one to test with. A network program whose client and server share a process needs no fixed port and no start-up ordering, so an automated check can drive it (Testing the Compiler Trusts).
Two processes: a handler that stops the server
A real server runs on its own. This one upper-cases each line it receives, and a line that says shutdown stops it.
#!ek9 defines module book.network.lineserver defines constant SERVER_PORT <- 4446 SHUTDOWN_COMMAND <- "shutdown" defines program LineServer() stdout <- Stdout() try -> tcpServer <- TCP(NetworkProperties(port: SERVER_PORT, backlog: 5, maxConcurrent: 4, localOnly: true)) if tcpServer? handler <- (tcpServer, stdout) is TCPHandler as function stdout.println("connection made") while input? line <- input.next() stdout.println(`received [${line}]`) output.println(line.trim().upperCase()) if line == SHUTDOWN_COMMAND tcpServer.stop() stdout.println("no more input on the connection") stdout.println("awaiting connections") if tcpServer.accept(handler) stdout.println("server stopped, the try will close it") else stdout.println(`not able to serve requests gracefully [${tcpServer}]`) else stdout.println(`failed to start the server [${tcpServer}]`)
The handler is a dynamic function that implements TCPHandler, and it captures tcpServer and stdout. The capture is what
lets a handler that was created inside the server's try reach back and end the server: tcpServer.stop() on the shutdown
line. input and output are not declared anywhere in the handler; they are the parameters of TCPHandler, in scope
because the function implements it, in the same way signal is in scope in a SignalHandler later in this chapter.
stop() ends the accept loop without closing the server. accept(handler) then returns true, the if prints its
line, the try ends, and only then is the socket closed. A stopped server stays set but cannot serve again, and calling
stop() twice is harmless. The help for TCP states the rule in one line: only the owner closes a server.1
The client is a second program, in a second file, that takes the message as its argument:
#!ek9 defines module book.network.lineclient defines constant SERVER_PORT <- 4446 defines program LineClient() -> message as String stdout <- Stdout() localhost <- Networking().loopbackV4() try -> tcpClient <- TCP(NetworkProperties(localhost, SERVER_PORT)) if tcpClient? stdout.println(`connecting to ${tcpClient}`) try -> connection <- tcpClient.connect() if connection? connection.output().println(message) stdout.println(`response [${connection.input().next()}]`) else stdout.println(`failed to connect [${tcpClient}]`) else stdout.println(`failed to create the client [${tcpClient}]`)
Start the server in one terminal, then run the client twice from another:
$ ek9 LineServer.ek9
$ ek9 LineClient.ek9 "Short Message"
$ ek9 LineClient.ek9 shutdown
The client prints, for the two runs:
connecting to TCP{client, ready, NetworkProperties{host: 127.0.0.1, port: 4446}}
response [SHORT MESSAGE]
connecting to TCP{client, ready, NetworkProperties{host: 127.0.0.1, port: 4446}}
response [SHUTDOWN]
and the server, which exits by itself after the second connection, printed:
awaiting connections
connection made
received [Short Message]
no more input on the connection
connection made
received [shutdown]
server stopped, the try will close it
no more input on the connection
The last two lines are a race between two threads, and this run shows one outcome: stop() ends the accept loop at once and
does not wait for the handler that called it to run to the end, so "server stopped" can arrive before that handler's final
line. If the order of those two lines matters to you, the handler is the wrong place to decide it.
What happens if you do what a Java programmer does, and close the server from the handler that is shutting it down?
handler <- (tcpServer) is TCPHandler as function while input? line <- input.next() output.println(line) if line == "shutdown" tcpServer.close()
EK9 : Error : E81060: 'close' on line 17 position 24: 'TCP.close()': the close operator is never called as a method, 'super.close()' included - make the value a try-with-resources resource; a type's own properties and its super class are closed by its generated close
See: ek9 -h E81060
There is no escape hatch. A handler cannot close the server under the try that owns it, so the double close, and the close
of a socket another thread is still reading, are not programs you can write. What you have instead is stop(), and the rule
that ending the loop and releasing the socket are two steps with two different owners.
Errors on a socket are reported, not thrown: both programs check tcpServer? and tcpClient? after creating them, and print
the object itself in the else branch, which shows its state. lastErrorMessage() holds the reason. A bind to a port that
is already taken gives you an unset server and a message, not an exception. Starting the line server a second time while the
first is running prints:
failed to start the server [TCP[unset]]
UDP: everything is a packet
UDP has no connection, so there is no handler and no stop(): a server is a socket that you read packets from and write
packets to. In EK9 a UDPPacket carries its own destination and its content, so one socket can address many peers, and a
reply is a packet addressed to where the request came from.
#!ek9 defines module book.network.udpechoserver defines constant ECHO_PORT <- 4447 END_COMMAND <- "end" defines program UdpEchoServer() stdout <- Stdout() try -> udpConnection <- UDP(NetworkProperties(port: ECHO_PORT, localOnly: true)) serving <- true while serving and udpConnection? packet <- udpConnection.receive() if packet? if packet.content == END_COMMAND farewell <- UDPPacket(packet.properties, "OK will stop the server") udpConnection.send(farewell) serving: false else udpConnection.send(packet) stdout.println("UDP server complete")
receive() waits up to the timeout in the properties (five seconds if none was given) and answers an unset packet when
nothing arrived, which is why the loop tests packet?. The server echoes every packet to packet.properties, the sender's
address, and stops when the content is end. The client gives its socket no port at all; a client that gives none is bound
to an ephemeral one, and the replies come back to it.
#!ek9 defines module book.network.udpechoclient defines constant ECHO_PORT <- 4447 END_COMMAND <- "end" defines function exchange() -> client as UDP packet as UDPPacket stdout <- Stdout() stdout.println(`sending [${packet.content}]`) client.send(packet) response <- client.receive() if response? stdout.println(`received [${response.content}]`) else stdout.println(`no response [${client.lastErrorMessage()}]`) defines program UdpEchoClient() localhost <- Networking().loopbackV4() try -> client <- UDP(NetworkProperties(timeout: 250ms)) exchange(client, UDPPacket(NetworkProperties(localhost, ECHO_PORT), "a test")) exchange(client, UDPPacket(NetworkProperties(localhost, ECHO_PORT), END_COMMAND)) Stdout().println("UDP client complete")
With the server running in another terminal, the client prints:
sending [a test]
received [a test]
sending [end]
received [OK will stop the server]
UDP client complete
and the server prints UDP server complete and exits. The exchange function is the place a lost datagram shows up: a
receive() that times out gives an unset packet, and the else prints lastErrorMessage(). UDP loses packets, and EK9 does
not hide that; it gives the loss a value you must look at.
A server that is a pipeline
UDP is also an iterator over the packets it receives: hasNext() waits for the next one, and it is false only once the
connection is closed or can no longer receive. That makes while udp.hasNext() and cat udp | ... serve for as long as the
socket is open, and the second is a stream pipeline whose source is the network.
#!ek9 defines module book.network.udppipelineserver defines constant PIPELINE_PORT <- 4448 STOP_SIGNAL <- "TERM" defines function packetHandler() as abstract -> incoming as UDPPacket <- outgoing as UDPPacket? defines program UdpPipelineServer() stdout <- Stdout() try -> udpConnection <- UDP(NetworkProperties(port: PIPELINE_PORT, localOnly: true)) stopHandler <- (stdout) is SignalHandler as function result: 0 stdout.println(`received ${signal}, stopping the server`) Signals().register(STOP_SIGNAL, stopHandler) echoFunction <- (udpConnection) is packetHandler outgoing = incoming udpConnection.send(outgoing) cat udpConnection | map by echoFunction > stdout stdout.println("UDP server complete")
packetHandler is an abstract function, a type that echoFunction implements as a dynamic function capturing the
connection (Functions as Values: the Type Hierarchy is about function types of exactly this kind). map by echoFunction sends each packet back
and > stdout prints what comes through. The pipeline has no end of its own, so something must end it, and here it is a
signal: Signals().register("TERM", stopHandler). A SignalHandler that assigns a set result ends the whole process with
that exit code; the handler assigns 0, so the process exits when the signal arrives and the final line of the program never
runs. A handler that wants a clean shutdown assigns an unset result instead and stops the loop itself, which is what the web
service in A Web Service, Step by Step does.
The client side is a pipeline too. > into a UDP sends each item, using the connection's | operator:
#!ek9 defines module book.network.udppipelineclient defines constant PIPELINE_PORT <- 4448 defines function packetConverter() as abstract -> content as String <- outgoing as UDPPacket? defines program UdpPipelineClient() host <- Networking().loopbackV4() try -> udpConnection <- UDP(NetworkProperties(timeout: 250ms)) cat ["first", "second", "third"] | map (host) extends packetConverter(outgoing: UDPPacket(NetworkProperties(host, PIPELINE_PORT), content)) > udpConnection Stdout().println("UDP client complete")
Run the server, run the client, then send the server a TERM signal. The client prints UDP client complete, and the server
prints:
UDPPacket{properties=NetworkProperties{host: 127.0.0.1, port: 55008}, content="first"}
UDPPacket{properties=NetworkProperties{host: 127.0.0.1, port: 55008}, content="second"}
UDPPacket{properties=NetworkProperties{host: 127.0.0.1, port: 55008}, content="third"}
received TERM, stopping the server
The port in each line is the ephemeral one the operating system gave the client's socket, so it differs from run to run.
Where the addresses come from
Programs, Input and Output put it in one sentence: "A web address, a URL, comes from the environment, through
Networking." The programs above use Networking().loopbackV4() and a port written into the code, and that is the exception
a loopback demonstration earns. A program that talks to anything real takes the address from the environment, and the
language enforces it: there is no URL literal, and urlOf refuses text the compiler can see (E08275).
Volume 1 used withPath and withPathSegment to build a link from a base address. A service that sends links, in a header
or in a body, has a sharper problem: the base must be the address the world uses, not the address of the request it is
answering. The Host header of a request is whatever the caller sent. So a service builds every link from a configured base,
PUBLIC_BASE_URL, and derives the rest with the withers, which keep the base's host.
#!ek9 defines module book.network.notelinks defines program NoteLinks() -> noteId as String stdout <- Stdout() base <- Networking().urlFromEnv("PUBLIC_BASE_URL") if base? notes <- base.resolve("notes") stdout.println(`note: ${notes.withPathSegment(noteId)}`) stdout.println(`segments: ${notes.withPathSegment(noteId).pathSegments()}`) stdout.println(`next page: ${notes.withQueryParam("page", "2")}`) else stdout.println("PUBLIC_BASE_URL is not set, or is not an http or https URL")
resolve joins a path onto the base; withPathSegment encodes its text as one segment, so the / in A/42 cannot
reshape the path; withQueryParam adds a query. With the variable unset, then set (the spelling is deliberately untidy, and
resolve keeps the base's /api/ prefix):
$ ek9 NoteLinks.ek9 A/42
$ PUBLIC_BASE_URL='HTTPS://Notes.Example.COM:443/api/' ek9 NoteLinks.ek9 A/42
PUBLIC_BASE_URL is not set, or is not an http or https URL
note: https://notes.example.com/api/notes/A%2F42
segments: ["api", "notes", "A/42"]
next page: https://notes.example.com/api/notes?page=2
The scheme and host are lower-cased and the default port is dropped, so two spellings of one address are written one way.
A policy for destinations
A program that reads URLs from its environment is configurable; it is not yet constrained. The EK9_URL_POLICY environment
variable names a file of allow and deny lines, and every URL the program makes from the environment is checked against it
as the program runs.
# The destinations this program may use (EK9_URL_POLICY=url-policy.txt)
allow *.partner.example
deny 169.254.169.254/32
The program reads three variables and reports what it was allowed to make:
#!ek9 defines module book.network.destinations defines function report() -> variableName as String stdout <- Stdout() destination <- Networking().urlFromEnv(variableName) if destination? stdout.println(`${variableName}: ${destination}`) else stdout.println(`${variableName}: refused or not set`) defines program CheckDestinations() report("PARTNER_URL") report("METADATA_URL") report("OTHER_URL")
Run with the policy, and the log format set to plain text so the refusals are readable (they go to standard error):
$ EK9_URL_POLICY=url-policy.txt EK9_SANITIZER_LOG_FORMAT=SIMPLE \
PARTNER_URL=https://api.partner.example/v1 \
METADATA_URL=http://169.254.169.254/latest/meta-data/ \
OTHER_URL=https://elsewhere.example.org/ ek9 CheckDestinations.ek9
PARTNER_URL: https://api.partner.example/v1
[URL_REFUSED] URL refused by the URL policy host=169.254.169.254 entry=deny 169.254.169.254/32 file=url-policy.txt
METADATA_URL: refused or not set
[URL_REFUSED] URL refused by the URL policy host=elsewhere.example.org entry=no allow entry matches file=url-policy.txt
OTHER_URL: refused or not set
The partner is allowed by its allow line. The cloud metadata address is refused by a deny line, and the third destination
by the absence of any allow line that matches it. With the same three variables and no policy, all three are made:
PARTNER_URL: https://api.partner.example/v1
METADATA_URL: http://169.254.169.254/latest/meta-data/
OTHER_URL: https://elsewhere.example.org/
So a policy file is opt-in, and a program run without one allows every destination. A policy that cannot be read is not ignored. With a typing mistake on line 3 of the file, the program does not start:
ek9: URL policy could not be loaded (exit 18)
EK9_URL_POLICY: url-policy-broken.txt line 3: expected 'allow' or 'deny' followed by one target, found 'den 169.254.169.254/32'
The policy is a deployment decision, made by whoever runs the program. An HTTP client library gives you none of this: the destinations it may reach are whatever the code decides to call.
The client side of HTTP
The last piece is the client of the server in A Web Service, Step by Step. HTTPClient is a closeable that the try owns, send takes an
HTTPClientRequest and gives back a Result of (HTTPClientResponse, HTTPError), and the program below runs a service and its
caller in one process on a port the operating system chose, the shape of EchoInProcess again.
#!ek9 defines module book.network.serveandcall defines function describe() -> result as Result of (HTTPClientResponse, HTTPError) stdout <- Stdout() if result.isOk() response <- result.ok() stdout.println(`${response.status()} ${response.header("Content-Type")}: ${response.bodyText("UTF-8")}`) else if result.isError() stdout.println(`not sent: ${result.error()}`) callGreeting() -> port as Integer stdout <- Stdout() base <- Networking().urlOf(`http://127.0.0.1:${port}/greeting`) get <- HTTPMethod("GET") try -> client <- HTTPClient() describe(client.send(HTTPClientRequest(get, base.withPathSegment("Ada")))) missing <- client.send(HTTPClientRequest(get, base.resolve("/nothing"))) if missing.isOk() stdout.println(`${missing.ok().status()} for /nothing`) describe(client.send(HTTPClientRequest(HTTPMethod("TRACE"), base))) defines service Greeting for :/greeting open greet() as GET for :/{name} -> personName as String :=: PATH "name" <- response as HTTPResponse: (who: personName) with trait of HTTPResponse override status() as pure <- rtn as Integer: 200 override content() <- rtn as Bytes: Bytes().withUtf8(`hello ${who}`) override contentType() as pure <- rtn as String: "text/plain" default operator ? defines application GreetingApp register Greeting() defines program ServeAndCall() with application of GreetingApp properties <- NetworkProperties(0, true) try -> httpServer <- HTTP(properties) caller <- (properties) is HTTPReadyHandler as function callGreeting(properties.port) server.stop() httpServer.serveUntilComplete(caller)
200 text/plain; charset=utf-8: hello Ada
404 for /nothing
not sent: PROTOCOL_ERROR: the request is not set
Three results, three meanings. The first request is answered, so the Result is ok and carries a response. The second is
answered with a 404: an error status is a response, so it is also an ok Result, and the program reads status(), not
the error side. The third asks for a method, TRACE, that is not one of the seven HTTPMethod values, so the request itself
is unset and the Result carries an HTTPError; nothing is thrown and nothing is sent. The port is only known at run time,
which is why urlOf is permitted here.2
What the model costs
You give up control of the socket. A NetworkProperties record has seven fields (host, port, packetSize, timeout,
backlog, maxConcurrent, localOnly), TCP has one connect() that takes no arguments, and there is no host-and-port
constructor to reach for; if a socket option is not one of those fields, it is not available. The streams are text: a TCP
handler is given a StringInput and a StringOutput, and the content of a UDPPacket is a String, so a binary protocol has
no direct route through these types. Failures arrive as unset values and a lastErrorMessage() you must remember to read,
which is lighter than an exception and easier to ignore. And the stop-and-close split means a handler cannot do the one thing
every socket tutorial shows it doing.
In exchange, two bugs of hand-written servers are closed off. A server opened in a try is not left open, because
the close is generated and runs when the try ends, on every path. It cannot be closed under a thread that is serving,
because the language refuses the call. And because the settings are one record and the addresses come from the environment,
the question "what can this program talk to?" is answered by reading a few lines and a policy file, not the call graph. The
package-level version of that question, which system powers a program may use at all, is the subject of Packaging and Sharing
Your Program.
A design note. Every language lets you close a socket from whichever thread holds the button, so people do, and the thread still reading it finds out afterwards. I would rather the language did not let you write it. A handler may ask the server to stop, because stopping is a request the owner can honour in its own time; only the owner may close, because only the owner knows that nobody is still using it. - Steve
-
The same rule is why the closeable family has no
close()call at all:ek9 -h E81060lists the three places the compiler generates a close, and Errors and Exceptions covers the resources atryowns. ↩ -
HTTPlogs throughjava.util.logging, so a run also prints twoINFO:lines on standard error, one when the server starts listening and one when it stops. The block above is standard output only. ↩