:recyle: Consolidate error diagnostics

AI-assisted-by: gpt-6.1-sol, deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-11-05 12:36:44 +01:00
parent e24d4e8a98
commit 10ad433c78
8 changed files with 266 additions and 70 deletions

View File

@ -48,6 +48,40 @@
#?(:cljs
(do
(def ^:private schema:cause-value
[:or nil? boolean? keyword? number? string?])
(def schema:cause-detail
"One level of a portable exception description."
[:map {:closed true}
[:message string?]
[:fn {:optional true} schema:cause-value]
[:code {:optional true} schema:cause-value]
[: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?]
[:message string?]
[:status {:optional true} [:enum "failed" "stale"]]
[:cause {:optional true} [:vector schema:cause-detail]]
[:partial {:optional true} map?]
[:reason {:optional true} :any]
[:deadline-exceeded {:optional true} boolean?]])
(defn project-diagnostic
"Returns the diagnostic fields of an execution envelope.
Args:
evidence: Plain execution evidence.
Returns:
Failure status, location, message and available diagnostic details."
[evidence]
(select-keys evidence [:status :phase :message :cause :partial :reason :deadline-exceeded]))
(def ^:private fatal-cause-depth 8)
(def ^:private evidence-cause-depth 3)
(def ^:private max-cause-chars 500)

View File

@ -8,8 +8,15 @@
"Projects browser phase evidence into saved attempts."
(:require
[app.common.schema :as sm]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.result :as result]))
(def ^:private schema:response
[:multi {:dispatch :status}
["ok" [:map [:status [:= "ok"]]]]
["failed" (sm/merge errors/schema:diagnostic [:map [:status [:= "failed"]]])]
["stale" [:map {:closed true} [:status [:= "stale"]]]]])
(defn partial-phases
"Returns the valid prefix of delivered phase history.
@ -33,6 +40,31 @@
out)
out)))
(defn check-response
"Returns checked bridge evidence or an invalid-output failure.
Stale responses retain their status and gain an ownership diagnostic.
Malformed responses retain their valid phase prefix and raw response.
Args:
phase: The preparation or attempt receiving the response.
preparation-id: Expected owner of delivered phase blocks.
response: Decoded bridge envelope.
Returns:
Execution evidence with a known status and diagnostic fields on failure."
[phase preparation-id response]
(if (sm/validate schema:response response)
(if (= "stale" (:status response))
(assoc response :phase (name phase) :message "browser context was superseded")
response)
(assoc (errors/fail-data
(errors/evidence :invalid-output
(ex-info "invalid bridge response"
{:type ::invalid-response :partial {:response response}})))
:phases (partial-phases (:phases response) preparation-id
(if (= :preparation phase) [:preparation] [:main :reset])))))
(defn attempt
"Returns an attempt with raw phase histories and available failure evidence.
@ -49,5 +81,5 @@
:outcome (if (= "ok" (:status evidence)) :completed :failed)
:phases (or (:phases evidence) [])}
(not= "ok" (:status evidence))
(assoc :failure (select-keys evidence [:status :phase :message :cause]))
(assoc :failure (dissoc (errors/project-diagnostic evidence) :partial))
(:partial evidence) (assoc :partial (:partial evidence))))

View File

