diff --git a/frontend/performance/benches/render_wasm/node/runner/server.cljs b/frontend/performance/benches/render_wasm/node/runner/server.cljs index 151998fe67..24457ebb36 100644 --- a/frontend/performance/benches/render_wasm/node/runner/server.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/server.cljs @@ -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))))))) diff --git a/frontend/test/performance_tests/render_wasm/server_test.cljs b/frontend/test/performance_tests/render_wasm/server_test.cljs index 31b9687d85..8dcb7c8d79 100644 --- a/frontend/test/performance_tests/render_wasm/server_test.cljs +++ b/frontend/test/performance_tests/render_wasm/server_test.cljs @@ -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)))))