mirror of
https://github.com/penpot/penpot.git
synced 2026-11-11 20:40:34 +00:00
✨ Collect full benchmark error traces
Keeps track of all errors in a vector. AI-assisted-by: gpt-6.1-sol, deepseek-flash
This commit is contained in:
parent
4ff55ead33
commit
e6fcbe5969
@ -739,22 +739,23 @@
|
||||
(h/call wasm/internal-module "_init_shapes_pool"
|
||||
(count (:objects (:snapshot graphics))))
|
||||
(h/call wasm/internal-module "_begin_loading")
|
||||
(try
|
||||
;; Current procedural scenes upload without layout data.
|
||||
(serialize-shape/serialize-shapes-batch
|
||||
(:ordered graphics)
|
||||
{:include-layout? false
|
||||
:include-fills-strokes? true})
|
||||
(finally
|
||||
(h/call wasm/internal-module "_end_loading")))
|
||||
(let [uploaded (attempt :catch-fatal
|
||||
(serialize-shape/serialize-shapes-batch
|
||||
(:ordered graphics)
|
||||
{:include-layout? false
|
||||
:include-fills-strokes? true})
|
||||
nil)
|
||||
ended (attempt :catch-fatal (h/call wasm/internal-module "_end_loading") nil)]
|
||||
(when (or uploaded ended)
|
||||
(throw (errors/combine
|
||||
[{:phase "upload" :operation :serialize-shapes :error uploaded}
|
||||
{:phase "upload" :operation :end-loading :error ended}]))))
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(assoc graphics :upload-ms (- (now) u0) :view view))
|
||||
(catch :default cause
|
||||
(if (errors/stale? cause)
|
||||
(throw cause)
|
||||
(throw (ex-info "upload failed"
|
||||
{:phase "upload"}
|
||||
cause))))))
|
||||
(throw (errors/with-context cause "upload failed" {:phase "upload"}))))))
|
||||
|
||||
(defn- preparation-data
|
||||
"Projects completed setup into preparation evidence.
|
||||
|
||||
@ -60,11 +60,11 @@
|
||||
[:type {:optional true} schema:cause-value]
|
||||
[:hint {:optional true} schema:cause-value]])
|
||||
|
||||
(def schema:diagnostic
|
||||
"Failure location and message with optional cause and partial evidence."
|
||||
[:map
|
||||
[:phase string?]
|
||||
(def ^:private diagnostic-fields
|
||||
[[:phase string?]
|
||||
[:message string?]
|
||||
[:operation {:optional true} keyword?]
|
||||
[:path {:optional true} [:string {:max 1024}]]
|
||||
[:status {:optional true} [:enum "failed" "stale"]]
|
||||
[:cause {:optional true} [:vector schema:cause-detail]]
|
||||
[:partial {:optional true} map?]
|
||||
@ -77,7 +77,16 @@
|
||||
[:signal {:optional true} [:string {:max 1024}]]
|
||||
[:endpoint {:optional true} [:string {:max 1024}]]])
|
||||
|
||||
(def ^:private fatal-cause-depth 8)
|
||||
(def schema:error-entry
|
||||
"One failure occurrence with its operation and portable diagnostics."
|
||||
(into [:map {:closed true}] diagnostic-fields))
|
||||
|
||||
(def schema:diagnostic
|
||||
"Failure location, message and an optional ordered collection of errors."
|
||||
(into [:map]
|
||||
(conj diagnostic-fields
|
||||
[:errors {:optional true} [:vector {:min 1} schema:error-entry]])))
|
||||
|
||||
(def ^:private evidence-cause-depth 3)
|
||||
(def ^:private max-cause-chars 500)
|
||||
(def ^:private unrenderable-value "unrenderable value")
|
||||
@ -107,12 +116,12 @@
|
||||
"True when `cause` or one of its causes has the ex-data `:fatal` marker."
|
||||
[cause]
|
||||
(loop [cause cause
|
||||
depth 0]
|
||||
seen #{}]
|
||||
(cond
|
||||
(nil? cause) false
|
||||
(:fatal (ex-data cause)) true
|
||||
(>= depth fatal-cause-depth) false
|
||||
:else (recur (ex-cause cause) (inc depth)))))
|
||||
(contains? seen cause) false
|
||||
:else (recur (ex-cause cause) (conj seen cause)))))
|
||||
|
||||
(defn as-failure
|
||||
"Returns an Error for `thrown`. Errors pass through; other values supply
|
||||
@ -145,6 +154,68 @@
|
||||
(assoc :partial (merge (:partial data) (:partial existing))))]
|
||||
(ex-info message context (if (some? existing) (ex-cause cause) cause))))
|
||||
|
||||
(defn- merge-error-context
|
||||
[outer inner]
|
||||
(cond-> (merge outer inner)
|
||||
(or (:partial outer) (:partial inner))
|
||||
(assoc :partial (merge (:partial outer) (:partial inner)))))
|
||||
|
||||
(defn- get-error-collection
|
||||
[cause]
|
||||
(loop [current cause
|
||||
seen #{}
|
||||
context {}]
|
||||
(let [data (ex-data current)
|
||||
context (merge-error-context context (dissoc data ::errors))]
|
||||
(if-let [entries (::errors data)]
|
||||
(update entries 0 #(merge-error-context context %))
|
||||
(when (and (ex-cause current) (not (contains? seen current)))
|
||||
(recur (ex-cause current) (conj seen current) context))))))
|
||||
|
||||
(defn get-errors
|
||||
"Returns failure occurrences in order, retaining their Error objects.
|
||||
|
||||
Args:
|
||||
cause: An Error, including one wrapped with additional context.
|
||||
|
||||
Returns:
|
||||
A flat vector of maps with an Error under :error and available phase,
|
||||
operation and path context."
|
||||
[cause]
|
||||
(or (get-error-collection cause)
|
||||
[(assoc (select-keys (ex-data cause) [:phase :operation :path]) :error cause)]))
|
||||
|
||||
(defn combine
|
||||
"Combines failure occurrences into an Error, or returns nil on success.
|
||||
|
||||
Args:
|
||||
entries: Ordered maps with :error and optional :phase, :operation and
|
||||
:path. Nil errors represent successful operations. An Error may
|
||||
contain a collection from an earlier operation.
|
||||
|
||||
Returns:
|
||||
An Error retaining a flat collection and the first error's message
|
||||
and data. Retained Error objects expose their causes. Any fatal
|
||||
occurrence makes the combined error fatal."
|
||||
[entries]
|
||||
(let [sources (filterv #(failure? (:error %)) entries)
|
||||
error (:error (first sources))
|
||||
entries (reduce (fn [collected {:keys [error] :as entry}]
|
||||
(let [context (dissoc entry :error)
|
||||
group (get-errors error)
|
||||
group (if (seq context)
|
||||
(mapv #(merge-error-context context %) group)
|
||||
group)]
|
||||
(if (empty? collected) group (into collected group))))
|
||||
[] sources)]
|
||||
(when error
|
||||
(ex-info (message-of error)
|
||||
(cond-> (merge (select-keys (first entries) [:phase :operation :path])
|
||||
(ex-data error)
|
||||
{::errors entries})
|
||||
(some #(fatal? (:error %)) sources) (assoc :fatal true))
|
||||
(if (::errors (ex-data error)) (ex-cause error) error)))))
|
||||
|
||||
(defn stale?
|
||||
"True when the exception's own data marks superseded work."
|
||||
[cause]
|
||||
@ -212,8 +283,10 @@
|
||||
Returns:
|
||||
Failure status, location, message and available diagnostic details."
|
||||
[evidence]
|
||||
(merge (select-keys evidence [:status :phase :message :cause :partial :reason :deadline-exceeded])
|
||||
(project-context evidence)))
|
||||
(cond-> (merge (select-keys evidence [:status :phase :message :cause :partial :reason :deadline-exceeded
|
||||
:operation :errors])
|
||||
(project-context evidence))
|
||||
(string? (:path evidence)) (assoc :path (bounded-text (:path evidence) 1024))))
|
||||
|
||||
(defn- sanitize-cause-value
|
||||
[value]
|
||||
@ -266,13 +339,39 @@
|
||||
(recur next (inc depth) (conj acc level))))))
|
||||
[{:message unrenderable-cause-text}]))
|
||||
|
||||
(defn evidence
|
||||
"Returns plain diagnostics for `phase`, including available partial
|
||||
measurements and a bounded description of the cause."
|
||||
(defn- diagnostic-data
|
||||
[phase cause]
|
||||
(let [partial (ex/ignoring (:partial (ex-data cause)))]
|
||||
(cond-> (merge (project-context (ex-data cause))
|
||||
(select-keys (ex-data cause) [:operation])
|
||||
{:phase (if (keyword? phase) (name phase) (str phase))
|
||||
:message (message-of cause)
|
||||
:cause (describe-cause cause)})
|
||||
partial (assoc :partial partial))))))
|
||||
partial (assoc :partial partial)
|
||||
(string? (:path (ex-data cause)))
|
||||
(assoc :path (bounded-text (:path (ex-data cause)) 1024)))))
|
||||
|
||||
(defn evidence
|
||||
"Returns plain diagnostics, including ordered failure occurrences.
|
||||
|
||||
Args:
|
||||
phase: Failure location for the enclosing operation.
|
||||
cause: Error with available operation context and collected failures.
|
||||
|
||||
Returns:
|
||||
Portable diagnostics with bounded cause descriptions. Collected
|
||||
errors retain occurrence order and messages within 1024 UTF-16 units."
|
||||
[phase cause]
|
||||
(let [entries (get-error-collection cause)]
|
||||
(cond-> (diagnostic-data phase cause)
|
||||
entries
|
||||
(assoc :errors
|
||||
(mapv (fn [{:keys [error operation path partial] entry-phase :phase :as entry}]
|
||||
(cond-> (merge (diagnostic-data (or entry-phase phase) error)
|
||||
(project-context entry)
|
||||
(select-keys entry [:reason :deadline-exceeded])
|
||||
{:message (bounded-text (message-of error) 1024)})
|
||||
operation (assoc :operation operation)
|
||||
partial (assoc :partial partial)
|
||||
(string? path) (assoc :path (bounded-text path 1024))))
|
||||
entries)))))))
|
||||
|
||||
@ -23,7 +23,8 @@
|
||||
[benches.render-wasm.node.cli.registry :as registry]
|
||||
[benches.render-wasm.node.cli.request :as request]
|
||||
[benches.render-wasm.node.log :as log]
|
||||
[benches.render-wasm.node.outcome :as outcome]))
|
||||
[benches.render-wasm.node.outcome :as outcome]
|
||||
[benches.render-wasm.report.format :as format]))
|
||||
|
||||
(defn on-signals
|
||||
"Listen for process interrupts so the caller can stop work and finish cleanup.
|
||||
@ -101,18 +102,29 @@
|
||||
(defn- log-failures
|
||||
[finished]
|
||||
(doseq [[index cause] (map-indexed vector (:errors finished))]
|
||||
(let [data (ex-data cause)
|
||||
context (mapv (fn [[key value]]
|
||||
[key (if (string? value)
|
||||
(errors/bounded-text (js/JSON.stringify value) 1024)
|
||||
value)])
|
||||
(errors/project-context data))
|
||||
path (when-let [path (:path data)]
|
||||
(errors/bounded-text (if (string? path)
|
||||
(js/JSON.stringify path)
|
||||
(pr-str path))
|
||||
1024))
|
||||
validation (some-> (::sm/explain data) sm/simplify)]
|
||||
(let [data (ex-data cause)
|
||||
context (mapv (fn [[key value]]
|
||||
[key (if (string? value)
|
||||
(errors/bounded-text (js/JSON.stringify value) 1024)
|
||||
value)])
|
||||
(errors/project-context data))
|
||||
path (when-let [path (:path data)]
|
||||
(errors/bounded-text (if (string? path)
|
||||
(js/JSON.stringify path)
|
||||
(pr-str path))
|
||||
1024))
|
||||
validation (some-> (::sm/explain data) sm/simplify)
|
||||
entries (:errors (errors/evidence (or (:phase data) :execution) cause))
|
||||
alias? (and (seq entries)
|
||||
(= (errors/bounded-text (errors/message-of cause) 1024) (:message (first entries))))
|
||||
entry-context (when (and alias?
|
||||
(not= (select-keys data [:phase :operation :path])
|
||||
(select-keys (first entries) [:phase :operation :path])))
|
||||
(format/format-error-entry
|
||||
(select-keys (first entries) [:phase :operation :path])))
|
||||
entries (if alias?
|
||||
(rest entries)
|
||||
entries)]
|
||||
(log/error {:message (errors/message-of cause)
|
||||
:fields (into (filterv (comp some? second)
|
||||
[[:type (:type data)] [:cause-type (:cause-type data)]
|
||||
@ -125,7 +137,11 @@
|
||||
[:count (:count data)] [:max-features (:max-features data)]
|
||||
[:validation validation]])
|
||||
(concat context
|
||||
(map #(vector :difference (:field %)) (:mismatches data))))
|
||||
(map #(vector :difference (:field %)) (:mismatches data))
|
||||
(when entry-context [[:error-context entry-context]])
|
||||
(map #(vector :error (format/format-error-entry %)) (take 8 entries))
|
||||
(when (> (count entries) 8)
|
||||
[[:errors-omitted (- (count entries) 8)]])))
|
||||
:tail-log (:log-path data)
|
||||
:tail? (zero? index)}))))
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
(:require
|
||||
["fs" :as fs]
|
||||
["path" :as path]
|
||||
[benches.render-wasm.errors :as errors :refer-macros [attempt]]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn contained?
|
||||
@ -32,12 +33,19 @@
|
||||
so readers see either the previous complete file or the new one. The parent
|
||||
directory must exist.
|
||||
|
||||
A failed write or rename leaves the previous file untouched and removes the
|
||||
temporary file."
|
||||
A failed write or rename leaves the previous file untouched and attempts to
|
||||
remove the temporary file. Failures retain operation context in order."
|
||||
[file content]
|
||||
(let [temporary (str file "." (random-uuid) ".tmp")]
|
||||
(try
|
||||
(fs/writeFileSync temporary content #js {:encoding "utf8" :flag "wx"})
|
||||
(fs/renameSync temporary file)
|
||||
(finally
|
||||
(fs/rmSync temporary #js {:force true})))))
|
||||
(let [temporary (str file "." (random-uuid) ".tmp")
|
||||
written (attempt :catch-fatal
|
||||
(fs/writeFileSync temporary content #js {:encoding "utf8" :flag "wx"})
|
||||
nil)
|
||||
renamed (when-not written
|
||||
(attempt :catch-fatal (fs/renameSync temporary file) nil))
|
||||
removed (when (or written renamed)
|
||||
(attempt :catch-fatal (fs/rmSync temporary #js {:force true}) nil))]
|
||||
(when-let [failure (errors/combine
|
||||
[{:phase "storage" :operation :write-temporary :path file :error written}
|
||||
{:phase "storage" :operation :rename-temporary :path file :error renamed}
|
||||
{:phase "storage" :operation :remove-temporary :path temporary :error removed}])]
|
||||
(throw failure))))
|
||||
|
||||
@ -140,9 +140,8 @@
|
||||
[{:keys [controller process]}]
|
||||
(.abort controller)
|
||||
(try
|
||||
(let [result (await process)]
|
||||
(throw (ex-info "Chromium exited before owned shutdown"
|
||||
{:type ::process-exit :exit-code (:exit-code result)})))
|
||||
(await process)
|
||||
nil
|
||||
(catch :default cause
|
||||
(when-not (= :process-cancelled (:type (ex-data cause)))
|
||||
(throw cause)))))
|
||||
@ -180,27 +179,34 @@
|
||||
(await (within #(.close ^js context)
|
||||
{:deadline deadline :phase :browser-context-close})))
|
||||
nil)]
|
||||
(when-let [cause (or page-failure context-failure)]
|
||||
(when-let [cause (errors/combine
|
||||
[{:phase "cleanup" :operation :close-page :error page-failure}
|
||||
{:phase "cleanup" :operation :close-context :error context-failure}])]
|
||||
(throw cause)))))
|
||||
|
||||
(defn- ^:async shutdown-owner
|
||||
"Closes page/context best effort, then kills the owned process group. A page
|
||||
close failure does not mask process/disconnect/profile failures because the
|
||||
group kill releases the same resources."
|
||||
"Disposes the owned process group, CDP connection and profile.
|
||||
Successful process shutdown releases page/context resources. Other shutdown
|
||||
failures retain the available page/context failure evidence."
|
||||
[{:keys [user-data-dir] :as owner}]
|
||||
(ex/ignoring (await (close-page owner)))
|
||||
(let [process-failure (attempt :catch-fatal (await (stop-process owner)) nil)
|
||||
(let [page-failure (attempt :catch-fatal (await (close-page owner)) nil)
|
||||
process-failure (attempt :catch-fatal (await (stop-process owner)) nil)
|
||||
disconnect-failure (attempt :catch-fatal (await (disconnect owner)) nil)
|
||||
profile-failure (attempt :catch-fatal
|
||||
(fs/rmSync user-data-dir #js {:recursive true :force true})
|
||||
nil)]
|
||||
(when-let [cause (or process-failure disconnect-failure profile-failure)]
|
||||
(throw cause))))
|
||||
(when (or process-failure disconnect-failure profile-failure)
|
||||
(throw (errors/combine
|
||||
[{:phase "cleanup" :operation :close-page :error page-failure}
|
||||
{:phase "cleanup" :operation :stop-process :error process-failure}
|
||||
{:phase "cleanup" :operation :disconnect-browser :error disconnect-failure}
|
||||
{:phase "cleanup" :operation :remove-profile :path user-data-dir :error profile-failure}])))))
|
||||
|
||||
(defn close
|
||||
"Returns one shutdown Promise for this owner. Aborting the internal process
|
||||
lifecycle invokes process/run's SIGTERM/5s/SIGKILL group cleanup, even during
|
||||
startup or blocked page JS. Only process cancellation is normal shutdown."
|
||||
startup or blocked page JS. A settled process exit or cancellation releases
|
||||
the owned process resources."
|
||||
[{:keys [shutdown signal abort-listener] :as owner}]
|
||||
(or @shutdown
|
||||
(do
|
||||
@ -268,20 +274,31 @@
|
||||
bounds))]
|
||||
(assoc owner :browser browser :version (.version browser) :endpoint address))
|
||||
(catch :default cause
|
||||
(let [cause (deadline-failure cause deadline)]
|
||||
(try
|
||||
(await (close owner))
|
||||
(catch :default cleanup-cause
|
||||
(throw (ex-info "browser shutdown failed after startup error"
|
||||
(merge (errors/project-context (ex-data cause))
|
||||
(errors/project-context (ex-data cleanup-cause))
|
||||
{:type ::startup-cleanup
|
||||
:endpoint @(:address endpoint) :user-data-dir user-data-dir
|
||||
:startup-failure (errors/describe-cause cause)})
|
||||
cleanup-cause))))
|
||||
(throw (ex-info (errors/message-of cause)
|
||||
(assoc (ex-data cause) :endpoint @(:address endpoint) :user-data-dir user-data-dir)
|
||||
cause))))))))
|
||||
(let [observed cause
|
||||
cause (deadline-failure cause deadline)
|
||||
cleanup-cause (attempt :catch-fatal (await (close owner)) nil)
|
||||
cleanup (when (errors/failure? cleanup-cause)
|
||||
(let [reported (loop [current observed seen #{}]
|
||||
(if (or (nil? current) (contains? seen current))
|
||||
(into seen (map :error (errors/get-errors observed)))
|
||||
(recur (ex-cause current) (conj seen current))))]
|
||||
(errors/combine
|
||||
(remove #(and (= :stop-process (:operation %))
|
||||
(contains? reported (:error %)))
|
||||
(errors/get-errors cleanup-cause)))))]
|
||||
(if cleanup
|
||||
(throw (errors/with-context
|
||||
(errors/combine [{:phase "browser-startup" :operation :start-browser :error cause}
|
||||
{:phase "cleanup" :operation :close-browser :error cleanup}])
|
||||
"browser shutdown failed after startup error"
|
||||
(merge (errors/project-context (ex-data cause))
|
||||
(errors/project-context (ex-data cleanup-cause))
|
||||
{:type ::startup-cleanup
|
||||
:endpoint @(:address endpoint) :user-data-dir user-data-dir
|
||||
:startup-failure (errors/describe-cause cause)})))
|
||||
(throw (ex-info (errors/message-of cause)
|
||||
(assoc (ex-data cause) :endpoint @(:address endpoint) :user-data-dir user-data-dir)
|
||||
cause)))))))))
|
||||
|
||||
(defn ^:async new-page
|
||||
"Creates a context and page in a browser process we own, navigates to host,
|
||||
|
||||
@ -209,12 +209,15 @@
|
||||
|
||||
(defn- finish
|
||||
[state status exit-code causes]
|
||||
(let [record (persist state #(assoc % :status status :ended-at (time/now)))]
|
||||
{:comparison record
|
||||
(let [saved (attempt :catch-fatal (persist state #(assoc % :status status :ended-at (time/now))))
|
||||
failure (when (failure? saved)
|
||||
(errors/combine (conj (mapv #(hash-map :error %) causes)
|
||||
{:phase "comparison-storage" :operation :finish-comparison :error saved})))]
|
||||
{:comparison (if failure @(:saved-ref state) saved)
|
||||
:home (:home state)
|
||||
:log-path (get-in state [:home :log-path])
|
||||
:errors (vec (remove nil? causes))
|
||||
:exit-code exit-code}))
|
||||
:errors (if failure [failure] (vec (remove nil? causes)))
|
||||
:exit-code (if (and failure (not (contains? #{130 143} exit-code))) 1 exit-code)}))
|
||||
|
||||
(defn- finish-root
|
||||
[state error]
|
||||
@ -225,12 +228,25 @@
|
||||
(finish state :completed 0 [error])
|
||||
(finish state :failed 1 [error]))))
|
||||
|
||||
(defn- failure-outcome
|
||||
[state signal cause]
|
||||
(assoc (outcome/failed cause {:comparison @(:saved-ref state)
|
||||
:home (:home state)
|
||||
:log-path (get-in state [:home :log-path])})
|
||||
:exit-code (if signal (outcome/interruption-code signal) 1)))
|
||||
|
||||
(defn- interrupt-comparison
|
||||
[state signal error]
|
||||
(let [reason (str "interrupted by " signal)]
|
||||
(mark-configs-unattempted state (:configurations @(:record-ref state)) reason)
|
||||
(mark-edges-unattempted state reason)
|
||||
(finish state :interrupted (outcome/interruption-code signal) [error])))
|
||||
(let [reason (str "interrupted by " signal)
|
||||
recorded (attempt :catch-fatal
|
||||
(mark-configs-unattempted state (:configurations @(:record-ref state)) reason)
|
||||
(mark-edges-unattempted state reason)
|
||||
nil)]
|
||||
(if (failure? recorded)
|
||||
(failure-outcome state signal
|
||||
(errors/combine [{:error error}
|
||||
{:phase "comparison-storage" :operation :record-interruption :error recorded}]))
|
||||
(finish state :interrupted (outcome/interruption-code signal) [error]))))
|
||||
|
||||
(defn- record-created-build
|
||||
[state slug build]
|
||||
@ -442,7 +458,11 @@
|
||||
(let [slug (get-in flow [:configuration :slug])
|
||||
facts (get-run-evidence slug (:run-home flow) response)
|
||||
evidence (classify-run (process/signal-name (:signal state)) facts)]
|
||||
(record-run-result state slug evidence)
|
||||
(try
|
||||
(record-run-result state slug evidence)
|
||||
(catch :default cause
|
||||
(throw (errors/combine [{:error (:error evidence)}
|
||||
{:phase "comparison-storage" :operation :record-run-result :error cause}]))))
|
||||
(config-run-outcome slug evidence)))
|
||||
|
||||
(defn- get-missing-run-cause
|
||||
@ -549,42 +569,55 @@
|
||||
"Records the result of analyzing an edge.
|
||||
|
||||
Returns:
|
||||
The first error seen so far.
|
||||
An Error retaining accumulated analysis failures, or nil on success.
|
||||
|
||||
Throws:
|
||||
A fatal failure after the edge and the remaining edges are recorded."
|
||||
Fatal analysis or persistence failures with available earlier failures."
|
||||
[state edge result error]
|
||||
(cond
|
||||
(and (failure? result) (errors/fatal? result))
|
||||
(do
|
||||
(mark-edge state edge :failed {:failure (errors/evidence :comparison-storage result)})
|
||||
(mark-edges-unattempted state "not attempted after comparison storage failure")
|
||||
(throw result))
|
||||
(let [fatal? (and (failure? result) (errors/fatal? result))
|
||||
analyzed (if (failure? result)
|
||||
(errors/combine [{:error error}
|
||||
{:phase "comparison" :operation :analyze-edge :error result}])
|
||||
error)
|
||||
recorded (attempt
|
||||
:catch-fatal
|
||||
(cond
|
||||
fatal?
|
||||
(do
|
||||
(mark-edge state edge :failed {:failure (errors/evidence :comparison-storage result)})
|
||||
(mark-edges-unattempted state "not attempted after comparison storage failure"))
|
||||
|
||||
(failure? result)
|
||||
(do
|
||||
(mark-edge state edge :failed {:failure (errors/evidence :comparison result)})
|
||||
(or error result))
|
||||
(failure? result)
|
||||
(mark-edge state edge :failed {:failure (errors/evidence :comparison result)})
|
||||
|
||||
(pos? (:unacknowledged result))
|
||||
(do
|
||||
(log/warn (str "Comparison " (:from edge) " -> " (:to edge)
|
||||
" has " (:unacknowledged result) " unacknowledged differences"))
|
||||
(mark-edge state edge :failed {:path (:path result)
|
||||
:failure {:unacknowledged-differences (:unacknowledged result)}})
|
||||
error)
|
||||
(pos? (:unacknowledged result))
|
||||
(do
|
||||
(log/warn (str "Comparison " (:from edge) " -> " (:to edge)
|
||||
" has " (:unacknowledged result) " unacknowledged differences"))
|
||||
(mark-edge state edge :failed {:path (:path result)
|
||||
:failure {:unacknowledged-differences (:unacknowledged result)}}))
|
||||
|
||||
:else
|
||||
(do
|
||||
(mark-edge state edge :completed {:path (:path result)})
|
||||
error)))
|
||||
:else
|
||||
(mark-edge state edge :completed {:path (:path result)}))
|
||||
nil)]
|
||||
(cond
|
||||
(failure? recorded)
|
||||
(throw (errors/combine [{:error analyzed}
|
||||
{:phase "comparison-storage" :operation :record-edge :error recorded}]))
|
||||
|
||||
fatal? (throw analyzed)
|
||||
:else analyzed)))
|
||||
|
||||
(defn- ^:async analyze-edges
|
||||
[state edges error]
|
||||
(await (timers/setImmediate))
|
||||
(if-let [signal (process/signal-name (:signal state))]
|
||||
(do
|
||||
(mark-edges-unattempted state (str "interrupted by " signal))
|
||||
(try
|
||||
(mark-edges-unattempted state (str "interrupted by " signal))
|
||||
(catch :default cause
|
||||
(throw (errors/combine [{:error error}
|
||||
{:phase "comparison-storage" :operation :record-interruption :error cause}]))))
|
||||
{:interrupted signal :error error})
|
||||
(if-let [[edge & pending] (seq edges)]
|
||||
(analyze-edges state pending (record-edge-outcome state edge (analyze-edge state edge) error))
|
||||
@ -602,11 +635,18 @@
|
||||
(if-let [signal (process/signal-name (:signal state))]
|
||||
(interrupt-comparison state signal error)
|
||||
(do
|
||||
(create-comparison-directory state)
|
||||
(try
|
||||
(create-comparison-directory state)
|
||||
(catch :default cause
|
||||
(throw (errors/combine [{:error error}
|
||||
{:phase "comparison-storage" :operation :create-comparison-directory :error cause}]))))
|
||||
(let [analyzed (attempt :catch-fatal
|
||||
(await (analyze-edges state (:edges @(:record-ref state)) error)))]
|
||||
(cond
|
||||
(failure? analyzed) (finish state :failed 1 [analyzed error])
|
||||
(failure? analyzed)
|
||||
(if-let [signal (process/signal-name (:signal state))]
|
||||
(failure-outcome state signal analyzed)
|
||||
(finish state :failed 1 [analyzed]))
|
||||
(:interrupted analyzed) (interrupt-comparison state (:interrupted analyzed) (:error analyzed))
|
||||
:else (finish-root state (:error analyzed)))))))
|
||||
|
||||
@ -622,7 +662,11 @@
|
||||
|
||||
(:stop? step)
|
||||
(do
|
||||
(mark-configs-unattempted state pending (:reason step))
|
||||
(try
|
||||
(mark-configs-unattempted state pending (:reason step))
|
||||
(catch :default cause
|
||||
(throw (errors/combine [{:error (:error step)}
|
||||
{:phase "comparison-storage" :operation :stop-configurations :error cause}]))))
|
||||
(run-edges state (:error step)))
|
||||
|
||||
:else (run-comparisons state pending)))
|
||||
@ -655,9 +699,7 @@
|
||||
(initialize-comparison state)
|
||||
(await (run-comparisons state (:configurations @(:record-ref state)))))]
|
||||
(if (failure? result)
|
||||
(outcome/failed result {:comparison @(:saved-ref state)
|
||||
:home home
|
||||
:log-path (:log-path home)})
|
||||
(failure-outcome state (process/signal-name (:signal state)) result)
|
||||
result)))))
|
||||
|
||||
(defn ^:async execute
|
||||
|
||||
@ -674,7 +674,10 @@
|
||||
(let [page-failure (await (release-resource (:page-ref state) (get-in state [:services :close-page])))
|
||||
browser-failure (await (release-resource (:owner-ref state) (get-in state [:services :close])))
|
||||
server-failure (await (release-resource (:host-ref state) server/close))
|
||||
failure (or page-failure browser-failure server-failure)]
|
||||
failure (errors/combine
|
||||
[{:phase "cleanup" :operation :close-page :error page-failure}
|
||||
{:phase "cleanup" :operation :close-browser :error browser-failure}
|
||||
{:phase "cleanup" :operation :close-server :error server-failure}])]
|
||||
(when failure
|
||||
(warn-safely state (str "run: cleanup failed: " (errors/message-of failure) "\n")))
|
||||
failure))
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
["fs" :as fs]
|
||||
["http" :as http]
|
||||
["path" :as path]
|
||||
[benches.render-wasm.errors :as errors]
|
||||
[benches.render-wasm.node.files :as files]
|
||||
[benches.render-wasm.node.settings :as settings]
|
||||
[clojure.string :as str]))
|
||||
@ -143,7 +144,10 @@
|
||||
(.abort controller)
|
||||
(.then (close host)
|
||||
(fn [_] (reject error))
|
||||
(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 []
|
||||
|
||||
@ -64,12 +64,29 @@
|
||||
[value]
|
||||
(errors/bounded-text (js/JSON.stringify value) 1024))
|
||||
|
||||
(defn format-error-entry
|
||||
"Formats a failure location with its available operation, message and path."
|
||||
[{:keys [phase operation message path]}]
|
||||
(str (when operation (str (pr-str operation) " "))
|
||||
(quoted-detail phase)
|
||||
(when (some? message) (str ": " (quoted-detail message)))
|
||||
(when path (str "\n Path " (quoted-detail path)))))
|
||||
|
||||
(defn- failure->text
|
||||
[{:keys [failure reason]}]
|
||||
(if failure
|
||||
(if-let [n (:unacknowledged-differences failure)]
|
||||
(str n " unacknowledged " (if (= n 1) "difference" "differences"))
|
||||
(str (quoted-detail (:phase failure)) ": " (quoted-detail (:message failure))))
|
||||
(if-let [entries (seq (:errors failure))]
|
||||
(str (if (not= (errors/bounded-text (:message failure) 1024) (:message (first entries)))
|
||||
(str (format-error-entry failure) "\n ")
|
||||
(when (not= (select-keys failure [:phase :operation :path])
|
||||
(select-keys (first entries) [:phase :operation :path]))
|
||||
(str "Context " (format-error-entry (dissoc failure :message)) "\n ")))
|
||||
(str/join "\n " (map format-error-entry (take 8 entries)))
|
||||
(when (> (count entries) 8)
|
||||
(str "\n " (- (count entries) 8) " more errors recorded")))
|
||||
(format-error-entry failure)))
|
||||
(quoted-detail reason)))
|
||||
|
||||
(defn- identifier->text
|
||||
|
||||
@ -88,6 +88,63 @@
|
||||
nil
|
||||
(catch :default cause cause)))
|
||||
|
||||
(t/deftest ^:async page-and-context-close-failures-both-survive
|
||||
(let [page-error (js/Error. "page disposal failed")
|
||||
context-error (js/Error. "context disposal failed")
|
||||
owner {:page #js {:isClosed (constantly false)
|
||||
:close #(js/Promise.reject page-error)}
|
||||
:context #js {:close #(js/Promise.reject context-error)}}
|
||||
cause (await (rejected (browser/close-page owner)))]
|
||||
(t/is (= [page-error context-error] (mapv :error (errors/get-errors cause))))))
|
||||
|
||||
(t/deftest ^:async browser-disposal-collects-failures-and-repeated-close-shares-the-result
|
||||
(doseq [stop-fails? [false true]]
|
||||
(let [page-error (js/Error. "page disposal failed")
|
||||
context-error (js/Error. "context disposal failed")
|
||||
process-error (js/Error. "process disposal failed")
|
||||
disconnect-error (js/Error. "CDP disconnect failed")
|
||||
controller (js/AbortController.)
|
||||
running (js/Promise.
|
||||
(fn [_ reject]
|
||||
(.addEventListener (.-signal controller) "abort"
|
||||
#(reject (if stop-fails?
|
||||
process-error
|
||||
(ex-info "cancelled" {:type :process-cancelled}))))))
|
||||
owner {:controller controller :process running :shutdown (atom nil)
|
||||
:user-data-dir @temporary-root
|
||||
:page #js {:isClosed (constantly false) :close #(js/Promise.reject page-error)}
|
||||
:context #js {:close #(js/Promise.reject context-error)}
|
||||
:connection (atom (js/Promise.resolve
|
||||
#js {:isConnected (constantly true)
|
||||
:close #(if stop-fails?
|
||||
(js/Promise.reject disconnect-error)
|
||||
(js/Promise.resolve nil))}))}
|
||||
closing (browser/close owner)]
|
||||
(t/is (identical? closing (browser/close owner)))
|
||||
(if stop-fails?
|
||||
(let [cause (await (rejected closing))]
|
||||
(t/is (= [page-error context-error process-error disconnect-error]
|
||||
(mapv :error (errors/get-errors cause)))))
|
||||
(t/is (nil? (await closing)))))))
|
||||
|
||||
(t/deftest ^:async browser-startup-retains-a-failure-of-its-disposal
|
||||
(let [run-process process/run
|
||||
close browser/close
|
||||
cleanup-error (js/Error. "startup disposal failed")]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{process/run (fn [options]
|
||||
(run-process (assoc options :command "/bin/sh" :args ["-c" "exit 17"])))
|
||||
browser/close (fn [owner]
|
||||
(.then (close owner)
|
||||
(fn [_] (throw cleanup-error))
|
||||
(fn [_] (throw cleanup-error))))}
|
||||
(let [cause (await (rejected (browser/launch {:startup-timeout-ms 10000})))
|
||||
entries (errors/get-errors cause)]
|
||||
(t/is (= ::browser/startup-cleanup (:type (ex-data cause))))
|
||||
(t/is (= ["Chromium exited before its CDP endpoint became ready" "startup disposal failed"]
|
||||
(mapv #(errors/message-of (:error %)) entries))))))))
|
||||
|
||||
(defn- ^:async check-stopped
|
||||
[{:keys [endpoint user-data-dir]}]
|
||||
(t/is (not (fs/existsSync user-data-dir)) "owned Chromium profile was removed")
|
||||
@ -123,7 +180,10 @@
|
||||
(.executablePath (.-chromium playwright))
|
||||
"/bin/sh")]
|
||||
(t/is (= {:command command :exit-code exit-code}
|
||||
(select-keys saved [:command :exit-code])))))))))
|
||||
(select-keys saved [:command :exit-code])))
|
||||
(t/is (= ::browser/endpoint-exit (:type (ex-data cause))))
|
||||
(t/is (= "Chromium exited before its CDP endpoint became ready" (errors/message-of cause)))
|
||||
(t/is (= 1 (count (errors/get-errors cause))))))))))
|
||||
|
||||
(t/deftest ^:async successful-close-removes-the-profile-and-cdp-listener
|
||||
(let [host (await (open-host build-id true))
|
||||
|
||||
@ -2014,6 +2014,53 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test("merge retains rename and temporary-file cleanup failures", testOptions, async (t) => {
|
||||
const fixture = createFrontend(t);
|
||||
const base = path.join(fixture.managed, "base.transit");
|
||||
const additional = path.join(fixture.managed, "additional.transit");
|
||||
for (const [file, runId] of [[base, "base"], [additional, "additional"]]) {
|
||||
fs.writeFileSync(file, runFixtureText({ runId, caseId: "case/load", value: 10, gitSha: "same" }));
|
||||
}
|
||||
const output = path.join(fixture.managed, "merged-run");
|
||||
const record = path.join(output, "run.transit");
|
||||
const guard = path.join(fixture.managed, "storage-failures.cjs");
|
||||
fs.writeFileSync(guard, `
|
||||
const fs = require("node:fs");
|
||||
const rename = fs.renameSync;
|
||||
const remove = fs.rmSync;
|
||||
const write = fs.writeFileSync;
|
||||
const record = ${JSON.stringify(record)};
|
||||
fs.writeFileSync = function (file) {
|
||||
if (String(file).startsWith(record + ".") && String(file).endsWith(".tmp")) {
|
||||
write.call(fs, record, "previous complete record");
|
||||
}
|
||||
return write.apply(this, arguments);
|
||||
};
|
||||
fs.renameSync = function (source, destination) {
|
||||
if (destination === record) throw new Error("record rename failed");
|
||||
return rename.apply(this, arguments);
|
||||
};
|
||||
fs.rmSync = function (file) {
|
||||
if (String(file).startsWith(record + ".") && String(file).endsWith(".tmp")) {
|
||||
throw new Error("temporary record removal failed");
|
||||
}
|
||||
return remove.apply(this, arguments);
|
||||
};
|
||||
`);
|
||||
fixture.nodeArgs = ["--require", guard];
|
||||
|
||||
const result = await runCli(fixture, ["merge", base, additional, "--output-dir", output, "--no-color"]);
|
||||
|
||||
assert.equal(result.exitCode, 1, result.stderr);
|
||||
assert.match(result.stderr, /record rename failed/);
|
||||
assert.match(result.stderr, /temporary record removal failed/);
|
||||
assert.ok(result.stderr.indexOf("record rename failed") < result.stderr.indexOf("temporary record removal failed"), result.stderr);
|
||||
assert.equal(fs.readFileSync(record, "utf8"), "previous complete record");
|
||||
assert.match(result.stderr, /rename-temporary/);
|
||||
assert.match(result.stderr, /remove-temporary/);
|
||||
assert.ok(result.stderr.includes(record), result.stderr);
|
||||
});
|
||||
|
||||
test("merge refuses an existing output directory", testOptions, async (t) => {
|
||||
const fixture = createFrontend(t);
|
||||
const base = path.join(fixture.managed, "base.transit");
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
(ns performance-tests.render-wasm.errors-test
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.transit :as transit]
|
||||
[benches.render-wasm.errors :as errors :refer [attempt failure?]]
|
||||
[cljs.test :as t :include-macros true]
|
||||
@ -16,6 +17,54 @@
|
||||
(t/is (nil? (attempt nil)))
|
||||
(t/is (= {:error :not-a-failure} (attempt {:error :not-a-failure}))))
|
||||
|
||||
(t/deftest composed-failures-retain-occurrence-order-and-fatal-propagation
|
||||
(let [upload (js/Error. "upload failed")
|
||||
end (js/Error. "ending load failed")
|
||||
close (js/Error. "browser disposal failed")
|
||||
save (js/Error. "saving evidence failed")
|
||||
initial (errors/combine [{:phase "upload" :operation :serialize-shapes :error upload}
|
||||
{:phase "upload" :operation :end-loading :error end}])
|
||||
wrapped (errors/fatal-error "preparation failed"
|
||||
{::errors/stale true :partial {:setup {:upload-ms 7}}}
|
||||
(errors/with-context initial "preparation failed"
|
||||
{:phase "preparation" :path "scene.transit"}))
|
||||
later (errors/combine [{:phase "cleanup" :operation :close-browser :error close}
|
||||
{:phase "storage" :operation :save-evidence :error save}])
|
||||
later (errors/fatal-error "cleanup failed"
|
||||
{:partial {:setup {:upload-ms 9}}
|
||||
:command "/bin/chromium" :exit-code 17}
|
||||
(errors/with-data later {:partial {:render {:frames 2}}}))
|
||||
combined (errors/combine [{:error wrapped} {:error later}])
|
||||
entries (errors/get-errors combined)
|
||||
deep (nth (iterate #(ex-info "wrapped" {} %) combined) 12)
|
||||
wire (-> (errors/evidence :runner deep)
|
||||
transit/encode-str transit/decode-str errors/project-diagnostic)]
|
||||
(t/is (= [upload end close save] (mapv :error entries)))
|
||||
(t/is (= [:serialize-shapes :end-loading :close-browser :save-evidence]
|
||||
(mapv :operation entries)))
|
||||
(t/is (= "scene.transit" (:path (first entries))))
|
||||
(t/is (= {:setup {:upload-ms 7}} (:partial (first entries))))
|
||||
(t/is (errors/stale? combined))
|
||||
(t/is (= (mapv errors/message-of [upload end close save]) (mapv :message (:errors wire))))
|
||||
(t/is (= {:command "/bin/chromium" :exit-code 17
|
||||
:partial {:setup {:upload-ms 9} :render {:frames 2}}}
|
||||
(select-keys (nth (:errors wire) 2) [:command :exit-code :partial])))
|
||||
(t/is (sm/validate errors/schema:diagnostic wire))
|
||||
(t/is (errors/fatal? deep))
|
||||
(t/is (errors/fatal? (errors/combine [{:error upload} {:error later}])))
|
||||
(t/is (try
|
||||
(attempt (throw combined))
|
||||
false
|
||||
(catch :default cause
|
||||
(= [upload end close save] (mapv :error (errors/get-errors cause))))))))
|
||||
|
||||
(t/deftest ordinary-failures-project-operation-and-path-context
|
||||
(let [cause (ex-info "record unavailable" {:operation :read-record :path "saved/run.transit"})
|
||||
wire (-> (errors/evidence :storage cause) transit/encode-str transit/decode-str)]
|
||||
(t/is (= {:operation :read-record :path "saved/run.transit"}
|
||||
(select-keys wire [:operation :path])))
|
||||
(t/is (sm/validate errors/schema:diagnostic wire))))
|
||||
|
||||
(t/deftest attempt-returns-the-error-its-body-throws
|
||||
(let [cause (ex-info "boom" {:a 1})
|
||||
result (attempt (throw cause))]
|
||||
|
||||
@ -708,6 +708,87 @@
|
||||
(mapv :phase (keep :failure (:configurations record)))))
|
||||
(t/is (every? #(= :unattempted (:status %)) (:edges record))))))
|
||||
|
||||
(t/deftest ^:async failed-edge-evidence-save-retains-the-analysis-and-persistence-errors
|
||||
(create-frontend-root)
|
||||
(doseq [[kind point] [[nil :record] [:zero-attempts :record] [:zero-attempts :directory]]]
|
||||
(let [prepared (comparison-prepared ["a" "b"]
|
||||
{:output-dir (path/join @temporary-root (str "edge-save-" (or kind "completed") "-" (name point)))})
|
||||
write feature-comparison/write-record]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{feature-comparison/write-record
|
||||
(fn [home record]
|
||||
(if (= :failed (get-in record [:edges 0 :status]))
|
||||
(throw (js/Error. "edge evidence unavailable"))
|
||||
(write home record)))}
|
||||
(let [response (await (feature-comparison/execute
|
||||
(frontend-root) prepared nil
|
||||
{:services (comparison-services
|
||||
{:prepare-build (build-service
|
||||
{:inspect (fn [_]
|
||||
(if (= :directory point)
|
||||
(fs/writeFileSync (path/join (:output-dir prepared) "comparisons") "blocked")
|
||||
(fs/mkdirSync
|
||||
(path/join (:output-dir prepared) "comparisons" "00__10.transit")
|
||||
#js {:recursive true})))})
|
||||
:execute-run (execute-service {:kind kind})})}))
|
||||
evidence (errors/evidence :comparison (first (:errors response)))
|
||||
entries (:errors evidence)]
|
||||
(t/is (= 1 (:exit-code response)))
|
||||
(if kind
|
||||
(do
|
||||
(t/is (= "configuration run made no attempts" (:message (first entries))))
|
||||
(t/is (= 1 (count (filter #(= "configuration run made no attempts" (:message %)) entries)))))
|
||||
(t/is (some #(str/includes? (:message %) "EISDIR") entries)))
|
||||
(t/is (some #(some (fn [cause]
|
||||
(if (= :directory point)
|
||||
(str/includes? (:message cause) "EEXIST")
|
||||
(= "edge evidence unavailable" (:message cause)))) (:cause %)) entries))
|
||||
(t/is (= (:comparison response) (saved-comparison response)))))))))
|
||||
|
||||
(t/deftest ^:async comparison-save-failures-retain-progress-errors-and-signal-codes
|
||||
(create-frontend-root)
|
||||
(doseq [[signal point] [[nil :terminal] ["SIGINT" :terminal] ["SIGTERM" :terminal]
|
||||
["SIGINT" :configuration] ["SIGTERM" :edge]
|
||||
["SIGINT" :run] ["SIGTERM" :run]]]
|
||||
(let [prepared (comparison-prepared ["a"] {:output-dir (path/join @temporary-root (str "finish-" signal "-" (name point)))})
|
||||
controller (js/AbortController.)
|
||||
write feature-comparison/write-record
|
||||
pending (js/Error. "interrupted operation failed")
|
||||
execute (execute-service (if (= :terminal point) {} {:kind :interrupted}))]
|
||||
(when (and signal (= :terminal point)) (.abort controller signal))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{feature-comparison/write-record
|
||||
(fn [home record]
|
||||
(if (case point
|
||||
:terminal (contains? #{:completed :interrupted} (:status record))
|
||||
:run (some #(= :failed (:status %)) (:configurations record))
|
||||
:configuration (some #(= :unattempted (:status %)) (:configurations record))
|
||||
:edge (some #(= :unattempted (:status %)) (:edges record)))
|
||||
(throw (js/Error. "comparison save failed"))
|
||||
(write home record)))}
|
||||
(let [response (await (feature-comparison/execute
|
||||
(frontend-root) prepared (.-signal controller)
|
||||
{:services (comparison-services
|
||||
{:prepare-build (build-service {})
|
||||
:execute-run (fn [& args]
|
||||
(.then (apply execute args)
|
||||
(fn [response]
|
||||
(if (= :terminal point)
|
||||
response
|
||||
(do
|
||||
(.abort controller signal)
|
||||
(assoc response :errors [pending]))))))})}))
|
||||
evidence (errors/evidence :comparison (first (:errors response)))]
|
||||
(t/is (= (case signal "SIGINT" 130 "SIGTERM" 143 1) (:exit-code response)))
|
||||
(t/is (= :running (get-in response [:comparison :status])))
|
||||
(t/is (= (:comparison response) (saved-comparison response)))
|
||||
(t/is (some #(some (fn [cause] (= "comparison save failed" (:message cause))) (:cause %))
|
||||
(:errors evidence)))
|
||||
(when (not= :terminal point)
|
||||
(t/is (= 1 (count (filter #(= "interrupted operation failed" (:message %)) (:errors evidence))))))))))))
|
||||
|
||||
(t/deftest ^:async edge-report-storage-failure-preserves-failed-and-unattempted-edges
|
||||
(create-frontend-root)
|
||||
(let [prepared (comparison-prepared ["a" "b"])
|
||||
|
||||
@ -340,8 +340,9 @@
|
||||
(def ^:private factory-source
|
||||
"ES module stubbing the Emscripten factory for entry tests. Records every
|
||||
renderer call in `globalThis.__benchCalls`; `globalThis.__benchThrowIn`
|
||||
names one module fn that throws. `globalThis.__benchRenderScript` holds
|
||||
optional [frame-type, duration-ms] pairs that advance `__benchNow`; absent
|
||||
names one module fn or an array of module fns that throw.
|
||||
`globalThis.__benchRenderScript` holds optional [frame-type, duration-ms]
|
||||
pairs that advance `__benchNow`; absent
|
||||
a script, `_render` immediately returns Full. The instance trap answers
|
||||
`undefined` for `then` and non-strings: promise assimilation would treat the
|
||||
instance as a never-settling thenable and hang the load. A real 16 MiB
|
||||
@ -384,7 +385,8 @@
|
||||
"if (p in t) return t[p];"
|
||||
"return function () {"
|
||||
"push(['call', p]);"
|
||||
"if (globalThis.__benchThrowIn === p) throw new Error('fake ' + p + ' failure');"
|
||||
"var faults = globalThis.__benchThrowIn;"
|
||||
"if (Array.isArray(faults) ? faults.includes(p) : faults === p) throw new Error('fake ' + p + ' failure');"
|
||||
"return 0;"
|
||||
"};"
|
||||
"}"
|
||||
@ -964,6 +966,22 @@
|
||||
(cleanup)
|
||||
(done)))))))
|
||||
|
||||
(t/deftest ^:async upload-retains-batch-and-end-loading-failures-in-order
|
||||
(doseq [functions [["_set_shapes_batch" "_end_loading"] ["_end_loading"]]]
|
||||
(await
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(unchecked-set js/globalThis "__benchThrowIn" (clj->js functions))
|
||||
(load-result (scene-args 7 {})
|
||||
(fn [m]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (= "upload failed" (:message m)))
|
||||
(t/is (= (mapv {"_set_shapes_batch" :serialize-shapes "_end_loading" :end-loading} functions)
|
||||
(mapv :operation (:errors m))))
|
||||
(t/is (= functions (mapv #(get-in % [:cause 0 :fn]) (:errors m))))
|
||||
(t/is (decl/transit-round-trips? m))
|
||||
(cleanup))))))))
|
||||
|
||||
(t/deftest nested-stale-marker-does-not-resolve-stale
|
||||
(let [terminate @#'browser/terminal-failure
|
||||
current @@#'browser/owner-epoch*
|
||||
|
||||
@ -301,15 +301,16 @@
|
||||
"setup failed"))))
|
||||
|
||||
(t/deftest comparison-reports-preparation-failures-once-per-source
|
||||
(let [failure {:phase "preparation" :message "setup failed"}
|
||||
(let [failure {:phase "preparation" :message "setup failed"
|
||||
:errors [{:phase "upload" :operation :serialize-shapes :message "setup failed"}
|
||||
{:phase "upload" :operation :end-loading :message "ending load failed"}]}
|
||||
run (-> (new-run 0 2 :fresh)
|
||||
(result/record-preparation (preparation :ready []))
|
||||
(add-attempt :ready false :completed [(block :main :ready [(event :test/work [2])])])
|
||||
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
|
||||
:diagnostics {} :phases [] :failure failure})
|
||||
(result/record-attempt (measurement/attempt :saved/pan :failed false
|
||||
{:status "failed" :phase "preparation"
|
||||
:message "setup failed" :phases []}))
|
||||
(assoc failure :status "failed" :phases [])))
|
||||
(result/record-case-outcome :saved/pan {:outcome :failed :failure failure})
|
||||
(result/finish-run "end" {:reason :failed :failure failure})
|
||||
(assoc-in [:metadata :diagnostics :screenshots]
|
||||
@ -325,11 +326,28 @@
|
||||
result/encode result/decode)
|
||||
text (format/format-comparison compared)]
|
||||
(t/is (= 2 (count (re-seq #"setup failed" text))))
|
||||
(t/is (= 2 (count (re-seq #"ending load failed" text))))
|
||||
(t/is (str/includes? text "median change"))
|
||||
(t/is (= 2 (count (re-seq #"Preparation :failed" text))))
|
||||
(t/is (str/includes? text "Baseline screenshots\n \"saved/pan\": skipped; reason :no-completed-measured-attempt"))
|
||||
(t/is (str/includes? text "Candidate screenshots\n \"saved/pan\": ok\n File \"candidate-pan.png\""))))
|
||||
|
||||
(t/deftest collected-failures-print-a-long-message-once-and-retain-enclosing-context
|
||||
(let [message (str "failure-marker " (apply str (repeat 1400 "x")))
|
||||
failure {:phase "preparation" :operation :prepare-scene :path "scene.transit" :message message
|
||||
:errors [{:phase "upload" :operation :serialize-shapes
|
||||
:message (subs message 0 1024)}]}
|
||||
run (-> (new-run 0 1 :fresh)
|
||||
(result/record-preparation {:preparation-id :failed :case-id :saved/pan :warmup? false
|
||||
:diagnostics {} :phases [] :failure failure})
|
||||
(result/finish-run "end" {:reason :failed :failure failure})
|
||||
result/encode result/decode)
|
||||
text (format/format-run (summary/summarize run))]
|
||||
(t/is (= 1 (count (re-seq #"failure-marker" text))))
|
||||
(t/is (str/includes? text ":prepare-scene \"preparation\""))
|
||||
(t/is (str/includes? text "Path \"scene.transit\""))
|
||||
(t/is (str/includes? text ":serialize-shapes \"upload\""))))
|
||||
|
||||
(t/deftest equal-diagnostics-keep-failures-from-distinct-preparations
|
||||
(let [failure {:phase "renderer" :message "shared renderer failure"}]
|
||||
(doseq [phases [[]
|
||||
@ -558,7 +576,9 @@
|
||||
[failed-attempt [:metadata :diagnostics :failures 0]]]
|
||||
invalid [{:phase 42 :message "bad location"}
|
||||
{:phase "main" :message nil}
|
||||
{:phase "main" :message "bad cause" :cause [42]}]]
|
||||
{:phase "main" :message "bad cause" :cause [42]}
|
||||
{:phase "main" :message "bad error collection"
|
||||
:errors [{:phase "cleanup" :message "nested collection" :errors []}]}]]
|
||||
(t/is (= ::result/invalid-record
|
||||
(error-type #(result/decode (transit/encode-str (assoc-in run path invalid)))))))))
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[benches.render-wasm.node.runner.browser :as browser]
|
||||
[benches.render-wasm.node.runner.feature-comparison :as feature-comparison]
|
||||
[benches.render-wasm.node.runner.run :as run]
|
||||
[benches.render-wasm.node.runner.server :as server]
|
||||
[benches.render-wasm.node.storage.artifacts :as artifacts]
|
||||
[benches.render-wasm.node.storage.runs :as runs]
|
||||
[benches.render-wasm.report.format :as format]
|
||||
@ -1109,6 +1110,23 @@
|
||||
(t/is (= :incomplete (get-in run [:termination :reason])))
|
||||
(t/is (str/includes? (fs/readFileSync file "utf8") "Run incomplete")))))
|
||||
|
||||
(t/deftest ^:async cleanup-retains-page-browser-and-server-failures-in-the-saved-report
|
||||
(let [base (fake-browser {:close-page-failure (constantly true) :close-failure (constantly true)})
|
||||
fake (assoc-in base [:service :prepare]
|
||||
(fn [& _] (throw (errors/fatal-error "preparation transport failed" {} nil))))
|
||||
close server/close]
|
||||
(await (mock/with-mocks*
|
||||
{server/close (fn [host]
|
||||
(.then (close host) (fn [_] (throw (js/Error. "server disposal failed")))))}
|
||||
(let [response (await (execute fake [(collected :case/a :fresh)] (options fake (output-dir))))
|
||||
saved (saved-record response)
|
||||
failure (get-in saved [:termination :failure])]
|
||||
(t/is (= 1 (:exit-code response)))
|
||||
(t/is (= ["page close failed" "browser shutdown failed" "server disposal failed"]
|
||||
(mapv :message (:errors failure))))
|
||||
(t/is (str/includes? (:report response) "server disposal failed"))
|
||||
(t/is (str/includes? (:report response) "browser shutdown failed")))))))
|
||||
|
||||
(t/deftest ^:async cleanup-error-keeps-completed-attempt-evidence-and-exits-nonzero
|
||||
(let [base (fake-browser {})
|
||||
fake (assoc base :service (assoc (:service base) :close
|
||||
|
||||
@ -11,8 +11,10 @@
|
||||
["net" :as net]
|
||||
["os" :as os]
|
||||
["path" :as path]
|
||||
[benches.render-wasm.errors :as errors]
|
||||
[benches.render-wasm.node.runner.server :as server]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
|
||||
(def ^:private temporary-root (atom nil))
|
||||
(def ^:private wasm-bytes [0 97 115 109 1 0 0 0])
|
||||
@ -168,6 +170,20 @@
|
||||
nil)
|
||||
(catch :default error error)))
|
||||
|
||||
(t/deftest ^:async startup-abort-retains-a-server-close-failure
|
||||
(let [controller (js/AbortController.)
|
||||
close server/close
|
||||
close-error (js/Error. "server disposal failed")]
|
||||
(.abort controller)
|
||||
(await (mock/with-mocks*
|
||||
{server/close (fn [host]
|
||||
(.then (close host) (fn [_] (throw close-error))))}
|
||||
(let [cause (await (bounded (startup-failure (server/open (build) {:signal (.-signal controller)}))))
|
||||
entries (errors/get-errors cause)]
|
||||
(t/is (= ::server/aborted (:type (ex-data cause))))
|
||||
(t/is (= ["renderer server startup aborted" "server disposal failed"]
|
||||
(mapv #(errors/message-of (:error %)) entries))))))))
|
||||
|
||||
(t/deftest ^:async startup-rejects-already-aborted-and-in-flight-signals
|
||||
(let [already (js/AbortController.)]
|
||||
(.abort already)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user