@ -56,17 +56,6 @@
(contains? (ex-data cause) ::browser/deadline-exceeded)
(assoc :deadline-exceeded (::browser/deadline-exceeded (ex-data cause)))))
(defn- failure-evidence
"Projects a bridge failure response into diagnostics evidence."
[evidence fallback-phase]
(if (map? evidence)
(cond-> (select-keys evidence [:status :phase :message :cause :partial :phases :reason :deadline-exceeded])
(nil? (:status evidence)) (assoc :status "failed")
(nil? (:phase evidence)) (assoc :phase (name fallback-phase)))
{:status "failed"
:phase (name fallback-phase)
:message (str evidence)}))
(defn- interruption
[signal]
(when-let [name (process/signal-name signal)]
@ -142,7 +131,7 @@
(defn- record-failure
[state case-id stage evidence]
(let [failure (cond-> (assoc (select-keys evidence [:status :phase :message :cause :partial :reason])
(let [failure (cond-> (assoc (errors/project-diagnostic evidence)
:stage stage)
(some? case-id) (assoc :case-id case-id))]
(reset! (:harness-failed? state) true)
@ -217,52 +206,56 @@
(defn- ^:async prepare-case
[state case page deadline warmup?]
(check-deadline deadline)
(let [build-id (:id (:selected-build state))
(let [build-id (:id (:selected-build state))
preparation-id (str (random-uuid))
response (attempt
(await ((get-in state [:services :prepare])
page
(transit/encode-str
{:case case
:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))
:module-url (renderer-url build-id "render-wasm.js")
:wasm-url (renderer-url build-id "render-wasm.wasm")})
{:deadline deadline :signal (:signal state) :phase :preparation})))
accepted (when (and (not (failure? response)) (= "ok" (:status response)))
(attempt
(let [preparation (assoc (:preparation response) :warmup? warmup?)]
(when-not (and (= preparation-id (:preparation-id preparation))
(= (:id case) (:case-id preparation))
(not (:failure preparation)))
(throw (ex-info "preparation success response is inconsistent" {})))
(record-preparation state preparation)
preparation)))]
response (attempt
(measurement/check-response
:preparation preparation-id
(await ((get-in state [:services :prepare])
page
(transit/encode-str
{:case case
:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))
:module-url (renderer-url build-id "render-wasm.js")
:wasm-url (renderer-url build-id "render-wasm.wasm")})
{:deadline deadline :signal (:signal state) :phase :preparation}))))
accepted (when (and (not (failure? response)) (= "ok" (:status response)))
(attempt
(let [preparation (assoc (:preparation response) :warmup? warmup?)]
(when-not (and (= preparation-id (:preparation-id preparation))
(= (:id case) (:case-id preparation))
(not (:failure preparation)))
(throw (ex-info "preparation success response is inconsistent" {})))
(record-preparation state preparation)
preparation)))]
(if (and accepted (not (failure? accepted)))
{:preparation accepted}
(let [failure (cond
(failure? accepted) (assoc (cause-evidence accepted :invalid-output)
:partial {:response response})
(failure? response) (cause-evidence response :preparation)
:else (failure-evidence response :preparation))
:else response)
phases (if (failure? accepted) (get-in response [:preparation :phases]) (:phases failure))]
(record-preparation state {:preparation-id preparation-id :case-id (:id case)
:warmup? warmup?
:phases (measurement/partial-phases phases preparation-id [:preparation])
:diagnostics (or (get-in failure [:partial :setup]) {})
:failure (dissoc failure :phases)})
:failure (errors/project-diagnostic failure)})
{:failure failure :preparation-id preparation-id}))))
(defn- ^:async run-attempt
"Returns the bridge evidence of one attempt, or the failure."
[state page preparation-id deadline]
(attempt
(await ((get-in state [:services :run])
page
(transit/encode-str
{:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))})
{:deadline deadline :signal (:signal state) :phase :attempt}))))
(measurement/check-response
:attempt preparation-id
(await ((get-in state [:services :run])
page
(transit/encode-str
{:preparation-id preparation-id
:timeout-ms (max 1 (process/remaining deadline))})
{:deadline deadline :signal (:signal state) :phase :attempt})))))
(defn- ^:async recycle-browser
[state]

View File

@ -10,6 +10,7 @@
[app.common.schema :as sm]
[app.common.transit :as transit]
[benches.render-wasm.codec :as codec]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.features.plan :as feature-plan]
[benches.render-wasm.quantities :as quantities]
[benches.render-wasm.report.analysis :as analysis]))
@ -46,7 +47,7 @@
[:effective-graphics {:optional true} map?]
[:diagnostics map?]
[:phases [:vector schema:phase]]
[:failure {:optional true} map?]])
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:attempt
"One chronological execution; failure consumes a slot and may retain evidence."
@ -57,7 +58,7 @@
[:warmup? boolean?]
[:outcome [:enum :completed :failed]]
[:phases [:vector schema:phase]]
[:failure {:optional true} map?]
[:failure {:optional true} errors/schema:diagnostic]
[:partial {:optional true} map?]])
(def schema:metadata
@ -70,16 +71,19 @@
[:environment {:optional true} [:map-of :string :string]]]]
[:environment map?]
[:scored? boolean?]
[:diagnostics {:optional true} map?]])
[:diagnostics {:optional true}
[:map [:failures {:optional true} [:vector errors/schema:diagnostic]]]]])
(def schema:termination
"A run's current state or final reason, with optional diagnostic details."
[:map [:reason keyword?]])
[:map
[:reason keyword?]
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:case-outcome
[:map {:closed true}
[:outcome [:enum :completed :failed :timed-out :interrupted]]
[:failure {:optional true} map?]])
[:failure {:optional true} errors/schema:diagnostic]])
(def schema:run
"schema:run stores saved case definitions, independent of any registry.
@ -194,7 +198,7 @@
[:run {:optional true} [:map [:run-id string?] [:record-path string?] [:log-path string?]
[:exit-code {:optional true} :int]]]
[:effective-features {:optional true} [:vector string?]]
[:failure {:optional true} :map]
[:failure {:optional true} errors/schema:diagnostic]
[:reason {:optional true} string?]]]]
[:edges
[:vector
@ -202,7 +206,9 @@
[:from string?] [:to string?] [:feature string?]
[:status [:enum :planned :completed :failed :unattempted]]
[:path {:optional true} string?]
[:failure {:optional true} :map]
[:failure {:optional true}
[:or errors/schema:diagnostic
[:map {:closed true} [:unacknowledged-differences [:int {:min 1}]]]]]
[:reason {:optional true} string?]]]]
[:warnings [:vector string?]]])

