The main functional blocks of the server are shown in Figure 8-2.
The listener object listens for incoming connections on the main port, for example port 80. For each new connection the operating system will create a new socket. The listener will spawn a new concurrent object that implements the http protocol over the socket.
The protocol object communicates with the resource store. It passes the HTTP request to the root node of the resource store. The request trickles down through the store until a node is reached that will handle it. This node has a concurrent object that performs the request. The handler transmits a response value back to the node which passes it back to the protocol object. This response value generates the body of the response on demand. For example if a file is to be returned then the file name is passed back to the protocol object which will arrange for it to be read from disk.
The messages passed between the protocol object, resource node and handler make up the connection protocol. It is implemented as SML datatypes passed over channels.
Logging is handled by a separate concurrent logging object. This ensures that logging messages from different sources don't get intermingled up. A separate logging protocol will be defined for sending logging messages.
The configuration module holds all of the configuration data for the server. Since this data is constant it is not in a concurrent object. It is just a global (singleton) module.
The allocation modules ration access to system resources. Limits are placed on the number of open file descriptors and the amount of temporary disk space that client connections may use. A client will be paused until the resources become available or a time-out happens.
An entity is a central object in the server. It could be a HTML web page or image being sent to the client or a form being posted by the client. Outgoing entities can come from a disk file or be generated by a CGI script. Incoming entities need to be stored before being passed to a CGI script. It must be possible to access entities concurrently.
The producer/consumer framework supports these ways of handling entities. A producer is a thread that delivers an entity to a consumer. The delivery happens over a CML channel using the Xfer protocol. There are different kinds of producers depending on the source of the entity. Figure 8-3 shows a class diagram for these objects.
datatype Entity = Entity of { info: Info, body: MKProducer } | None and Info = Info of { etype: MType option, encoding: Encoding option, length: int option, last_mod: Date.date option } |
An entity has attributes for its content type (a MIME type), encoding, length and last modification date. The encoding indicates the kind of compression used on the entity, which is not implemented at the moment. All of these attributes are optional. The length will always be taken from the size of the entity's data when available.
The body of an entity is a place holder for the data. The data could be stored in memory or in a disk file etc. If it is in a disk file then each producer opens the file concurrently. An entity can be null (None) with neither header nor body for the cases where HTTP requests contain no entity.
Producers and consumers are abstract classes. The framework allows any object to be a producer or consumer if it can talk the Xfer protocol over a channel. This procotol is illustrated by the sequence diagram in Figure 8-4.
(* A producer sends messages of this type to its consumer. *) and XferProto = XferInfo of Info (* send this first *) | XferBytes of Word8Vector.vector (* then lots of these *) | XferDone (* then one of these *) | XferAbort (* or else one of these *) withtype Consumer = XferProto CML.chan and MKProducer = Abort.Abort -> Info -> Consumer -> CML.thread_id (* This creates a producer for an entity. *) val startProducer: Abort.Abort -> Entity -> Consumer -> CML.thread_id |
The Info message sends the attributes of the entity. Then multiple Bytes messages send the body of the entity as chunks of bytes. The normal end of the transfer is indicated by the Done message. If the client connection is aborted, for example by a time-out, then the transfer can be aborted after the Info message by an Abort message. If an entity is null then no Info or Bytes messages are sent, only a Done. The producer thread terminates after the Done or Abort message.
An entity has a startProducer method that spawns a new producer thread. Most entities can spawn any number of these and run them concurrently. An exception is for process producers. A process producer delivers its entity from the stdout of a CGI script. This can only be done exactly once. The child process will terminate as soon as the delivery as done.
In actuality in the current implementation of the server there will only be one producer for an entity. But if caching of entities in memory was implemented then it would be possible to run multiple concurrent producers.
The main objects of the protocol between a client connection and the store are the Request and Response objects. A Request contains all the information about the HTTP request from the client including details of where to return the response. The Response contains the status, headers and an entity (which may be empty).
Here is the definition of a Request.
datatype Request = Request of { method: Method, url: URL.URL, protocol: string, (* e.g. "HTTP/1.0" *) headers: HTTPHeader.Header list, entity: Entity.Entity, (* Information from the connection. *) port: int, client: NetHostDB.in_addr, (* The store sends its response here. *) rvar: Response SyncVar.ivar, (* This is the abort object from the connection. *) abort: Abort.Abort } and Method = GET | HEAD | POST |
The connection information includes the client's address for CGI scripts. The port number is useful for associating resources such as temporary files with a request. A response to the request is sent to the I-variable in the request. This allows the connection and resource store to be completely decoupled. The connection manager thread will block waiting for a response. Responses may be held up for example if there is a shortage of file descriptors or temporary file disk space.
The abort object in a request carries an indication of whether the connection has been aborted, typically because of a time-out. It provides both a flag that can be tested and a CML event that can be synchronised on. Whenever a request is to be blocked for some reason the blocking thread should also select on the abort condition to return from the wait early.
Here is the response. It just carries the status, extra headers and the entity.
and Response = Response of { status: HTTPStatus.Status, headers: HTTPHeader.Header list, entity: Entity.Entity } |
The Content-Type, Content-Encoding, Content-Length and Last-Modified headers are derived from the entity itself. The headers field provides any other headers. A CGI script can ask that a request be redirected to a different URL. This is allowed for in the Response but I haven't fully implemented it.
Each node in the resource store is implemented by at least two threads. The first is a backbone thread that routes the requests through the store. The second is the handler thread that runs the request. A node could be written to run multiple concurrent requests in which case the handler thread would spawn more handler threads. In the current implementation no node handles requests concurrently. The backbone node also implements common policies such as authorisation.
Figure 8-5 is a collaboration diagram that shows the interractions between the threads.
The backbone nodes are instances of a generic node represented in the diagram by the GENERIC_NODE signature. Actually a generic node is a functor which is specialised for each kind of handler. This gives the backbone node access to properties of the kind of handler which it uses to decide how to route a request.
A request enters the resource store by calling the deliver() function. This passes the request to the root backbone node. A backbone node will examine the URL and decide whether it is for itself or for a child node or if it can't be handled at all. If it is for a child node it will use the forward_child() function to pass it to the child node.
If a request is to be handled by the node it will be passed to the handler object via a mailbox. The mailbox provides unlimited buffering of requests. This frees up the backbone node for more requests. This way a slow handler node cannot prevent requests passing through to child nodes.
The handler object generates a Response value and sends it back to the backbone node. The backbone node then routes it to the client's connection object.
The connection protocol is best illustrated by the collaboration of the HTTP protocol handler and a CGI handler. Figure 8-6 shows this while omitting the resource store nodes.
In step 1, the protocol handler reads a complete request from the client connection. This includes parsing the headers and reading in any entity body. It is important to read in the entire body at this point because simple clients such as Perl scripts will block waiting for the server to read the complete request before fetching any of the response. If the server tried to send a response (maybe an error response) while the client was blocked there would be some risk of a deadlock.
Bodies normally only accompany posted forms and are fairly small. Large bodies normally only come from uploaded files. If the entity body is large it will be copied to a temporary file. Step 2 shows the creation of a temporary file. If the entity body is small it will be kept in memory and the temporary file step will be skipped. The entity object will contain the appropriate producer for the file copy or the in-memory copy.
Next, in step 3, the entire request is wrapped up and delivered to the resource store which routes it to the CGI handler. The CGI handler creates a child process in step 4 passing in the CGI environment variables. In step 5 a producer and consumer pair are created to transfer the entity body from the request to the stdin of the CGI script. The consumer is run as a separate thread to avoid a possible deadlock if the script does not read all of its input before writing some output.
Next, in step 6, the CGI handler reads the headers of the script's response. The entity body is not read at this time. Instead an entity with a special proc producer is created to represent the body waiting in the pipe. This entity is wrapped up with the status and headers to make a Response object which, in step 7, is sent back to the HTTP protocol handler which originated the request.
Finally, the protocol handler writes the response status and headers to the connection. In step 8 it creates a producer for the response's entity body. In step 9 it behaves as the consumer to write the body to the connection in step 10. This means that the body data is read directly out of the pipe from the CGI script and written to the network socket.
The proc producer deals with shutting down the CGI script process when it completes the transfer.
A tricky aspect of the design is dealing with abnormal events such as time-outs, broken connections etc. The connection handler starts a timer when the connection is established. If the entire request is not completed within the time-out then it must be aborted. Similarly if the connection is broken then the server must abort further processing.
There is no way to interrupt a CML thread. Abnormal events must be delivered conventionally using a message or CML event. The threads running the request must poll for the abort condition. Ideally the abort condition is checked before every expensive operation. Everywhere a thread blocks to wait on an event, it must wait on an abort event too.
In practice it will be awkward to check comprehensively for an abort. I can allow the objects in the request processing (see Figure 8-6) to continue on for a while after the connection is aborted as long as they have no side-effects. The garbage collector will clean up the left-overs. Here are the places where I check for an abort condition.
The HTTP protocol handler will abort waiting for a response from the resource store. It will then immediately finish with the connection.
A receive operation from the network socket will be aborted.
When the request waits for the allocation of some system resource it can abort.
A producer that is transferring from a file or a child process will check for an abort condition before each data transfer. However it can't abort an I/O wait.
Reading and writing to a CGI child process checks for abort conditions. The child process will be killed in this case.
If the client closes the connection there is no way for the server to detect this quickly. The close won't be noticed until the server attempts to write the response to the socket. At this point an I/O exception will result and an abort condition will be forced to shutdown the rest of the request handling.
A robust server must not fail if it runs out of the resources it needs for the load it is serving. This would at the least leave it open to a denial-of-service attack. The resources to worry about are
file descriptors
temporary file space
the process limit
The file descriptor budget that is proportional to the number of connections is
1 for the socket
1 to write and then read a temporary file if required
up to 4 while opening the pipes to a child process, 2 while talking to the child
This could mean 4 file descriptors per connection when running a CGI script with a large uploaded file. The file descriptor limit on my RedHat 7.1 system is 1024 so there is a possibility of running out of descriptors at a modest number of connections. (Well not really, for a small scale web server like this, but it's a good exercise). The server includes an Open File Manager that counts the number of open file descriptors. It blocks the processing of a connection until enough file descriptors are available for the connection.
Similarly a limit is placed on the total amount of disk space that temporary files can consume. A connection will be blocked until the required amount of disk space becomes available. There is also a limit on the maximum size a request can have so that a single request won't fill the disk space or block waiting for space that will never be available.
I haven't implemented a manager for the number of child processes. On my system I have a 2040 process limit. Since there is likely to be only one per connection the connection limit should be enough to prevent the server from running out of processes.
If a connection is blocked long enough due to lack of resources then it will time-out. I rely on the garbage collector to clean up after a time-out so I will need to have some sort of finalisation to close open files and remove temporary files after the collector has run. I've implemented a generic finalisation system for the server based on weak references.
At the moment the server is only shut down with an INTR or TERM signal. I haven't implemented any cleanup of temporary files or child processes. A better way to control the server would be to have some privileged port that allows the administrator to
report statistics such as the current number of connections
change the maximum number of connections
stop the server
The proper way to shutdown the server would be to set the connection limit to zero to refuse all new connections, wait for all existing connections to close and then stop the server. I'll leave this as an exercise for the reader.