♻️ Refactor benchmarks server

Clean up, add tests

AI-assisted-by: claude-5.5-sonnet, deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-11-10 17:54:04 +01:00
parent e6fcbe5969
commit e16b7589ed
2 changed files with 296 additions and 131 deletions

View File

@ -26,151 +26,284 @@
".svg" "image/svg+xml"
".png" "image/png"})
(def ^:private owners (js/WeakMap.))
(def ^:private status-text
{400 "bad request"
403 "forbidden"
404 "not found"
405 "method not allowed"})
(def ^:private fs-error-status
{"ENOENT" 404
"ENOTDIR" 404
"EACCES" 403})
(def ^:private owners
"Maps each owned server to {:sockets Set :shutdown atom}."
(js/WeakMap.))
;; Request URL to file
(defn- rejection
"Returns an Error that `error-status` maps to `status`."
[status message]
(ex-info message {:status status}))
(defn- unsafe-pathname?
[pathname]
(or (str/includes? pathname "\u0000")
(str/includes? pathname "\\")
(some #{".."} (str/split pathname #"/"))))
(defn- parse-pathname
"Returns the decoded path of a request URL without its query and fragment.
Throws:
400 for a path without a leading slash and for a malformed escape.
403 for a path with a NUL, a backslash or a `..` segment."
[url]
(let [pathname (js/decodeURIComponent (first (str/split url #"[?#]" 2)))]
(cond
(not (str/starts-with? pathname "/")) (throw (rejection 400 "invalid request path"))
(unsafe-pathname? pathname) (throw (rejection 403 "forbidden request path"))
:else pathname)))
(defn- pathname->relative
[pathname]
(if (= pathname "/") "index.html" (subs pathname 1)))
(defn- contained
"Returns `file` when it lies within `root`, otherwise throws 403 with `message`."
[root message file]
(if (files/contained? root file)
file
(throw (rejection 403 message))))
(defn- request-file
[root url]
(let [pathname (js/decodeURIComponent (first (str/split url #"[?#]" 2)))]
(when-not (str/starts-with? pathname "/")
(throw (ex-info "invalid request path" {:status 400})))
(when (or (str/includes? pathname "\u0000")
(str/includes? pathname "\\")
(some #{".."} (str/split pathname #"/")))
(throw (ex-info "forbidden request path" {:status 403})))
(let [relative (if (= pathname "/") "index.html" (subs pathname 1))
file (path/resolve root relative)]
(when-not (files/contained? root file)
(throw (ex-info "forbidden request path" {:status 403})))
(let [resolved (fs/realpathSync file)]
(when-not (files/contained? root resolved)
(throw (ex-info "forbidden artifact symlink" {:status 403})))
resolved))))
"Returns the real path of the file that `url` selects below `root`.
(defn- respond-error
[^js request ^js response status]
(.writeHead response status #js {"Content-Type" "text/plain; charset=utf-8"})
(.end response (when-not (= "HEAD" (.-method request))
(case status
400 "bad request"
403 "forbidden"
404 "not found"
405 "method not allowed"
"server error"))))
Throws:
400 or 403 for a bad path.
403 for a path or a symlink that leaves `root`.
The filesystem error when the file cannot be resolved."
[root url]
(->> (parse-pathname url)
(pathname->relative)
(path/resolve root)
(contained root "forbidden request path")
(fs/realpathSync)
(contained root "forbidden artifact symlink")))
;; File to response
(defn- error-status
"Returns the HTTP status for a failure while serving a request."
[error]
(or (:status (ex-data error))
(when (instance? js/URIError error) 400)
(get fs-error-status (.-code ^js error) 500)))
(defn- error-response
[method status]
{:status status
:headers {"Content-Type" "text/plain; charset=utf-8"}
:body (when-not (= "HEAD" method)
(get status-text status "server error"))})
(defn- method-not-allowed
[method]
(assoc-in (error-response method 405) [:headers "Allow"] "GET, HEAD"))
(defn- file-response
[method file ^js stat]
{:status 200
:headers {"Content-Type" (get mime (path/extname file) "application/octet-stream")
"Content-Length" (.-size stat)}
:body (when (= "GET" method)
(fs/readFileSync file))})
(defn- get-file-response
[root method url]
(try
(let [file (request-file root url)
^js stat (fs/statSync file)]
(if (.isFile stat)
(file-response method file stat)
(error-response method 404)))
(catch :default error
(error-response method (error-status error)))))
(defn- respond
"Returns {:status :headers :body} for a request with `method` and `url`."
[root method url]
(if (contains? #{"GET" "HEAD"} method)
(get-file-response root method url)
(method-not-allowed method)))
(defn- write-response
[^js response {:keys [status headers body]}]
(.writeHead response status (clj->js headers))
(.end response body))
(defn- serve
[root ^js request ^js response]
(if-not (contains? #{"GET" "HEAD"} (.-method request))
(do
(.setHeader response "Allow" "GET, HEAD")
(respond-error request response 405))
(try
(let [file (request-file root (.-url request))
^js stat (fs/statSync file)]
(if-not (.isFile stat)
(respond-error request response 404)
(let [body (when (= "GET" (.-method request)) (fs/readFileSync file))]
(.writeHead response 200
#js {"Content-Type" (get mime (path/extname file) "application/octet-stream")
"Content-Length" (.-size stat)})
(.end response body))))
(catch :default error
(respond-error request response
(or (:status (ex-data error))
(when (instance? js/URIError error) 400)
(case (.-code error)
"ENOENT" 404
"ENOTDIR" 404
"EACCES" 403
500)))))))
(write-response response (respond root (.-method request) (.-url request))))
;; Shutdown
(defn- close-failure
"Returns `error` unless it is nil or reports a server that was not running."
[error]
(when-not (or (nil? error)
(= "ERR_SERVER_NOT_RUNNING" (.-code ^js error)))
error))
(defn- close-server
"Closes `server` and destroys its `sockets`.
Returns a promise that resolves once the server closed. A server that is
not running counts as closed."
[^js server ^js sockets]
(js/Promise.
(fn [resolve reject]
(let [settle (fn [error]
(if-let [failure (close-failure error)]
(reject failure)
(resolve nil)))]
(try
(.close server settle)
(run! #(.destroy ^js %) (array-seq (js/Array.from sockets)))
(catch :default error
(settle error)))))))
(defn close
"Closes an owned host once and destroys only that server's connections.
Repeated calls return the same shutdown promise, including during startup."
Args:
host: Map returned by `open`.
Returns:
Promise that resolves once the server closed. Repeated calls return the
same promise, including during startup."
[{:keys [server]}]
(let [{:keys [sockets shutdown]} (.get owners server)]
(or @shutdown
(let [result (js/Promise.
(fn [resolve reject]
(try
(.close ^js server
(fn [error]
(if (and error (not= "ERR_SERVER_NOT_RUNNING" (.-code error)))
(reject error)
(resolve nil))))
(doseq [socket (array-seq (js/Array.from sockets))]
(.destroy ^js socket))
(catch :default error
(if (= "ERR_SERVER_NOT_RUNNING" (.-code error))
(resolve nil)
(reject error))))))]
(reset! shutdown result)
result))))
(reset! shutdown (close-server server sockets)))))
(defn open
;; Startup
(defn- track-connection
[^js sockets shutdown ^js socket]
(.add sockets socket)
(.once socket "close" #(.delete sockets socket))
(when @shutdown
(.destroy socket)))
(defn- create-host
"Returns {:server :build} for a server of `build`'s files that does not
listen yet."
[build]
(let [root (fs/realpathSync (:dir build))
^js server (http/createServer (partial serve root))
sockets (js/Set.)
shutdown (atom nil)]
(.set owners server {:sockets sockets :shutdown shutdown})
(.on server "connection" (partial track-connection sockets shutdown))
{:server server :build build}))
(defn- aborted?
[^js signal]
(and signal (.-aborted signal)))
(defn- aborted-error
[]
(ex-info "renderer server startup aborted" {:type ::aborted}))
(defn- timeout-error
[timeout-ms]
(ex-info "renderer server startup timed out"
{:type ::startup-timeout :timeout-ms timeout-ms}))
(defn- subscribe-startup
"Routes the end of `server` startup to `succeed` or `fail`.
`succeed` receives the listening event. `fail` receives the first error
event of `server`, an `::aborted` error when `signal` (optional) aborts and
a `::startup-timeout` error after `timeout-ms`.
Returns a function that removes the subscriptions."
[^js server signal timeout-ms succeed fail]
(let [on-abort #(fail (aborted-error))
timer (js/setTimeout #(fail (timeout-error timeout-ms)) timeout-ms)]
(.once server "listening" succeed)
(.once server "error" fail)
(when signal
(.addEventListener ^js signal "abort" on-abort #js {:once true}))
(fn []
(js/clearTimeout timer)
(.removeListener server "listening" succeed)
(.removeListener server "error" fail)
(when signal
(.removeEventListener ^js signal "abort" on-abort)))))
(defn- ^:async listen
"Waits until `server` listens on 127.0.0.1 and an ephemeral port.
Aborting `close-signal` closes the server. Rejects as `subscribe-startup`
describes, and when `signal` is already aborted, without listening."
[^js server close-signal signal timeout-ms]
(when (aborted? signal)
(throw (aborted-error)))
;; Nothing awaits before the subscription, so an abort that follows the
;; call reaches it.
(let [^js startup (js/Promise.withResolvers)
release (subscribe-startup server signal timeout-ms
(.-resolve startup) (.-reject startup))]
(try
(.listen server #js {:host "127.0.0.1" :port 0 :signal close-signal})
(await (.-promise startup))
(finally
(release)))))
(defn- ^:async startup-failure
"Closes `host` after a failed startup.
Returns `error`, or an Error that also holds the failure of the close."
[host ^js controller error]
(.abort controller)
(try
(await (close host))
error
(catch :default close-error
(errors/combine
[{:phase "server-startup" :operation :listen :error error}
{:phase "cleanup" :operation :close-server :error close-error}]))))
(defn- base-url
[^js server]
(str "http://127.0.0.1:" (.-port (.address server))))
(defn ^:async open
"Starts serving build's directory on 127.0.0.1 and an ephemeral port.
Resolves to {:server NodeHttpServer :base-url string :build build}. Startup
has a 30000 ms default timeout and accepts an optional AbortSignal. Abort,
timeout and listen errors close the owned server before rejecting."
Args:
build: Map with the directory to serve at `:dir`.
options: Optional map with `:startup-timeout-ms` (30000 by default) and
`:signal`, an AbortSignal.
Returns:
Promise of {:server NodeHttpServer :base-url string :build build}.
Throws:
Rejects on abort, timeout and listen errors, after closing the owned
server. A failed close joins the startup error."
([build] (open build {}))
([build {:keys [startup-timeout-ms signal] :or {startup-timeout-ms settings/default-startup-timeout-ms}}]
(js/Promise.
(fn [resolve reject]
(let [root (fs/realpathSync (:dir build))
^js server (http/createServer #(serve root %1 %2))
sockets (js/Set.)
shutdown (atom nil)
settled (atom false)
controller (js/AbortController.)
timer (atom nil)
host {:server server :build build}]
(.set owners server {:sockets sockets :shutdown shutdown})
(.on server "connection"
(fn [socket]
(.add sockets socket)
(.once ^js socket "close" #(.delete sockets socket))
(when @shutdown
(.destroy ^js socket))))
(letfn [(cleanup []
(js/clearTimeout @timer)
(.removeListener server "error" failed)
(.removeListener server "listening" listening)
(when signal
(.removeEventListener ^js signal "abort" aborted)))
(failed [error]
(when (compare-and-set! settled false true)
(cleanup)
(.abort controller)
(.then (close host)
(fn [_] (reject error))
(fn [close-error]
(reject (errors/combine
[{:phase "server-startup" :operation :listen :error error}
{:phase "cleanup" :operation :close-server :error close-error}]))))))
(aborted []
(failed (ex-info "renderer server startup aborted" {:type ::aborted})))
(listening []
(when (compare-and-set! settled false true)
(cleanup)
(resolve (assoc host :base-url
(str "http://127.0.0.1:" (.-port (.address server)))))))]
(.once server "error" failed)
(.once server "listening" listening)
(when signal
(.addEventListener ^js signal "abort" aborted #js {:once true}))
(if (and signal (.-aborted ^js signal))
(aborted)
(do
(reset! timer
(js/setTimeout
#(failed (ex-info "renderer server startup timed out"
{:type ::startup-timeout
:timeout-ms startup-timeout-ms}))
startup-timeout-ms))
(try
(.listen server #js {:host "127.0.0.1"
:port 0
:signal (.-signal controller)})
(catch :default error
(failed error)))))))))))
([build {:keys [startup-timeout-ms signal]
:or {startup-timeout-ms settings/default-startup-timeout-ms}}]
(let [host (create-host build)
controller (js/AbortController.)]
(try
(await (listen (:server host) (.-signal controller) signal startup-timeout-ms))
(assoc host :base-url (base-url (:server host)))
(catch :default error
(let [failure (await (startup-failure host controller error))]
(throw failure)))))))

View File

@ -199,3 +199,35 @@
(t/is (= 200 (:status (await (request host "/" "GET")))))
(finally
(await (server/close host))))))
(defn- ^:async with-listen
"Calls f while http.Server#listen runs `listen` and returns the promise f returns."
[listen f]
(let [proto (.-prototype http/Server)]
(set! (.-listen proto) listen)
(try
(await (f))
(finally
(js-delete proto "listen")))))
(t/deftest ^:async startup-timeout-rejects-with-the-configured-limit
(let [never-listens (fn [& _] nil)
error (await (with-listen never-listens
#(startup-failure (server/open (build) {:startup-timeout-ms 50}))))]
(t/is (= ::server/startup-timeout (:type (ex-data error))))
(t/is (= 50 (:timeout-ms (ex-data error))))))
(t/deftest ^:async startup-rejects-with-the-listen-failure
(let [thrown (js/Error. "listen threw")
emitted (js/Error. "server emitted error")
throws (fn [& _] (throw thrown))
emits (fn [& _] (this-as ^js self (js/process.nextTick #(.emit self "error" emitted))))]
(doseq [[failure listen] [[thrown throws] [emitted emits]]]
(let [error (await (with-listen listen #(startup-failure (server/open (build)))))]
(t/is (identical? failure error) (ex-message failure))))))
(t/deftest ^:async startup-rejects-when-the-build-directory-is-missing
(let [missing (path/join @temporary-root "missing")
error (await (startup-failure (server/open {:id "missing-build" :dir missing})))]
(t/is (= "ENOENT" (.-code ^js error)))))