diff --git a/src/runtime/roblox_experience_composition.cc b/src/runtime/roblox_experience_composition.cc index f7065d6..d4e42af 100644 --- a/src/runtime/roblox_experience_composition.cc +++ b/src/runtime/roblox_experience_composition.cc @@ -22,7 +22,8 @@ namespace { constexpr size_t kMaxPendingLaunchRequests = 8; // libroblox's static TLS requires the proven 16 MiB guest-thread stack floor. constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024; -constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000}; +// Covers helper exec through window creation, including cold Flatpak starts. +constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000}; constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/"; Status Invalid(std::string message) { @@ -159,24 +160,33 @@ Status LaunchRobloxWebSurface( } std::fprintf(stderr, " [%s] opening validated Roblox web surface\n", transport); + const auto started = std::chrono::steady_clock::now(); const WebViewHelperLaunchResult launched = LaunchWebViewHelper(helper, url, std::move(exit_observer)); if (!launched) { return Unavailable("could not display Roblox web surface: " + launched.error); } - if (launched.process == nullptr || - !launched.process->WaitUntilReady(kWebSurfaceReadyTimeout)) { + const bool ready = launched.process != nullptr && + launched.process->WaitUntilReady(kWebSurfaceReadyTimeout); + const long long elapsed_ms = + std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + if (!ready) { if (launched.process != nullptr) { (void)launched.process->RequestClose(); } return Unavailable( - "Roblox web surface did not become ready before the deadline"); + "Roblox web surface did not become ready before the deadline (" + + std::to_string(elapsed_ms) + " ms of " + + std::to_string(kWebSurfaceReadyTimeout.count()) + " ms)"); } if (process != nullptr) { *process = launched.process; } - std::fprintf(stderr, " [%s] Roblox web surface launched\n", transport); + std::fprintf(stderr, " [%s] Roblox web surface launched in %lld ms\n", + transport, elapsed_ms); return Status::Ok(); } diff --git a/tests/roblox_experience_composition_test.cc b/tests/roblox_experience_composition_test.cc index 95b4a39..f507666 100644 --- a/tests/roblox_experience_composition_test.cc +++ b/tests/roblox_experience_composition_test.cc @@ -1023,7 +1023,7 @@ TEST(RobloxExperienceCompositionTest, class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test { protected: struct Helper { - Helper() { + explicit Helper(const char* before_ready = "") { char pattern[] = "/tmp/mocktail_surface_close_XXXXXX"; const char* created = mkdtemp(pattern); if (created == nullptr) return; @@ -1035,6 +1035,7 @@ import socket, sys sys.stdin.buffer.read() s = socket.socket(fileno=198) s.settimeout(5) +)PY" << before_ready << R"PY( s.send(b'MWVE' + bytes([1, 3, 0, 0]) + bytes(4)) while True: packet = s.recv(1024 * 1024) @@ -1071,6 +1072,14 @@ while True: ++static_cast(context)->calls; } + static Status Open(RobloxExperienceComposition* composition, + const std::shared_ptr& probe) { + return composition->OpenWebSurface( + "https://www.roblox.com/challenge/cdn/hybrid", "webview", + RobloxExperienceComposition::WebSurfaceRoute::kWebView, + {probe, &RobloxExperienceCompositionWebSurfaceTest::ObserveExit}, {}); + } + static std::unique_ptr MakeComposition( const SecureRobloxCredential* credential = nullptr) { return std::make_unique( @@ -1278,6 +1287,47 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest, EXPECT_EQ(probe->calls, 1); } +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + WaitsForSlowHelperStartupBeforeRejectingSurface) { + Helper helper("import time\ntime.sleep(3.5)\n"); + ASSERT_FALSE(helper.path.empty()); + ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0); + SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"}; + auto composition = MakeComposition(&credential); + auto probe = std::make_shared(); + + ::testing::internal::CaptureStderr(); + const Status status = Open(composition.get(), probe); + const std::string log = ::testing::internal::GetCapturedStderr(); + unsetenv("MOCKTAIL_WEBVIEW_HELPER"); + + EXPECT_TRUE(status.ok()) << status.message(); + const std::string marker = "Roblox web surface launched in "; + const size_t at = log.find(marker); + ASSERT_NE(at, std::string::npos) << log; + EXPECT_GE(std::stol(log.substr(at + marker.size())), 3500) << log; + EXPECT_TRUE(Close(composition.get()).ok()); +} + +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + ReportsElapsedStartupTimeWhenHelperNeverBecomesReady) { + Helper helper("sys.exit(0)\n"); + ASSERT_FALSE(helper.path.empty()); + ASSERT_EQ(setenv("MOCKTAIL_WEBVIEW_HELPER", helper.path.c_str(), 1), 0); + SecureRobloxCredential credential{".ROBLOSECURITY=typed-secret"}; + auto composition = MakeComposition(&credential); + auto probe = std::make_shared(); + + const Status status = Open(composition.get(), probe); + unsetenv("MOCKTAIL_WEBVIEW_HELPER"); + + ASSERT_FALSE(status.ok()); + EXPECT_NE(status.message().find("did not become ready"), std::string::npos) + << status.message(); + EXPECT_NE(status.message().find(" ms of 5000 ms"), std::string::npos) + << status.message(); +} + namespace {} // namespace } // namespace runtime } // namespace mocktail