EK9 in Depth

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

  1. The same rule is why the closeable family has no close() call at all: ek9 -h E81060 lists the three places the compiler generates a close, and Errors and Exceptions covers the resources a try owns. ↩

  2. HTTP logs through java.util.logging, so a run also prints two INFO: lines on standard error, one when the server starts listening and one when it stops. The block above is standard output only. ↩