View File

@ -279,7 +279,7 @@
(mapv (fn [[index configuration]]
(if (zero? index)
(assoc (completed-configuration configuration)
:status :failed :failure {:phase :run}
:status :failed :failure {:phase "run" :message "run interrupted"}
:reason "SIGINT")
(assoc configuration :status :unattempted
:reason "stopped after failure")))
@ -288,7 +288,7 @@
(mapv (fn [[index edge]]
(if (zero? index)
(assoc edge :status :failed :path "/comparison.transit"
:failure {:message "partial"})
:failure {:phase "comparison" :message "partial"})
(assoc edge :status :unattempted
:reason "stopped after failure")))
(map-indexed vector (:edges record))))))
@ -489,7 +489,7 @@
:warmup? false
:outcome :failed
:phases []
:failure {:message "fixture failure"}})
:failure {:phase "main" :message "fixture failure"}})
(let [prep (preparation case-id digest)]
(-> run
(result/record-preparation prep)
@ -503,7 +503,7 @@
run
cases))
termination (case kind
:failed {:reason :failed :failure {:message "fixture failure"}}
:failed {:reason :failed :failure {:phase "main" :message "fixture failure"}}
:zero-attempts {:reason :incomplete}
:interrupted {:reason :interrupted :signal "SIGINT"}
{:reason :completed})

View File

