From ed523c61c106b8e19614e4d56f0b5d39b8392478 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:02:28 +0300 Subject: [PATCH] Track actual gameplay sources in async build results --- CMakeLists.txt | 7 ++ include/faset/editor/session.hpp | 1 - src/editor/build_service.cpp | 2 + src/editor/session.cpp | 19 +--- tests/build_schema_tool.cpp | 16 ++- tests/editor_schema_signature_tests.cpp | 140 ++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 tests/editor_schema_signature_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 082e26a..a2e1e5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,13 @@ if(TARGET faset_editor_commands AND TARGET faset_build_service) target_link_libraries(faset_editor_session_tests PRIVATE faset_editor_session) target_compile_definitions(faset_editor_session_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") add_test(NAME editor_session_settings COMMAND faset_editor_session_tests) + add_executable(faset_editor_schema_signature_tests tests/editor_schema_signature_tests.cpp) + target_link_libraries(faset_editor_schema_signature_tests PRIVATE faset_editor_session) + target_compile_definitions(faset_editor_schema_signature_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + add_dependencies(faset_editor_schema_signature_tests faset_build_schema_tool) + add_test(NAME editor_schema_signature + COMMAND faset_editor_schema_signature_tests $) + set_tests_properties(editor_schema_signature PROPERTIES TIMEOUT 180) add_executable(faset_editor_autosave_tests tests/editor_autosave_tests.cpp) target_link_libraries(faset_editor_autosave_tests PRIVATE faset_editor_session) add_test(NAME editor_autosave COMMAND faset_editor_autosave_tests) diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp index b53173f..9abbafc 100644 --- a/include/faset/editor/session.hpp +++ b/include/faset/editor/session.hpp @@ -75,7 +75,6 @@ class Session { std::string pending_play_job_; Json pending_play_scene_; std::unique_ptr plugins_; - std::map submitted_sources_; std::string schema_source_signature_; bool schema_loaded_ = false; std::string schema_error_; diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 458afbe..630469b 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -501,6 +501,7 @@ struct BuildService::Impl { {"schema", path_to_utf8(directory / "schema.json")}, {"lua_enabled", job.lua.enabled()}, {"lua_fingerprint", job.lua.fingerprint}, + {"source_signature", inputs.source_hash}, {"fingerprint", fingerprint}, {"schema_cache_hit", reused}, {"generation_reused", reused}, @@ -957,6 +958,7 @@ struct BuildService::Impl { {"generation", job.status.id}, {"build", built}, {"schema", built.at("schema")}, + {"source_signature", built.at("source_signature")}, {"player", built.at("player")}, {"build_directory", built.at("build_directory")}, {"configuration", built.at("configuration")}}; diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 6ad0d0d..59e5a28 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -285,13 +285,10 @@ void Session::poll() { if (value.result.contains("schema")) try { load_schema(path_from_utf8(value.result.at("schema").get())); - // This signature represents the sources submitted with this job, not later - // edits. - if (value.result.contains("source_signature")) - schema_source_signature_ = - value.result.at("source_signature").get(); - else if (submitted_sources_.contains(value.id)) - schema_source_signature_ = submitted_sources_.at(value.id); + // The worker may start after the request and capture newer sources. + // Only its validated build snapshot can identify this schema. + schema_source_signature_ = + value.result.at("source_signature").get(); atomic_write_json(config_.project_root / ".faset/schema-state.json", {{"source_signature", schema_source_signature_}}); } catch (const std::exception& error) { @@ -496,9 +493,7 @@ void Session::register_commands() { "Incrementally compile gameplay and export C++/Lua metadata in separate native " "processes. Returns a job ID.", schema(Json::object()), [&](const Json&) { - const auto signature = source_signature(); const auto id = builds_.start_build(); - submitted_sources_[id] = signature; return Json{{"job", id}}; }); commands_.add( @@ -509,9 +504,7 @@ void Session::register_commands() { schema(Json::object()), [&](const Json&) { require(scripting::loadLuaProject(config_.project_root).enabled(), "lua.disabled", "Declare scripting.lua.scripts in project.faset.json first"); - const auto signature = source_signature(); const auto id = builds_.start_build(); - submitted_sources_[id] = signature; return Json{{"job", id}}; }); commands_.add("faset_lua_reload", @@ -655,12 +648,10 @@ void Session::register_commands() { "output directory. Returns a job ID.", schema({{"document", text}, {"output", text}}, {"document", "output"}), [&](const Json& args) { - const auto signature = source_signature(); const auto id = builds_.start_export( resolved_or_throw(commands_, args.at("document")), project_path(config_.project_root, path_from_utf8(args.at("output").get()))); - submitted_sources_[id] = signature; return Json{{"job", id}}; }); commands_.add( @@ -685,9 +676,7 @@ void Session::register_commands() { schema({{"document", text}}, {"document"}), [&](const Json& args) { stop_player(); pending_play_scene_ = resolved_or_throw(commands_, args.at("document")); - const auto signature = source_signature(); pending_play_job_ = builds_.start_build(); - submitted_sources_[pending_play_job_] = signature; return Json{{"job", pending_play_job_}, {"play_pending", true}}; }); commands_.add( diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index ba4c5ca..04916b0 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -1,5 +1,8 @@ #include +#include +#include #include +#include // Native stand-in for CMake and SchemaExporter. Tests exercise the real // asynchronous BuildService and publication code without compiling a game. @@ -52,6 +55,15 @@ int tool_main(int argc, char** argv) { return 0; } if (argc > 2 && std::string_view(argv[1]) == "--build") { + if (fs::exists("block-native-build")) { + atomic_write("build-blocked", "ready\n"); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (fs::exists("block-native-build") && + std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (fs::exists("block-native-build")) + throw std::runtime_error("Timed out waiting for build fixture gate"); + } if (fs::exists("emit-clang-error-and-fail-build")) { for (int index = 0; index < 250; ++index) std::cerr << "/external/library.cpp:1:1: warning: dependency warning " @@ -81,7 +93,9 @@ int tool_main(int argc, char** argv) { for (const auto* name : {"sdl3", "entt", "box2d", "box3d", "json", "stb"}) atomic_write(build / "_deps" / (std::string(name) + "-src") / "LICENSE.txt", "Synthetic dependency notice for packaging tests only.\n"); - const auto self = fs::absolute(path_from_utf8(argv[0])); + const auto invoked = path_from_utf8(argv[0]); + const auto self = invoked.has_parent_path() ? fs::absolute(invoked) + : find_executable(argv[0]); #ifdef _WIN32 constexpr auto suffix = ".exe"; #else diff --git a/tests/editor_schema_signature_tests.cpp b/tests/editor_schema_signature_tests.cpp new file mode 100644 index 0000000..029a419 --- /dev/null +++ b/tests/editor_schema_signature_tests.cpp @@ -0,0 +1,140 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace faset; + +namespace { +void check(bool condition, std::string_view message) { + if (!condition) + throw std::runtime_error(std::string(message)); +} + +void prepend_fixture_to_path(const fs::path& directory) { + const auto* previous = std::getenv("PATH"); + const auto value = path_to_utf8(directory) + +#ifdef _WIN32 + ";" + +#else + ":" + +#endif + (previous ? previous : ""); +#ifdef _WIN32 + check(_putenv_s("PATH", value.c_str()) == 0, "Could not configure fixture PATH"); +#else + check(setenv("PATH", value.c_str(), 1) == 0, "Could not configure fixture PATH"); +#endif +} + +bool finished(const Json& job) { + const auto state = job.at("state").get(); + return state == "succeeded" || state == "failed" || state == "cancelled"; +} +} // namespace + +int test_main(int argc, char** argv) { + const auto root = fs::temp_directory_path() / ("Faset signature race " + new_id()); + try { + check(argc == 2, "Expected the native build fixture path"); + const auto project = root / "project"; + const auto tools = root / "tools"; + fs::create_directories(tools); +#ifdef _WIN32 + const auto cmake_fixture = tools / "cmake.exe"; +#else + const auto cmake_fixture = tools / "cmake"; +#endif + fs::copy_file(path_from_utf8(argv[1]), cmake_fixture); + prepend_fixture_to_path(tools); + + { + editor::Session session({project, path_from_utf8(FASET_TEST_ENGINE), {}}); + session.scaffold("Source signature race", 2); + atomic_write_json(project / "schema-fixture.json", + {{"format", "faset.schema"}, + {"version", 1}, + {"types", Json::array()}}); + auto document = session.authoring().create("Export source signature", 2); + auto& commands = session.commands(); + + const auto before = + editor::gameplay_source_hash(project, scripting::loadLuaProject(project)); + atomic_write(project / "block-native-build", "block\n"); + const auto blocker = commands.call("faset_build", Json::object()).at("job"); + const auto gate_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (!fs::exists(project / "build-blocked") && + std::chrono::steady_clock::now() < gate_deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (!fs::exists(project / "build-blocked")) { + const auto blocked_job = commands.call("faset_job", {{"id", blocker}}); + throw std::runtime_error("The first build never reached the controlled worker " + "gate: " + blocked_job.dump()); + } + + // Submit both requests while the old source exists and another job owns the worker. + // They must publish the later hash actually captured when their turn begins. + const auto build = commands.call("faset_build", Json::object()).at("job"); + const auto exported = + commands.call("faset_export", + {{"document", document.at("id")}, {"output", "Exports/Race"}}) + .at("job"); + atomic_write(project / "Scripts/Gameplay.cpp", + read_text(project / "Scripts/Gameplay.cpp") + "\n// queued edit\n"); + const auto after = + editor::gameplay_source_hash(project, scripting::loadLuaProject(project)); + check(before != after, "Queued source edit did not change the gameplay signature"); + commands.call("faset_job_cancel", {{"id", blocker}}); + fs::remove(project / "block-native-build"); + + Json build_job, export_job, blocker_job; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(150); + do { + session.poll(); + blocker_job = commands.call("faset_job", {{"id", blocker}}); + build_job = commands.call("faset_job", {{"id", build}}); + export_job = commands.call("faset_job", {{"id", exported}}); + if (finished(blocker_job) && finished(build_job) && finished(export_job)) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + session.poll(); + check(blocker_job.at("state") == "cancelled", "Blocked build was not cancelled"); + check(build_job.at("state") == "succeeded", + "Queued build failed: " + build_job.value("error", std::string())); + check(export_job.at("state") == "succeeded", + "Queued export failed: " + export_job.value("error", std::string())); + + const auto status = commands.call("faset_schema_status", Json::object()); + check(status.at("loaded") == true && status.at("stale") == false, + "Editor marked the just-built source schema stale after a queued edit"); + check(build_job.at("result").value("source_signature", std::string()) == after, + "Build result did not report the worker's captured source hash"); + check(export_job.at("result").value("source_signature", std::string()) == after, + "Export result did not report the worker's captured source hash"); + check(read_json(project / ".faset/schema-state.json").at("source_signature") == after, + "Persisted schema provenance differs from the worker's source snapshot"); + } + fs::remove_all(root); + std::cout << "Queued Build and Export preserve the actual worker source signature\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << "\nFixture retained at " << path_to_utf8(root) << '\n'; + return 1; + } +} + +#ifdef _WIN32 +int wmain(int argc, wchar_t** argv) { + return run_utf8_main(argc, argv, test_main); +} +#else +int main(int argc, char** argv) { + return test_main(argc, argv); +} +#endif