♻️ CLI command execution refactor

AI-assisted-by: gpt-6.1-sol, deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-11-02 19:54:51 +01:00
parent 67d99366d9
commit 45527f7460
12 changed files with 573 additions and 91 deletions

View File

@ -16,14 +16,11 @@
[clojure.string :as str]
[clojure.tools.cli :as tools]))
(def ^:private commands
(into {} registry/commands))
(defn- get-definition
[topic]
(if (nil? topic)
{:usage help/usage :description help/description :options []}
(get commands topic)))
(registry/get-definition topic)))
(defn- get-declarations
[definition]
@ -46,7 +43,7 @@
first-arg (first argv)
topic (when (and first-arg (not (str/starts-with? first-arg "-"))) first-arg)
args (if topic (rest argv) argv)]
(when (and topic (not (contains? commands topic)))
(when (and topic (nil? (registry/get-definition topic)))
(throw (argument-error topic (str "unknown command: " topic) nil)))
(let [definition (get-definition topic)]
(try
@ -68,9 +65,7 @@
:options ((:resolve definition)
(dissoc options :help :quiet :verbose :color) arguments)}))
resolved (request/check (request/apply-defaults partial))]
(if (and (not= "help" (:command resolved)) (:check definition))
(update resolved :options (:check definition))
resolved))
(update resolved :options #(registry/check-options (:command resolved) %)))
(catch :default cause
(if (contains? #{::shared/invalid-args ::request/invalid-request
::feature-plan/too-many-features ::feature-plan/confirmation-required}

View File

@ -31,3 +31,10 @@
[options arguments]
(shared/check-arguments arguments 0)
options)
(defn check-options
"Checks measurement feature policy and returns the resolved build options."
[resolved]
(options/feature-selection {:default-features? (:default-features resolved)
:features (:features resolved)})
resolved)

View File

@ -15,19 +15,54 @@
[benches.render-wasm.node.cli.run :as run]))
(def commands
[["build" {:usage build/usage :description build/description
:options build/cli-options :resolve build/resolve-options :schema build/schema:options}]
["run" {:usage run/usage :description run/description
:options run/cli-options :resolve run/resolve-options :schema run/schema:options
:help-options {:build {:kind :prepare}} :help-defaults run/get-help-defaults}]
["ab" {:usage ab/usage :description ab/description
:options ab/cli-options :resolve ab/resolve-options :schema ab/schema:options
:check ab/check-options}]
["compare" {:usage compare/usage :description compare/description
:options compare/cli-options :resolve compare/resolve-options :schema compare/schema:options}]
["merge" {:usage merge/usage :description merge/description
:options merge/cli-options :resolve merge/resolve-options :schema merge/schema:options}]
["purge" {:usage purge/usage :description purge/description
:options purge/cli-options :resolve purge/resolve-options :schema purge/schema:options}]])
[["build" {:usage build/usage
:description build/description
:options build/cli-options
:resolve build/resolve-options
:schema build/schema:options
:check build/check-options}]
["run" {:usage run/usage
:description run/description
:options run/cli-options
:resolve run/resolve-options
:schema run/schema:options
:help-options {:build {:kind :prepare}}
:help-defaults run/get-help-defaults
:check run/check-options}]
["ab" {:usage ab/usage
:description ab/description
:options ab/cli-options
:resolve ab/resolve-options
:schema ab/schema:options
:check ab/check-options}]
["compare" {:usage compare/usage
:description compare/description
:options compare/cli-options
:resolve compare/resolve-options
:schema compare/schema:options}]
["merge" {:usage merge/usage
:description merge/description
:options merge/cli-options
:resolve merge/resolve-options
:schema merge/schema:options}]
["purge" {:usage purge/usage
:description purge/description
:options purge/cli-options
:resolve purge/resolve-options
:schema purge/schema:options}]])
(def command-names (mapv first commands))
(def ^:private definitions (into {} commands))
(defn get-definition
"Returns a command definition, or nil for an unknown command."
[command]
(get definitions command))
(defn check-options
"Checks command policy and returns the resolved options."
[command options]
(if-let [check (:check (get-definition command))]
(check options)
options))

View File