@ -17,8 +17,13 @@
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.measurement :as measurement]
[benches.render-wasm.report.format :as format]
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[benches.render-wasm.runtime.camera :as camera]
[cljs.test :as t :include-macros true]))
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
;; Forward declaration: entry-test helpers below build args against the
;; fake Emscripten factory URL defined alongside the fake-DOM harness.
@ -743,19 +748,49 @@
:preparation {:callback #(work :preparation %)}
:reset #(work :reset %)}
#(work :main %))
(let [request (-> (transit/decode-str (scene-args 7 {}))
(assoc :preparation-id "phase-failure")
(transit/encode-str))
prepared (transit/decode-str (await (browser/prepare-scene-request request)))
run (transit/encode-str {:preparation-id "phase-failure"})
evidence (if (= failed-phase :preparation)
prepared
(transit/decode-str (await (browser/run-prepared run))))
stopped (transit/decode-str (await (browser/run-prepared run)))
blocks (:phases evidence)]
(let [request (-> (transit/decode-str (scene-args 7 {}))
(assoc :preparation-id "phase-failure")
(transit/encode-str))
prepared (transit/decode-str (await (browser/prepare-scene-request request)))
run (transit/encode-str {:preparation-id "phase-failure"})
evidence (if (= failed-phase :preparation)
prepared
(transit/decode-str (await (browser/run-prepared run))))
stopped (transit/decode-str (await (browser/run-prepared run)))
blocks (:phases evidence)
diagnostic (errors/project-diagnostic evidence)
preparation (if (= failed-phase :preparation)
{:preparation-id "phase-failure" :case-id :rects/lifecycle
:warmup? false :diagnostics (get-in evidence [:partial :setup])
:phases blocks :failure diagnostic}
(assoc (:preparation prepared) :warmup? false))
saved (-> (cond->
(-> (result/create-run
{:run-id "phase-failure" :started-at "start"
:plan {:cases [(load-case)] :warmups 0 :repetitions 1}
:metadata {:git {:sha nil :dirty nil} :build {:functional {}}
:environment {} :scored? true}})
(result/record-preparation preparation))
(or (= context :fresh) (not= failed-phase :preparation))
(result/record-attempt
(measurement/attempt :rects/lifecycle "phase-failure" false
(if (= failed-phase :preparation)
(dissoc evidence :phases)
evidence))))
(result/record-case-outcome :rects/lifecycle
{:outcome :failed})
(result/finish-run "end" {:reason :failed})
result/encode result/decode)
stored (if (= failed-phase :preparation)
(first (:preparations saved))
(first (:attempts saved)))]
(t/is (= "failed" (:status evidence)))
(t/is (= (name failed-phase) (:phase evidence)))
(t/is (= "phase fixture failed" (:message evidence)))
(t/is (= (:cause evidence) (get-in stored [:failure :cause])))
(t/is (= blocks (:phases stored)))
(t/is (= "phase-failure" (:preparation-id stored)))
(t/is (str/includes? (format/format-run (summary/summarize saved)) "phase fixture failed"))
(t/is (= (case failed-phase
:preparation [:preparation]
:main [:preparation :main]

View File

@ -54,7 +54,8 @@
run (measurement/attempt :saved/pan prep warmup?
(cond-> {:status (if (= :completed outcome) "ok" "failed")
:phases phases}
(= :failed outcome) (assoc :message "renderer failed")))))
(= :failed outcome) (assoc :phase (name (:phase (last phases)))
:message "renderer failed")))))
(defn- sample-run
[values]
@ -223,7 +224,7 @@
(let [run (-> (new-run 0 1 :reuse)
(result/record-preparation
(assoc (preparation :prep [(event :test/setup [9])])
:failure {:message "initial Full failed"})))
:failure {:phase "preparation" :message "initial Full failed"})))
report (first (:cases (summary/summarize run)))
metric (get-in report [:metrics [:preparation :test/setup :command/elapsed]])]
(t/is (= {:executions 1 :completed 1 :failed 0 :warmup 0} (get-in report [:phases :preparation])))
@ -255,7 +256,7 @@
initial values)
(result/record-attempt
(measurement/attempt :saved/pan :prep false
{:status "failed" :message "render failed"
{:status "failed" :phase "main" :message "render failed"
:cause [{:message "WASM trap"}]
:partial {:clock js/NaN}
:phases [(assoc (block :main :prep [(event :test/pan [99])])
@ -294,7 +295,7 @@
(let [failed (-> (new-run 0 1 :reuse)
(result/record-preparation {:preparation-id :prep :case-id :saved/pan :warmup? false
:diagnostics {} :phases []
:failure {:message "setup failed"}})
:failure {:phase "preparation" :message "setup failed"}})
(result/finish-run "end" {:reason :preparation-error}))]
(t/is (str/includes? (format/format-run (summary/summarize (result/decode (result/encode failed))))
"setup failed"))))
@ -409,7 +410,7 @@
(result/record-preparation
(-> (preparation :prep [(event :test/setup [9])])
(dissoc :fingerprint :effective-graphics)
(assoc :failure {:message "setup failed"}))))]
(assoc :failure {:phase "preparation" :message "setup failed"}))))]
(doseq [options [{} {:allow-scene-differences true}]]
(t/is (= ::compare/incompatible (error-type #(compare/compare-runs run run options)))))
(let [report (compare/compare-runs run run {:allow-all-differences true})]
@ -422,7 +423,7 @@
(doseq [bad [(assoc-in run [:attempts 0 :attempt-id] 3)
(assoc-in run [:attempts 0 :phases] [])
(assoc-in run [:preparations 0 :phases] [])
(assoc-in run [:preparations 0 :failure] {:message "setup failed"})
(assoc-in run [:preparations 0 :failure] {:phase "preparation" :message "setup failed"})
(update-in run [:attempts 0] assoc :outcome :failed
:phases [(assoc (block :main :prep []) :outcome :failed)
(block :reset :prep [])])
@ -434,6 +435,30 @@
(t/is (= ::result/invalid-record
(error-type #(result/decode (transit/encode-str bad))))))))
(t/deftest saved-failures-reject-malformed-diagnostics
(let [diagnostic {:phase "main" :message "renderer failed"}
failed-preparation (-> (new-run 0 1 :reuse)
(result/record-preparation (assoc (preparation :prep []) :failure diagnostic))
(result/finish-run "end" {:reason :failed})
result/encode result/decode)
failed-attempt (-> (new-run 0 1 :reuse)
(result/record-preparation (preparation :prep []))
(add-attempt :prep false :failed [(assoc (block :main :prep []) :outcome :failed)])
(result/record-case-outcome :saved/pan {:outcome :failed :failure diagnostic})
(result/finish-run "end" {:reason :failed :failure diagnostic})
(assoc-in [:metadata :diagnostics :failures] [diagnostic])
result/encode result/decode)]
(doseq [[run path] [[failed-preparation [:preparations 0 :failure]]
[failed-attempt [:attempts 0 :failure]]
[failed-attempt [:case-outcomes :saved/pan :failure]]
[failed-attempt [:termination :failure]]
[failed-attempt [:metadata :diagnostics :failures 0]]]
invalid [{:phase 42 :message "bad location"}
{:phase "main" :message nil}
{:phase "main" :message "bad cause" :cause [42]}]]
(t/is (= ::result/invalid-record
(error-type #(result/decode (transit/encode-str (assoc-in run path invalid)))))))))
(t/deftest case-outcomes-validate-completion-and-close-the-recording-boundary
(let [run (sample-run [1])
completed (result/record-case-outcome run :saved/pan {:outcome :completed})

View File

@ -573,7 +573,8 @@
:invalid-preparation (update response :preparation dissoc :fingerprint)
:contradictory-preparation
(-> response
(assoc-in [:preparation :failure] {:message "callback failed"})
(assoc-in [:preparation :failure]
{:phase "preparation" :message "callback failed"})
(assoc-in [:preparation :phases 0 :outcome] :failed))
response)
response)))
@ -585,8 +586,14 @@
(if (= :case/a case-id)
(case phase
:main {:status "failed" :phase "main" :message "main failed"
:cause [{:message "WASM trap" :code "E_RENDER"}]
:reason :renderer-rejected :deadline-exceeded false
:partial {:completed-call 7}
:phases [(assoc block :outcome :failed)]}
:reset {:status "failed" :phase "reset" :message "reset failed"
:cause [{:message "WASM trap" :code "E_RENDER"}]
:reason :renderer-rejected :deadline-exceeded false
:partial {:completed-call 7}
:phases [block (assoc block :phase :reset :outcome :failed)]}
:invalid-main {:status "ok" :phases []}
:invalid-reset {:status "ok" :phases [block {:phase :reset}]})
@ -601,6 +608,11 @@
(t/is (= {:warmups 0 :repetitions (if (and prep-failed? (= :reuse context)) 2 1)}
(get-in saved [:remaining :case/a])))
(t/is (= [:completed :completed] (mapv :outcome (attempts-for saved :case/b))))
(when (#{:main :reset} phase)
(t/is (= {:cause [{:message "WASM trap" :code "E_RENDER"}]
:reason :renderer-rejected :deadline-exceeded false}
(select-keys (:failure (first attempts)) [:cause :reason :deadline-exceeded])))
(t/is (= {:completed-call 7} (:partial (first attempts)))))
(when (= :contradictory-preparation phase)
(t/is (= "invalid-output" (get-in (first (preparations-for saved :case/a)) [:failure :phase]))))
(when (= :invalid-reset phase)
@ -609,6 +621,65 @@
(when (#{:invalid-main :invalid-reset} phase)
(t/is (= "invalid-output" (get-in (first attempts) [:failure :phase])))))))
(t/deftest ^:async malformed-bridge-diagnostics-stop-the-case-with-delivered-evidence
(doseq [stage [:prepare :run]
evidence [nil "unexpected response"
{:status "unknown" :phase "main" :message "unexpected status"}
{:status "failed" :phase "main"}
{:status "failed" :phase "main" :message "bad cause" :cause [:invalid]}]]
(let [base (fake-browser {})
service (get-in base [:service stage])
delivered (atom nil)
fake (assoc-in base [:service stage]
(fn [page request bounds]
(let [response (service page request bounds)
params (transit/decode-str request)
case-id (if (= :prepare stage)
(get-in params [:case :id])
(get-in @(:state base) [:preparations (:preparation-id params)]))]
(if (= :case/a case-id)
(let [phases (if (= :prepare stage)
(get-in response [:preparation :phases])
(conj (:phases response) {:phase :reset}))
rejected (cond-> evidence
(map? evidence) (assoc :phases phases))]
(reset! delivered rejected)
(transit/decode-str (transit/encode-str rejected)))
response))))
response (await (execute fake [(collected :case/a :reuse) (collected :case/b :reuse)]
(assoc (options fake (output-dir)) :repetitions 2)))
saved (saved-record response)
failed (if (= :prepare stage)
(first (preparations-for saved :case/a))
(first (attempts-for saved :case/a)))]
(t/is (= 1 (:exit-code response)))
(t/is (= "invalid-output" (get-in failed [:failure :phase])))
(t/is (= "invalid bridge response" (get-in failed [:failure :message])))
(t/is (= @delivered (if (= :prepare stage)
(get-in failed [:failure :partial :response])
(get-in failed [:partial :response]))))
(t/is (= (if (map? evidence) [(first (:phases @delivered))] []) (:phases failed)))
(t/is (= {:warmups 0 :repetitions (if (= :prepare stage) 2 1)}
(get-in saved [:remaining :case/a])))
(t/is (= [:completed :completed] (mapv :outcome (attempts-for saved :case/b))))
(t/is (str/includes? (:report response) "invalid bridge response")))))
(t/deftest ^:async stale-bridge-results-stop-the-case-with-ownership-diagnostics
(doseq [stage [:prepare :run]]
(let [base (fake-browser {})
fake (assoc-in base [:service stage] (constantly {:status "stale"}))
response (await (execute fake [(collected :case/a :reuse)]
(assoc (options fake (output-dir)) :repetitions 2)))
saved (saved-record response)
failure (get-in saved [:case-outcomes :case/a :failure])]
(t/is (= 1 (:exit-code response)))
(t/is (= :failed (get-in saved [:case-outcomes :case/a :outcome])))
(t/is (= "stale" (:status failure)))
(t/is (= (if (= :prepare stage) "preparation" "attempt") (:phase failure)))
(t/is (= "browser context was superseded" (:message failure)))
(t/is (= {:warmups 0 :repetitions (if (= :prepare stage) 2 1)}
(get-in saved [:remaining :case/a]))))))
(t/deftest ^:async runner-terminates-a-blocked-page-and-completes-the-next-case
(let [dir (build-dir)
owners (atom [])