From b1fca952507223e4ec3f18f06f9e9a5fe1f05141 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Tue, 11 Aug 2026 08:32:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9Bbu?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web_server/Gallery_Plot_Session.cpp | 97 +++++++++--- web_server/tests/Web_Bridge_Tests.cpp | 213 ++++++++++++++++++-------- webapp/app.js | 97 ++++++++---- webapp/index.html | 4 +- webapp_gallery/app.js | 119 ++++++++++---- webapp_gallery/index.html | 14 +- 6 files changed, 394 insertions(+), 150 deletions(-) diff --git a/web_server/Gallery_Plot_Session.cpp b/web_server/Gallery_Plot_Session.cpp index 2ba9bbe..3373f03 100644 --- a/web_server/Gallery_Plot_Session.cpp +++ b/web_server/Gallery_Plot_Session.cpp @@ -226,6 +226,8 @@ public: std::uint64_t buffered_bytes, std::uint64_t changed_pixel_frames, std::uint64_t duplicate_pixel_frames, + std::uint64_t frame_request_timeout_count, + double last_pixel_receive_age_ms, double last_pixel_change_age_ms) noexcept { client_transport_fps_ = std::isfinite(transport_fps) ? std::clamp(transport_fps, 0.0, 100000.0) : 0.0; @@ -234,6 +236,9 @@ public: client_buffered_bytes_ = buffered_bytes; client_changed_pixel_frames_ = changed_pixel_frames; client_duplicate_pixel_frames_ = duplicate_pixel_frames; + client_frame_request_timeout_count_ = frame_request_timeout_count; + client_last_pixel_receive_age_ms_ = std::isfinite(last_pixel_receive_age_ms) ? + std::max(0.0, last_pixel_receive_age_ms) : 0.0; client_last_pixel_change_age_ms_ = std::isfinite(last_pixel_change_age_ms) ? std::max(0.0, last_pixel_change_age_ms) : 0.0; } @@ -560,7 +565,9 @@ public: std::chrono::duration(telemetry_now - performance_started_).count(), 1e-9); const double render_fps = recent_rate(render_history_, telemetry_now); - const double pixel_fps = recent_rate(pixel_history_, telemetry_now); + const double pixel_fps = recent_pixel_rate(pixel_history_, telemetry_now); + const double pixel_megabytes_per_second = recent_pixel_megabytes_per_second( + pixel_history_, telemetry_now); nlohmann::json telemetry{ {"case", case_id_}, {"frame_mode", frame_mode_name(frame_mode_)}, @@ -587,8 +594,7 @@ public: {"maximum_pixel_encode_ms", maximum_pixel_encode_ms_}, {"last_pixel_request_ms", last_pixel_request_ms_}, {"last_pixel_bytes", last_pixel_bytes_}, - {"pixel_payload_megabytes_per_second", - pixel_fps * static_cast(last_pixel_bytes_) / 1e6}, + {"pixel_payload_megabytes_per_second", pixel_megabytes_per_second}, {"automatic_low_latency_scheduler", automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency}, {"kernel_paint_ms", static_cast(observer.paint_duration_ns) / 1e6}, @@ -630,6 +636,8 @@ public: {"websocket_buffered_bytes", client_buffered_bytes_}, {"changed_pixel_frames", client_changed_pixel_frames_}, {"duplicate_pixel_frames", client_duplicate_pixel_frames_}, + {"frame_request_timeout_count", client_frame_request_timeout_count_}, + {"last_pixel_receive_age_ms", client_last_pixel_receive_age_ms_}, {"last_pixel_change_age_ms", client_last_pixel_change_age_ms_} }}, {"last_action_result", last_action_result_} @@ -787,6 +795,10 @@ public: private: using Performance_Clock = std::chrono::steady_clock; + struct Pixel_Performance_Sample { + Performance_Clock::time_point time; + std::size_t bytes; + }; static void record_timestamp(std::deque& history, Performance_Clock::time_point now) { @@ -805,6 +817,39 @@ private: return seconds > 0.0 ? static_cast(history.size() - 1) / seconds : 0.0; } + static void record_pixel_sample(std::deque& history, + Performance_Clock::time_point now, + std::size_t bytes) { + history.push_back({now, bytes}); + const auto oldest = now - std::chrono::seconds(1); + while (history.size() > 2 && history.front().time < oldest) + history.pop_front(); + } + + static double recent_pixel_rate(const std::deque& history, + Performance_Clock::time_point now) { + if (history.size() < 2 || now - history.back().time > std::chrono::seconds(1)) + return 0.0; + const double seconds = + std::chrono::duration(history.back().time - history.front().time).count(); + return seconds > 0.0 ? static_cast(history.size() - 1) / seconds : 0.0; + } + + static double recent_pixel_megabytes_per_second( + const std::deque& history, + Performance_Clock::time_point now) { + if (history.size() < 2 || now - history.back().time > std::chrono::seconds(1)) + return 0.0; + const double seconds = + std::chrono::duration(history.back().time - history.front().time).count(); + if (seconds <= 0.0) + return 0.0; + std::size_t bytes{}; + for (std::size_t index = 1; index < history.size(); ++index) + bytes += history[index].bytes; + return static_cast(bytes) / seconds / 1e6; + } + void record_performance(std::chrono::steady_clock::time_point started, bool rendered) { const auto finished = std::chrono::steady_clock::now(); ++render_attempt_count_; @@ -834,7 +879,7 @@ private: total_pixel_encode_ms_ += last_pixel_encode_ms_; last_pixel_bytes_ = pixel_bytes; ++pixel_frame_count_; - record_timestamp(pixel_history_, encode_finished); + record_pixel_sample(pixel_history_, encode_finished, pixel_bytes); } void maybe_log_performance(Performance_Clock::time_point now) { @@ -845,7 +890,9 @@ private: const auto unix_ms = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); - const double pixel_fps = recent_rate(pixel_history_, now); + const double pixel_fps = recent_pixel_rate(pixel_history_, now); + const double pixel_megabytes_per_second = recent_pixel_megabytes_per_second( + pixel_history_, now); const nlohmann::json line{ {"event", "gallery_performance"}, {"unix_ms", unix_ms}, @@ -860,14 +907,15 @@ private: {"client_presentation_fps", client_presentation_fps_}, {"client_changed_pixel_frames", client_changed_pixel_frames_}, {"client_duplicate_pixel_frames", client_duplicate_pixel_frames_}, + {"client_frame_request_timeout_count", client_frame_request_timeout_count_}, + {"client_last_pixel_receive_age_ms", client_last_pixel_receive_age_ms_}, {"client_last_pixel_change_age_ms", client_last_pixel_change_age_ms_}, {"last_render_ms", last_render_ms_}, {"last_pixel_snapshot_ms", last_pixel_snapshot_ms_}, {"last_pixel_encode_ms", last_pixel_encode_ms_}, {"last_pixel_request_ms", last_pixel_request_ms_}, {"pixel_payload_bytes", last_pixel_bytes_}, - {"pixel_payload_megabytes_per_second", - pixel_fps * static_cast(last_pixel_bytes_) / 1e6}, + {"pixel_payload_megabytes_per_second", pixel_megabytes_per_second}, {"websocket_buffered_bytes", client_buffered_bytes_}, {"automatic_low_latency_scheduler", automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency}, @@ -1371,12 +1419,14 @@ private: double maximum_pixel_encode_ms_{}; double last_pixel_request_ms_{}; std::size_t last_pixel_bytes_{}; - std::deque pixel_history_; + std::deque pixel_history_; double client_transport_fps_{}; double client_presentation_fps_{}; std::uint64_t client_buffered_bytes_{}; std::uint64_t client_changed_pixel_frames_{}; std::uint64_t client_duplicate_pixel_frames_{}; + std::uint64_t client_frame_request_timeout_count_{}; + double client_last_pixel_receive_age_ms_{}; double client_last_pixel_change_age_ms_{}; bool rendered_since_last_pixel_{}; std::string last_action_result_; @@ -1385,12 +1435,7 @@ private: struct Gallery_Plot_Session::Impl { explicit Impl(bool enable_automatic_low_latency) : automatic_low_latency(enable_automatic_low_latency), - session_id(next_session_id()) { - if (automatic_low_latency) { - render_worker = std::jthread( - [this](std::stop_token stop) { run_automatic_renderer(stop); }); - } - } + session_id(next_session_id()) {} ~Impl() { render_worker.request_stop(); @@ -1402,6 +1447,13 @@ struct Gallery_Plot_Session::Impl { return next.fetch_add(1, std::memory_order_relaxed); } + void ensure_render_worker() { + if (!automatic_low_latency || render_worker.joinable()) + return; + render_worker = std::jthread( + [this](std::stop_token stop) { run_automatic_renderer(stop); }); + } + void update_client_metrics(std::string_view message) { if (!scene) return; @@ -1433,6 +1485,8 @@ struct Gallery_Plot_Session::Impl { finite_metric("presentation_fps"), buffered_bytes, unsigned_metric("changed_pixel_frames"), unsigned_metric("duplicate_pixel_frames"), + unsigned_metric("frame_request_timeout_count"), + finite_metric("last_pixel_receive_age_ms"), finite_metric("last_pixel_change_age_ms")); } @@ -1497,11 +1551,10 @@ struct Gallery_Plot_Session::Impl { } continue; } - const auto spin_window = interval <= std::chrono::milliseconds(10) ? - std::chrono::duration_cast( - std::chrono::milliseconds(2)) : - std::chrono::duration_cast( - std::chrono::microseconds(250)); + const auto spin_window = std::min( + interval / 4, + std::chrono::duration_cast( + std::chrono::microseconds(250))); const auto coarse_deadline = deadline > cycle_started + spin_window ? deadline - spin_window : cycle_started; if (Clock::now() < coarse_deadline) { @@ -1515,8 +1568,8 @@ struct Gallery_Plot_Session::Impl { } lock.unlock(); - while (!stop.stop_requested() && Clock::now() < deadline) { - } + while (!stop.stop_requested() && Clock::now() < deadline) + std::this_thread::yield(); lock.lock(); } } @@ -1560,6 +1613,8 @@ struct Gallery_Plot_Session::Impl { Gallery_Protocol::default_state(open->case_id, open->frame_mode), open->frame_mode, automatic_low_latency); ++scene_generation; + if (scene->frame_mode() == Gallery_Frame_Mode::Low_Latency) + ensure_render_worker(); return Web_Response{Web_Response_Type::Json, Gallery_Protocol::case_json(scene->case_id(), scene->state(), scene->telemetry_json(), diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index 51d0caf..207a6a9 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -12,11 +12,13 @@ #include #include #include +#include #include #include #include #include #include +#include namespace renderive::web { namespace { @@ -74,6 +76,31 @@ nlohmann::json patch_controls(Gallery_Plot_Session& session, nlohmann::json patc Gallery_Request_Kind::Patch, request.dump()))); } +template +bool wait_for_condition(Predicate&& predicate, + std::chrono::milliseconds timeout = std::chrono::milliseconds(750)) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + if (predicate()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } while (std::chrono::steady_clock::now() < deadline); + return predicate(); +} + +void set_gallery_view_active(Gallery_Plot_Session& session, bool active) { + const auto event = Web_Event_Adapter::decode(active ? + R"({"category":"event","type":"show"})" : + R"({"category":"event","type":"hide"})"); + ASSERT_TRUE(event.has_value()); + EXPECT_FALSE(session.handle(*event).has_value()); +} + +std::uint64_t successful_render_count(Gallery_Plot_Session& session) { + return observe_telemetry(session).at("performance").at("successful_render_count") + .get(); +} + TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { const auto resize = Web_Event_Adapter::decode( R"({"category":"event","type":"resize","width":800,"height":450})"); @@ -648,7 +675,7 @@ TEST(RenderiveWebGallery, EveryCanvasAcceptsTheCompleteWebEventSetAndStillRender for (const auto case_id : cases) { for (const auto mode : modes) { SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode)); - Gallery_Plot_Session session; + Gallery_Plot_Session session(mode == "low_latency"); ASSERT_EQ(response_json(session.handle(gallery_request( Gallery_Request_Kind::Open, open_message(case_id, mode)))).at("type"), "case_state"); @@ -704,41 +731,41 @@ TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) { }; constexpr std::array modes{"manual", "low_latency", "playback"}; for (const std::string_view mode : modes) { - for (const std::string_view case_id : cases) { - Gallery_Plot_Session session; - const auto opened = session.handle(gallery_request( - Gallery_Request_Kind::Open, open_message(case_id, mode))); - ASSERT_TRUE(opened.has_value()) << case_id; - ASSERT_EQ(opened->type, Web_Response_Type::Json) << case_id; - const auto state = parse_json(opened->payload); - EXPECT_EQ(state.at("type"), "case_state") << case_id; - EXPECT_EQ(state.at("case").at("id").get(), std::string(case_id)) << case_id; - EXPECT_EQ(state.at("frame_mode").at("id"), std::string(mode)) << case_id; - EXPECT_EQ(state.at("controls").at("descriptor").at("protocol"), - "adminive.resource") << case_id; - EXPECT_EQ(state.at("controls").at("view").at("protocol"), - "adminive.view") << case_id; - - EXPECT_FALSE(session.handle(Viewport_Resize{{480, 280}}).has_value()) << case_id; - const auto frame = session.handle(Frame_Request{}); - ASSERT_TRUE(frame.has_value()) << case_id; - ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << case_id; - EXPECT_EQ(frame->payload.substr(0, 4), "RVP1") << case_id; - EXPECT_EQ(read_u32_le(frame->payload, 4), 480U) << case_id; - EXPECT_EQ(read_u32_le(frame->payload, 8), 280U) << case_id; - const auto observed = session.handle(gallery_request( - Gallery_Request_Kind::Observe, - R"({"category":"event","type":"gallery_observe"})")); - ASSERT_TRUE(observed.has_value()) << case_id; - const auto telemetry = parse_json(observed->payload); - EXPECT_EQ(telemetry.at("type"), "observer_state") << case_id; - EXPECT_EQ(telemetry.at("frame_mode").at("id"), std::string(mode)) << case_id; - EXPECT_TRUE(telemetry.at("telemetry").contains("kernel_observer")) << case_id; - EXPECT_TRUE(telemetry.at("telemetry").contains("performance")) << case_id; - EXPECT_TRUE(telemetry.at("telemetry").contains("data_shape")) << case_id; - EXPECT_GT(telemetry.at("telemetry").at("data_shape").at("rendered_elements") - .get(), 0U) << case_id; - } + for (const std::string_view case_id : cases) { + Gallery_Plot_Session session(mode == "low_latency"); + const auto opened = session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode))); + ASSERT_TRUE(opened.has_value()) << case_id; + ASSERT_EQ(opened->type, Web_Response_Type::Json) << case_id; + const auto state = parse_json(opened->payload); + EXPECT_EQ(state.at("type"), "case_state") << case_id; + EXPECT_EQ(state.at("case").at("id").get(), std::string(case_id)) + << case_id; + EXPECT_EQ(state.at("frame_mode").at("id"), std::string(mode)) << case_id; + EXPECT_EQ(state.at("controls").at("descriptor").at("protocol"), + "adminive.resource") << case_id; + EXPECT_EQ(state.at("controls").at("view").at("protocol"), + "adminive.view") << case_id; + EXPECT_FALSE(session.handle(Viewport_Resize{{480, 280}}).has_value()) << case_id; + const auto frame = session.handle(Frame_Request{}); + ASSERT_TRUE(frame.has_value()) << case_id; + ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << case_id; + EXPECT_EQ(frame->payload.substr(0, 4), "RVP1") << case_id; + EXPECT_EQ(read_u32_le(frame->payload, 4), 480U) << case_id; + EXPECT_EQ(read_u32_le(frame->payload, 8), 280U) << case_id; + const auto observed = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); + ASSERT_TRUE(observed.has_value()) << case_id; + const auto telemetry = parse_json(observed->payload); + EXPECT_EQ(telemetry.at("type"), "observer_state") << case_id; + EXPECT_EQ(telemetry.at("frame_mode").at("id"), std::string(mode)) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("kernel_observer")) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("performance")) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("data_shape")) << case_id; + EXPECT_GT(telemetry.at("telemetry").at("data_shape").at("rendered_elements") + .get(), 0U) << case_id; + } } } @@ -903,7 +930,7 @@ TEST(RenderiveWebGallery, ManualStrategyRunsOnlyExplicitPrepareRefreshRenderCycl sequence_after_actions); } -TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveAt1000Hz) { +TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveUnderAggressiveFrequency) { Gallery_Plot_Session session(true); ASSERT_EQ(response_json(session.handle(gallery_request( Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"), @@ -913,7 +940,9 @@ TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveAt1000Hz) { R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000,"pixel_stream_fps":60}})"))).at("type"), "case_state"); - std::this_thread::sleep_for(std::chrono::milliseconds(80)); + ASSERT_TRUE(wait_for_condition([&session] { + return successful_render_count(session) >= 4; + })); std::string first_pixels; std::string last_pixels; for (int iteration = 0; iteration < 12; ++iteration) { @@ -935,13 +964,11 @@ TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveAt1000Hz) { const auto telemetry = observe_telemetry(session); const auto& observer = telemetry.at("kernel_observer"); EXPECT_EQ(observer.at("target_interval_ns"), 1'000'000); - EXPECT_TRUE(observer.at("limit_state") == "paint_limited" || + EXPECT_GT(observer.at("latest_sequence").get(), 4U); + EXPECT_GT(telemetry.at("performance").at("successful_render_count").get(), 4U); + EXPECT_TRUE(observer.at("limit_state") == "frequency_limited" || + observer.at("limit_state") == "paint_limited" || observer.at("limit_state") == "render_limited"); - EXPECT_GT(observer.at("latest_sequence").get(), 12U); - EXPECT_GT(telemetry.at("performance").at("measured_fps").get(), 100.0); - EXPECT_TRUE(telemetry.at("low_latency_limit").at("paint_limited").get() || - telemetry.at("low_latency_limit").at("render_limited").get()); - EXPECT_FALSE(telemetry.at("low_latency_limit").at("frequency_limited").get()); } TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndReportsEmptyQueue) { @@ -1023,38 +1050,98 @@ TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) { Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))); ASSERT_TRUE(session.handle(gallery_request( Gallery_Request_Kind::Patch, - R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000}})"))); - - std::this_thread::sleep_for(std::chrono::milliseconds(350)); + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":120}})"))); + ASSERT_TRUE(wait_for_condition([&session] { + return successful_render_count(session) >= 4; + })); const auto observed = session.handle(gallery_request( Gallery_Request_Kind::Observe, - R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"last_pixel_change_age_ms":12.5}})")); + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})")); ASSERT_TRUE(observed.has_value()); const auto telemetry = parse_json(observed->payload).at("telemetry"); - SCOPED_TRACE(telemetry.dump(2)); const auto& performance = telemetry.at("performance"); - RecordProperty("configured_fps", telemetry.at("kernel_observer") - .at("configured_frequency_hz").get()); - RecordProperty("measured_fps", performance.at("measured_fps").get()); - RecordProperty("successful_render_count", - performance.at("successful_render_count").get()); - RecordProperty("limit_state", - telemetry.at("kernel_observer").at("limit_state").get()); EXPECT_TRUE(performance.at("automatic_low_latency_scheduler").get()); - EXPECT_GT(performance.at("successful_render_count").get(), 100U); - EXPECT_GT(performance.at("measured_fps").get(), 300.0); - EXPECT_DOUBLE_EQ(telemetry.at("kernel_observer").at("configured_frequency_hz"), 1000.0); - EXPECT_EQ(telemetry.at("kernel_observer").at("target_interval_ns"), 1'000'000); - EXPECT_TRUE(telemetry.at("low_latency_limit").at("paint_limited").get() || - telemetry.at("low_latency_limit").at("render_limited").get()); - EXPECT_FALSE(telemetry.at("low_latency_limit").at("frequency_limited").get()); + EXPECT_GE(performance.at("successful_render_count").get(), 4U); + EXPECT_GT(performance.at("measured_fps").get(), 0.0); + EXPECT_DOUBLE_EQ(telemetry.at("kernel_observer").at("configured_frequency_hz"), 120.0); + EXPECT_EQ(telemetry.at("kernel_observer").at("target_interval_ns"), 8'333'333); + EXPECT_TRUE(telemetry.at("kernel_observer").at("limit_state") == "frequency_limited" || + telemetry.at("kernel_observer").at("limit_state") == "paint_limited" || + telemetry.at("kernel_observer").at("limit_state") == "render_limited"); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("transport_fps"), 120.0); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("presentation_fps"), 60.0); EXPECT_EQ(telemetry.at("client_performance").at("changed_pixel_frames"), 17); EXPECT_EQ(telemetry.at("client_performance").at("duplicate_pixel_frames"), 3); + EXPECT_EQ(telemetry.at("client_performance").at("frame_request_timeout_count"), 2); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_receive_age_ms"), 8.5); EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_change_age_ms"), 12.5); } +TEST(RenderiveWebGallery, ProductionLowLatencyStopsWhileHiddenAndResumes) { + Gallery_Plot_Session session(true); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":120}})"))); + ASSERT_TRUE(wait_for_condition([&session] { + return successful_render_count(session) >= 3; + })); + set_gallery_view_active(session, false); + const auto stopped_count = successful_render_count(session); + EXPECT_FALSE(session.handle(Frame_Request{}).has_value()); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + EXPECT_EQ(successful_render_count(session), stopped_count); + set_gallery_view_active(session, true); + EXPECT_TRUE(wait_for_condition([&session, stopped_count] { + return successful_render_count(session) > stopped_count; + })); + EXPECT_TRUE(session.handle(Frame_Request{}).has_value()); +} + +TEST(RenderiveWebGallery, ProductionLowLatencyOnlyRendersActiveSessions) { + constexpr std::size_t session_count = 8; + std::vector> sessions; + sessions.reserve(session_count); + for (std::size_t index = 0; index < session_count; ++index) { + auto session = std::make_unique(true); + ASSERT_TRUE(session->handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))); + ASSERT_TRUE(session->handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":60}})"))); + if (index >= 2) + set_gallery_view_active(*session, false); + sessions.push_back(std::move(session)); + } + for (std::size_t index = 0; index < 2; ++index) { + ASSERT_TRUE(wait_for_condition([&sessions, index] { + return successful_render_count(*sessions[index]) >= 2; + })); + } + std::array hidden_counts{}; + for (std::size_t index = 2; index < session_count; ++index) + hidden_counts[index] = successful_render_count(*sessions[index]); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + for (std::size_t index = 2; index < session_count; ++index) + EXPECT_EQ(successful_render_count(*sessions[index]), hidden_counts[index]) << index; + set_gallery_view_active(*sessions[0], false); + set_gallery_view_active(*sessions[1], false); + const auto first_stopped = successful_render_count(*sessions[0]); + const auto second_stopped = successful_render_count(*sessions[1]); + set_gallery_view_active(*sessions[2], true); + set_gallery_view_active(*sessions[3], true); + EXPECT_TRUE(wait_for_condition([&sessions, baseline = hidden_counts[2]] { + return successful_render_count(*sessions[2]) > baseline; + })); + EXPECT_TRUE(wait_for_condition([&sessions, baseline = hidden_counts[3]] { + return successful_render_count(*sessions[3]) > baseline; + })); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + EXPECT_EQ(successful_render_count(*sessions[0]), first_stopped); + EXPECT_EQ(successful_render_count(*sessions[1]), second_stopped); +} + void expect_low_latency_canvas_to_change(std::string_view case_id) { SCOPED_TRACE(std::string(case_id)); Gallery_Plot_Session session(true); diff --git a/webapp/app.js b/webapp/app.js index 73c8ef3..a24a295 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -35,13 +35,13 @@ const state = { socket: null, reconnectTimer: 0, frameTimer: 0, + frameTimeout: 0, framePending: false, requestStarted: 0, lastFrameAt: 0, smoothedFps: 0, frameCount: 0, paused: false, - manuallyReconnecting: false, requestedWidth: 960, requestedHeight: 600 }; @@ -120,20 +120,68 @@ function syncViewport() { sendEvent("resize", {width, height}); } -function scheduleFrame(delay = 42) { +function streamActive() { + return !state.paused && !document.hidden; +} + +function stopFramePump() { window.clearTimeout(state.frameTimer); - if (!state.paused) - state.frameTimer = window.setTimeout(requestFrame, delay); + state.frameTimer = 0; +} + +function clearFrameRequest() { + window.clearTimeout(state.frameTimeout); + state.frameTimeout = 0; + state.framePending = false; +} + +function scheduleFrame(delay = 42) { + stopFramePump(); + if (!streamActive()) + return; + state.frameTimer = window.setTimeout(() => { + state.frameTimer = 0; + requestFrame(); + }, delay); } function requestFrame() { - if (state.paused || state.framePending) + if (!streamActive() || state.framePending) + return; + if (!sendEvent("frame")) return; state.requestStarted = performance.now(); - state.framePending = sendEvent("frame"); + state.framePending = true; + window.clearTimeout(state.frameTimeout); + state.frameTimeout = window.setTimeout(() => { + state.frameTimeout = 0; + if (!state.framePending) + return; + state.framePending = false; + if (!streamActive() || state.socket?.readyState !== WebSocket.OPEN) + return; + sendEvent("show"); + scheduleFrame(42); + }, 2000); +} + +function syncStreamActivity() { + stopFramePump(); + const active = streamActive(); + if (!active) { + state.lastFrameAt = 0; + state.smoothedFps = 0; + elements.fps.textContent = "0.0"; + } + if (!state.socket || state.socket.readyState !== WebSocket.OPEN) + return; + sendEvent(active ? "show" : "hide"); + if (active) + requestFrame(); } function drawPixelFrame(buffer) { + clearFrameRequest(); if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) throw new Error("像素帧长度无效"); const bytes = new Uint8Array(buffer); @@ -163,7 +211,6 @@ function drawPixelFrame(buffer) { const megabitsPerSecond = roundTrip > 0 ? buffer.byteLength * 8 / roundTrip / 1000 : 0; state.lastFrameAt = now; state.frameCount += 1; - state.framePending = false; elements.fps.textContent = state.smoothedFps.toFixed(1); elements.latency.textContent = roundTrip.toFixed(1); @@ -176,12 +223,10 @@ function drawPixelFrame(buffer) { function connect() { window.clearTimeout(state.reconnectTimer); - window.clearTimeout(state.frameTimer); - state.framePending = false; - if (state.socket && state.socket.readyState < WebSocket.CLOSING) { - state.manuallyReconnecting = true; + stopFramePump(); + clearFrameRequest(); + if (state.socket && state.socket.readyState < WebSocket.CLOSING) state.socket.close(1000, "reconnect"); - } setConnectionState("", "正在连接", "建立纯 WebSocket 像素通道"); const socket = new WebSocket(endpoint); @@ -191,14 +236,12 @@ function connect() { socket.addEventListener("open", () => { if (socket !== state.socket) return; - state.manuallyReconnecting = false; setConnectionState("online", "通道在线", "等待首个 RGBA 像素帧"); elements.connectionLabel.textContent = "通道在线"; - sendEvent("show"); syncViewport(); sendEvent("resize", {width: state.requestedWidth, height: state.requestedHeight}); sendControlSnapshot(); - requestFrame(); + syncStreamActivity(); }); socket.addEventListener("message", event => { @@ -207,7 +250,7 @@ function connect() { try { drawPixelFrame(event.data); } catch (error) { - state.framePending = false; + clearFrameRequest(); setConnectionState("offline", "帧协议错误", error.message); socket.close(1003, "invalid pixel frame"); } @@ -216,10 +259,10 @@ function connect() { socket.addEventListener("close", event => { if (socket !== state.socket) return; - state.framePending = false; + stopFramePump(); + clearFrameRequest(); setConnectionState("offline", "通道离线", event.reason || `WebSocket 已关闭 (${event.code})`); - if (!state.manuallyReconnecting) - state.reconnectTimer = window.setTimeout(connect, 1200); + state.reconnectTimer = window.setTimeout(connect, 1200); }); socket.addEventListener("error", () => { @@ -309,9 +352,7 @@ elements.pause.addEventListener("click", () => { elements.pause.setAttribute("aria-pressed", String(state.paused)); elements.pause.classList.toggle("paused", state.paused); elements.pauseLabel.textContent = state.paused ? "PAUSED" : "LIVE"; - sendEvent(state.paused ? "hide" : "show"); - if (!state.paused) - requestFrame(); + syncStreamActivity(); }); elements.reconnect.addEventListener("click", connect); @@ -321,15 +362,11 @@ new ResizeObserver(() => { resizeFrame = requestAnimationFrame(syncViewport); }).observe(elements.stage); -document.addEventListener("visibilitychange", () => { - if (document.hidden) { - sendEvent("hide"); - } else if (!state.paused) { - sendEvent("show"); - requestFrame(); - } +document.addEventListener("visibilitychange", syncStreamActivity); +window.addEventListener("beforeunload", () => { + stopFramePump(); + sendEvent("hide"); }); -window.addEventListener("beforeunload", () => sendEvent("hide")); syncViewport(); connect(); diff --git a/webapp/index.html b/webapp/index.html index bae9c86..fe87dd5 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -93,9 +93,9 @@
FRAME RATE0.0 fps
-
ROUND TRIP ms
+
FRAME RTT ms
RESOLUTION— × —
-
PIXEL RATE0.0 Mb/s
+
EFFECTIVE PIXEL RATE0.0 Mb/s
diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js index 48fdf91..111f75f 100644 --- a/webapp_gallery/app.js +++ b/webapp_gallery/app.js @@ -73,6 +73,7 @@ class GalleryCard { this.framePending = false; this.frameTimer = null; this.frameTimeout = null; + this.frameTimeoutCount = 0; this.latestPixelBuffer = null; this.transportTimes = []; this.presentationTimes = []; @@ -87,7 +88,8 @@ class GalleryCard { this.reconnectTimer = null; this.disposed = false; this.ready = false; - this.visible = true; + this.intersecting = false; + this.backendActive = null; this.lastFrameRequest = 0; this.lastObserveRequest = 0; this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true); @@ -122,18 +124,44 @@ class GalleryCard { this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver.observe(this.shell); this.intersectionObserver = new IntersectionObserver(entries => { - const wasVisible = this.visible; - this.visible = entries[0]?.isIntersecting !== false; - if (this.ready && wasVisible !== this.visible) this.send(this.visible ? "show" : "hide"); - if (this.visible) this.scheduleFramePump(); + const intersecting = entries[0]?.isIntersecting === true; + if (this.intersecting === intersecting) return; + this.intersecting = intersecting; + this.syncActivity(); }, {rootMargin: "160px"}); this.intersectionObserver.observe(this.node); - this.connect(); } setSocketState(state, text) { this.socketState.dataset.state = state; this.socketState.querySelector("span").textContent = text; } + categoryVisible() { + return activeCategory === "全部" || this.definition.category === activeCategory; + } + displayVisible() { + return !document.hidden && this.mode.id === activeMode && this.categoryVisible() && this.intersecting; + } + streamActive() { + return !streamsPaused && this.displayVisible(); + } + stopFramePump() { + if (this.frameTimer !== null) clearTimeout(this.frameTimer); + this.frameTimer = null; + } + syncActivity(force = false) { + const visible = this.displayVisible(); + if (visible && ![WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) { + this.connect(); + return; + } + const active = !streamsPaused && visible; + if (!active) this.stopFramePump(); + if (this.ready && (force || this.backendActive !== active)) { + this.send(active ? "show" : "hide"); + this.backendActive = active; + } + if (active) this.scheduleFramePump(true); + } connect() { if (this.disposed || [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) return; clearTimeout(this.reconnectTimer); @@ -153,12 +181,13 @@ class GalleryCard { socket.addEventListener("close", () => { if (this.socket !== socket) return; this.ready = false; + this.backendActive = null; this.framePending = false; - clearTimeout(this.frameTimer); this.frameTimer = null; + this.stopFramePump(); clearTimeout(this.frameTimeout); this.frameTimeout = null; - this.setSocketState("error", "连接关闭 · 自动重连"); + this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"); this.updateMotionStatus(); - if (!this.disposed) this.reconnectTimer = setTimeout(() => this.connect(), 1000); + if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000); }); socket.addEventListener("error", () => { if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连"); @@ -187,13 +216,13 @@ class GalleryCard { this.actions = data.actions?.data || []; this.telemetry = data.telemetry || {}; this.ready = true; + this.backendActive = null; this.node.dataset.ready = "true"; - this.send(this.visible ? "show" : "hide"); + this.syncActivity(); this.node.querySelector(".card-controls").textContent = this.controls.length; this.node.querySelector(".card-actions").textContent = this.actions.length; this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`); this.updatePerformance(); - this.scheduleFramePump(true); if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`); if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); } } @@ -214,6 +243,8 @@ class GalleryCard { this.node.querySelector(".perf-points").textContent = `${Number(dataShape.input_points || 0).toLocaleString()}→${Number(dataShape.rendered_elements || 0).toLocaleString()}`; this.node.querySelector(".perf-limit").textContent = limitNames[observer.limit_state] || observer.limit_state || "N/A"; this.node.querySelector(".perf-event").textContent = observer.last_event || "none"; + this.node.querySelector(".perf-timeouts").textContent = this.frameTimeoutCount.toLocaleString(); + this.node.querySelector(".perf-pixel-age").textContent = this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—"; this.updateObserverDashboard(observer); this.updateMotionStatus(); const setLimitFlag = (selector, active, duration) => { @@ -244,14 +275,20 @@ class GalleryCard { pixelSignature(buffer, width, height, stride) { const bytes = new Uint8Array(buffer, 16, stride * height); let hash = (2166136261 ^ width ^ (height << 16)) >>> 0; - for (let offset = 0; offset < bytes.length; offset += 64) + const sampleCount = Math.min(4096, bytes.length); + if (sampleCount <= 1) return Math.imul(hash ^ (bytes[0] || 0), 16777619) >>> 0; + for (let index = 0; index < sampleCount; index++) { + const offset = Math.floor(index * (bytes.length - 1) / (sampleCount - 1)); hash = Math.imul(hash ^ bytes[offset], 16777619) >>> 0; + } return hash; } updateMotionStatus(now = performance.now()) { let state = "waiting", label = "等待动态帧"; if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { state = "stalled"; label = "像素流断开"; + } else if (!this.streamActive()) { + state = "waiting"; label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图"; } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { @@ -268,11 +305,19 @@ class GalleryCard { receivePixels(buffer) { this.framePending = false; if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; } - if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) return; + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) { + this.setSocketState("error", "像素帧无效 · 自动重连"); + this.socket?.close(1003, "invalid pixel frame"); + return; + } const header = new DataView(buffer, 0, 16); const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); - if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) return; + if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) { + this.setSocketState("error", "像素帧协议错误 · 自动重连"); + this.socket?.close(1003, "invalid pixel frame"); + return; + } const now = performance.now(); const signature = this.pixelSignature(buffer, width, height, stride); if (this.lastPixelSignature === null || signature !== this.lastPixelSignature) { @@ -282,6 +327,7 @@ class GalleryCard { this.lastPixelSignature = signature; this.lastPixelReceivedAt = now; this.latestPixelBuffer = buffer; + this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`); this.transportFps = this.recordRate(this.transportTimes, now); this.frameCount++; this.frameLabel.textContent = this.frameCount.toLocaleString(); @@ -304,54 +350,63 @@ class GalleryCard { } this.presentationFps = this.recordRate(this.presentationTimes, time); } - recordRate(history, now) { - history.push(now); - while (history.length > 2 && history[0] < now - 1000) history.shift(); + currentRate(history, now) { + while (history.length && history[0] < now - 1000) history.shift(); if (history.length < 2) return 0; return (history.length - 1) * 1000 / Math.max(1, history[history.length - 1] - history[0]); } + recordRate(history, now) { + history.push(now); + return this.currentRate(history, now); + } resize() { if (this.socket?.readyState !== WebSocket.OPEN) return; const rect = this.shell.getBoundingClientRect(); this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))}); } requestFrame(time, explicit = false) { - if (streamsPaused && !explicit || !this.visible || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return; + if ((!explicit && !this.streamActive()) || (explicit && !this.displayVisible()) || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return; if (!explicit && this.mode.id === "manual") return; const streamControl = this.controls.find(item => item.id === "pixel_stream_fps"); const interval = this.mode.id === "low_latency" ? 1000 / Math.max(1, Math.min(60, Number(streamControl?.value || 30))) : 66; - if (!explicit && time - this.lastFrameRequest < interval) return; + if (!explicit && this.mode.id !== "low_latency" && time - this.lastFrameRequest < interval) return; this.lastFrameRequest = time; this.framePending = true; this.send("frame"); this.frameTimeout = setTimeout(() => { this.framePending = false; this.frameTimeout = null; - this.scheduleFramePump(); + this.frameTimeoutCount++; + if (!this.streamActive() || this.socket?.readyState !== WebSocket.OPEN) return; + this.setSocketState("error", "像素响应超时 · 正在恢复"); + this.syncActivity(true); }, 1500); } scheduleFramePump(reset = false) { if (this.mode.id !== "low_latency") return; - if (reset && this.frameTimer !== null) { clearTimeout(this.frameTimer); this.frameTimer = null; } - if (this.frameTimer !== null || this.framePending || streamsPaused || !this.visible || - !this.ready || this.socket?.readyState !== WebSocket.OPEN) return; + if (reset) this.stopFramePump(); + if (this.frameTimer !== null || this.framePending || !this.streamActive() || !this.ready || this.socket?.readyState !== WebSocket.OPEN) return; const streamControl = this.controls.find(item => item.id === "pixel_stream_fps"); const interval = 1000 / Math.max(1, Math.min(60, Number(streamControl?.value || 30))); - const delay = Math.max(0, this.lastFrameRequest + interval - performance.now()); + const delay = Math.max(0, Math.ceil(this.lastFrameRequest + interval - performance.now())); this.frameTimer = setTimeout(() => { this.frameTimer = null; this.requestFrame(performance.now()); }, delay); } observe(time) { - if (!this.ready || !this.visible || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return; + if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return; this.lastObserveRequest = time; + this.transportFps = this.currentRate(this.transportTimes, time); + this.presentationFps = this.currentRate(this.presentationTimes, time); this.send("gallery_observe", {client_metrics: { transport_fps: this.transportFps, presentation_fps: this.presentationFps, websocket_buffered_bytes: this.socket.bufferedAmount || 0, changed_pixel_frames: this.changedPixelFrames, duplicate_pixel_frames: this.duplicatePixelFrames, + frame_request_timeout_count: this.frameTimeoutCount, + last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0, last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0 }}); } @@ -385,6 +440,9 @@ function createPage(mode) { pages.set(mode.id, {mode, page, cards}); return pages.get(mode.id); } +function syncCardActivity() { + for (const page of pages.values()) for (const card of page.cards) card.syncActivity(); +} function selectMode(id) { activeMode = id; let selected = pages.get(id); @@ -393,12 +451,16 @@ function selectMode(id) { [...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id)); elements.modeDescription.textContent = selected.mode.description; applyCategory(); + syncCardActivity(); closeMenu(); } function applyCategory() { const page = pages.get(activeMode); if (!page) return; - for (const card of page.cards) card.node.hidden = activeCategory !== "全部" && card.definition.category !== activeCategory; + for (const card of page.cards) { + card.node.hidden = !card.categoryVisible(); + card.syncActivity(); + } } function installNavigation() { elements.modeTabs.replaceChildren(...modes.map(mode => { @@ -542,13 +604,14 @@ function loop(time) { elements.streamToggle.addEventListener("click", () => { streamsPaused = !streamsPaused; elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流"; - if (!streamsPaused) for (const page of pages.values()) for (const card of page.cards) card.scheduleFramePump(true); + syncCardActivity(); }); elements.menuClose.addEventListener("click", closeMenu); elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); })); document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); }); document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); }); -window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.reconnectTimer); card.socket?.close(); } }); +document.addEventListener("visibilitychange", syncCardActivity); +window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; card.stopFramePump(); clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } }); connectCatalog(); requestAnimationFrame(loop); diff --git a/webapp_gallery/index.html b/webapp_gallery/index.html index 76735c9..2f84e8b 100644 --- a/webapp_gallery/index.html +++ b/webapp_gallery/index.html @@ -78,17 +78,19 @@
创建 Kernel Scene
-
0.0
后台 FPS
-
0.0
WS 像素 FPS
-
0.0
页面 FPS
+
0.0
后端渲染 FPS
+
0.0
像素响应 FPS
+
0.0
浏览器呈现 FPS
0.00
Core 渲染 ms
0.00
像素编码 ms
-
0.0
像素 MB/s
-
0
队列
-
0
丢弃
+
0.0
响应负载 MB/s
+
0
Kernel 待处理
+
0
Kernel 丢弃
0→0
输入→绘制
N/A
当前瓶颈
none
观察事件
+
0
像素超时
+
最近像素龄
限速来源