@ -53,6 +53,13 @@ Managed output: frontend/target/performance/render-wasm/runs/<run-id>/run.transi
{:kind :existing :id (:build-id options)}
(assoc preparation :kind :prepare)))))
(defn check-options
"Checks measurement feature policy for the selected build source."
[options]
(when (= :prepare (get-in options [:build :kind]))
(build/check-options (:build options)))
options)
(defn get-help-defaults
"Returns CLI defaults for run options with a preparation source."
[options]

View File

@ -12,47 +12,22 @@
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.node.artifacts :as artifacts]
[benches.render-wasm.node.build :as build]
[benches.render-wasm.node.execution :as execution]
[benches.render-wasm.node.feature-comparison :as feature-comparison]
[benches.render-wasm.node.feature-plan :as feature-plan]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.options :as options]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.process :as process]
[benches.render-wasm.node.run :as run]))
(defn- ^:async with-interruption
"Calls `(f signal)` with an AbortSignal that SIGINT and SIGTERM abort, and
returns its outcome. A failure becomes an outcome with exit code 1. After an
interruption the exit code is 130 or 143 and only fatal failures stay in
the errors: the others come from the aborted work."
[f]
(let [controller (js/AbortController.)
signal (.-signal controller)
interrupted (atom nil)
remove-signals (process/on-signals
(fn [signal-name]
(when-not (.-aborted signal)
(reset! interrupted signal-name)
(.abort controller
(ex-info "interrupted"
{:type ::interrupted :signal signal-name})))))]
(try
(let [result (attempt :catch-fatal (await (f signal)))
finished (if (failure? result) (outcome/failed result {}) result)]
(cond-> finished
@interrupted (-> (update :errors #(filterv errors/fatal? %))
(assoc :exit-code (outcome/interruption-code @interrupted)))))
(finally
(remove-signals)))))
(defn- run-command
[root options]
(with-interruption
(execution/with-interruption
#(run/run root (select-keys options (sm/keys run/schema:run-options)) %)))
(defn- build-command
[root options]
(with-interruption
(execution/with-interruption
(^:async fn [signal]
(await (build/prepare {:root root
:features {:default-features? (:default-features options)
@ -66,7 +41,7 @@
(defn- ab-command
[root options]
(with-interruption
(execution/with-interruption
(^:async fn [signal]
(let [prepared (await (feature-plan/prepare root options signal))]
(if (:dry-run options)

View File

@ -0,0 +1,139 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns benches.render-wasm.node.execution
"Checked command execution, final publication and exit status."
(:require
[app.common.schema :as sm]
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
[benches.render-wasm.node.cli.registry :as registry]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.outcome :as outcome]
[benches.render-wasm.node.request :as request]))
(defn on-signals
"Installs SIGINT/SIGTERM callbacks receiving the signal name. Returns an
idempotent remover. The caller can abort its work and let output drain."
[callback]
(let [interrupt (fn [] (callback "SIGINT"))
terminate (fn [] (callback "SIGTERM"))]
(.on js/process "SIGINT" interrupt)
(.on js/process "SIGTERM" terminate)
(fn []
(.removeListener js/process "SIGINT" interrupt)
(.removeListener js/process "SIGTERM" terminate))))
(defn ^:async with-interruption
"Awaits `(f signal)` and returns its outcome. SIGINT/SIGTERM abort the signal
and select status 130/143. Interrupted outcomes retain fatal errors and partial
evidence. Thrown or rejected failures become outcomes."
[f]
(let [controller (js/AbortController.)
signal (.-signal controller)
interrupted (atom nil)
remove-signals (on-signals
(fn [signal-name]
(when-not (.-aborted signal)
(reset! interrupted signal-name)
(.abort controller
(ex-info "interrupted"
{:type ::interrupted :signal signal-name})))))]
(try
(let [result (attempt :catch-fatal (await (f signal)))
finished (if (failure? result) (outcome/failed result {}) result)]
(cond-> finished
@interrupted (-> (update :errors #(filterv errors/fatal? %))
(assoc :exit-code (outcome/interruption-code @interrupted)))))
(finally
(remove-signals)))))
(defn- configure-console
[console local]
(log/configure (merge (select-keys local [:stdout :stderr]) console)))
(defn- check-gpu
[command options]
(when (and (:require-gpu options)
(or (= command "run")
(and (= command "ab") (not (:dry-run options))))
(not= "1" (.-PENPOT_GPU (.-env js/process))))
(throw (ex-info "--require-gpu needs PENPOT_GPU=1. Start the container with ./manage.sh run-devenv --gpu and use pnpm run render-bench."
{:type ::gpu-required}))))
(defn- log-failures
[finished]
(doseq [[index cause] (map-indexed vector (:errors finished))]
(let [data (ex-data cause)
validation (some-> (::sm/explain data) sm/simplify)]
(log/error {:message (errors/message-of cause)
:fields (into (filterv (comp some? second)
[[:type (:type data)] [:cause-type (:cause-type data)]
[:command (:command data)]
[:phase (:phase data)] [:build (:build-id data)]
[:reason (:reason data)] [:manifest-error (:manifest-error data)]
[:baseline (:baseline data)] [:candidate (:candidate data)]
[:features (:features data)]
[:count (:count data)] [:max-features (:max-features data)]
[:validation validation]])
(map #(vector :difference (:field %)) (:mismatches data)))
:tail-log (:log-path data)
:tail? (zero? index)}))))
(defn- report-fallback
[cause local]
(attempt :catch-fatal
(let [message (errors/message-of cause)
stream (or (:stderr local) (.-stderr js/process))]
(.write ^js stream
(str "Could not report benchmark failure: "
(subs message 0 (min 1024 (count message))) "\n")))))
(defn- finish
[result local]
(binding [log/*log-path* (:log-path result)]
(let [finished (if-some [report (:report result)]
(outcome/publish result #(log/result report))
result)
reported (attempt :catch-fatal (log-failures finished))]
(if (failure? reported)
(do
(report-fallback reported local)
(:exit-code (outcome/add-error finished reported)))
(:exit-code finished)))))
(defn fail
"Prints a failure and returns its numeric status. Optional resolved console
settings and local streams configure its output."
([cause]
(fail cause {} {}))
([cause console local]
(configure-console console local)
(finish (outcome/failed (errors/as-failure cause) {}) local)))
(defn ^:async execute
"Checks a complete received request, awaits its supported handler and returns
a numeric exit status after final publication. Handlers receive local signal,
root access and services plus resolved options. Local settings may supply
streams, a root thunk and services."
([received handlers]
(execute received handlers {}))
([received handlers local]
(configure-console {} local)
(let [result (attempt :catch-fatal
(let [{:keys [command console options]} (request/check received)]
(configure-console console local)
(let [handler (or (get handlers command)
(throw (ex-info "command is unavailable at this execution entry"
{:type ::unsupported-command :command command})))
options (registry/check-options command options)]
(check-gpu command options)
(await (with-interruption
(fn [signal]
(handler (assoc (select-keys local [:get-root :services])
:signal signal)
options)))))))
result (if (failure? result) (outcome/failed result {}) result)]
(finish result local))))

View File

@ -286,15 +286,3 @@
(.destroy ^js stream))
(when-let [stream (.-stderr child)]
(.destroy ^js stream)))))))
(defn on-signals
"Installs SIGINT/SIGTERM callbacks receiving the signal name. Returns an
idempotent remover. The caller can abort its work and let output drain."
[callback]
(let [interrupt (fn [] (callback "SIGINT"))
terminate (fn [] (callback "SIGTERM"))]
(.on js/process "SIGINT" interrupt)
(.on js/process "SIGTERM" terminate)
(fn []
(.removeListener js/process "SIGINT" interrupt)
(.removeListener js/process "SIGTERM" terminate))))

View File

@ -7,10 +7,13 @@
(ns performance-tests.render-wasm.compare-test
(:require
["fs" :as fs]
["node:stream" :as stream]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.node.compare :as compare]
[benches.render-wasm.node.execution :as execution]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.report.compare :as report-compare]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]
@ -191,6 +194,26 @@
(into {} (map (juxt :field :rule))
(get-in comparison [:pairs 0 :mismatches])))))))
(t/deftest comparison-failure-identifies-incompatible-fields
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))
candidate (write-run (path/join @temporary-root "candidate.transit")
(make-run "candidate" :case/load 12
:environment {:node "v25" :browser "Fake"}))
cause (failure #(compare/execute baseline candidate {}))
text (atom "")
stderr (stream/Writable.
#js {:write (fn [chunk _encoding done]
(swap! text str (.toString chunk))
(done))})]
(try
(t/is (some? cause))
(t/is (= 1 (execution/fail cause {:color :never} {:stderr stderr})))
(t/is (str/includes? @text "incompatible saved runs"))
(t/is (str/includes? @text ":environment"))
(finally
(log/configure {})))))
(t/deftest scoped-permissions-keep-operation-and-runtime-checks-strict
(let [baseline (write-run (path/join @temporary-root "baseline.transit")
(make-run "baseline" :case/load 10))]

View File

@ -0,0 +1,331 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns performance-tests.render-wasm.execution-test
(:require
["fs" :as fs]
["node:module" :refer [createRequire]]
["node:stream" :as stream]
["node:timers/promises" :as timers]
["os" :as os]
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.node.cli :as cli]
[benches.render-wasm.node.execution :as execution]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.request :as request]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
(def temporary-root (atom nil))
(defn- within
[promise]
(let [timer (volatile! nil)]
(-> (js/Promise.race
#js [promise
(js/Promise.
(fn [_ reject]
(vreset! timer (js/setTimeout #(reject (js/Error. "Signal test stalled")) 5000))))])
(.finally #(js/clearTimeout @timer)))))
(t/deftest ^:async real-signals-reach-the-callback-and-remover-restores-listeners
(let [before-int (.listenerCount js/process "SIGINT")
before-term (.listenerCount js/process "SIGTERM")
received (atom [])
next-signal (volatile! nil)
remove (signals/on-signals
(fn [signal]
(swap! received conj signal)
(@next-signal signal)))
receive (fn [signal]
(within
(js/Promise.
(fn [resolve _]
(vreset! next-signal resolve)
(.kill js/process (.-pid js/process) signal)))))]
(try
(t/is (= "SIGINT" (await (receive "SIGINT"))))
(t/is (= "SIGTERM" (await (receive "SIGTERM"))))
(t/is (= ["SIGINT" "SIGTERM"] @received))
(finally
(remove)))
(remove)
(t/is (= before-int (.listenerCount js/process "SIGINT")))
(t/is (= before-term (.listenerCount js/process "SIGTERM")))))
(t/use-fixtures :each
{:before #(reset! temporary-root (fs/mkdtempSync (path/join (os/tmpdir) "benchmark-execution-")))
:after (fn []
(log/configure {})
(fs/rmSync @temporary-root #js {:recursive true :force true}))})
(defn- capture
[]
(let [output (atom "")
errors (atom "")
writer (fn [text]
(stream/Writable.
#js {:write (fn [chunk _encoding done]
(swap! text str (.toString chunk))
(done))}))]
{:stdout (writer output) :stderr (writer errors) :output output :errors errors}))
(defn- resolved
[command options]
(request/apply-defaults {:command command :console {:color :never} :options options}))
(defn- listeners
[]
[(.listenerCount js/process "SIGINT") (.listenerCount js/process "SIGTERM")])
(defn- within
[promise]
(let [timer (volatile! nil)]
(-> (js/Promise.race
#js [promise
(js/Promise.
(fn [_ reject]
(vreset! timer (js/setTimeout #(reject (js/Error. "Execution test stalled")) 5000))))])
(.finally #(js/clearTimeout @timer)))))
(t/deftest ^:async sync-and-async-handlers-publish-once-and-release-listeners
(doseq [async [false true]]
(let [local (capture)
before (listeners)
request (cli/parse-resolve ["compare" "--help" "--no-color"])
handler (fn [_ {:keys [topic]}]
(log/info "Preparing help")
{:exit-code 0 :errors [] :log-path nil :report (cli/get-help topic)})
handler (if async
(^:async fn [context options]
(await (timers/setTimeout 1))
(handler context options))
handler)]
(t/is (= 0 (await (execution/execute request {"help" handler}
(assoc local :get-root #(throw (js/Error. "root unavailable")))))))
(t/is (= (str (cli/get-help "compare") "\n") @(:output local)))
(t/is (= "Preparing help\n" @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest ^:async handlers-use-local-root-and-services-with-complete-options
(let [local (capture)
file (path/join @temporary-root "input.txt")]
(fs/writeFileSync file "saved input" "utf8")
(t/is (= 0
(await
(execution/execute
(cli/parse-resolve ["run" "--build" "ready" "--warmups" "0"])
{"run" (fn [{:keys [get-root services]} {:keys [warmups build]}]
{:exit-code 0 :errors [] :log-path nil
:report (str (:id build) ":" warmups ":"
((:read-text services) (path/join (get-root) "input.txt")))})}
(assoc local :get-root (constantly @temporary-root)
:services {:read-text #(fs/readFileSync % "utf8")})))))
(t/is (= "ready:0:saved input\n" @(:output local)))))
(t/deftest ^:async malformed-and-unsupported-requests-fail-before-output-allocation
(let [valid (resolved "run" {:build {:kind :existing :id "ready"}})]
(doseq [[received field handlers]
[[(dissoc valid :console) "console" nil]
[(update valid :options dissoc :warmups) "warmups" nil]
[(assoc (update valid :options dissoc :warmups)
:console {:level :warn :color :always}) "warmups" nil]
[(assoc-in valid [:options :extra] true) "extra" nil]
[(assoc-in valid [:console :color] :invalid) "color" nil]
[(assoc-in valid [:console :stdout] #js {:write #(throw (js/Error. "untrusted stream"))})
"stdout" nil]
[valid "unsupported-command" {}]]]
(let [local (capture)
output (path/join @temporary-root (str (random-uuid)))
before (listeners)
handler (fn [_ _]
(fs/mkdirSync output)
{:exit-code 0 :errors [] :log-path nil})]
(t/is (= 1 (await (execution/execute received (or handlers {"run" handler})
(assoc local :get-root #(fs/mkdirSync output))))))
(t/is (str/includes? @(:errors local) field))
(t/is (not (str/includes? @(:errors local) "\u001b[")))
(t/is (not (fs/existsSync output)))
(t/is (= before (listeners)))))))
(t/deftest ^:async received-command-policies-reject-before-output-allocation
(doseq [[command options message]
[["build" {:features ["stats"]} "diagnostic-features"]
["run" {:build {:kind :prepare :features ["profile"]}} "diagnostic-features"]
["ab" {:features ["profile-raf"] :dry-run true} "diagnostic-features"]
["ab" {:features ["cache"]} "confirmation-required"]
["ab" {:features ["cache"] :filter " "} "confirmation-required"]
["ab" {:features ["a" "b"] :max-features 1 :dry-run true} "too-many-features"]]]
(let [local (capture)
output (path/join @temporary-root (str (random-uuid)))
request (request/check (resolved command options))]
(t/is (= 1 (await (execution/execute
request
{command (fn [_ _]
(fs/mkdirSync output)
{:exit-code 0 :errors [] :log-path nil})}
(assoc local :get-root #(fs/mkdirSync output))))))
(t/is (str/includes? @(:errors local) message))
(t/is (not (fs/existsSync output))))))
(t/deftest ^:async gpu-policy-uses-checked-console-and-dry-run-ab-needs-no-gpu
(let [env (.-env js/process)
old (.-PENPOT_GPU env)]
(try
(js-delete env "PENPOT_GPU")
(doseq [[command options expected]
[["run" {:build {:kind :existing :id "ready"} :require-gpu true} 1]
["ab" {:features ["cache"] :confirm true :require-gpu true} 1]
["ab" {:features ["cache"] :dry-run true :require-gpu true} 0]]]
(let [local (capture)
request (assoc (resolved command options) :console {:level :warn :color :always})]
(t/is (= expected
(await (execution/execute
request {command (fn [_ _] {:exit-code 0 :errors [] :log-path nil
:report "dry plan"})} local))))
(if (zero? expected)
(t/is (= "dry plan\n" @(:output local)))
(do
(t/is (str/includes? @(:errors local) "PENPOT_GPU=1"))
(t/is (str/includes? @(:errors local) "\u001b["))))))
(finally
(if (nil? old) (js-delete env "PENPOT_GPU") (set! (.-PENPOT_GPU env) old))))))
(t/deftest ^:async thrown-and-rejected-failures-use-checked-console-and-release-listeners
(doseq [handler [(fn [_ _] (throw (js/Error. "failed work")))
(^:async fn [_ _]
(await (timers/setTimeout 1))
(throw (js/Error. "failed work")))
(^:async fn [_ _]
(await (timers/setTimeout 1))
(throw (errors/fatal-error "failed work" {} (js/Error. "storage"))))]]
(let [local (capture)
before (listeners)
request (assoc (resolved "help" {:topic nil}) :console {:level :warn :color :never})]
(t/is (= 1 (await (execution/execute
request {"help" (fn [context options]
(log/info "hidden progress")
(handler context options))} local))))
(t/is (= "✖ failed work\n" @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest ^:async final-publication-uses-the-outcomes-log-and-numeric-status
(let [local (capture)
file (path/join @temporary-root "command.log")]
(t/is (= 7 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 7 :errors [] :log-path file :report "saved report"})}
local))))
(t/is (= "saved report\n" (fs/readFileSync file "utf8")))
(t/is (= "saved report\n" @(:output local)))
(let [silent (capture)]
(t/is (= 0 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path nil})} silent))))
(t/is (= "" @(:output silent))))))
(t/deftest ^:async final-errors-share-the-command-log-and-print-one-tail
(let [local (capture)
file (path/join @temporary-root "failures.log")]
(fs/writeFileSync file "saved evidence\n" "utf8")
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _]
{:exit-code 1 :log-path file
:errors [(js/Error. "first failure") (js/Error. "second failure")]})}
local))))
(t/is (= 2 (count (re-seq #"✖" @(:errors local)))))
(t/is (= 1 (count (re-seq #" tail\n" @(:errors local)))))
(t/is (str/includes? @(:errors local) "saved evidence"))
(t/is (str/includes? (fs/readFileSync file "utf8") "second failure"))))
(t/deftest ^:async publication-failure-fails-the-command-and-preserves-interruption-status
(doseq [[code expected] [[0 1] [130 130] [143 143]]]
(let [local (capture)
written (atom [])
broken #js {:write (fn [text]
(swap! written conj text)
(throw (js/Error. "stdout closed")))}]
(t/is (= expected (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code code :errors [] :log-path nil :report "result"})}
(assoc local :stdout broken)))))
(t/is (= ["result\n"] @written))
(t/is (= "✖ stdout closed\n" @(:errors local)))))
(let [local (capture)]
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path @temporary-root :report "result"})}
local))))
(t/is (str/includes? @(:errors local) "cannot append benchmark log"))
(t/is (str/includes? @(:errors local) "Could not append log"))))
(t/deftest ^:async real-signals-retain-partial-evidence-and-fatal-errors
(doseq [[first-signal second-signal code] [["SIGINT" "SIGTERM" 130] ["SIGTERM" "SIGINT" 143]]]
(let [local (capture)
before (listeners)
file (path/join @temporary-root first-signal)
handler (fn [{:keys [signal]} _]
(js/Promise.
(fn [resolve reject]
(.addEventListener
signal "abort"
(fn []
(-> (timers/setTimeout 1)
(.then (fn []
(t/is (.emit js/process second-signal))
(fs/writeFileSync file "partial evidence" "utf8")
(resolve {:exit-code 1 :log-path nil :report "partial report"
:errors [(js/Error. "aborted work")
(errors/fatal-error "fatal storage" {} (js/Error. "disk"))]})))
(.catch reject)))
#js {:once true})
(.kill js/process (.-pid js/process) first-signal))))]
(t/is (= code (await (within (execution/execute (resolved "help" {:topic nil})
{"help" handler} local)))))
(t/is (= "partial evidence" (fs/readFileSync file "utf8")))
(t/is (= "partial report\n" @(:output local)))
(t/is (= "✖ fatal storage\n" @(:errors local)))
(t/is (= before (listeners))))))
(t/deftest failure-adapters-preserve-usage-validation-and-decoder-diagnostics
(doseq [[get-cause message]
[[#(cli/parse-resolve ["purge"]) "Usage: runner purge"]
[#(request/check (update (resolved "run" {:build {:kind :existing :id "ready"}})
:options dissoc :warmups)) "warmups"]
[#(transit/decode-str "invalid transit") nil]
[#((createRequire (path/join @temporary-root "entry.cjs"))
(path/join @temporary-root "missing.cjs")) "missing.cjs"]]]
(let [local (capture)
cause (try
(get-cause)
(t/is false "expected a boundary failure")
(catch :default cause cause))]
(t/is (= 1 (execution/fail cause {} local)))
(t/is (str/includes? @(:errors local) (or message (errors/message-of cause))))
(t/is (= 1 (count (re-seq #"✖" @(:errors local))))))))
(t/deftest ^:async error-reporting-failure-uses-a-bounded-fallback
(doseq [[code expected] [[1 1] [130 130]]]
(let [local (capture)
fallback (atom [])
broken #js {:write (fn [text]
(if (str/starts-with? text "Could not report")
(swap! fallback conj text)
(throw (js/Error. (apply str (repeat 2000 "x"))))))}]
(t/is (= expected (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code code :log-path nil :errors [(js/Error. "work failed")]})}
(assoc local :stderr broken)))))
(t/is (= 1 (count @fallback)))
(t/is (< (count (first @fallback)) 1100))))
(let [broken #js {:write #(throw (js/Error. "closed"))}]
(t/is (= 1 (await (execution/execute
(resolved "help" {:topic nil})
{"help" (fn [_ _] {:exit-code 0 :errors [] :log-path nil :report "result"})}
{:stdout broken :stderr broken}))))))

View File

@ -15,6 +15,7 @@
[benches.render-wasm.errors :as errors]
[benches.render-wasm.feature-plan :as feature-plan]
[benches.render-wasm.node.build :as build]
[benches.render-wasm.node.execution :as execution]
[benches.render-wasm.node.feature-comparison :as feature-comparison]
[benches.render-wasm.node.feature-plan :as node-plan]
[benches.render-wasm.node.files :as files]
@ -1102,7 +1103,7 @@
(doseq [[signal exit-code] [["SIGINT" 130] ["SIGTERM" 143]]]
(let [prepared (comparison-prepared ["a"] {:output-dir (path/join @temporary-root signal)})
controller (js/AbortController.)
remove (process/on-signals #(.abort controller %))
remove (execution/on-signals #(.abort controller %))
execute (execute-service
(fn [build]
(when (seq (get-in build [:manifest :functional :features]))

View File

@ -452,28 +452,3 @@
(finally
(.abort controller "test-cleanup")
(await held)))))
(t/deftest ^:async real-signals-reach-the-callback-and-remover-restores-listeners
(let [before-int (.listenerCount js/process "SIGINT")
before-term (.listenerCount js/process "SIGTERM")
received (atom [])
next-signal (volatile! nil)
remove (process/on-signals
(fn [signal]
(swap! received conj signal)
(@next-signal signal)))
receive (fn [signal]
(within
(js/Promise.
(fn [resolve _]
(vreset! next-signal resolve)
(.kill js/process (.-pid js/process) signal)))))]
(try
(t/is (= "SIGINT" (await (receive "SIGINT"))))
(t/is (= "SIGTERM" (await (receive "SIGTERM"))))
(t/is (= ["SIGINT" "SIGTERM"] @received))
(finally
(remove)))
(remove)
(t/is (= before-int (.listenerCount js/process "SIGINT")))
(t/is (= before-term (.listenerCount js/process "SIGTERM")))))

View File

@ -16,10 +16,12 @@
[performance-tests.render-wasm.build-test]
[performance-tests.render-wasm.builder-test]
[performance-tests.render-wasm.cli-test]
[performance-tests.render-wasm.commands-test]
[performance-tests.render-wasm.compare-test]
[performance-tests.render-wasm.containers-test]
[performance-tests.render-wasm.contracts-test]
[performance-tests.render-wasm.errors-test]
[performance-tests.render-wasm.execution-test]
[performance-tests.render-wasm.feature-comparison-test]
[performance-tests.render-wasm.fingerprint-test]
[performance-tests.render-wasm.geometry-scenes-test]
@ -35,7 +37,8 @@
[performance-tests.render-wasm.runner-test]
[performance-tests.render-wasm.runs-test]
[performance-tests.render-wasm.scene-snapshot-test]
[performance-tests.render-wasm.server-test]))
[performance-tests.render-wasm.server-test]
[performance-tests.render-wasm.signals-test]))
(enable-console-print!)
@ -55,10 +58,12 @@
'performance-tests.render-wasm.build-test
'performance-tests.render-wasm.builder-test
'performance-tests.render-wasm.cli-test
'performance-tests.render-wasm.commands-test
'performance-tests.render-wasm.compare-test
'performance-tests.render-wasm.containers-test
'performance-tests.render-wasm.contracts-test
'performance-tests.render-wasm.errors-test
'performance-tests.render-wasm.execution-test
'performance-tests.render-wasm.feature-comparison-test
'performance-tests.render-wasm.fingerprint-test
'performance-tests.render-wasm.geometry-scenes-test
@ -74,7 +79,8 @@
'performance-tests.render-wasm.runner-test
'performance-tests.render-wasm.runs-test
'performance-tests.render-wasm.scene-snapshot-test
'performance-tests.render-wasm.server-test])
'performance-tests.render-wasm.server-test
'performance-tests.render-wasm.signals-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")