diff --git a/Makefile b/Makefile index 620b147..b1cdea1 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ serve_cov: serve.c gcc serve.c -o serve_cov -Wall -Wextra -O2 -fprofile-arcs -ftest-coverage -lgcov serve: serve.c - gcc serve.c -o serve -Wall -Wextra -O2 -DNDEBUG + gcc serve.c -o serve -Wall -Wextra -O2 -DNDEBUG -DRELEASE report: lcov --capture --directory . --output-file coverage.info diff --git a/README.md b/README.md index e47b3e3..63b583c 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,8 @@ This is a minimal web server designed to serve my blog. I'm writing it to be rob - Single core (This will probably change when I get a better VPS) # How I test it -I'm still testing manually. I usually stress test the server locally using `wrk` and see if it breaks. I also test it under `valgrind` and sanitizers. \ No newline at end of file +I'm still testing manually. I usually stress test the server locally using `wrk` and see if it breaks. I also test it under `valgrind` and sanitizers. + +# Known Issues +- Server replies to HTTP/1.0 clients as HTTP/1.1 +- Since poll is edge triggered, when the server is full and can't accept all new connections the remaining ones are left waiting until some other event wakes up poll() diff --git a/serve.c b/serve.c index 5798284..18a14ff 100644 --- a/serve.c +++ b/serve.c @@ -17,7 +17,14 @@ #include #include +#ifdef RELEASE +#define PORT 80 +#define LOG_DIRECTORY_SIZE_LIMIT_MB (25 * 1024) +#else #define PORT 8080 +#define LOG_DIRECTORY_SIZE_LIMIT_MB 10 +#endif + #define SHOW_IO 0 #define SHOW_REQUESTS 1 #define REQUEST_TIMEOUT_SEC 5 @@ -26,7 +33,6 @@ #define LOG_BUFFER_SIZE (1<<20) #define LOG_BUFFER_LIMIT (1<<24) #define LOG_FLUSH_TIMEOUT_SEC 3 -#define LOG_DIRECTORY_SIZE_LIMIT_MB 10 #define INPUT_BUFFER_LIMIT_MB 1 #ifndef NDEBUG @@ -327,7 +333,7 @@ int parse_request_head(string str, Request *request) char to_lower(char c) { - if (c >= 'A' || c <= 'Z') + if (c >= 'A' && c <= 'Z') return c - 'A' + 'a'; else return c; @@ -353,11 +359,13 @@ string trim(string s) size_t cur = 0; while (cur < s.size && is_space(s.data[cur])) cur++; - + if (cur == s.size) { s.data = ""; s.size = 0; } else { + s.data += cur; + s.size -= cur; while (is_space(s.data[s.size-1])) s.size--; } @@ -711,6 +719,7 @@ typedef struct { ByteQueue output; int served_count; bool closing; + bool keep_alive; uint64_t creation_time; uint64_t start_time; } Connection; @@ -927,17 +936,29 @@ int num_conns = 0; bool should_keep_alive(Connection *conn) { + // Don't keep alive if the peer doesn't want to + if (conn->keep_alive == false) { + DEBUG("Not keeping alive because peer wants to close\n"); + return false; + } + // Don't keep alive if the request is too old - if (now - conn->creation_time > CONNECTION_TIMEOUT_SEC * 1000) + if (now - conn->creation_time > CONNECTION_TIMEOUT_SEC * 1000) { + DEBUG("Not keeping alive because the connection is too old\n"); return false; + } // Don't keep alive if we served a lot of requests to this connection - if (conn->served_count > 100) + if (conn->served_count > 100) { + DEBUG("Not keeping alive because too many requests were served using this connection\n"); return false; + } // Don't keep alive if the server is more than 70% full - if (num_conns > 0.7 * MAX_CONNECTIONS) + if (num_conns > 0.7 * MAX_CONNECTIONS) { + DEBUG("Not keeping alive because the server is more than 70%% full\n"); return false; + } return true; } @@ -1053,7 +1074,21 @@ bool respond_to_available_requests(struct pollfd *polldata, Connection *conn) break; // Request wasn't completely received yet // Reset the request timer - conns->start_time = now; + conn->start_time = now; + + conn->keep_alive = false; + string keep_alive_header; + if (find_header(&request, LIT("Connection"), &keep_alive_header)) { + DEBUG("Found Connection header\n"); + if (string_match_case_insensitive(trim(keep_alive_header), LIT("Keep-Alive"))) { + conn->keep_alive = true; + DEBUG("Matched Keep-Alive (%.*s)\n", (int) trim(keep_alive_header).size, trim(keep_alive_header).data); + } else { + DEBUG("Didn't match Keep-Alive (%.*s)\n", (int) trim(keep_alive_header).size, trim(keep_alive_header).data); + } + } else { + DEBUG("No Connection header\n"); + } // Respond ResponseBuilder builder; @@ -1069,6 +1104,11 @@ bool respond_to_available_requests(struct pollfd *polldata, Connection *conn) polldata->events |= POLLOUT; polldata->revents |= POLLOUT; } + if (!conn->keep_alive) { + polldata->events &= ~POLLIN; + conn->closing = true; + conn->start_time = now; + } pipeline_count++; if (pipeline_count == 10) { @@ -1154,6 +1194,9 @@ bool write_to_socket(int fd, ByteQueue *queue) int main(int argc, char **argv) { + (void) argc; + (void) argv; + signal(SIGTERM, handle_sigterm); signal(SIGQUIT, handle_sigterm); signal(SIGINT, handle_sigterm); @@ -1277,16 +1320,22 @@ int main(int argc, char **argv) log_data(LIT("Closing timeout\n")); } else { // Request timeout - byte_queue_write(&conn->output, LIT( - "HTTP/1.1 408 Request Timeout\r\n" - "Connection: Close\r\n" - "\r\n")); - conn->closing = true; - conn->start_time = now; - pollarray[i].events &= ~POLLIN; - pollarray[i].events |= POLLOUT; - pollarray[i].revents |= POLLOUT; - log_data(LIT("Request timeout\n")); + if (byte_queue_size(&conn->input) == 0) { + // Connection was idle, so just close it + remove = true; + log_data(LIT("Idle connection timeout\n")); + } else { + byte_queue_write(&conn->output, LIT( + "HTTP/1.1 408 Request Timeout\r\n" + "Connection: Close\r\n" + "\r\n")); + conn->closing = true; + conn->start_time = now; + pollarray[i].events &= ~POLLIN; + pollarray[i].events |= POLLOUT; + pollarray[i].revents |= POLLOUT; + log_data(LIT("Request timeout\n")); + } } } else if (!remove && (pollarray[i].revents & POLLIN)) { diff --git a/tests/test.py b/tests/test.py index 35ced7f..f28ecbe 100644 --- a/tests/test.py +++ b/tests/test.py @@ -6,25 +6,24 @@ @dataclass class Delay: - ms: int + ms: float @dataclass class Send: data: bytes - timeout: Optional[int] = None + timeout: Optional[float] = None @dataclass class Recv: data: bytes - timeout: Optional[int] = None + timeout: Optional[float] = None @dataclass class Close: pass def print_bytes(prefix, data): - lines = str(data).split("\r\n") - print(prefix, f"\\r\\n\n{prefix}".join(lines), sep="") + print(prefix, f"\\r\\n\n{prefix}".join(data.decode("utf-8").split("\r\n")), sep="") def run_test(test, addr, port): @@ -44,7 +43,7 @@ def run_test(test, addr, port): n = conn.send(data[sent:]) if n == 0: break - #print_bytes("< ", data[sent:sent+n]) + print_bytes("< ", data[sent:sent+n]) sent += n case Recv(data, timeout): @@ -54,7 +53,7 @@ def run_test(test, addr, port): chunk = conn.recv(len(data) - count) if chunk == b"": break - #print_bytes("> ", chunk) + print_bytes("> ", chunk) chunks.append(chunk) count += len(chunk) received = b''.join(chunks) @@ -69,31 +68,66 @@ def run_test(test, addr, port): case _: pass + tests = [ [ + # Test "Connection: Close" Send(b"GET / HTTP/1.1\r\nConnection: Close\r\n\r\n"), - Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Close\r\n\r\n"), + Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Close\r\nContent-Length: 15 \r\n\r\nNothing here :|"), Close(), ], [ + # Test "Connection: Keep-Alive" Send(b"GET / HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n"), Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Keep-Alive\r\nContent-Length: 15 \r\n\r\nNothing here :|"), + Send(b"GET / HTTP/1.1\r\nConnection: Close\r\n\r\n"), + Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Close\r\nContent-Length: 15 \r\n\r\nNothing here :|"), + Close(), + ], + [ + # Test that the connection header is insensitive to case and whitespace + Send(b"GET / HTTP/1.1\r\nConnection: keEp-ALiVE \r\n\r\n"), + Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Keep-Alive\r\nContent-Length: 15 \r\n\r\nNothing here :|"), + Send(b"GET / HTTP/1.1\r\nConnection: closE\r\n\r\n"), + Recv(b"HTTP/1.1 404 Not Found\r\nConnection: Close\r\nContent-Length: 15 \r\n\r\nNothing here :|"), + Close(), ], [ Send(b"XXX\r\n\r\n"), Recv(b"HTTP/1.1 400 Bad Request\r\nConnection: Close\r\n\r\n"), ], + [ + Send(b"GET /hello HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n"), + Recv(b"HTTP/1.1 200 OK\r\nConnection: Keep-Alive\r\nContent-Length: 13 \r\n\r\nHello, world!"), + ], + [ + # Test request timeout + Send(b"GET /hel"), + Delay(6), + Recv(b"HTTP/1.1 408 Request Timeout\r\nConnection: Close\r\n\r\n"), + Close() + ], + [ + # Test idle connection timeout + Delay(6), + Close() + ], ] p = subprocess.Popen(['../serve_cov'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) time.sleep(0.5) +total = 0 +passed = 0 for i, test in enumerate(tests): try: run_test(test, "127.0.0.1", 8080) + print("Test", i, "passed\n") + passed += 1 except Exception as e: - print("Test", i, "failed:", e.with_traceback(None)) - + print("Test", i, "failed:", e.with_traceback(None), "\n") + total += 1 +print("passed: ", passed, "/", total, sep="") p.terminate() p.wait()