diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebb2356..7bb678e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,13 +26,13 @@ jobs: steps: # ------------------------------------------------------------------ # - # 1. Check out this extension repo # + # 1. Check out this extension repo # # ------------------------------------------------------------------ # - name: Checkout extension uses: actions/checkout@v5 # ------------------------------------------------------------------ # - # 2. Set platform identifiers # + # 2. Set platform identifiers # # ------------------------------------------------------------------ # - name: Set platform identifiers id: platform @@ -54,7 +54,7 @@ jobs: esac # ------------------------------------------------------------------ # - # 3. Determine release tag # + # 3. Determine release tag # # ------------------------------------------------------------------ # - name: Determine csbuild release id: cs-release @@ -69,7 +69,7 @@ jobs: fi # ------------------------------------------------------------------ # - # 4. Download & extract covscript SDK + cspkg repo # + # 4. Download & extract covscript SDK + cspkg repo # # ------------------------------------------------------------------ # - name: Download covscript SDK id: sdk @@ -104,7 +104,7 @@ jobs: ls -la cspkg-repo/ # ------------------------------------------------------------------ # - # 5. Configure cspkg # + # 5. Configure cspkg # # ------------------------------------------------------------------ # - name: Configure cspkg shell: bash @@ -134,7 +134,7 @@ jobs: cat "$HOME/.cspkg/config.json" # ------------------------------------------------------------------ # - # 6. Persist environment for later steps # + # 6. Persist environment for later steps # # ------------------------------------------------------------------ # - name: Setup environment shell: bash @@ -144,7 +144,7 @@ jobs: echo "${CS_HOME}/bin" >> "$GITHUB_PATH" # ------------------------------------------------------------------ # - # 7. Run cspkg install # + # 7. Setup cspkg environment # # ------------------------------------------------------------------ # - name: Run cspkg install shell: bash @@ -155,10 +155,56 @@ jobs: cspkg install --import --yes cspkg install ecs_bootstrap --yes cspkg version + cspkg doctor cspkg list # ------------------------------------------------------------------ # - # 8. Setup MSYS2 + OpenSSL (Windows only) # + # 8. Reproducible build check (Date header line is volatile) # + # ------------------------------------------------------------------ # + - name: Reproducible build check + shell: bash + run: | + set -e + # Check every source package in the repo, not a hardcoded list. + # nullglob avoids iterating the literal glob when nothing matches. + shopt -s nullglob + for src in *.ecs; do + pkg="${src%.ecs}" + echo "=== Checking ${pkg}.csp reproducibility ===" + cp "${pkg}.csp" "/tmp/${pkg}.csp.bak" + if [ -f "${pkg}.csym" ]; then + cp "${pkg}.csym" "/tmp/${pkg}.csym.bak" + fi + cspkg build "${pkg}.ecs" --compile + # The .csp header contains a Date line that always differs. + # Strip it by pattern before comparing; everything else must + # be byte-identical when the source has not changed. + sed '/^# Date:/d' "/tmp/${pkg}.csp.bak" > "/tmp/${pkg}.csp.old" + sed '/^# Date:/d' "${pkg}.csp" > "/tmp/${pkg}.csp.new" + if ! diff -q "/tmp/${pkg}.csp.old" "/tmp/${pkg}.csp.new"; then + echo "ERROR: ${pkg}.csp changed beyond the date line!" + echo " Did you forget to recompile after editing ${pkg}.ecs?" + diff "/tmp/${pkg}.csp.old" "/tmp/${pkg}.csp.new" || true + exit 1 + fi + # The .csym has no volatile header — it must be byte-identical. + if [ -f "/tmp/${pkg}.csym.bak" ]; then + if [ ! -f "${pkg}.csym" ]; then + echo "ERROR: ${pkg}.csym is missing after build!" + echo " Did you forget to regenerate ${pkg}.csym after editing ${pkg}.ecs?" + exit 1 + fi + if ! cmp -s "/tmp/${pkg}.csym.bak" "${pkg}.csym"; then + echo "ERROR: ${pkg}.csym is stale!" + echo " Did you forget to regenerate ${pkg}.csym after editing ${pkg}.ecs?" + exit 1 + fi + fi + echo " ${pkg} reproducible — ok" + done + + # ------------------------------------------------------------------ # + # 9a. Setup MSYS2 + OpenSSL (Windows only) # # ------------------------------------------------------------------ # - name: Setup MSYS2 if: runner.os == 'Windows' @@ -173,16 +219,6 @@ jobs: mingw-w64-ucrt-x86_64-make mingw-w64-ucrt-x86_64-openssl - # ------------------------------------------------------------------ # - # 9a. Build extension (Linux / macOS) # - # ------------------------------------------------------------------ # - - name: Build extension - if: runner.os != 'Windows' - shell: bash - env: - CS_DEV_PATH: ${{ steps.sdk.outputs.cs_home }}/ - run: bash csbuild/make.sh - # ------------------------------------------------------------------ # # 9b. Build extension (Windows / MSYS2 UCRT64) # # ------------------------------------------------------------------ # @@ -195,6 +231,16 @@ jobs: export PATH="${{ steps.msys2.outputs.msys2-location }}/ucrt64/bin:${PATH}" export CS_DEV_PATH="$(cygpath -u "${{ steps.sdk.outputs.cs_home }}")/" bash csbuild/make.sh + + # ------------------------------------------------------------------ # + # 9c. Build extension (Linux / macOS) # + # ------------------------------------------------------------------ # + - name: Build extension + if: runner.os != 'Windows' + shell: bash + env: + CS_DEV_PATH: ${{ steps.sdk.outputs.cs_home }}/ + run: bash csbuild/make.sh # ------------------------------------------------------------------ # # 10. Install built extension + packages # @@ -229,14 +275,16 @@ jobs: cs tests/test_udp.csc # ------------------------------------------------------------------ # - # 13. Run HTTP round-trip tests # + # 13. Run HTTP client + server tests # # ------------------------------------------------------------------ # - - name: Run HTTP round-trip tests + - name: Run HTTP client/server tests shell: bash run: | set -e cs tests/test_http_roundtrip.csc + cs tests/test_http_client.csc cs tests/test_http_server.csc + cs tests/test_proxy.csc # ------------------------------------------------------------------ # # 14. Run fiber + socket tests # @@ -257,9 +305,9 @@ jobs: cs tests/test_async_tcp.csc # ------------------------------------------------------------------ # - # 16. Run integration tests # + # 16. Run TLS integration tests # # ------------------------------------------------------------------ # - - name: Run integration tests + - name: Run TLS integration tests shell: bash run: | set -e @@ -267,7 +315,17 @@ jobs: cs tests/test_tls_errors.csc # ------------------------------------------------------------------ # - # 16b. Run TLS custom trust mode test (self-signed cert) # + # 16b. Run master-slave integration tests # + # ------------------------------------------------------------------ # + - name: Run master-slave integration tests + shell: bash + run: | + set -e + cs tests/test_master_slave.csc + cs tests/test_http_compliance.csc + + # ------------------------------------------------------------------ # + # 16c. Run TLS custom trust mode test (self-signed cert) # # ------------------------------------------------------------------ # - name: Run TLS custom trust mode test shell: bash diff --git a/CNI_API.md b/CNI_API.md index 470b36f..315e634 100644 --- a/CNI_API.md +++ b/CNI_API.md @@ -105,6 +105,7 @@ sock.connect_ssl("localhost", {"trust_mode": "insecure"}.to_hash_map()) | `set_opt_no_delay` | `(value: boolean)` | 设置 `TCP_NODELAY` 选项(禁用 Nagle 算法) | | `set_opt_keep_alive` | `(value: boolean)` | 设置 `SO_KEEPALIVE` 选项 | | `available` | `() → int` | 可读取的字节数(非阻塞) | +| `peer_closed` | `() → boolean` | 对端是否已关闭连接(非阻塞、非破坏性,使用 1 字节 `MSG_PEEK` 探测)。空闲但存活返回 `false`;收到 FIN 或连接已失效返回 `true`。有异步读挂起时返回 `false`(该读操作自身会暴露 EOF)。TLS 套接字上探测的是底层传输而非解密流 | | `receive` | `(max: int) → string` | 读取最多 `max` 字节。阻塞直到至少 1 字节可读 | | `read` | `(size: int) → string` | 读取恰好 `size` 字节。阻塞直到全部读完 | | `send` | `(data: string) → int` | 发送数据(单次部分写入,返回实际写入字节数)。需完整发送时使用 `write` | diff --git a/NETUTILS.md b/NETUTILS.md index 00f7be7..ca0f884 100644 --- a/NETUTILS.md +++ b/NETUTILS.md @@ -1,6 +1,6 @@ # CovScript NetUtils 协议文档 -版本:2.0 +版本:2.1 作者:Covariant Script OSC @@ -27,7 +27,7 @@ ## 2. 基本常量与工具函数 * `server_name = "CovScript-NetUtils"` -* `server_version = "2.0"` +* `server_version = "2.1"` 与协议/实现相关的重要工具函数: @@ -181,7 +181,7 @@ Master 分配 `rank`:先尝试使用 `deprecated_rank`(回收的编号), 控制逻辑: * 若某连接空闲时间超过 `keep_alive_timeout`,Master 会在关闭前尝试回写 `408 Request Timeout`(或直接关闭)。 -* 若单条连接处理的请求数超过 `max_keep_alive`,Master 会主动向客户端发送 `408` 并关闭连接。 +* 若单条连接处理的请求数达到 `max_keep_alive`,服务器会正常处理最后一个请求,并在该响应中携带 `Connection: close`,随后关闭连接(自 2.1 起不再发送 `408`)。 * Slave 的请求处理若超时(`receive_content_s` 超时),Master 将用错误码构造响应并关闭该 Slave 连接。 ## 7. 静态文件服务与安全(wwwroot、path_normalize) @@ -443,8 +443,8 @@ sequenceDiagram M->>C: 发回 HTTP 回复 end - opt 超出 Keep-alive 限制 - M->>C: 发回 HTTP 回复(408) + opt 达到 Keep-alive 请求数上限 + M->>C: 最后一个 HTTP 回复携带 Connection: close
随后关闭连接 end ``` diff --git a/README.md b/README.md index 7327c9e..b6fcec6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ A high-performance network extension for the [Covariant Script](http://covscript | Package | Type | Version | Description | |---|---|---|---| -| `network` | C++ Extension | `1.38.0_v6.4` | TCP/UDP sockets, TLS/SSL, async I/O, event loop | -| `netutils` | CovScript | `2.0` | HTTP server/client framework with single-process, distributed master/slave, and OpenAI API modes | +| `network` | C++ Extension | `1.38.0_v6.5` | TCP/UDP sockets, TLS/SSL, async I/O, event loop | +| `netutils` | CovScript | `2.1` | HTTP server/client framework with single-process, distributed master/slave, and OpenAI API modes | | `argparse` | CovScript | `1.1` | Lightweight command-line argument parser | > **Note:** `netutils` and `argparse` are provided as both source (`.ecs`), compiled package (`.csp`), and bytecode module (`.csym`) — place them in your project's `imports/` directory. diff --git a/csbuild/netutils.json b/csbuild/netutils.json index 88f0d67..8445160 100644 --- a/csbuild/netutils.json +++ b/csbuild/netutils.json @@ -3,7 +3,7 @@ "Name": "netutils", "Info": "Network Utilities", "Author": "CovScript Organization", - "Version": "2.0", + "Version": "2.1", "Source": "netutils.ecs", "Target": "netutils.csp", "Dependencies": [ diff --git a/csbuild/network.json b/csbuild/network.json index e1f8884..f0fb2b4 100644 --- a/csbuild/network.json +++ b/csbuild/network.json @@ -3,7 +3,7 @@ "Name": "network", "Info": "Socket Extension", "Author": "CovScript Organization", - "Version": "1.38.0_v6.4", + "Version": "1.38.0_v6.5", "Target": "build/imports/network.cse", "Dependencies": [] } diff --git a/include/network.hpp b/include/network.hpp index 887e562..68dc156 100644 --- a/include/network.hpp +++ b/include/network.hpp @@ -717,6 +717,38 @@ namespace cs_impl { return sock.available(); } + // For TLS sockets the probe reflects the raw transport, not + // the decrypted stream — MSG_PEEK sees encrypted bytes, so + // peer_closed() detects transport-level FIN but not TLS + // close_notify. Callers that need TLS-level closure should + // read the stream until eof. + bool peer_closed() noexcept + { + if (!try_begin_io_job(io_direction::read)) + return false; + bool closed = false; + if (!sock.is_open()) { + closed = true; + } + else { + asio::error_code ec; + sock.non_blocking(true, ec); + if (!ec) { + char probe = 0; + std::size_t peeked = sock.receive( + asio::buffer(&probe, 1), + asio::socket_base::message_peek, ec); + asio::error_code restore_ec; + sock.non_blocking(false, restore_ec); + closed = peeked == 0 && + ec != asio::error::would_block && + ec != asio::error::try_again; + } + } + finish_io_job(io_direction::read); + return closed; + } + std::string receive(std::size_t maximum) { scoped_io_job job(*this, io_direction::read); diff --git a/netutils.csp b/netutils.csp index 2b83502..62bac6d 100644 --- a/netutils.csp +++ b/netutils.csp @@ -1,6 +1,6 @@ # Generated by Extended CovScript Compiler # DO NOT MODIFY -# Date: Wed Jul 15 14:37:42 2026 +# Date: Thu Jul 16 11:20:16 2026 @charset: utf8 import ecs as netutils_ecs struct __netutils_ecs_lambda_impl_1__ @@ -44,7 +44,7 @@ struct __netutils_ecs_lambda_impl_3__ if session.args != null && !session.args.empty() url.append("?" + session.args) end - var hop_by_hop = netutils_ecs.type_constructor.__hash_map({"host" : 1, "connection" : 1, "content-length" : 1, "transfer-encoding" : 1, "keep-alive" : 1, "proxy-connection" : 1, "proxy-authenticate" : 1, "proxy-authorization" : 1, "te" : 1, "trailer" : 1, "upgrade" : 1}) + var hop_by_hop = netutils_ecs.type_constructor.__hash_map({"host" : 1, "connection" : 1, "content-length" : 1, "transfer-encoding" : 1, "keep-alive" : 1, "proxy-connection" : 1, "proxy-authenticate" : 1, "proxy-authorization" : 1, "te" : 1, "trailer" : 1, "upgrade" : 1, "x-forwarded-for" : 1}) var req_headers = new array if session.request_headers != null foreach it in session.request_headers @@ -54,6 +54,7 @@ struct __netutils_ecs_lambda_impl_3__ end end end + req_headers.push_back("X-Forwarded-For: " + session.sock.remote_endpoint().address()) var resp = null try if session.content_length != null && session.content_length > 0 @@ -95,7 +96,7 @@ import network; using network import regex import codec.json as json constant server_name = "CovScript-NetUtils" -constant server_version = "2.0" +constant server_version = "2.1" constant http_client_read_chunk = 8192 constant http_max_header_line_size = 8192 constant http_max_header_size = 65536 @@ -107,6 +108,7 @@ constant default_http_thread_count = 4 constant default_http_worker_count = 32 var request_line_reg = regex.build_optimize("^([A-Z]+) ([^ ?]+)(?:\\?([^ ]*))? HTTP/([0-9.]+)$") var request_header_reg = regex.build_optimize("^([^:]*): ?(.*)$") +var keep_alive_replace_reg = regex.build_optimize("Connection: keep-alive") namespace state_codes constant code_200 = "200 OK" constant code_400 = "400 Bad Request" @@ -479,12 +481,12 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) var error_code = null var header_size = 0 loop - async.read_until(sock, state, "\r\n") var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + async.read_until(sock, state, "\r\n") if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -542,12 +544,12 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) session.post_data = state.get_buffer(session.content_length) var remaining = session.content_length - session.post_data.size while remaining > 0 - state = async.read(sock, remaining) var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + state = async.read(sock, remaining) if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -642,7 +644,8 @@ function call_http_handler(session, server) if server->url_map.exist(error_code) server->url_map[error_code](*server, session) else - send_error_response(session.sock, error_code) + session.connection = "close" + session.send_response(error_code, "", "text/html") end return false else @@ -676,25 +679,26 @@ function simple_worker(self) if self->server->stopped break end - if ++request_count > self->server->max_keep_alive - log("Keep-alive exceeded max request count.") - send_error_response(sock, state_codes.code_408) - break - end var session = read_http_header(sock, read_state, last_request_time + self->server->keep_alive_timeout, self->server->max_body_size) if session == null break end - session.sock = sock - if !call_http_handler(session, self->server) - break + var force_close = false + if ++request_count >= self->server->max_keep_alive + session.connection = "close" + force_close = true end + session.sock = sock + var handler_ok = call_http_handler(session, self->server) if session.response_state != null && !session.response_state.wait() log("Write response error: " + session.response_state.get_error()) break end + if !handler_ok + break + end last_request_time = runtime.time() - if !sock.is_open() || session.connection == "close" + if !sock.is_open() || force_close || session.connection == "close" break end end @@ -707,7 +711,6 @@ struct http_conn var sock = null var read_state = null var state = 0 - var close = false var keep_alive = true var request_count = 0 var last_request_time = 0 @@ -825,19 +828,13 @@ function master_request_worker(self) conn->state = -1 continue end - if runtime.time() - conn->last_request_time >= self->server->keep_alive_timeout - log("Keep-alive exceeded timeout limit.") - conn->keep_alive = false - else - if ++conn->request_count > self->server->max_keep_alive - log("Keep-alive exceeded max request count.") - conn->keep_alive = false - end + if ++conn->request_count >= self->server->max_keep_alive + log("Keep-alive exceeded max request count.") + session.connection = "close" end conn->last_request_time = runtime.time() if session.connection == "close" conn->keep_alive = false - conn->close = true end conn->request_queue.push_back(move(session)) conn->state = 0 @@ -867,25 +864,25 @@ function master_response_worker(self) while !conn->request_queue.empty() link session = conn->request_queue.front if session != null && session.response != null + if !conn->keep_alive + session.response = regex.replace(keep_alive_replace_reg, session.response, "Connection: close") + end var response_state = async.write(conn->sock, session.response) if !response_state.wait() log("Write response error: " + response_state.get_error()) conn->keep_alive = false - conn->close = true conn->request_queue = new array break end conn->request_queue.pop_front() --conn->request_idx + conn->last_request_time = runtime.time() else fiber.yield() break end end if conn->request_queue.empty() && !conn->keep_alive - if !conn->close - send_error_response(conn->sock, state_codes.code_408) - end if !conn->sock.safe_shutdown() log("safe_shutdown returned false — async jobs may still be pending") end diff --git a/netutils.csym b/netutils.csym index 4941b7d..dfdc37f 100644 --- a/netutils.csym +++ b/netutils.csym @@ -1,4 +1,4 @@ -#$cSYM/1.0(.\netutils.ecs):-,-,-,-,-,1923,1923,1923,1923,1923,1923,1923,1924,1923,1923,1930,1930,1930,1930,1930,1930,1930,1930,1930,1931,1930,1930,1940,1940,1940,1940,1940,1940,1940,1940,1940,1941,1942,1944,1945,1946,1947,1948,1950,1951,1952,1959,1960,1961,1962,1963,1964,1965,1966,1967,1968,1969,1970,1971,1972,1973,1974,1975,1970,1970,1970,1970,1970,1976,1976,1977,1978,1976,1979,1980,1982,1983,1984,1985,1986,1987,1988,1989,1990,1991,1992,1993,1994,1995,1940,1940,0,2,3,3,4,6,7,14,15,16,17,20,23,24,27,28,32,33,35,36,37,38,39,40,41,42,43,44,45,46,47,48,63,67,72,74,75,76,77,78,79,80,81,82,83,85,86,93,94,96,99,100,104,105,106,107,109,111,112,113,114,115,119,120,121,122,123,124,125,126,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,129,129,129,129,129,151,151,152,153,151,154,155,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,159,159,159,159,159,196,196,197,198,196,199,200,202,203,204,205,207,208,209,210,212,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,271,272,273,273,274,275,275,275,276,277,278,279,280,281,282,283,296,298,299,300,301,302,303,304,305,306,307,308,309,311,312,313,314,315,316,317,319,320,322,324,325,326,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,419,419,419,419,419,421,421,422,421,423,424,425,426,427,428,431,432,433,435,436,437,438,439,440,441,442,443,444,446,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,497,498,499,500,501,502,503,504,505,506,508,509,510,511,512,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,552,553,554,555,556,562,563,564,565,566,567,568,569,570,571,572,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,637,638,639,641,642,643,645,646,647,648,649,650,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,677,678,679,680,681,682,683,684,685,687,688,689,690,691,692,693,694,695,697,698,699,701,702,703,704,705,706,707,708,710,711,712,713,715,716,718,719,720,721,722,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,828,829,830,831,831,832,833,834,834,835,837,838,839,840,841,842,843,844,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,986,987,988,989,990,991,993,994,995,996,997,998,999,1000,1001,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1054,1055,1056,1057,1058,1059,1061,1062,1063,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1101,1102,1103,1104,1105,1106,1107,1108,1110,1111,1112,1113,1114,1115,1116,1116,1117,1118,1119,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1169,1170,1171,1172,1174,1175,1176,1177,1178,1179,1181,1182,1183,1185,1186,1187,1189,1190,1191,1192,1193,1194,1195,1197,1198,1199,1200,1201,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1220,1221,1222,1223,1224,1225,1226,1232,1233,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1248,1248,1248,1248,1248,1263,1263,1264,1263,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1241,1241,1241,1241,1241,1280,1280,1281,1282,1283,1280,1284,1285,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1313,1314,1315,1316,1317,1318,1319,1298,1298,1298,1298,1298,1320,1320,1321,1322,1320,1323,1324,1325,1326,1327,1328,1330,1331,1332,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1357,1357,1357,1357,1357,1368,1368,1369,1370,1368,1371,1372,1373,1374,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423,1424,1425,1427,1428,1429,1430,1431,1432,1433,1434,1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450,1451,1452,1453,1454,1455,1456,1457,1458,1457,1457,1457,1457,1457,1459,1459,1460,1459,1461,1462,1463,1464,1465,1466,1467,1470,1471,1472,1473,1474,1475,1476,1477,1478,1479,1480,1481,1487,1488,1490,1491,1492,1493,1494,1495,1496,1497,1498,1499,1500,1501,1502,1503,1504,1505,1506,1507,1509,1510,1511,1512,1513,1514,1515,1516,1517,1518,1519,1520,1521,1522,1523,1524,1505,1505,1505,1505,1505,1525,1525,1526,1527,1525,1528,1529,1530,1531,1532,1533,1534,1535,1536,1537,1538,1539,1541,1542,1543,1544,1545,1546,1547,1548,1551,1552,1553,1554,1555,1556,1557,1558,1559,1560,1561,1562,1563,1564,1565,1566,1567,1568,1569,1570,1571,1572,1573,1574,1575,1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587,1588,1589,1590,1591,1592,1593,1594,1588,1588,1588,1588,1588,1595,1595,1596,1597,1598,1595,1599,1603,1604,1605,1606,1607,1608,1609,1610,1611,1613,1614,1615,1616,1617,1618,1620,1623,1624,1625,1626,1627,1628,1629,1630,1631,1632,1633,1634,1635,1636,1637,1638,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1656,1657,1659,1660,1661,1662,1664,1665,1666,1667,1669,1670,1671,1673,1674,1675,1677,1678,1679,1681,1682,1683,1685,1686,1687,1688,1689,1690,1691,1692,1693,1693,1694,1695,1695,1697,1698,1699,1700,1701,1702,1703,1705,1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720,1721,1720,1720,1720,1720,1720,1722,1722,1723,1724,1722,1725,1726,1728,1732,1733,1734,1736,1737,1738,1739,1740,1741,1742,1743,1744,1745,1746,1747,1749,1750,1751,1752,1753,1754,1755,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770,1772,1773,1774,1775,1776,1777,1778,1779,1780,1781,1782,1783,1784,1786,1787,1788,1789,1790,1791,1792,1793,1794,1795,1796,1797,1798,1799,1800,1801,1802,1803,1804,1805,1806,1807,1808,1809,1810,1811,1812,1814,1815,1816,1817,1818,1819,1820,1822,1823,1824,1825,1826,1827,1828,1829,1830,1831,1832,1833,1834,1835,1836,1837,1838,1839,1840,1841,1842,1843,1845,1845,1846,1847,1848,1849,1850,1851,1852,1853,1854,1855,1855,1856,1857,1858,1859,1860,1861,1862,1863,1864,1865,1866,1867,1868,1869,1870,1871,1872,1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893,1894,1895,1896,1897,1898,1899,1900,1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1915,1916,1917,1918,1919,1920,1921,1921,1921,1922,1925,1926,1927,1928,1928,1928,1929,1932,1933,1934,1935,1935,1935,1936,1937,1938,1939,1939,1939,1939,1996,1997,1998,1999,2000,2000,2001,2002,2003,2004,2005,2006,2007,2007,2007,2008,2009,2010,2011,2012,2013,2014,2014,2015,2016,2017,2018,2019,2020,2021,2022,2023,2024,2025,2026,2027,2028,2029,2030,2031,2032,2033,2034,2035,2036,2037,2038,2039,2040,2041,2042,2043,2044,2046,2048,2049,2051,2052,2053,2054,2055,2056,2057,2059,2060,2061,2062,2063,2064,2065,2067,2068,2069,2070,2071,2074,2075,2076,2077,2078 +#$cSYM/1.0(netutils.ecs):-,-,-,-,-,1969,1969,1969,1969,1969,1969,1969,1970,1969,1969,1976,1976,1976,1976,1976,1976,1976,1976,1976,1977,1976,1976,1986,1986,1986,1986,1986,1986,1986,1986,1986,1987,1988,1990,1991,1992,1993,1994,1996,1997,1998,2006,2007,2008,2009,2010,2011,2012,2013,2014,2015,2017,2018,2019,2020,2021,2022,2023,2024,2019,2019,2019,2019,2019,2025,2025,2026,2027,2025,2028,2029,2031,2032,2033,2034,2035,2036,2037,2038,2039,2040,2041,2042,2043,2044,1986,1986,0,2,3,3,4,6,7,13,14,15,16,18,19,20,22,23,29,30,31,33,34,35,36,37,38,39,40,41,42,43,44,45,46,61,65,70,72,73,74,75,76,77,78,79,80,81,83,84,91,92,95,98,99,103,104,105,106,109,111,112,113,114,115,119,120,121,122,123,124,125,126,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,130,130,130,130,130,152,152,153,154,152,155,156,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,161,161,161,161,161,198,198,199,200,198,201,202,204,205,206,207,209,210,211,212,214,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,274,275,276,276,277,278,278,278,279,280,281,282,283,284,285,286,299,302,303,304,305,306,307,308,309,310,311,312,313,317,318,319,320,321,322,323,325,326,328,330,331,332,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,426,426,426,426,426,428,428,429,428,430,431,432,433,434,435,438,439,440,442,443,444,445,446,447,448,449,450,451,455,457,458,459,460,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,508,509,510,511,512,513,514,515,516,517,519,520,521,522,523,525,526,527,528,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,566,567,568,569,570,576,577,578,579,580,581,582,583,584,585,586,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,644,645,646,647,648,649,650,651,657,658,659,661,662,663,666,667,668,669,670,671,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,694,695,696,697,698,700,701,704,705,706,707,708,709,710,711,714,715,716,717,718,719,720,721,722,725,726,727,729,730,731,732,733,734,735,738,739,740,741,743,744,747,748,749,750,751,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,860,861,862,863,864,866,867,868,869,870,871,872,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,904,906,907,908,909,910,911,912,913,914,915,916,920,921,922,923,924,925,926,927,928,929,930,931,932,933,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1026,1027,1028,1029,1030,1031,1033,1034,1035,1036,1037,1038,1039,1040,1041,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1094,1095,1096,1097,1098,1099,1101,1102,1103,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1143,1144,1145,1146,1147,1148,1149,1150,1152,1153,1154,1155,1156,1157,1158,1158,1159,1160,1161,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1212,1213,1214,1215,1218,1219,1220,1221,1222,1223,1225,1226,1227,1229,1230,1231,1233,1234,1235,1236,1237,1238,1239,1241,1242,1243,1244,1245,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1264,1265,1266,1267,1268,1269,1270,1276,1277,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1292,1292,1292,1292,1292,1307,1307,1308,1307,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322,1323,1285,1285,1285,1285,1285,1324,1324,1325,1326,1327,1324,1328,1329,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1357,1358,1359,1360,1361,1362,1363,1342,1342,1342,1342,1342,1364,1364,1365,1366,1364,1367,1368,1369,1370,1371,1372,1374,1375,1376,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1401,1401,1401,1401,1401,1412,1412,1413,1414,1412,1415,1416,1417,1418,1420,1421,1422,1423,1424,1425,1426,1427,1428,1429,1430,1431,1432,1433,1434,1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450,1451,1452,1453,1454,1455,1456,1457,1458,1459,1460,1461,1462,1463,1464,1465,1466,1467,1468,1469,1471,1472,1473,1474,1475,1476,1477,1478,1479,1480,1481,1482,1483,1484,1485,1486,1487,1488,1489,1490,1491,1492,1493,1494,1495,1496,1497,1498,1499,1500,1501,1502,1501,1501,1501,1501,1501,1503,1503,1504,1503,1505,1506,1507,1508,1509,1510,1511,1514,1515,1516,1517,1518,1519,1520,1521,1522,1523,1524,1525,1531,1532,1534,1535,1536,1537,1538,1539,1540,1541,1542,1543,1544,1545,1546,1547,1548,1549,1550,1551,1553,1554,1555,1556,1557,1558,1559,1560,1561,1562,1563,1564,1565,1566,1567,1568,1549,1549,1549,1549,1549,1569,1569,1570,1571,1569,1572,1573,1574,1575,1576,1577,1578,1579,1580,1581,1582,1583,1586,1587,1588,1589,1590,1591,1592,1593,1596,1597,1598,1599,1600,1601,1602,1603,1604,1605,1606,1607,1608,1609,1610,1611,1612,1613,1614,1615,1616,1617,1618,1619,1620,1622,1623,1624,1625,1626,1627,1628,1629,1630,1631,1632,1633,1634,1635,1636,1637,1638,1639,1633,1633,1633,1633,1633,1640,1640,1641,1642,1643,1640,1644,1648,1649,1650,1651,1652,1653,1654,1655,1656,1658,1659,1660,1661,1662,1663,1665,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1686,1687,1688,1689,1690,1691,1692,1693,1694,1695,1696,1701,1702,1704,1705,1706,1707,1709,1710,1711,1712,1714,1715,1716,1718,1719,1720,1722,1723,1724,1726,1727,1728,1730,1731,1732,1733,1734,1735,1736,1737,1738,1738,1739,1740,1740,1742,1743,1744,1745,1746,1747,1748,1750,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1765,1765,1765,1765,1765,1767,1767,1768,1769,1767,1770,1771,1773,1777,1778,1779,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791,1792,1794,1795,1796,1797,1798,1799,1800,1803,1804,1805,1806,1807,1808,1809,1810,1811,1812,1813,1814,1815,1816,1818,1819,1820,1821,1822,1823,1824,1825,1826,1827,1828,1829,1830,1832,1833,1834,1835,1836,1837,1838,1839,1840,1841,1842,1843,1844,1845,1846,1847,1848,1849,1850,1851,1852,1853,1854,1855,1856,1857,1858,1860,1861,1862,1863,1864,1865,1866,1868,1869,1870,1871,1872,1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1883,1884,1885,1886,1887,1888,1889,1891,1891,1892,1893,1894,1895,1896,1897,1898,1899,1900,1901,1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1915,1916,1917,1918,1919,1920,1921,1922,1923,1924,1925,1926,1927,1928,1929,1930,1931,1932,1933,1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944,1945,1946,1947,1948,1949,1950,1951,1952,1953,1954,1955,1956,1957,1958,1959,1960,1961,1962,1963,1964,1965,1966,1967,1967,1967,1968,1971,1972,1973,1974,1974,1974,1975,1978,1979,1980,1981,1981,1981,1982,1983,1984,1985,1985,1985,1985,2045,2046,2047,2048,2049,2049,2050,2051,2052,2053,2054,2055,2056,2056,2056,2057,2058,2059,2060,2061,2062,2063,2063,2064,2065,2066,2067,2068,2069,2070,2071,2072,2073,2074,2075,2076,2077,2078,2079,2080,2081,2082,2083,2084,2085,2086,2087,2088,2089,2090,2091,2092,2093,2095,2097,2098,2100,2101,2102,2103,2104,2105,2106,2108,2109,2110,2111,2112,2113,2114,2116,2117,2118,2119,2120,2123,2124,2125,2126,2127 package netutils import codec.json.value as json_value @@ -6,33 +6,31 @@ import network.*, regex import codec.json constant server_name = "CovScript-NetUtils" -constant server_version = "2.0" +constant server_version = "2.1" # ============================================================================ -# Configuration constants — override before use to adjust behavior +# Configuration — override before use # ============================================================================ -# HTTP client low-level I/O -constant http_client_read_chunk = 8192 # bytes per recv() call -constant http_max_header_line_size = 8192 # maximum request/status/header line -constant http_max_header_size = 65536 # maximum complete header block -constant http_max_body_size = 67108864 # maximum buffered HTTP body (64 MiB) +constant http_client_read_chunk = 8192 # bytes per client recv() call +constant http_max_header_line_size = 8192 # single header line +constant http_max_header_size = 65536 # total header block +constant http_max_body_size = 67108864 # buffered body limit (64 MiB) -# Framing protocol (must match C++ side NETWORK_FIXED_HEX_SIZE) -constant framing_hex_size = 16 # length of fixed-hex size header +constant framing_hex_size = 16 # must match C++ NETWORK_FIXED_HEX_SIZE +constant default_http_port = 80 +constant default_https_port = 443 -# Default ports (IANA-registered; override for non-standard deployments) -constant default_http_port = 80 -constant default_https_port = 443 +constant default_http_thread_count = 4 # Asio I/O threads +constant default_http_worker_count = 32 # per-process HTTP handler fibers -# Server defaults — tuning knobs exposed for documentation and test assertions -constant default_http_thread_count = 4 # Asio I/O threads -constant default_http_worker_count = 32 # per-process HTTP handler fibers - -# Internal Functions +# ============================================================================ +# Utilities +# ============================================================================ var request_line_reg = regex.build_optimize("^([A-Z]+) ([^ ?]+)(?:\\?([^ ]*))? HTTP/([0-9.]+)$") var request_header_reg = regex.build_optimize("^([^:]*): ?(.*)$") +var keep_alive_replace_reg = regex.build_optimize("Connection: keep-alive") namespace state_codes constant code_200 = "200 OK" @@ -95,6 +93,7 @@ function compose_http_time() time_padding(tm.sec, 2) + " GMT" end +# Build a minimal HTTP response (always Connection: close, empty body). function compose_response(code) return "HTTP/1.1 " + code + "\r\n" + "Connection: close\r\n" + @@ -108,6 +107,7 @@ function send_error_response(sock, code) state.wait() end +# Set to an iostream.fstream to enable server-side logging. var log_stream = null function log(msg) @@ -127,6 +127,7 @@ function send_content(sock, content) return true end +# Read a framed message (no timeout). Returns payload or null. function receive_content(sock) try var state = async.read(sock, framing_hex_size) @@ -156,6 +157,7 @@ function receive_content(sock) end end +# Read a framed message with timeout. Returns {error_code, payload}. function receive_content_s(sock, timeout) var start_time = runtime.time() try @@ -213,6 +215,7 @@ end var path_extractor = regex.build_optimize("^([A-Za-z]:)") +# Normalize a filesystem path: resolve . and .., convert separators. function path_normalize(path) if path == null || path.empty() return "." @@ -297,6 +300,7 @@ var mime_map = { ".json": "application/json" } as hash_map +# Map file extension to MIME type. function get_mime(path) var pos = path.rfind(".", path.size - 1) if pos == -1 @@ -310,6 +314,8 @@ function get_mime(path) end end +# Parsed HTTP request. In single-process mode write_response is async.write; +# in multi-process mode deserialize() swaps it to send_content. class http_session var url = null var args = null @@ -384,6 +390,7 @@ class http_session end end +# Parse raw request header lines into an http_session. Returns null on rejection. function create_http_session(request_header) var session = new http_session var request_line = request_header.pop_front() @@ -445,18 +452,22 @@ function create_http_session(request_header) return move(session) end +# Read and parse one HTTP request from sock. Handles timeouts, size limits, +# and body consumption. Sends error response on failure. Returns session or null. function read_http_header(sock, state, keep_alive_timeout, max_body_size) # Read HTTP headers var header = new array var error_code = null var header_size = 0 loop - async.read_until(sock, state, "\r\n") + # Check the deadline before starting the read: breaking with a + # just-started async op pending would leave it dangling past shutdown. var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + async.read_until(sock, state, "\r\n") if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -517,12 +528,13 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) session.post_data = state.get_buffer(session.content_length) var remaining = session.content_length - session.post_data.size while remaining > 0 - state = async.read(sock, remaining) + # Same as above: check the deadline before starting the read. var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + state = async.read(sock, remaining) if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -551,6 +563,8 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) return move(session) end +# Route session to the matching handler. Falls back to wwwroot static serving. +# Returns true if a handler was invoked successfully, false on error. function call_http_handler(session, server) var error_code = null if server->url_map.exist(session.url) @@ -626,7 +640,11 @@ function call_http_handler(session, server) if server->url_map.exist(error_code) server->url_map[error_code](*server, session) else - send_error_response(session.sock, error_code) + # Route through the session so multi-process mode sends a framed + # response to the master instead of raw HTTP bytes on the + # slave<->master socket (which would desynchronize the framing). + session.connection = "close" + session.send_response(error_code, "", "text/html") end return false else @@ -634,7 +652,9 @@ function call_http_handler(session, server) end end -# Workers +# ============================================================================ +# Workers (single-process: coroutine per connection) +# ============================================================================ struct worker_type var co = null @@ -644,6 +664,7 @@ struct worker_type var server = null end +# Single-process worker: accept, read, handle, write, keep-alive loop. function simple_worker(self) loop if self->server->stopped @@ -666,27 +687,33 @@ function simple_worker(self) if self->server->stopped break end - if ++request_count > self->server->max_keep_alive - log("Keep-alive exceeded max request count.") - send_error_response(sock, state_codes.code_408) - break - end var session = read_http_header(sock, read_state, last_request_time + self->server->keep_alive_timeout, self->server->max_body_size) if session == null break end + # Close cleanly once max_keep_alive requests are served: advertise + # Connection: close on the final response + var force_close = false + if ++request_count >= self->server->max_keep_alive + session.connection = "close" + force_close = true + end # Call handler session.sock = sock - if !call_http_handler(session, self->server) - break - end + var handler_ok = call_http_handler(session, self->server) + # Wait for the response write (including error responses sent on + # handler failure) before deciding the connection's fate. if session.response_state != null && !session.response_state.wait() log("Write response error: " + session.response_state.get_error()) break end + if !handler_ok + break + end last_request_time = runtime.time() - # Keep-Alive check - if !sock.is_open() || session.connection == "close" + # Keep-Alive check — force_close guards against handlers that + # overwrite session.connection after the limit was reached + if !sock.is_open() || force_close || session.connection == "close" break end end @@ -696,12 +723,12 @@ function simple_worker(self) end end +# Per-connection state for the master process. struct http_conn var sock = null var read_state = null # -1 = close, 0 = established, 1 = busy var state = 0 - var close = false var keep_alive = true var request_count = 0 var last_request_time = 0 @@ -709,6 +736,7 @@ struct http_conn var request_queue = new array end +# Per-slave state tracked by the master. struct slave_node var last_conn_time = null var sock = null @@ -717,6 +745,7 @@ struct slave_node var state = 0 end +# Accept new slave connections and perform handshake. function master_spawn_worker(self) loop if self->server->stopped @@ -766,6 +795,7 @@ function master_spawn_worker(self) end end +# Accept HTTP client connections and push onto conn_list. function master_accept_worker(self) loop if self->server->stopped @@ -791,6 +821,7 @@ function master_accept_worker(self) end end +# Read HTTP requests from connections and enqueue for dispatch. function master_request_worker(self) link clist = self->server->conn_list loop @@ -826,25 +857,24 @@ function master_request_worker(self) conn->state = -1 continue end - # Check keep-alive - if runtime.time() - conn->last_request_time >= self->server->keep_alive_timeout - log("Keep-alive exceeded timeout limit.") - conn->keep_alive = false - else if ++conn->request_count > self->server->max_keep_alive + # Check keep-alive — read_http_header already enforces the timeout; + # only the per-connection request counter needs checking here. + if ++conn->request_count >= self->server->max_keep_alive log("Keep-alive exceeded max request count.") - conn->keep_alive = false + session.connection = "close" end conn->last_request_time = runtime.time() # Check connection type if session.connection == "close" conn->keep_alive = false - conn->close = true end conn->request_queue.push_back(move(session)) conn->state = 0 end end +# Write completed responses back to clients; closes connections +# that have exhausted keep-alive. function master_response_worker(self) link clist = self->server->conn_list loop @@ -869,25 +899,33 @@ function master_response_worker(self) while !conn->request_queue.empty() link session = conn->request_queue.front if session != null && session.response != null + # When keep-alive is disabled the connection must close + # regardless of what the slave handler wrote. Force the + # header to prevent a mismatch between the response and + # the actual connection lifecycle. + if !conn->keep_alive + session.response = regex.replace(keep_alive_replace_reg, + session.response, "Connection: close") + end var response_state = async.write(conn->sock, session.response) if !response_state.wait() log("Write response error: " + response_state.get_error()) conn->keep_alive = false - conn->close = true conn->request_queue = new array break end conn->request_queue.pop_front() --conn->request_idx + # Restart the keep-alive idle window only once the response is + # written, matching simple_worker: slave processing time must + # not eat into the client's idle allowance. + conn->last_request_time = runtime.time() else fiber.yield() break end end if conn->request_queue.empty() && !conn->keep_alive - if !conn->close - send_error_response(conn->sock, state_codes.code_408) - end if !conn->sock.safe_shutdown() log("safe_shutdown returned false — async jobs may still be pending") end @@ -896,6 +934,7 @@ function master_response_worker(self) end end +# Send queued requests to available slaves; heartbeat idle slaves. function master_dispatch_worker(self) link slist = self->server->slave_list loop @@ -985,6 +1024,7 @@ function master_dispatch_worker(self) end end +# Multi-process slave: connect to master, handshake, process dispatched requests. function slave_worker(self) loop if self->server->stopped @@ -1064,7 +1104,9 @@ function slave_worker(self) end end -# Public Interfaces +# ============================================================================ +# Public API +# ============================================================================ function hex_char_to_int(c) if c >= "0" && c <= "9" @@ -1120,6 +1162,7 @@ function url_decode(s) return out end +# Parse query string into key-value pairs. Duplicate keys become arrays. function parse_http_args(input) var qs = input var qpos = input.find("?", 0) @@ -1164,15 +1207,16 @@ function parse_http_args(input) end -# =========================================================== -# OpenAI API Compatible Client -# =========================================================== +# ============================================================================ +# HTTP Client / OpenAI API Compatible Client +# ============================================================================ var http_url_reg = regex.build_optimize("^(?i)(https?)://([^/:]+)(?::([0-9]+))?(.*)$") var http_status_line_reg = regex.build_optimize("^HTTP/([0-9.]+) ([0-9]{3})(?: (.*))?$") var http_header_line_reg = regex.build_optimize("^([^:]+):\\s*(.*)$") var transfer_chunked_reg = regex.build_optimize(".*chunked.*") +# Synchronous HTTP/1.1 client with TLS support. class http_client var sock = null var async_guard = null @@ -1540,6 +1584,7 @@ class http_client return resp_body end + # Send an HTTP request and return {status_code, headers, body} or null. function http_request(method, url, headers, body) var target = parse_url(url) if target == null @@ -1756,6 +1801,7 @@ function local_addr() return addr end +# HTTP/1.1 server -- single-process (coroutine pool) or master/slave. class http_server var acceptor = null var async_guard = null @@ -1958,7 +2004,8 @@ class http_server var hop_by_hop = {"host": 1, "connection": 1, "content-length": 1, "transfer-encoding": 1, "keep-alive": 1, "proxy-connection": 1, "proxy-authenticate": 1, "proxy-authorization": 1, "te": 1, - "trailer": 1, "upgrade": 1} as hash_map + "trailer": 1, "upgrade": 1, + "x-forwarded-for": 1} as hash_map var req_headers = new array if session.request_headers != null foreach it in session.request_headers @@ -1968,6 +2015,8 @@ class http_server end end end + # Append the real client IP so the backend sees per-user addresses instead of the proxy's own IP. + req_headers.push_back("X-Forwarded-For: " + session.sock.remote_endpoint().address()) var resp = null try if session.content_length != null && session.content_length > 0 @@ -2044,7 +2093,7 @@ class http_server end end function stop() - # Signal all workers to exit their loops + # Signal all workers to exit stopped = true # Close acceptors — cancels pending async_accept, wakes blocked fibers acceptor = null diff --git a/netutils.ecs b/netutils.ecs index 9e0bc58..a9f1d99 100644 --- a/netutils.ecs +++ b/netutils.ecs @@ -5,33 +5,31 @@ import network.*, regex import codec.json constant server_name = "CovScript-NetUtils" -constant server_version = "2.0" +constant server_version = "2.1" # ============================================================================ -# Configuration constants — override before use to adjust behavior +# Configuration — override before use # ============================================================================ -# HTTP client low-level I/O -constant http_client_read_chunk = 8192 # bytes per recv() call -constant http_max_header_line_size = 8192 # maximum request/status/header line -constant http_max_header_size = 65536 # maximum complete header block -constant http_max_body_size = 67108864 # maximum buffered HTTP body (64 MiB) +constant http_client_read_chunk = 8192 # bytes per client recv() call +constant http_max_header_line_size = 8192 # single header line +constant http_max_header_size = 65536 # total header block +constant http_max_body_size = 67108864 # buffered body limit (64 MiB) -# Framing protocol (must match C++ side NETWORK_FIXED_HEX_SIZE) -constant framing_hex_size = 16 # length of fixed-hex size header +constant framing_hex_size = 16 # must match C++ NETWORK_FIXED_HEX_SIZE +constant default_http_port = 80 +constant default_https_port = 443 -# Default ports (IANA-registered; override for non-standard deployments) -constant default_http_port = 80 -constant default_https_port = 443 +constant default_http_thread_count = 4 # Asio I/O threads +constant default_http_worker_count = 32 # per-process HTTP handler fibers -# Server defaults — tuning knobs exposed for documentation and test assertions -constant default_http_thread_count = 4 # Asio I/O threads -constant default_http_worker_count = 32 # per-process HTTP handler fibers - -# Internal Functions +# ============================================================================ +# Utilities +# ============================================================================ var request_line_reg = regex.build_optimize("^([A-Z]+) ([^ ?]+)(?:\\?([^ ]*))? HTTP/([0-9.]+)$") var request_header_reg = regex.build_optimize("^([^:]*): ?(.*)$") +var keep_alive_replace_reg = regex.build_optimize("Connection: keep-alive") namespace state_codes constant code_200 = "200 OK" @@ -94,6 +92,7 @@ function compose_http_time() time_padding(tm.sec, 2) + " GMT" end +# Build a minimal HTTP response (always Connection: close, empty body). function compose_response(code) return "HTTP/1.1 " + code + "\r\n" + "Connection: close\r\n" + @@ -107,6 +106,7 @@ function send_error_response(sock, code) state.wait() end +# Set to an iostream.fstream to enable server-side logging. var log_stream = null function log(msg) @@ -126,6 +126,7 @@ function send_content(sock, content) return true end +# Read a framed message (no timeout). Returns payload or null. function receive_content(sock) try var state = async.read(sock, framing_hex_size) @@ -155,6 +156,7 @@ function receive_content(sock) end end +# Read a framed message with timeout. Returns {error_code, payload}. function receive_content_s(sock, timeout) var start_time = runtime.time() try @@ -212,6 +214,7 @@ end var path_extractor = regex.build_optimize("^([A-Za-z]:)") +# Normalize a filesystem path: resolve . and .., convert separators. function path_normalize(path) if path == null || path.empty() return "." @@ -296,6 +299,7 @@ var mime_map = { ".json": "application/json" } as hash_map +# Map file extension to MIME type. function get_mime(path) var pos = path.rfind(".", path.size - 1) if pos == -1 @@ -309,6 +313,8 @@ function get_mime(path) end end +# Parsed HTTP request. In single-process mode write_response is async.write; +# in multi-process mode deserialize() swaps it to send_content. class http_session var url = null var args = null @@ -383,6 +389,7 @@ class http_session end end +# Parse raw request header lines into an http_session. Returns null on rejection. function create_http_session(request_header) var session = new http_session var request_line = request_header.pop_front() @@ -444,18 +451,22 @@ function create_http_session(request_header) return move(session) end +# Read and parse one HTTP request from sock. Handles timeouts, size limits, +# and body consumption. Sends error response on failure. Returns session or null. function read_http_header(sock, state, keep_alive_timeout, max_body_size) # Read HTTP headers var header = new array var error_code = null var header_size = 0 loop - async.read_until(sock, state, "\r\n") + # Check the deadline before starting the read: breaking with a + # just-started async op pending would leave it dangling past shutdown. var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + async.read_until(sock, state, "\r\n") if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -516,12 +527,13 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) session.post_data = state.get_buffer(session.content_length) var remaining = session.content_length - session.post_data.size while remaining > 0 - state = async.read(sock, remaining) + # Same as above: check the deadline before starting the read. var timeout = keep_alive_timeout - runtime.time() if timeout <= 0 error_code = state_codes.code_408 break end + state = async.read(sock, remaining) if !state.wait_for(timeout) if state.has_done() if state.eof() @@ -550,6 +562,8 @@ function read_http_header(sock, state, keep_alive_timeout, max_body_size) return move(session) end +# Route session to the matching handler. Falls back to wwwroot static serving. +# Returns true if a handler was invoked successfully, false on error. function call_http_handler(session, server) var error_code = null if server->url_map.exist(session.url) @@ -625,7 +639,11 @@ function call_http_handler(session, server) if server->url_map.exist(error_code) server->url_map[error_code](*server, session) else - send_error_response(session.sock, error_code) + # Route through the session so multi-process mode sends a framed + # response to the master instead of raw HTTP bytes on the + # slave<->master socket (which would desynchronize the framing). + session.connection = "close" + session.send_response(error_code, "", "text/html") end return false else @@ -633,7 +651,9 @@ function call_http_handler(session, server) end end -# Workers +# ============================================================================ +# Workers (single-process: coroutine per connection) +# ============================================================================ struct worker_type var co = null @@ -643,6 +663,7 @@ struct worker_type var server = null end +# Single-process worker: accept, read, handle, write, keep-alive loop. function simple_worker(self) loop if self->server->stopped @@ -665,27 +686,33 @@ function simple_worker(self) if self->server->stopped break end - if ++request_count > self->server->max_keep_alive - log("Keep-alive exceeded max request count.") - send_error_response(sock, state_codes.code_408) - break - end var session = read_http_header(sock, read_state, last_request_time + self->server->keep_alive_timeout, self->server->max_body_size) if session == null break end + # Close cleanly once max_keep_alive requests are served: advertise + # Connection: close on the final response + var force_close = false + if ++request_count >= self->server->max_keep_alive + session.connection = "close" + force_close = true + end # Call handler session.sock = sock - if !call_http_handler(session, self->server) - break - end + var handler_ok = call_http_handler(session, self->server) + # Wait for the response write (including error responses sent on + # handler failure) before deciding the connection's fate. if session.response_state != null && !session.response_state.wait() log("Write response error: " + session.response_state.get_error()) break end + if !handler_ok + break + end last_request_time = runtime.time() - # Keep-Alive check - if !sock.is_open() || session.connection == "close" + # Keep-Alive check — force_close guards against handlers that + # overwrite session.connection after the limit was reached + if !sock.is_open() || force_close || session.connection == "close" break end end @@ -695,12 +722,12 @@ function simple_worker(self) end end +# Per-connection state for the master process. struct http_conn var sock = null var read_state = null # -1 = close, 0 = established, 1 = busy var state = 0 - var close = false var keep_alive = true var request_count = 0 var last_request_time = 0 @@ -708,6 +735,7 @@ struct http_conn var request_queue = new array end +# Per-slave state tracked by the master. struct slave_node var last_conn_time = null var sock = null @@ -716,6 +744,7 @@ struct slave_node var state = 0 end +# Accept new slave connections and perform handshake. function master_spawn_worker(self) loop if self->server->stopped @@ -765,6 +794,7 @@ function master_spawn_worker(self) end end +# Accept HTTP client connections and push onto conn_list. function master_accept_worker(self) loop if self->server->stopped @@ -790,6 +820,7 @@ function master_accept_worker(self) end end +# Read HTTP requests from connections and enqueue for dispatch. function master_request_worker(self) link clist = self->server->conn_list loop @@ -825,25 +856,24 @@ function master_request_worker(self) conn->state = -1 continue end - # Check keep-alive - if runtime.time() - conn->last_request_time >= self->server->keep_alive_timeout - log("Keep-alive exceeded timeout limit.") - conn->keep_alive = false - else if ++conn->request_count > self->server->max_keep_alive + # Check keep-alive — read_http_header already enforces the timeout; + # only the per-connection request counter needs checking here. + if ++conn->request_count >= self->server->max_keep_alive log("Keep-alive exceeded max request count.") - conn->keep_alive = false + session.connection = "close" end conn->last_request_time = runtime.time() # Check connection type if session.connection == "close" conn->keep_alive = false - conn->close = true end conn->request_queue.push_back(move(session)) conn->state = 0 end end +# Write completed responses back to clients; closes connections +# that have exhausted keep-alive. function master_response_worker(self) link clist = self->server->conn_list loop @@ -868,25 +898,33 @@ function master_response_worker(self) while !conn->request_queue.empty() link session = conn->request_queue.front if session != null && session.response != null + # When keep-alive is disabled the connection must close + # regardless of what the slave handler wrote. Force the + # header to prevent a mismatch between the response and + # the actual connection lifecycle. + if !conn->keep_alive + session.response = regex.replace(keep_alive_replace_reg, + session.response, "Connection: close") + end var response_state = async.write(conn->sock, session.response) if !response_state.wait() log("Write response error: " + response_state.get_error()) conn->keep_alive = false - conn->close = true conn->request_queue = new array break end conn->request_queue.pop_front() --conn->request_idx + # Restart the keep-alive idle window only once the response is + # written, matching simple_worker: slave processing time must + # not eat into the client's idle allowance. + conn->last_request_time = runtime.time() else fiber.yield() break end end if conn->request_queue.empty() && !conn->keep_alive - if !conn->close - send_error_response(conn->sock, state_codes.code_408) - end if !conn->sock.safe_shutdown() log("safe_shutdown returned false — async jobs may still be pending") end @@ -895,6 +933,7 @@ function master_response_worker(self) end end +# Send queued requests to available slaves; heartbeat idle slaves. function master_dispatch_worker(self) link slist = self->server->slave_list loop @@ -984,6 +1023,7 @@ function master_dispatch_worker(self) end end +# Multi-process slave: connect to master, handshake, process dispatched requests. function slave_worker(self) loop if self->server->stopped @@ -1063,7 +1103,9 @@ function slave_worker(self) end end -# Public Interfaces +# ============================================================================ +# Public API +# ============================================================================ function hex_char_to_int(c) if c >= "0" && c <= "9" @@ -1119,6 +1161,7 @@ function url_decode(s) return out end +# Parse query string into key-value pairs. Duplicate keys become arrays. function parse_http_args(input) var qs = input var qpos = input.find("?", 0) @@ -1163,15 +1206,16 @@ function parse_http_args(input) end -# =========================================================== -# OpenAI API Compatible Client -# =========================================================== +# ============================================================================ +# HTTP Client / OpenAI API Compatible Client +# ============================================================================ var http_url_reg = regex.build_optimize("^(?i)(https?)://([^/:]+)(?::([0-9]+))?(.*)$") var http_status_line_reg = regex.build_optimize("^HTTP/([0-9.]+) ([0-9]{3})(?: (.*))?$") var http_header_line_reg = regex.build_optimize("^([^:]+):\\s*(.*)$") var transfer_chunked_reg = regex.build_optimize(".*chunked.*") +# Synchronous HTTP/1.1 client with TLS support. class http_client var sock = null var async_guard = null @@ -1539,6 +1583,7 @@ class http_client return resp_body end + # Send an HTTP request and return {status_code, headers, body} or null. function http_request(method, url, headers, body) var target = parse_url(url) if target == null @@ -1755,6 +1800,7 @@ function local_addr() return addr end +# HTTP/1.1 server -- single-process (coroutine pool) or master/slave. class http_server var acceptor = null var async_guard = null @@ -1957,7 +2003,8 @@ class http_server var hop_by_hop = {"host": 1, "connection": 1, "content-length": 1, "transfer-encoding": 1, "keep-alive": 1, "proxy-connection": 1, "proxy-authenticate": 1, "proxy-authorization": 1, "te": 1, - "trailer": 1, "upgrade": 1} as hash_map + "trailer": 1, "upgrade": 1, + "x-forwarded-for": 1} as hash_map var req_headers = new array if session.request_headers != null foreach it in session.request_headers @@ -1967,6 +2014,8 @@ class http_server end end end + # Append the real client IP so the backend sees per-user addresses instead of the proxy's own IP. + req_headers.push_back("X-Forwarded-For: " + session.sock.remote_endpoint().address()) var resp = null try if session.content_length != null && session.content_length > 0 @@ -2043,7 +2092,7 @@ class http_server end end function stop() - # Signal all workers to exit their loops + # Signal all workers to exit stopped = true # Close acceptors — cancels pending async_accept, wakes blocked fibers acceptor = null diff --git a/network.cpp b/network.cpp index 4d8e46f..fefa7c6 100644 --- a/network.cpp +++ b/network.cpp @@ -321,6 +321,11 @@ namespace network_cs_ext { } } + bool peer_closed(socket_t &sock) + { + return sock->peer_closed(); + } + string receive(socket_t &sock, number max) { auto size = checked_io_buffer_size(max); @@ -1198,6 +1203,7 @@ namespace network_cs_ext { .add_var("set_opt_no_delay", make_cni(tcp::socket::set_opt_no_delay)) .add_var("set_opt_keep_alive", make_cni(tcp::socket::set_opt_keep_alive)) .add_var("available", make_cni(tcp::socket::available)) + .add_var("peer_closed", make_cni(tcp::socket::peer_closed)) .add_var("receive", make_cni(tcp::socket::receive)) .add_var("read", make_cni(tcp::socket::read)) .add_var("send", make_cni(tcp::socket::send)) diff --git a/run_tests.bat b/run_tests.bat index 8730d9d..7a6bd37 100644 --- a/run_tests.bat +++ b/run_tests.bat @@ -52,6 +52,11 @@ echo --- tests\test_master_slave.csc --- %CS% %IMPORT_FLAGS% tests\test_master_slave.csc if errorlevel 1 exit /b 1 +echo. +echo --- tests\test_http_compliance.csc --- +%CS% %IMPORT_FLAGS% tests\test_http_compliance.csc +if errorlevel 1 exit /b 1 + if not "%DEEPSEEK_API_KEY%"=="" ( echo. echo --- tests\test_deepseek.csc --- diff --git a/run_tests.sh b/run_tests.sh index d1222f6..c2da1f0 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -44,6 +44,10 @@ echo "" echo "--- tests/test_master_slave.csc ---" "$CS" $IMPORT_FLAGS tests/test_master_slave.csc +echo "" +echo "--- tests/test_http_compliance.csc ---" +"$CS" $IMPORT_FLAGS tests/test_http_compliance.csc + if [ -n "$DEEPSEEK_API_KEY" ]; then echo "" echo "--- tests/test_deepseek.csc ---" diff --git a/tests/test_http_compliance.csc b/tests/test_http_compliance.csc new file mode 100644 index 0000000..9fcb560 --- /dev/null +++ b/tests/test_http_compliance.csc @@ -0,0 +1,1465 @@ +import netutils +import network.tcp as tcp +import network.async as async + +var _pass = 0 +var _fail = 0 +var _section = "" + +function section(name) + _section = name + system.out.println("") + system.out.println("=== " + name + " ===") +end + +function check(label, ok) + if ok + system.out.println("[PASS] " + _section + " | " + label) + _pass += 1 + else + system.out.println("[FAIL] " + _section + " | " + label) + _fail += 1 + end +end + +function check_eq(label, a, b) + if a != b + system.out.println(" expected: " + to_string(b) + ", got: " + to_string(a)) + end + check(label, a == b) +end + +function check_not_null(label, v) + check(label, v != null) +end + +function check_contains(label, haystack, needle) + if haystack == null + check(label, false) + system.out.println(" string is null") + else + var ok = haystack.find(needle, 0) != -1 + if !ok + var preview = haystack + if preview.size > 400 + preview = haystack.substr(0, 400) + end + system.out.println(" expected to contain: " + needle) + system.out.println(" actual: " + preview) + end + check(label, ok) + end +end + +function check_not_contains(label, haystack, needle) + if haystack == null + check(label, false) + system.out.println(" string is null") + else + var ok = haystack.find(needle, 0) == -1 + if !ok + system.out.println(" found unexpected: " + needle) + end + check(label, ok) + end +end + +# ============================================================ +# Port & server infrastructure +# ============================================================ +var _next_port = 15800 + +function alloc_port() + var port = _next_port + _next_port += 1 + var max_port = _next_port + 100 + while port < max_port + try + var acpt = tcp.acceptor(tcp.endpoint_v4(port)) + acpt = null + return port + catch e + port = _next_port + _next_port += 1 + end + end + return 0 +end + +# ---- single-server helpers ---- +function drive(server) + server.poll() + async.poll_once() +end + +function drive_n(server, n) + var i = 0 + while i < n + drive(server) + i += 1 + end +end + +# Shared response reader: polls srv_a (and srv_b when not null) while reading +# one HTTP response. Body reads are capped at the remaining Content-Length so +# bytes of a pipelined follow-up response are never consumed and discarded. +function read_response_from(client, srv_a, srv_b, timeout_ms) + var buf = "" + var start = runtime.time() + + while buf.find("\r\n\r\n", 0) == -1 && runtime.time() - start < timeout_ms + var n = client.available() + if n > 0 + var chunk = client.receive(n) + if chunk != null && !chunk.empty() + buf += chunk + end + else + # No buffered data and the peer has closed: no response is coming + if client.peer_closed() + break + end + end + srv_a.poll() + if srv_b != null + srv_b.poll() + end + async.poll_once() + runtime.delay(2) + end + if buf.find("\r\n\r\n", 0) == -1 + return buf + end + + var hdr_end = buf.find("\r\n\r\n", 0) + var headers = buf.substr(0, hdr_end) + var body_start = hdr_end + 4 + + var cl = -1 + var cl_pos = headers.find("Content-Length:", 0) + if cl_pos != -1 + var cl_end = headers.find("\r\n", cl_pos) + if cl_end == -1 + cl_end = headers.size + end + var cl_str = headers.substr(cl_pos + 15, cl_end - cl_pos - 15).trim() + try + cl = to_integer(cl_str) + catch e + cl = -1 + end + end + + if cl >= 0 + var body_got = buf.size - body_start + while body_got < cl && runtime.time() - start < timeout_ms + var n = client.available() + if n > cl - body_got + n = cl - body_got + end + if n > 0 + var chunk = client.receive(n) + if chunk != null && !chunk.empty() + buf += chunk + body_got += chunk.size + end + else + # Body truncated by peer close: stop waiting for the rest + if client.peer_closed() + break + end + end + srv_a.poll() + if srv_b != null + srv_b.poll() + end + async.poll_once() + runtime.delay(2) + end + end + return buf +end + +function read_http_response(client, server, timeout_ms) + return read_response_from(client, server, null, timeout_ms) +end + +function http_req(method, path, host_port, headers, body) + var s = method + " " + path + " HTTP/1.1\r\nHost: " + host_port + "\r\n" + foreach h in headers + s += h + "\r\n" + end + if body != null && !body.empty() + s += "Content-Length: " + to_string(body.size) + "\r\n" + end + s += "\r\n" + if body != null && !body.empty() + s += body + end + return s +end + +# ---- master-slave helpers ---- +function drive_ms(master, slave) + master.poll() + slave.poll() + async.poll_once() +end + +function drive_ms_n(master, slave, n) + var i = 0 + while i < n + drive_ms(master, slave) + i += 1 + end +end + +function read_response_ms(client, master, slave, timeout_ms) + return read_response_from(client, master, slave, timeout_ms) +end + +# Shared handler state (reset per section) +var g_count = 0 +var g_method = "" +var g_path = "" +var g_body = "" +var g_headers = null + +function echo_handler(srv, session) + g_count += 1 + g_method = session.method + g_path = session.url + g_body = session.post_data + var resp = "method=" + session.method + " path=" + session.url + if session.post_data != null && !session.post_data.empty() + resp += " body=" + session.post_data + end + session.send_response("200 OK", resp, "text/plain") +end + +function reset_state() + g_count = 0 + g_method = "" + g_path = "" + g_body = "" + g_headers = null +end + +# ====================================================================== +# C01 — Request line: methods, URL, HTTP version +# ====================================================================== +section("C01: request line parsing (single)") + +reset_state() +var p = alloc_port() +system.out.println("port=" + to_string(p)) + +var srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/api/v1/echo", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# GET with query string +var c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/api/v1/echo?key=val&x=1", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +var r = read_http_response(c, srv, 5000) +check_contains("C01-01: 200 OK", r, "200 OK") +check_contains("C01-02: method=GET", r, "method=GET") +# url stores path only; query string goes to args (RFC 3986) +check_contains("C01-03: path (url excludes query)", r, "path=/api/v1/echo") +check_eq("C01-04: handler saw GET", g_method, "GET") +c.close() +drive_n(srv, 5) + +# POST +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("POST", "/api/v1/echo", "127.0.0.1:" + to_string(p), {"Connection: close"}, "hello=world")) +r = read_http_response(c, srv, 5000) +check_contains("C01-05: POST 200", r, "200 OK") +check_contains("C01-06: body forwarded", r, "body=hello=world") +check_eq("C01-07: handler saw POST", g_method, "POST") +c.close() +drive_n(srv, 5) + +# PUT +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("PUT", "/api/v1/echo", "127.0.0.1:" + to_string(p), {"Connection: close"}, "put-data")) +r = read_http_response(c, srv, 5000) +check_contains("C01-08: PUT 200", r, "200 OK") +check_eq("C01-09: handler saw PUT", g_method, "PUT") +c.close() +drive_n(srv, 5) + +# HTTP/1.0 +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /api/v1/echo HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C01-10: HTTP/1.0 gets 200", r, "200 OK") +check_contains("C01-11: HTTP/1.0 defaults to Connection: close", r, "close") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C02 — Response structure: status line & mandatory headers +# ====================================================================== +section("C02: response structure (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/resp", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/resp", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check("C02-01: response received", !r.empty()) + +# Status line +check_contains("C02-02: HTTP/1.1 in status", r, "HTTP/1.1") +check_contains("C02-03: 200 OK", r, "200 OK") +# Mandatory headers (RFC 7231) +check_contains("C02-04: Date header", r, "Date: ") +check_contains("C02-05: Server header", r, "Server: " + netutils.server_name + "/" + netutils.server_version) +check_contains("C02-06: Content-Length header", r, "Content-Length: ") +check_contains("C02-07: Content-Type header", r, "Content-Type: ") +check_contains("C02-08: Connection header", r, "Connection: ") + +c.close() +srv.acceptor = null +srv = null + +# ====================================================================== +# C03 — Error status codes +# ====================================================================== +section("C03: error status codes (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_body_size": 100}.to_hash_map()) +srv.listen(p) +drive_n(srv, 10) + +# 400 Bad Request — malformed +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GARBAGE\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C03-01: 400 for garbage", r, "400") +c.close() +drive_n(srv, 5) + +# 404 Not Found — no route, no wwwroot +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/no/such/path", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check("C03-02: 403 or 404", r.find("403", 0) != -1 || r.find("404", 0) != -1) +c.close() +drive_n(srv, 5) + +# 413 Payload Too Large +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +var big = new string +var bi = 0 +while bi < 200 + big += "x" + bi += 1 +end +c.write(http_req("POST", "/echo", "127.0.0.1:" + to_string(p), {"Connection: close"}, big)) +r = read_http_response(c, srv, 5000) +check_contains("C03-03: 413 for oversized body", r, "413") +c.close() +drive_n(srv, 5) + +# 431 — header too large +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +var huge_hdr = "GET /echo HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Big: " +var hi = 0 +while hi < 10000 + huge_hdr += "A" + hi += 1 +end +huge_hdr += "\r\nConnection: close\r\n\r\n" +c.write(huge_hdr) +r = read_http_response(c, srv, 5000) +check_contains("C03-04: 431 for huge header", r, "431") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C04 — Keep-alive: max requests, client close, timeout (single) +# ====================================================================== +section("C04: keep-alive lifecycle (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_keep_alive": 3, "keep_alive_timeout": 10000}.to_hash_map()) +srv.bind_func("/ka", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) + +# Request 1 → keep-alive +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C04-01: resp-1 keep-alive", r, "keep-alive") +check_not_contains("C04-02: resp-1 no 408", r, "408 Request Timeout") + +# Request 2 → keep-alive +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C04-03: resp-2 keep-alive", r, "keep-alive") +check_not_contains("C04-04: resp-2 no 408", r, "408 Request Timeout") + +# Request 3 → max_keep_alive reached → Connection: close +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C04-05: resp-3 close", r, "close") +check_contains("C04-06: resp-3 200 OK", r, "200 OK") +check_not_contains("C04-07: resp-3 no 408", r, "408 Request Timeout") + +# Request 4 → connection already closed by server +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 1000) +check("C04-08: req-4 gets no response", r.empty() || r.find("200 OK", 0) == -1) +check_eq("C04-09: exactly 3 handler calls", g_count, 3) + +c.close() +srv.acceptor = null +srv = null + +# ====================================================================== +# C05 — Request body: various Content-Length values +# ====================================================================== +section("C05: request body sizes (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_body_size": 65536}.to_hash_map()) +srv.bind_func("/body", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# Zero-length body +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("POST", "/body", "127.0.0.1:" + to_string(p), {"Connection: close"}, "")) +r = read_http_response(c, srv, 5000) +check_contains("C05-01: empty body 200", r, "200 OK") +check_eq("C05-02: empty body null", g_body == null || g_body.empty(), true) +c.close() +drive_n(srv, 5) + +# Small body +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("POST", "/body", "127.0.0.1:" + to_string(p), {"Connection: close"}, "ABC")) +r = read_http_response(c, srv, 5000) +check_contains("C05-03: small body 200", r, "200 OK") +check_eq("C05-04: body=ABC", g_body, "ABC") +c.close() +drive_n(srv, 5) + +# 4KB body +reset_state() +var kb4 = new string +var i4 = 0 +while i4 < 4096 + kb4 += "X" + i4 += 1 +end +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("POST", "/body", "127.0.0.1:" + to_string(p), {"Connection: close"}, kb4)) +r = read_http_response(c, srv, 5000) +check_contains("C05-05: 4KB body 200", r, "200 OK") +check_eq("C05-06: handler got 4096 bytes", g_body.size, 4096) +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C06 — Concurrent connections (single) +# ====================================================================== +section("C06: concurrent connections (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 2, "worker_count": 32}.to_hash_map()) +srv.bind_func("/concurrent", echo_handler) +srv.listen(p) +drive_n(srv, 20) + +var sockets = new array +var idx = 0 +while idx < 8 + var sock = new tcp.socket + sock.connect(tcp.endpoint("127.0.0.1", p)) + check("C06-01: socket-" + to_string(idx) + " connected", sock.is_open()) + sock.write(http_req("GET", "/concurrent", "127.0.0.1", {"Connection: close"}, null)) + sockets.push_back(sock) + idx += 1 +end + +var start6 = runtime.time() +while g_count < 8 && runtime.time() - start6 < 5000 + drive(srv) + runtime.delay(5) +end +check_eq("C06-02: all 8 concurrent handled", g_count, 8) + +foreach s in sockets + s.close() +end +srv.acceptor = null +srv = null + +# ====================================================================== +# C07 — Error responses are Connection: close (single) +# ====================================================================== +section("C07: error responses use Connection: close (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("BOGUS * / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C07-01: error response has Connection: close", r, "Connection: close") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C08 — bind_func vs bind_page vs wwwroot (single) +# ====================================================================== +section("C08: routing priority (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +# bind_func has highest priority +function exact_handler(srv2, sess) + sess.send_response("200 OK", "exact-match", "text/plain") +end +srv.bind_func("/exact", exact_handler) + +srv.listen(p) +drive_n(srv, 10) + +# Exact function match +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/exact", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C08-01: bind_func exact match", r, "exact-match") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C09 — Custom error page binding (bind_code) +# ====================================================================== +section("C09: custom error pages (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/ok", echo_handler) +function custom_403_handler(srv2, sess) + sess.send_response("403 Forbidden", "custom-403-page", "text/html") +end +srv.bind_func("403 Forbidden", custom_403_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/nonexistent", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C09-01: custom 403 status", r, "403 Forbidden") +check_contains("C09-02: custom 403 body", r, "custom-403-page") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C10 — Request header handling (Host, Content-Length edge cases) +# ====================================================================== +section("C10: request header handling (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/headers", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# Missing Host header +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /headers HTTP/1.1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +# Host is optional in HTTP/1.1 per some interpretations, but many servers require it. +# This server creates the session anyway — just check it doesn't crash. +check("C10-01: survives missing Host", !r.empty()) +c.close() +drive_n(srv, 5) + +# Duplicate Content-Length → rejected (400) +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("POST /headers HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\nContent-Length: 5\r\nConnection: close\r\n\r\nbody") +r = read_http_response(c, srv, 5000) +check_contains("C10-02: duplicate Content-Length rejected", r, "400") +c.close() +drive_n(srv, 5) + +# Content-Length mismatch (negative) → rejected +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("POST /headers HTTP/1.1\r\nHost: x\r\nContent-Length: -1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C10-03: negative Content-Length rejected", r, "400") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C11 — Transfer-Encoding in request → rejected +# ====================================================================== +section("C11: Transfer-Encoding request rejected (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/chunked", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("POST /chunked HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C11-01: chunked request rejected", r, "400") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C12 — Keep-alive: client close & timeout (single) +# ====================================================================== +section("C12: keep-alive client close & timeout (single)") + +# --- C12a: client-requested Connection: close --- +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_keep_alive": 100, "keep_alive_timeout": 10000}.to_hash_map()) +srv.bind_func("/cc", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) + +# Request 1: keep-alive +c.write(http_req("GET", "/cc", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C12-01: resp-1 keep-alive", r, "keep-alive") +check_contains("C12-02: resp-1 200 OK", r, "200 OK") + +# Request 2: client asks to close +c.write(http_req("GET", "/cc", "127.0.0.1", {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C12-03: resp-2 Connection: close", r, "close") +check_contains("C12-04: resp-2 200 OK", r, "200 OK") +check_not_contains("C12-05: resp-2 no 408", r, "408 Request Timeout") + +# Request 3: must fail — server already closed +c.write(http_req("GET", "/cc", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 1000) +check("C12-06: req-3 no response after client close", r.empty() || r.find("200 OK", 0) == -1) +check_eq("C12-07: handler called twice", g_count, 2) +c.close() +drive_n(srv, 5) + +# --- C12b: keep-alive timeout --- +reset_state() +srv.acceptor = null +srv = null + +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_keep_alive": 100, "keep_alive_timeout": 500}.to_hash_map()) +srv.bind_func("/to", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", "/to", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C12-08: first response 200 OK", r, "200 OK") + +# Wait past the keep-alive timeout so server closes +system.out.println("C12: waiting for keep-alive timeout (500ms)...") +runtime.delay(800) +drive_n(srv, 20) + +c.write(http_req("GET", "/to", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_http_response(c, srv, 1000) +check("C12-09: no response after timeout", r.empty() || r.find("200 OK", 0) == -1) +check_eq("C12-10: handler called once", g_count, 1) +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C13 — Method semantics: HEAD, OPTIONS, no-body responses +# ====================================================================== +section("C13: method semantics (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/methods", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# HEAD — response must have no body but correct Content-Length +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("HEAD", "/methods", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C13-01: HEAD 200 OK", r, "200 OK") +check_contains("C13-02: Content-Length present", r, "Content-Length: ") +check_eq("C13-03: handler saw HEAD", g_method, "HEAD") +c.close() +drive_n(srv, 5) + +# DELETE +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("DELETE", "/methods", "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C13-04: DELETE 200 OK", r, "200 OK") +check_eq("C13-05: handler saw DELETE", g_method, "DELETE") +c.close() +drive_n(srv, 5) + +# Unknown method (e.g. FOO) should still parse correctly +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("FOO /methods HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C13-06: unknown method still 200 OK", r, "200 OK") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C14 — URL handling: percent-encoding, special characters +# ====================================================================== +section("C14: URL handling (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1}.to_hash_map()) +srv.bind_func("/path/with%20spaces", echo_handler) +srv.bind_func("/path/%2525encoded", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# Percent-encoded characters in URL — kept literal per RFC 3986; the raw +# request-line path is what the handler sees (no decoding by the server). +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /path/with%20spaces HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C14-01: percent-encoded URL matched literally", r, "200 OK") +c.close() +drive_n(srv, 5) + +# Double-encoded percent sign preserved as-is in the raw URL +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /path/%2525encoded HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C14-02: double-encoded percent matched literally", r, "200 OK") +c.close() +drive_n(srv, 5) + +# Query string with percent-encoded value +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /path/with%20spaces?key=hello%20world HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C14-03: query string with encoded values", r, "200 OK") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C15 — Pipelining: multiple requests before reading responses +# ====================================================================== +section("C15: pipelining (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_keep_alive": 10, "keep_alive_timeout": 10000}.to_hash_map()) +srv.bind_func("/pipe", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) + +# Pipeline 3 requests before reading any response +var pl_req = http_req("GET", "/pipe?n=1", "127.0.0.1", {"Connection: keep-alive"}, null) +pl_req += http_req("GET", "/pipe?n=2", "127.0.0.1", {"Connection: keep-alive"}, null) +pl_req += http_req("GET", "/pipe?n=3", "127.0.0.1", {"Connection: close"}, null) +c.write(pl_req) + +# Read responses in order +r = read_http_response(c, srv, 5000) +check_contains("C15-01: pipelined resp-1 200", r, "200 OK") +check_contains("C15-02: pipelined resp-1 body has path", r, "path=/pipe") + +r = read_http_response(c, srv, 5000) +check_contains("C15-03: pipelined resp-2 200", r, "200 OK") +check_contains("C15-04: pipelined resp-2 body has path", r, "path=/pipe") + +r = read_http_response(c, srv, 5000) +check_contains("C15-05: pipelined resp-3 200", r, "200 OK") +check_contains("C15-06: pipelined resp-3 body has path", r, "path=/pipe") +check_contains("C15-07: last response close", r, "close") + +check_eq("C15-08: handler called 3 times", g_count, 3) +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# C16 — Edge cases: null bytes, oversized URL, empty body POST +# ====================================================================== +section("C16: edge cases (single)") + +reset_state() +p = alloc_port() +system.out.println("port=" + to_string(p)) + +srv = new netutils.http_server +srv.set_config({"thread_count": 1, "worker_count": 1, "max_body_size": 65536}.to_hash_map()) +srv.bind_func("/edge", echo_handler) +srv.listen(p) +drive_n(srv, 10) + +# Empty POST with Content-Length: 0 +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("POST /edge HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C16-01: empty POST 200", r, "200 OK") +check_eq("C16-02: empty body in handler", g_body == null || g_body.empty(), true) +c.close() +drive_n(srv, 5) + +# Double Host header — the second one should overwrite +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write("GET /edge HTTP/1.1\r\nHost: first.example.com\r\nHost: second.example.com\r\nConnection: close\r\n\r\n") +r = read_http_response(c, srv, 5000) +check_contains("C16-03: double Host survives", r, "200 OK") +c.close() +drive_n(srv, 5) + +# Very long URL (but within header limits) +reset_state() +var long_path = "/edge/" +var li = 0 +while li < 200 + long_path += "a" + li += 1 +end +srv.bind_func(long_path, echo_handler) +drive_n(srv, 5) +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", p)) +c.write(http_req("GET", long_path, "127.0.0.1:" + to_string(p), {"Connection: close"}, null)) +r = read_http_response(c, srv, 5000) +check_contains("C16-04: long URL 200", r, "200 OK") +c.close() + +srv.acceptor = null +srv = null + +# ====================================================================== +# M01 — Master-slave: basic request/response round-trip +# ====================================================================== +section("M01: master-slave basic round-trip") + +reset_state() +var http_p = alloc_port() +var slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +var master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2, "max_keep_alive": 100, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/api/echo", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +var slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/api/echo", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +check("M01-01: connected", c.is_open()) + +# GET +c.write(http_req("GET", "/api/echo", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M01-02: 200 OK", r, "200 OK") +check_contains("M01-03: method=GET", r, "method=GET") +check_contains("M01-04: path=/api/echo", r, "path=/api/echo") +check_eq("M01-05: handler called", g_count, 1) +c.close() +drive_ms_n(master, slave, 10) + +# POST with body +reset_state() +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("POST", "/api/echo", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, "ms-post-body")) +r = read_response_ms(c, master, slave, 5000) +check_contains("M01-06: POST 200", r, "200 OK") +check_contains("M01-07: body forwarded", r, "body=ms-post-body") +check_eq("M01-08: handler got body", g_body, "ms-post-body") +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M02 — Master-slave: keep-alive max & no stray 408 +# ====================================================================== +section("M02: master-slave keep-alive") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2, "max_keep_alive": 2, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/ka", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/ka", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) + +# Request 1 → keep-alive +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M02-01: resp-1 keep-alive", r, "keep-alive") +check_not_contains("M02-02: resp-1 no 408", r, "408 Request Timeout") + +# Request 2 → max_keep_alive=2, should be the last → Connection: close +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M02-03: resp-2 close", r, "close") +check_contains("M02-04: resp-2 200 OK", r, "200 OK") +check_not_contains("M02-05: resp-2 no 408", r, "408 Request Timeout") + +# Request 3 → should fail +c.write(http_req("GET", "/ka", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 1000) +check("M02-06: req-3 gets no response", r.empty() || r.find("200 OK", 0) == -1) +check_eq("M02-07: exactly 2 handler calls", g_count, 2) + +c.close() +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M03 — Master-slave: error code propagation +# ====================================================================== +section("M03: master-slave error propagation") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +master.bind_func("/ok", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/ok", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +# Request to unmapped URL → slave returns the error via the framed +# protocol and the master delivers it to the client. The original +# status code (403, no wwwroot configured) must pass through intact +# instead of degrading to a generic 500. +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("GET", "/no/route", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, null)) +r = read_response_ms(c, master, slave, 5000) +check("M03-01: error response received", !r.empty()) +check("M03-02: response is HTTP", r.find("HTTP/", 0) != -1) +check_contains("M03-03: original status propagated", r, "403") +check_not_contains("M03-04: not degraded to 500", r, "500 Internal Server Error") +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M04 — Master-slave: concurrent requests +# ====================================================================== +section("M04: master-slave concurrent") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 4, "max_keep_alive": 100, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/echo", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/echo", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +var ms_sockets = new array +var j = 0 +while j < 4 + var sock = new tcp.socket + sock.connect(tcp.endpoint("127.0.0.1", http_p)) + check("M04-01: socket-" + to_string(j) + " connected", sock.is_open()) + sock.write(http_req("GET", "/echo", "127.0.0.1", {"Connection: close"}, null)) + ms_sockets.push_back(sock) + j += 1 +end + +var ms_start = runtime.time() +while g_count < 4 && runtime.time() - ms_start < 5000 + drive_ms(master, slave) + runtime.delay(5) +end +check_eq("M04-02: all 4 concurrent handled", g_count, 4) + +foreach s in ms_sockets + s.close() +end +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M05 — Master-slave: POST body forwarding (various sizes) +# ====================================================================== +section("M05: master-slave POST body") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2, "max_keep_alive": 100, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/post", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/post", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +# Small POST +var body_small = "name=cov" +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("POST", "/post", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, body_small)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M05-01: small POST 200", r, "200 OK") +check_contains("M05-02: body forwarded", r, "body=" + body_small) +check_eq("M05-03: handler body", g_body, body_small) +c.close() +drive_ms_n(master, slave, 10) + +# 2KB POST +reset_state() +var body_2k = new string +var k = 0 +while k < 2048 + body_2k += "Y" + k += 1 +end +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("POST", "/post", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, body_2k)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M05-04: 2KB POST 200", r, "200 OK") +check_eq("M05-05: handler body size 2048", g_body.size, 2048) +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M06 — Master-slave: slave disconnect, master survives +# ====================================================================== +section("M06: master-slave slave disconnect") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +master.bind_func("/echo", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/echo", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +# Verify working state +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("GET", "/echo", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M06-01: pre-disconnect 200", r, "200 OK") +c.close() + +# Kill slave +slave.stop() +slave = null +runtime.delay(200) +drive_n(master, 20) + +# Master must still accept TCP +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +check("M06-02: master still accepts after slave death", c.is_open()) +c.close() + +master.stop() +master = null + +# ====================================================================== +# M07 — Master-slave: client close & timeout +# ====================================================================== +section("M07: master-slave client close & timeout") + +# --- M07a: client-requested close --- +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2, "max_keep_alive": 100, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/ms-cc", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/ms-cc", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) + +# Request 1: keep-alive +c.write(http_req("GET", "/ms-cc", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M07-01: resp-1 keep-alive", r, "keep-alive") +check_not_contains("M07-02: resp-1 no 408", r, "408 Request Timeout") + +# Request 2: client close +c.write(http_req("GET", "/ms-cc", "127.0.0.1", {"Connection: close"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M07-03: resp-2 close", r, "close") +check_not_contains("M07-04: resp-2 no 408", r, "408 Request Timeout") + +# Request 3: must fail +c.write(http_req("GET", "/ms-cc", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 1000) +check("M07-05: req-3 no response", r.empty() || r.find("200 OK", 0) == -1) +check_eq("M07-06: handler called twice", g_count, 2) +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# --- M07b: keep-alive timeout --- +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2, "max_keep_alive": 100, "keep_alive_timeout": 500}.to_hash_map()) +master.bind_func("/ms-to", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/ms-to", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("GET", "/ms-to", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M07-07: first response 200 OK", r, "200 OK") + +system.out.println("M07: waiting for keep-alive timeout (500ms)...") +runtime.delay(800) +drive_ms_n(master, slave, 20) + +c.write(http_req("GET", "/ms-to", "127.0.0.1", {"Connection: keep-alive"}, null)) +r = read_response_ms(c, master, slave, 1000) +check("M07-08: no response after timeout", r.empty() || r.find("200 OK", 0) == -1) +check_eq("M07-09: handler called once", g_count, 1) +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M08 — Master-slave: pipelining +# ====================================================================== +section("M08: master-slave pipelining") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 4, "max_keep_alive": 10, "keep_alive_timeout": 10000}.to_hash_map()) +master.bind_func("/pipe", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/pipe", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) + +var ms_pl = http_req("GET", "/pipe?n=a", "127.0.0.1", {"Connection: keep-alive"}, null) +ms_pl += http_req("GET", "/pipe?n=b", "127.0.0.1", {"Connection: keep-alive"}, null) +ms_pl += http_req("GET", "/pipe?n=c", "127.0.0.1", {"Connection: close"}, null) +c.write(ms_pl) + +r = read_response_ms(c, master, slave, 5000) +check_contains("M08-01: resp-1 200", r, "200 OK") +check_contains("M08-02: resp-1 body", r, "path=/pipe") + +r = read_response_ms(c, master, slave, 5000) +check_contains("M08-03: resp-2 200", r, "200 OK") +check_contains("M08-04: resp-2 body", r, "path=/pipe") + +r = read_response_ms(c, master, slave, 5000) +check_contains("M08-05: resp-3 200", r, "200 OK") +check_contains("M08-06: resp-3 body", r, "path=/pipe") +check_contains("M08-07: last response close", r, "close") + +check_eq("M08-08: handler called 3 times", g_count, 3) +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# M09 — Master-slave: HEAD method +# ====================================================================== +section("M09: master-slave HEAD method") + +reset_state() +http_p = alloc_port() +slave_p = alloc_port() +system.out.println("http=" + to_string(http_p) + " slave=" + to_string(slave_p)) + +master = new netutils.http_server +master.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +master.bind_func("/head-test", echo_handler) +master.set_master(slave_p) +master.listen(http_p) + +slave = new netutils.http_server +slave.set_config({"thread_count": 2, "worker_count": 2}.to_hash_map()) +slave.bind_func("/head-test", echo_handler) +slave.set_slave("127.0.0.1", slave_p) + +drive_ms_n(master, slave, 30) + +c = new tcp.socket +c.connect(tcp.endpoint("127.0.0.1", http_p)) +c.write(http_req("HEAD", "/head-test", "127.0.0.1:" + to_string(http_p), {"Connection: close"}, null)) +r = read_response_ms(c, master, slave, 5000) +check_contains("M09-01: HEAD 200", r, "200 OK") +check_contains("M09-02: Content-Length present", r, "Content-Length: ") +check_eq("M09-03: handler saw HEAD", g_method, "HEAD") +c.close() + +slave.stop() +master.stop() +slave = null +master = null + +# ====================================================================== +# Results +# ====================================================================== +section("Results") +system.out.println("") +system.out.println("PASS: " + _pass) +system.out.println("FAIL: " + _fail) +if _fail > 0 + system.exit(1) +end