diff --git a/frontend/performance/benches/render_wasm/browser/bridge.cljs b/frontend/performance/benches/render_wasm/browser/bridge.cljs index e295f05a8e..02857b3a07 100644 --- a/frontend/performance/benches/render_wasm/browser/bridge.cljs +++ b/frontend/performance/benches/render_wasm/browser/bridge.cljs @@ -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. diff --git a/frontend/performance/benches/render_wasm/errors.cljc b/frontend/performance/benches/render_wasm/errors.cljc index 6be436d783..837d435dba 100644 --- a/frontend/performance/benches/render_wasm/errors.cljc +++ b/frontend/performance/benches/render_wasm/errors.cljc @@ -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))))))) diff --git a/frontend/performance/benches/render_wasm/node/cli/execution.cljs b/frontend/performance/benches/render_wasm/node/cli/execution.cljs index 1ade131c43..c7acb84be5 100644 --- a/frontend/performance/benches/render_wasm/node/cli/execution.cljs +++ b/frontend/performance/benches/render_wasm/node/cli/execution.cljs @@ -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)})))) diff --git a/frontend/performance/benches/render_wasm/node/files.cljs b/frontend/performance/benches/render_wasm/node/files.cljs index 7d2fed8c64..e902a5ca87 100644 --- a/frontend/performance/benches/render_wasm/node/files.cljs +++ b/frontend/performance/benches/render_wasm/node/files.cljs @@ -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)))) diff --git a/frontend/performance/benches/render_wasm/node/runner/browser.cljs b/frontend/performance/benches/render_wasm/node/runner/browser.cljs index f1792e9d70..b2fd51ec4c 100644 --- a/frontend/performance/benches/render_wasm/node/runner/browser.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/browser.cljs @@ -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, diff --git a/frontend/performance/benches/render_wasm/node/runner/feature_comparison.cljs b/frontend/performance/benches/render_wasm/node/runner/feature_comparison.cljs index 4700be121f..2597196c00 100644 --- a/frontend/performance/benches/render_wasm/node/runner/feature_comparison.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/feature_comparison.cljs @@ -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 diff --git a/frontend/performance/benches/render_wasm/node/runner/run.cljs b/frontend/performance/benches/render_wasm/node/runner/run.cljs index 6dca72948d..d2d2feb849 100644 --- a/frontend/performance/benches/render_wasm/node/runner/run.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/run.cljs @@ -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)) diff --git a/frontend/performance/benches/render_wasm/node/runner/server.cljs b/frontend/performance/benches/render_wasm/node/runner/server.cljs index 4a40825fba..151998fe67 100644 --- a/frontend/performance/benches/render_wasm/node/runner/server.cljs +++ b/frontend/performance/benches/render_wasm/node/runner/server.cljs @@ -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 [] diff --git a/frontend/performance/benches/render_wasm/report/format.cljs b/frontend/performance/benches/render_wasm/report/format.cljs index f27aaadf0b..d80dbb0ec9 100644 --- a/frontend/performance/benches/render_wasm/report/format.cljs +++ b/frontend/performance/benches/render_wasm/report/format.cljs @@ -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 diff --git a/frontend/test/performance_tests/render_wasm/browser_test.cljs b/frontend/test/performance_tests/render_wasm/browser_test.cljs index 8c0e0ccdc1..90ab342ad3 100644 --- a/frontend/test/performance_tests/render_wasm/browser_test.cljs +++ b/frontend/test/performance_tests/render_wasm/browser_test.cljs @@ -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)) diff --git a/frontend/test/performance_tests/render_wasm/cli_integration.cjs b/frontend/test/performance_tests/render_wasm/cli_integration.cjs index 117e5b9548..6c8f7d0106 100644 --- a/frontend/test/performance_tests/render_wasm/cli_integration.cjs +++ b/frontend/test/performance_tests/render_wasm/cli_integration.cjs @@ -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"); diff --git a/frontend/test/performance_tests/render_wasm/errors_test.cljs b/frontend/test/performance_tests/render_wasm/errors_test.cljs index 4f774da050..70752d966e 100644 --- a/frontend/test/performance_tests/render_wasm/errors_test.cljs +++ b/frontend/test/performance_tests/render_wasm/errors_test.cljs @@ -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))] diff --git a/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs b/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs index c84ca0f8c0..3882fbb93e 100644 --- a/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs +++ b/frontend/test/performance_tests/render_wasm/feature_comparison_test.cljs @@ -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"]) diff --git a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs index d17fceeb0c..3d02923a7f 100644 --- a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs +++ b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs @@ -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* diff --git a/frontend/test/performance_tests/render_wasm/report_test.cljs b/frontend/test/performance_tests/render_wasm/report_test.cljs index 1b2c3ae3f4..9559d83dc9 100644 --- a/frontend/test/performance_tests/render_wasm/report_test.cljs +++ b/frontend/test/performance_tests/render_wasm/report_test.cljs @@ -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))))))))) diff --git a/frontend/test/performance_tests/render_wasm/run_test.cljs b/frontend/test/performance_tests/render_wasm/run_test.cljs index 77ce2f4c31..5473375ceb 100644 --- a/frontend/test/performance_tests/render_wasm/run_test.cljs +++ b/frontend/test/performance_tests/render_wasm/run_test.cljs @@ -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 diff --git a/frontend/test/performance_tests/render_wasm/server_test.cljs b/frontend/test/performance_tests/render_wasm/server_test.cljs index c7ade77e0b..31b9687d85 100644 --- a/frontend/test/performance_tests/render_wasm/server_test.cljs +++ b/frontend/test/performance_tests/render_wasm/server_test.cljs @@ -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)