From 6662025e6be5b7bdd4613b1c11695c0f1ec8af5c Mon Sep 17 00:00:00 2001 From: Jeff Crouse Date: Tue, 11 Aug 2026 08:55:59 -0400 Subject: [PATCH] feat(gpu/ADR-0019): surface silent GPU-operator init failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GPU op whose shader/pipeline failed to initialize did `if (!pipe_) return;` — a black frame with NO log, toast, health signal, or node badge, and no way to tell a broken shader from a routing problem. (This cost hours in the grid reactive-visuals session: a custom op rendered pure black with zero feedback.) Reuse the already-wired per-node error channel (vivid_report_gpu_error → VisualNode::runtime_error → node badge) and make it loud + observable: - gpu_common.h: `report_if_no_pipeline(c, pipe, err)` — ops call it in the pipeline guard so a failed init reports the concise WGSL error EVERY frame it is down (the runtime value-inits ctx per frame; a one-shot report flickers). Plus `concise_gpu_error()` to reduce a wgpu validation dump to one line. - Sweep 14 GPU ops onto it (cosine_palette, time_machine, solids, emitter, instancer, feedback, bloom, mesh, note_type, text, vectortext, gradient, step_bars, blit_op). Ops with two pipelines (mesh/note_type/vectortext) report inline. The 6 ops that already reported (switch/image/video/mesh_render/ mesh_displace/model) are unchanged and now get the promotion for free. - frame.cpp `promote_operator_errors`: edge-trigger a node's new runtime_error to VLOG_ERR (→ auto-toast + header dot, mirroring the ADR-0016 shader-file path). Without this a broken op only showed a node badge — invisible headless. - runtime health: `errored_ops` count in the snapshot + get_health JSON; severity WARNING (not Error) since the channel also carries soft notices like Render3D's light-ceiling — it must not red-alert a whole session. Verified: a project op with intentionally-broken WGSL now yields get_health.errored_ops=1 + a promoted "operator 'X' error: " log (was: silent black). Full ctest 99/99; test_runtime_health covers the new errored_ops severity + JSON. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QB6eSNJE55ru3g4vsL77GT --- .../content-visual/cosine_palette.cpp | 6 ++-- .../packages/content-visual/emitter.cpp | 6 ++-- .../packages/content-visual/instancer.cpp | 6 ++-- .../packages/content-visual/solids.cpp | 6 ++-- .../packages/content-visual/time_machine.cpp | 6 ++-- app/operators/packages/core-visuals/blit_op.h | 6 ++-- app/operators/packages/core-visuals/bloom.cpp | 6 ++-- .../packages/core-visuals/feedback.cpp | 6 ++-- app/operators/packages/core-visuals/mesh.cpp | 9 +++-- .../packages/core-visuals/note_type.cpp | 9 +++-- app/operators/packages/core-visuals/text.cpp | 6 ++-- .../packages/core-visuals/vectortext.cpp | 9 +++-- .../packages/example-visuals/gradient.cpp | 6 ++-- app/operators/steps/step_bars.cpp | 6 ++-- app/src/app/frame.cpp | 19 +++++++++++ app/src/app/runtime_health.cpp | 11 +++--- app/src/app/runtime_health.h | 1 + app/src/app/runtime_health_collect.cpp | 1 + app/src/gpu/visual_graph.cpp | 6 ++++ app/src/gpu/visual_graph.h | 4 +++ app/src/operator_api/gpu_common.h | 34 +++++++++++++++++++ app/tests/test_runtime_health.cpp | 8 +++++ 22 files changed, 131 insertions(+), 46 deletions(-) diff --git a/app/operators/packages/content-visual/cosine_palette.cpp b/app/operators/packages/content-visual/cosine_palette.cpp index 1916e3892..2b1405a43 100644 --- a/app/operators/packages/content-visual/cosine_palette.cpp +++ b/app/operators/packages/content-visual/cosine_palette.cpp @@ -50,7 +50,7 @@ struct CosinePaletteOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param c_r{"c_r", 1.0f, 0.f, 4.f}, c_g{"c_g", 1.0f, 0.f, 4.f}, c_b{"c_b", 1.0f, 0.f, 4.f}; vivid::Param d_r{"d_r", 0.0f, 0.f, 1.f}, d_g{"d_g", 0.33f, 0.f, 1.f}, d_b{"d_b", 0.67f, 0.f, 1.f}; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced via report_if_no_pipeline below WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUSampler samp_ = nullptr; WGPUBindGroup bg_ = nullptr; ~CosinePaletteOp() override { @@ -71,7 +71,7 @@ struct CosinePaletteOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kWGSL, "CosinePalette", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 80, "CosinePalette U"); WGPUBindGroupLayoutEntry e[3]{}; e[0].binding = 0; e[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; @@ -91,7 +91,7 @@ struct CosinePaletteOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; auto pv = [&](int i, float def) { return c->param_values ? c->param_values[i] : def; }; // uniform: a.xyz0, b.xyz0, c.xyz0, d.xyz0, (phase, mix, 0, 0) — 20 floats / 80 bytes. const float u[20] = { diff --git a/app/operators/packages/content-visual/emitter.cpp b/app/operators/packages/content-visual/emitter.cpp index f31f6af07..f7e818ad4 100644 --- a/app/operators/packages/content-visual/emitter.cpp +++ b/app/operators/packages/content-visual/emitter.cpp @@ -63,7 +63,7 @@ struct EmitterOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param size{"size", 0.4f, 0.f, 1.f}; // particle radius vivid::Param spread{"spread", 0.8f, 0.f, 1.f}; // horizontal emit spread by pos - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; WGPUBuffer quad_ = nullptr; @@ -93,7 +93,7 @@ struct EmitterOp : vivid::OperatorBase, vivid::GpuProcessable { bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kWGSL, "Emitter", err); - if (!sh_ || !err.empty()) { vivid_report_gpu_error(c, ("Emitter WGSL: " + err).c_str()); return false; } + if (!sh_ || !err.empty()) { err_ = "Emitter WGSL: " + vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 16, "Emitter U"); const QVert quad[6] = { {-1,-1},{1,-1},{1,1}, {-1,-1},{1,1},{-1,1} }; WGPUBufferDescriptor qd{}; qd.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; qd.size = sizeof(quad); @@ -138,7 +138,7 @@ struct EmitterOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; const float* p = c->param_values; auto pv = [&](int i, float d) { return p ? p[i] : d; }; const int nper = 6 + static_cast(54.f * pv(0, count.value)); // particles per burst const float spd = 0.3f + 1.4f * pv(1, speed.value); diff --git a/app/operators/packages/content-visual/instancer.cpp b/app/operators/packages/content-visual/instancer.cpp index ec48df08e..243b6e82c 100644 --- a/app/operators/packages/content-visual/instancer.cpp +++ b/app/operators/packages/content-visual/instancer.cpp @@ -78,7 +78,7 @@ struct InstancerOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param sides{"sides", 6.f, 3.f, 8.f}; // polygon sides (shape=2) vivid::Param pulse{"pulse", 0.6f, 0.f, 1.f}; // pop amount on each note-on FIRE (re-strikes re-pop) - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; WGPUBuffer quad_ = nullptr; // static unit quad (6 verts) @@ -105,7 +105,7 @@ struct InstancerOp : vivid::OperatorBase, vivid::GpuProcessable { bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kWGSL, "Instancer", err); - if (!sh_ || !err.empty()) { vivid_report_gpu_error(c, ("Instancer WGSL: " + err).c_str()); return false; } + if (!sh_ || !err.empty()) { err_ = "Instancer WGSL: " + vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "Instancer U"); const QVert quad[6] = { {-1,-1},{1,-1},{1,1}, {-1,-1},{1,1},{-1,1} }; WGPUBufferDescriptor qd{}; qd.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; qd.size = sizeof(quad); @@ -150,7 +150,7 @@ struct InstancerOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; const float* p = c->param_values; auto pv = [&](int i, float d) { return p ? p[i] : d; }; const float base = 0.02f + 0.22f * pv(0, size.value); const float spr = pv(1, spread.value); diff --git a/app/operators/packages/content-visual/solids.cpp b/app/operators/packages/content-visual/solids.cpp index 92a1322a6..965180912 100644 --- a/app/operators/packages/content-visual/solids.cpp +++ b/app/operators/packages/content-visual/solids.cpp @@ -106,7 +106,7 @@ struct SolidsOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param trail{"trail", 0.3f, 0.f, 1.f}; vivid::Param wireframe{"wireframe", 0.f, 0.f, 1.f}; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; WGPUBuffer vbuf_[3] = {nullptr,nullptr,nullptr}; uint32_t vcount_[3] = {0,0,0}; // cube/tetra/octa @@ -151,7 +151,7 @@ struct SolidsOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kWGSL, "Solids", err); - if (!sh_ || !err.empty()) { vivid_report_gpu_error(c, ("Solids WGSL: " + err).c_str()); return false; } + if (!sh_ || !err.empty()) { err_ = "Solids WGSL: " + vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 80, "Solids U"); // mat4(64) + 4 f32(16) vbuf_[0] = make_vb(c, make_cube(), vcount_[0]); vbuf_[1] = make_vb(c, make_tetra(), vcount_[1]); @@ -199,7 +199,7 @@ struct SolidsOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; ensure_depth(c); const float* p = c->param_values; auto pv = [&](int i, float d){ return p ? p[i] : d; }; const int si = std::clamp(static_cast(std::round(pv(0, shape.value))), 0, 2); diff --git a/app/operators/packages/content-visual/time_machine.cpp b/app/operators/packages/content-visual/time_machine.cpp index bf4a8dd4c..9499c7d57 100644 --- a/app/operators/packages/content-visual/time_machine.cpp +++ b/app/operators/packages/content-visual/time_machine.cpp @@ -65,7 +65,7 @@ struct TimeMachineOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param frames {"frames", 30, 2, 120}; // history length vivid::Param offset {"offset", 0.f, 0.f, 1.f}; // shift the whole read-head back in time - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_blit_ = nullptr, sh_slit_ = nullptr; WGPUBindGroupLayout bgl_blit_ = nullptr, bgl_slit_ = nullptr; WGPUPipelineLayout pl_blit_ = nullptr, pl_slit_ = nullptr; @@ -139,7 +139,7 @@ struct TimeMachineOp : vivid::OperatorBase, vivid::GpuProcessable { std::string err; sh_blit_ = vivid::gpu::create_shader_checked(c->device, kBlitWGSL, "TimeMachine.blit", err); sh_slit_ = vivid::gpu::create_shader_checked(c->device, kSlitWGSL, "TimeMachine.slit", err); - if (!sh_blit_ || !sh_slit_ || !err.empty()) return false; + if (!sh_blit_ || !sh_slit_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "TimeMachine U"); // blit BGL: sampler(0), tex(1) WGPUBindGroupLayoutEntry be[2]{}; @@ -173,7 +173,7 @@ struct TimeMachineOp : vivid::OperatorBase, vivid::GpuProcessable { void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_slit_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_slit_, err_)) return; auto pv = [&](int i, float def) { return c->param_values ? c->param_values[i] : def; }; const int frames_req = c->param_values ? static_cast(c->param_values[1]) : static_cast(frames.value); diff --git a/app/operators/packages/core-visuals/blit_op.h b/app/operators/packages/core-visuals/blit_op.h index 4b8697260..852ebc1e9 100644 --- a/app/operators/packages/core-visuals/blit_op.h +++ b/app/operators/packages/core-visuals/blit_op.h @@ -30,7 +30,7 @@ inline const char* kBlitWGSL = R"( struct BlitOp : vivid::OperatorBase, vivid::GpuProcessable { const char* label_; explicit BlitOp(const char* label) : label_(label) {} - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUSampler samp_ = nullptr; WGPUBindGroup bg_ = nullptr; ~BlitOp() override { @@ -45,7 +45,7 @@ struct BlitOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kBlitWGSL, label_, err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } WGPUBindGroupLayoutEntry e[2]{}; e[0].binding = 0; e[0].visibility = WGPUShaderStage_Fragment; e[0].texture.sampleType = WGPUTextureSampleType_Float; e[0].texture.viewDimension = WGPUTextureViewDimension_2D; @@ -62,7 +62,7 @@ struct BlitOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; const WGPUTextureView in = (c->input_texture_count > 0) ? c->input_texture_views[0] : c->output_texture_view; if (bg_) { wgpuBindGroupRelease(bg_); bg_ = nullptr; } WGPUBindGroupEntry be[2]{}; diff --git a/app/operators/packages/core-visuals/bloom.cpp b/app/operators/packages/core-visuals/bloom.cpp index b77edd829..43daadfc0 100644 --- a/app/operators/packages/core-visuals/bloom.cpp +++ b/app/operators/packages/core-visuals/bloom.cpp @@ -92,7 +92,7 @@ struct BloomOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param intensity{"intensity", 0.8f, 0.f, 3.f}; vivid::Param radius{"radius", 1.5f, 0.f, 6.f}; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_bright_ = nullptr, sh_blurh_ = nullptr, sh_blurv_ = nullptr, sh_comp_ = nullptr; WGPUBindGroupLayout bgl1_ = nullptr, bgl2_ = nullptr; WGPUPipelineLayout pl1_ = nullptr, pl2_ = nullptr; @@ -166,7 +166,7 @@ struct BloomOp : vivid::OperatorBase, vivid::GpuProcessable { sh_blurh_ = vivid::gpu::create_shader_checked(c->device, blurh.c_str(), "Bloom.blurH", err); sh_blurv_ = vivid::gpu::create_shader_checked(c->device, blurv.c_str(), "Bloom.blurV", err); sh_comp_ = vivid::gpu::create_shader_checked(c->device, kCompositeWGSL, "Bloom.composite", err); - if (!sh_bright_ || !sh_blurh_ || !sh_blurv_ || !sh_comp_ || !err.empty()) return false; + if (!sh_bright_ || !sh_blurh_ || !sh_blurv_ || !sh_comp_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "Bloom U"); bgl1_ = make_bgl(c->device, 1); // bright + blur: one source texture bgl2_ = make_bgl(c->device, 2); // composite: original + bloom @@ -198,7 +198,7 @@ struct BloomOp : vivid::OperatorBase, vivid::GpuProcessable { void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_comp_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_comp_, err_)) return; ensure_tex(c); release_frame_bgs(); diff --git a/app/operators/packages/core-visuals/feedback.cpp b/app/operators/packages/core-visuals/feedback.cpp index 5a9dbc0f8..389566820 100644 --- a/app/operators/packages/core-visuals/feedback.cpp +++ b/app/operators/packages/core-visuals/feedback.cpp @@ -40,7 +40,7 @@ struct FeedbackOp : vivid::OperatorBase, vivid::GpuProcessable { static constexpr const char* kSummary = "Frame feedback / trails: blends the input with a decaying history texture."; static constexpr std::array kKeywords = {"effect", "feedback", "trails"}; vivid::Param decay{"decay", 0.5f, 0.f, 1.f}; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUSampler samp_ = nullptr; WGPUBindGroup bg_ = nullptr; WGPUTexture hist_ = nullptr; WGPUTextureView hist_view_ = nullptr; uint32_t hw_ = 0, hh_ = 0; @@ -73,7 +73,7 @@ struct FeedbackOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kFeedbackWGSL, "Feedback", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "Feedback U"); WGPUBindGroupLayoutEntry e[4]{}; e[0].binding = 0; e[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; @@ -95,7 +95,7 @@ struct FeedbackOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; ensure_hist(c); const float d = 0.82f + (c->param_values ? c->param_values[0] : decay.value) * 0.16f; // 0.82..0.98 float u[8] = { float(c->output_width), float(c->output_height), float(c->time), d, 0.f, 0.f, 0.f, 0.f }; diff --git a/app/operators/packages/core-visuals/mesh.cpp b/app/operators/packages/core-visuals/mesh.cpp index 5cdb5bc66..b0b360f99 100644 --- a/app/operators/packages/core-visuals/mesh.cpp +++ b/app/operators/packages/core-visuals/mesh.cpp @@ -82,7 +82,7 @@ struct MeshOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param spin{"spin", 0.35f, 0.f, 1.f}, tilt{"tilt", 0.5f, 0.f, 1.f}; vivid::Param r{"r", 0.9f, 0.f, 1.f}, g{"g", 0.92f, 0.f, 1.f}, b{"b", 1.f, 0.f, 1.f}; vivid::Param bg_r{"bg_r", 0.03f, 0.f, 1.f}, bg_g{"bg_g", 0.03f, 0.f, 1.f}, bg_b{"bg_b", 0.05f, 0.f, 1.f}; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline solid_pipe_ = nullptr, wire_pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; WGPUBuffer tri_vbo_ = nullptr, line_vbo_ = nullptr; uint32_t tri_n_ = 0, line_n_ = 0; @@ -194,7 +194,7 @@ struct MeshOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kMeshWGSL, "Mesh", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 160, "Mesh U"); WGPUBindGroupLayoutEntry e{}; e.binding = 0; e.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; e.buffer.type = WGPUBufferBindingType_Uniform; e.buffer.minBindingSize = 160; @@ -211,7 +211,10 @@ struct MeshOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!solid_pipe_ || !wire_pipe_) return; + if (!solid_pipe_ || !wire_pipe_) { // ADR-0019: surface the init failure, don't render silent + vivid_report_gpu_error(c, err_.empty() ? "GPU pipeline failed to initialize" : err_.c_str()); + return; + } const float* p = c->param_values; auto pv = [&](int i, float d) { return p ? p[i] : d; }; const int s = static_cast(std::lround(pv(0, shape.value) * 3.f)); // 0..3 if (s != shape_) rebuild_geometry(c, s); diff --git a/app/operators/packages/core-visuals/note_type.cpp b/app/operators/packages/core-visuals/note_type.cpp index fcd42706d..639d897e8 100644 --- a/app/operators/packages/core-visuals/note_type.cpp +++ b/app/operators/packages/core-visuals/note_type.cpp @@ -65,7 +65,7 @@ struct TypeOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::FtFont font_; bool font_tried_ = false; std::string text_, baked_text_ = "\x01"; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline stencil_pipe_ = nullptr, cover_pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; @@ -167,7 +167,7 @@ struct TypeOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kWGSL, "Type", err); - if (!sh_ || !err.empty()) { vivid_report_gpu_error(c, ("Type WGSL: " + err).c_str()); return false; } + if (!sh_ || !err.empty()) { err_ = "Type WGSL: " + vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 48, "Type U"); WGPUBindGroupLayoutEntry e{}; e.binding = 0; e.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; e.buffer.type = WGPUBufferBindingType_Uniform; e.buffer.minBindingSize = 48; @@ -184,7 +184,10 @@ struct TypeOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!stencil_pipe_ || !cover_pipe_) return; + if (!stencil_pipe_ || !cover_pipe_) { // ADR-0019: surface the init failure, don't render silent + vivid_report_gpu_error(c, err_.empty() ? "GPU pipeline failed to initialize" : err_.c_str()); + return; + } const float* p = c->param_values; auto pv = [&](int i, float d) { return p ? p[i] : d; }; text_ = build_text(vivid::elements::input_signal(c, 0), static_cast(std::lround(pv(9, mode.value)))); if (!vbo_ || text_ != baked_text_) rebuild_geometry(c); diff --git a/app/operators/packages/core-visuals/text.cpp b/app/operators/packages/core-visuals/text.cpp index 6a068a27e..a69251598 100644 --- a/app/operators/packages/core-visuals/text.cpp +++ b/app/operators/packages/core-visuals/text.cpp @@ -60,7 +60,7 @@ struct TextOp : vivid::OperatorBase, vivid::GpuProcessable { std::string loaded_path_ = "\x01", text_, baked_text_ = "\x01"; vivid::FtFont font_; bool font_tried_ = false; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUSampler samp_ = nullptr; WGPUTexture txt_ = nullptr; WGPUTextureView txtv_ = nullptr; WGPUBindGroup bg_ = nullptr; @@ -143,7 +143,7 @@ struct TextOp : vivid::OperatorBase, vivid::GpuProcessable { bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kTextWGSL, "Text", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 48, "Text U"); WGPUBindGroupLayoutEntry e[4]{}; e[0].binding = 0; e[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; @@ -165,7 +165,7 @@ struct TextOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; if (file.str_value != loaded_path_) { // reload the string (the .txt file's contents) on change loaded_path_ = file.str_value; text_.clear(); diff --git a/app/operators/packages/core-visuals/vectortext.cpp b/app/operators/packages/core-visuals/vectortext.cpp index f0c2ccba3..eac5294b5 100644 --- a/app/operators/packages/core-visuals/vectortext.cpp +++ b/app/operators/packages/core-visuals/vectortext.cpp @@ -55,7 +55,7 @@ struct VectorTextOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::FtFont font_; bool font_tried_ = false; std::string loaded_path_ = "\x01", text_, baked_text_ = "\x01"; - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline stencil_pipe_ = nullptr, cover_pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; @@ -140,7 +140,7 @@ struct VectorTextOp : vivid::OperatorBase, vivid::GpuProcessable { } bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kVectorTextWGSL, "VectorText", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 48, "VectorText U"); WGPUBindGroupLayoutEntry e{}; e.binding = 0; e.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; e.buffer.type = WGPUBufferBindingType_Uniform; e.buffer.minBindingSize = 48; @@ -157,7 +157,10 @@ struct VectorTextOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!stencil_pipe_ || !cover_pipe_) return; + if (!stencil_pipe_ || !cover_pipe_) { // ADR-0019: surface the init failure, don't render silent + vivid_report_gpu_error(c, err_.empty() ? "GPU pipeline failed to initialize" : err_.c_str()); + return; + } if (file.str_value != loaded_path_) { // reload the string from the .txt file loaded_path_ = file.str_value; text_.clear(); if (!file.str_value.empty()) { std::ifstream f(file.str_value); if (f) text_.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); } diff --git a/app/operators/packages/example-visuals/gradient.cpp b/app/operators/packages/example-visuals/gradient.cpp index 41f1fb83d..a2bc2f247 100644 --- a/app/operators/packages/example-visuals/gradient.cpp +++ b/app/operators/packages/example-visuals/gradient.cpp @@ -38,7 +38,7 @@ struct GradientOp : vivid::OperatorBase, vivid::GpuProcessable { vivid::Param hue {"hue", 0.0f, 0.f, 1.f}; vivid::Param tilt{"tilt", 0.0f, 0.f, 1.f}; // 0 = vertical, 1 = horizontal - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; @@ -53,7 +53,7 @@ struct GradientOp : vivid::OperatorBase, vivid::GpuProcessable { bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kGradientWGSL, "Gradient", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "Gradient U"); WGPUBindGroupLayoutEntry e{}; e.binding = 0; e.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; @@ -70,7 +70,7 @@ struct GradientOp : vivid::OperatorBase, vivid::GpuProcessable { } void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; const float h = c->param_values ? c->param_values[0] : hue.value; const float t = c->param_values ? c->param_values[1] : tilt.value; float u[8] = { float(c->output_width), float(c->output_height), float(c->time), h, t, 0, 0, 0 }; diff --git a/app/operators/steps/step_bars.cpp b/app/operators/steps/step_bars.cpp index ef111e01f..26c373eb3 100644 --- a/app/operators/steps/step_bars.cpp +++ b/app/operators/steps/step_bars.cpp @@ -57,7 +57,7 @@ struct StepBarsOp : vivid::OperatorBase, vivid::GpuProcessable { int editor_sel_ = 0; // keyboard-selected column (editor-only UI state, lives on the instance) - bool tried_ = false; + bool tried_ = false; std::string err_; // ADR-0019: surfaced per-frame via report_if_no_pipeline WGPUShaderModule sh_ = nullptr; WGPUBindGroupLayout bgl_ = nullptr; WGPUPipelineLayout pl_ = nullptr; WGPURenderPipeline pipe_ = nullptr; WGPUBuffer ubo_ = nullptr; WGPUBindGroup bg_ = nullptr; @@ -76,7 +76,7 @@ struct StepBarsOp : vivid::OperatorBase, vivid::GpuProcessable { bool lazy_init(const VividGpuContext* c) { std::string err; sh_ = vivid::gpu::create_shader_checked(c->device, kBarsWGSL, "StepBars", err); - if (!sh_ || !err.empty()) return false; + if (!sh_ || !err.empty()) { err_ = vivid::gpu::concise_gpu_error(err); return false; } ubo_ = vivid::gpu::create_uniform_buffer(c->device, 32, "StepBars U"); WGPUBindGroupLayoutEntry e{}; e.binding = 0; e.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; @@ -94,7 +94,7 @@ struct StepBarsOp : vivid::OperatorBase, vivid::GpuProcessable { void process_gpu(const VividGpuContext* c) override { if (!tried_) { tried_ = true; lazy_init(c); } - if (!pipe_) return; + if (vivid::gpu::report_if_no_pipeline(c, pipe_, err_)) return; float u[8]; for (int i = 0; i < kSteps; ++i) u[i] = c->param_values ? c->param_values[i] : s[i].value; wgpuQueueWriteBuffer(c->queue, ubo_, 0, u, sizeof(u)); diff --git a/app/src/app/frame.cpp b/app/src/app/frame.cpp index 19bc4dee2..3544d5836 100644 --- a/app/src/app/frame.cpp +++ b/app/src/app/frame.cpp @@ -596,6 +596,24 @@ void apply_shader_reloads(App& app) { } } +// ADR-0019: promote a GPU operator's per-frame runtime error (set via vivid_report_gpu_error, read +// back into VisualNode::runtime_error) to a log line — which the log→toast promotion below turns into +// a toast, and the header dot reflects. Edge-triggered per node so a persistent error logs ONCE, and +// re-logs if it clears then recurs. Mirrors apply_shader_reloads' loudness for the shader-file path; +// without this a broken compiled op only showed a node badge (easy to miss, invisible to headless/MCP). +void promote_operator_errors(App& app) { + if (!app.vgraph) return; + for (auto& n : app.vgraph->nodes()) { + if (!n.runtime_error.empty() && !n.runtime_error_reported) { + n.runtime_error_reported = true; + VLOG_ERR(app, "operator '%s' error: %s", + (n.label.empty() ? n.op_type : n.label).c_str(), n.runtime_error.c_str()); + } else if (n.runtime_error.empty() && n.runtime_error_reported) { + n.runtime_error_reported = false; // cleared — allow a fresh report if it recurs + } + } +} + void run_frame_loop(App& app, Window& win) { // Local aliases to the shared engine (App) + this view (Window) so the tick // body reads naturally; every object is owned by main(), not here. @@ -809,6 +827,7 @@ void run_frame_loop(App& app, Window& win) { vgraph.set_metronome(static_cast(transport.bpm.load(std::memory_order_relaxed)), transport.beats_per_bar.load(std::memory_order_relaxed), beats); vgraph.run_chain(frame.encoder, tsec); + promote_operator_errors(app); // ADR-0019: a compiled op that failed init is now loud } gpu.gpu_mark(frame.encoder, "visuals"); // GPU timing: end of the output render (vs. the editor UI that follows) win.preview.out_aspect = vgraph.rt_aspect(); // cache: drives the preview's height + hit-rects diff --git a/app/src/app/runtime_health.cpp b/app/src/app/runtime_health.cpp index 2cfcba16f..f80f26791 100644 --- a/app/src/app/runtime_health.cpp +++ b/app/src/app/runtime_health.cpp @@ -13,9 +13,11 @@ Severity severity(const HealthSnapshot& s) { // Only oversized blocks feed this — an idle/empty session's by-design silence is not a bailout. if (s.audio_bailout_error_threshold > 0 && s.audio_render_bailouts >= s.audio_bailout_error_threshold) return Severity::Error; - // Recoverable-but-noteworthy: the GPU reported (and survived) errors, or the agent - // control surface isn't up. - if (s.gpu_errors > 0 || !s.control_running) return Severity::Warning; + // Recoverable-but-noteworthy: the GPU reported (and survived) errors, an operator reported a + // runtime problem (a failed-init op rendering black, or a soft notice like Render3D's light + // ceiling), or the agent control surface isn't up. Warning, not Error — the app keeps running and + // the channel carries soft notices too, so it must not spuriously red-alert a whole session. + if (s.gpu_errors > 0 || s.errored_ops > 0 || !s.control_running) return Severity::Warning; // ADR-0031: over-budget callbacks or skipped try_lock handoffs are recoverable realtime pressure — // a passive Warning (frame.cpp only promotes Error to a toast, so this never nags). if (s.audio_over_budget > 0 || s.audio_handoff_skips > 0) return Severity::Warning; @@ -70,7 +72,8 @@ nlohmann::json to_json(const HealthSnapshot& s) { j["gpu"] = { {"ok", s.gpu_ok}, {"errors", s.gpu_errors} }; if (!s.gpu_last_error.empty()) j["gpu"]["last_error"] = s.gpu_last_error; j["graph"] = { {"op_nodes", s.op_nodes}, {"op_types", s.op_types}, - {"missing_ops", s.missing_ops}, {"output_fed", s.output_fed} }; + {"missing_ops", s.missing_ops}, {"errored_ops", s.errored_ops}, + {"output_fed", s.output_fed} }; j["packages"] = { {"loaded", s.packages_loaded} }; j["control"] = { {"running", s.control_running} }; return j; diff --git a/app/src/app/runtime_health.h b/app/src/app/runtime_health.h index d5628397a..3ee811e30 100644 --- a/app/src/app/runtime_health.h +++ b/app/src/app/runtime_health.h @@ -64,6 +64,7 @@ struct HealthSnapshot { int op_nodes = 0; // op nodes in the visuals chain int op_types = 0; // registered operator types (built-in + loaded) int missing_ops = 0; // chain nodes whose op type isn't registered (BROKEN) + int errored_ops = 0; // chain nodes reporting a runtime_error this frame (ADR-0019) int packages_loaded = 0; // dlopen'd operator dylibs // Structural blank-vs-empty signal (P2-03): true = a producer feeds the active Output; false = // nothing is wired to Output ("empty by design"). A benign, intended state — NOT a failure — so diff --git a/app/src/app/runtime_health_collect.cpp b/app/src/app/runtime_health_collect.cpp index 8199cd6d2..25c877706 100644 --- a/app/src/app/runtime_health_collect.cpp +++ b/app/src/app/runtime_health_collect.cpp @@ -38,6 +38,7 @@ HealthSnapshot collect_health(const App& app) { // authority (it excludes the Output/Video host contracts, which carry no operator yet are not // "missing" — counting them here made severity() spuriously Error in every session). if (app.vgraph) s.missing_ops = app.vgraph->missing_op_count(); + if (app.vgraph) s.errored_ops = app.vgraph->errored_op_count(); // Structural blank-vs-empty signal (P2-03): does a producer feed the active Output? if (app.vgraph) s.output_fed = app.vgraph->output_has_feed(); diff --git a/app/src/gpu/visual_graph.cpp b/app/src/gpu/visual_graph.cpp index 32d4a82f4..180aa29d1 100644 --- a/app/src/gpu/visual_graph.cpp +++ b/app/src/gpu/visual_graph.cpp @@ -106,6 +106,12 @@ int VisualGraph::missing_op_count() const { return n; } +int VisualGraph::errored_op_count() const { + int n = 0; + for (const auto& nd : nodes_) if (!nd.runtime_error.empty()) ++n; + return n; +} + std::vector VisualGraph::missing_op_node_indices() const { std::vector out; for (int i = 0; i < static_cast(nodes_.size()); ++i) if (nodes_[i].op_missing()) out.push_back(i); diff --git a/app/src/gpu/visual_graph.h b/app/src/gpu/visual_graph.h index 7fd9853de..5cd5605f0 100644 --- a/app/src/gpu/visual_graph.h +++ b/app/src/gpu/visual_graph.h @@ -100,6 +100,9 @@ struct VisualNode { // refreshed (or cleared) every frame it runs. Feeds error() above. ADR-0051 P4 wired this in: // the ABI field existed and operators could set it, but nothing read it back. std::string runtime_error; + // Edge-trigger latch so the app promotes a NEW runtime_error to a log line + toast exactly once + // (not every frame it persists), and re-promotes if it clears then recurs. ADR-0019 loudness. + bool runtime_error_reported = false; // Port-indexed input-edge access; out-of-range reads return -1 (unconnected). int in(int port) const { return (port >= 0 && port < static_cast(inputs.size())) ? inputs[port] : -1; } @@ -138,6 +141,7 @@ class VisualGraph { WGPUDevice device() const { return dev_; } WGPUQueue queue() const { return q_; } int missing_op_count() const; // nodes whose op type isn't a real operator (ADR-0019) + int errored_op_count() const; // nodes carrying a runtime_error this frame (ADR-0019) std::vector missing_op_node_indices() const; // indices of those broken nodes (diagnostics panel) int add_node(const std::string& type); // returns new node index (fresh id) void load_node(const std::string& type, int id); // append with a persisted id diff --git a/app/src/operator_api/gpu_common.h b/app/src/operator_api/gpu_common.h index f2ef9509a..19e5cfa53 100644 --- a/app/src/operator_api/gpu_common.h +++ b/app/src/operator_api/gpu_common.h @@ -93,6 +93,40 @@ inline WGPUShaderModule create_shader_checked(WGPUDevice device, const char* fra return sm; } +// Reduce a multi-line wgpu validation dump to the single most useful diagnostic line (the "error:" +// line if present, else the first substantive line) — a legible message for a node badge / toast / +// log rather than the full boilerplate. Returns the input unchanged when nothing better is found. +inline std::string concise_gpu_error(const std::string& msg) { + std::string best; + size_t start = 0; + while (start <= msg.size()) { + const size_t nl = msg.find('\n', start); + std::string line = msg.substr(start, nl == std::string::npos ? std::string::npos : nl - start); + while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) line.erase(line.begin()); + if (const size_t at = line.find("error: "); at != std::string::npos) + best = line.substr(at + 7); + else if (line.find("Validation Error") == std::string::npos && + line.find("In wgpu") == std::string::npos && !line.empty() && best.empty()) + best = line; + if (nl == std::string::npos) break; + start = nl + 1; + } + return best.empty() ? msg : best; +} + +// ADR-0019: surface a GPU operator's failed pipeline init instead of silently rendering black. Call +// this in process_gpu's pipeline guard: `if (report_if_no_pipeline(c, pipe_, init_err_)) return;`. +// When `pipe` is null it reports `err` (or a generic message) through the per-node error channel +// (vivid_report_gpu_error), which the runtime promotes to the node badge + log/toast/health. Must be +// called EVERY frame while the pipeline is down (the runtime re-reads the flag per frame), so `err` +// must outlive the call — store it in an operator std::string member. Returns true when it reported. +inline bool report_if_no_pipeline(const VividGpuContext* c, WGPURenderPipeline pipe, + const std::string& err) { + if (pipe) return false; + vivid_report_gpu_error(c, err.empty() ? "GPU pipeline failed to initialize" : err.c_str()); + return true; +} + // --------------------------------------------------------------------------- // Helper: create a fullscreen render pipeline with N color targets (MRT). // `formats[i]` is the WGPUTextureFormat of color attachment i; the fragment diff --git a/app/tests/test_runtime_health.cpp b/app/tests/test_runtime_health.cpp index 86e7b745a..0582dd384 100644 --- a/app/tests/test_runtime_health.cpp +++ b/app/tests/test_runtime_health.cpp @@ -26,6 +26,12 @@ int main() { warn2.control_running = false; CHECK(severity(warn2) == Severity::Warning); + // An operator reporting a runtime error (a failed-init op rendering black, or a soft notice) -> + // Warning, NOT Error (ADR-0019): the app keeps running and the channel carries soft notices too. + HealthSnapshot warn3 = ok; + warn3.errored_ops = 1; + CHECK(severity(warn3) == Severity::Warning); + // Device lost -> Error (hard breakage). HealthSnapshot err1 = ok; err1.gpu_ok = false; @@ -57,6 +63,8 @@ int main() { CHECK(j["app_version"] == "9.9.9"); CHECK(j["graph"]["op_nodes"] == 3); CHECK(j["graph"]["missing_ops"] == 0); + CHECK(j["graph"]["errored_ops"] == 0); + CHECK(to_json(warn3)["graph"]["errored_ops"] == 1); CHECK(j["graph"]["output_fed"] == true); // default (fed) CHECK(to_json(unfed)["graph"]["output_fed"] == false); CHECK(j["gpu"]["ok"] == true);