diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 5d61f49b8..e289705a1 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -62,9 +62,8 @@ jobs: - name: Check all Gateway bindings run: scripts/check-gateway-bindings.py - # Building the workspace Module once keeps the module-path publishers off a cold compile, and - # a build failure reports as a build failure rather than as a publish timeout. The typed taxi - # test builds and privately copies the exact Wasm artifact it inspects. + # Build failures report before any fixture starts. Each test process asks Cargo for the + # current artifact, caches its bytes, and publishes private copies through --bin-path. - name: Build the module wasm run: cargo build --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers diff --git a/Cargo.lock b/Cargo.lock index f7868a9fc..f06f4414b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ dependencies = [ "log", "lyracore-module", "lyracore-shared", + "serde_json", "spacetimedb-lib", "spacetimedb-sdk", "tokio", diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 66811fed8..604308b55 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -50,6 +50,7 @@ env_logger = "0.11" dhat = { version = "0.3", optional = true } [dev-dependencies] +serde_json = "1" # Work-item 220 (binding schema-parity test, gateway/tests/schema_parity.rs): the module crate # compiles natively (its own test suite proves it — no wasm-only dep), so a dev-only dependency # lets the test compare module `#[table]` structs against the hand-maintained gateway bindings diff --git a/module/tests/fixture_publish.rs b/module/tests/fixture_publish.rs new file mode 100644 index 000000000..20e67053b --- /dev/null +++ b/module/tests/fixture_publish.rs @@ -0,0 +1,28 @@ +mod support; + +use std::sync::Barrier; + +use support::Standalone; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn concurrent_fixtures_publish_complete_modules() { + let fixtures: Vec<_> = (0..4) + .map(|index| Standalone::start(&format!("concurrent-publish-{index}"))) + .collect(); + let ready = Barrier::new(fixtures.len()); + std::thread::scope(|threads| { + for (index, mut standalone) in fixtures.into_iter().enumerate() { + let ready = &ready; + threads.spawn(move || { + ready.wait(); + if index == 0 { + standalone.publish_module_anonymous(); + } else { + standalone.publish_module(); + } + standalone.assert_sql("SELECT COUNT(*) AS count FROM game_item_template"); + }); + } + }); +} diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index 5fac679ab..0da87a55e 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -1,3 +1,8 @@ +mod module_wasm; + +#[allow(unused_imports)] // Some integration targets do not publish a Module. +pub use module_wasm::module_bytes; + use std::collections::BTreeMap; use std::ffi::OsString; use std::fs::{self, OpenOptions}; @@ -121,40 +126,26 @@ impl Standalone { .expect("private Owner Token is missing") } - #[allow(dead_code)] // Only the integration tests that publish a module use this. + #[allow(dead_code)] // Only the integration tests that publish a Module use this. pub fn publish_module(&mut self) { - let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let module_dir = workspace.join("module"); - self.publish( - &[ - "--module-path", - module_dir.to_str().unwrap(), - "--build-options=--features=debug_reducers", - ], - &[], - ); + self.publish_module_bytes(module_bytes()); } - /// Copy built Wasm bytes into this standalone's private directory and publish that copy. - #[allow(dead_code)] // Used by tests that build their own Wasm artifact. + /// Copy built Wasm bytes into this Standalone's private directory and publish that copy. + #[allow(dead_code)] // Used by tests that inspect the same artifact they publish. pub fn publish_module_bytes(&mut self, wasm: &[u8]) { - let path = self.data_dir.join("published-module.wasm"); - fs::write(&path, wasm).expect("failed to copy private Wasm artifact"); - self.publish(&["--bin-path", path.to_str().unwrap()], &[]); + self.publish_bytes(wasm, &[]); } - #[allow(dead_code)] // Used when a developer's cached token is not valid for an isolated server. + #[allow(dead_code)] // Used by tests that publish without an Owner Token. pub fn publish_module_anonymous(&mut self) { - let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let module_dir = workspace.join("module"); - self.publish( - &[ - "--module-path", - module_dir.to_str().unwrap(), - "--build-options=--features=debug_reducers", - ], - &["--anonymous"], - ); + self.publish_bytes(module_bytes(), &["--anonymous"]); + } + + fn publish_bytes(&mut self, wasm: &[u8], extra: &[&str]) { + let path = self.data_dir.join("published-module.wasm"); + fs::write(&path, wasm).expect("failed to copy private Wasm artifact"); + self.publish(&["--bin-path", path.to_str().unwrap()], extra); } pub fn call(&self, reducer: &str, args: &[&str]) -> Output { diff --git a/module/tests/support/module_wasm.rs b/module/tests/support/module_wasm.rs new file mode 100644 index 000000000..5697b4fa3 --- /dev/null +++ b/module/tests/support/module_wasm.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; +use std::process::Command; +use std::sync::OnceLock; + +/// Build once per test process and retain immutable bytes. Publishers copy these into their own +/// directories so concurrent fixtures never share the CLI optimizer's output file. +pub fn module_bytes() -> &'static [u8] { + static WASM: OnceLock> = OnceLock::new(); + WASM.get_or_init(build_module_bytes) +} + +fn build_module_bytes() -> Vec { + let module_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = module_dir.parent().unwrap(); + let output = Command::new("cargo") + .current_dir(workspace) + .args([ + "build", + "--locked", + "--release", + "--target", + "wasm32-unknown-unknown", + "-p", + "lyracore-module", + "--features=debug_reducers", + "--message-format=json-render-diagnostics", + ]) + .output() + .expect("failed to run the test Module build"); + assert!( + output.status.success(), + "the test Module build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let wasm = String::from_utf8(output.stdout) + .expect("Cargo artifact output was not UTF-8") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| message["reason"] == "compiler-artifact") + .filter(|message| message["target"]["name"] == "lyracore_module") + .filter_map(|message| message["filenames"].as_array().cloned()) + .flatten() + .filter_map(|filename| filename.as_str().map(PathBuf::from)) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "wasm") + }) + .expect("Cargo did not report the built Module Wasm artifact"); + let bytes = std::fs::read(&wasm).expect("the test Module output is missing"); + assert!(bytes.starts_with(b"\0asm"), "the built module is not Wasm"); + bytes +} diff --git a/module/tests/typed_taxi_contracts.rs b/module/tests/typed_taxi_contracts.rs index 40bbfe74b..d48588e5c 100644 --- a/module/tests/typed_taxi_contracts.rs +++ b/module/tests/typed_taxi_contracts.rs @@ -1,7 +1,6 @@ mod support; -use std::path::PathBuf; -use std::process::{Command, Output}; +use std::process::Output; use support::Standalone; @@ -11,9 +10,9 @@ const MISSING_ACTOR: &str = "999999"; #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn gateway_taxi_gates_keep_refusals_typed_and_invariants_fatal() { - let wasm = build_module_bytes(); + let wasm = support::module_bytes(); let mut standalone = Standalone::start("typed-taxi-contracts"); - standalone.publish_module_bytes(&wasm); + standalone.publish_module_bytes(wasm); assert_loot_boundary_failure(&standalone, ACTOR, "loot:boundary_operator_rejected"); standalone.assert_call("claim_operator", &[]); standalone.assert_call("debug_spawn_player_entity", &[ACTOR]); @@ -189,47 +188,3 @@ fn failed_text(reducer: &str, output: Output) -> String { ); text } - -fn build_module_bytes() -> Vec { - let module_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace = module_dir.parent().unwrap(); - let output = Command::new("cargo") - .current_dir(workspace) - .args([ - "build", - "--locked", - "--release", - "--target", - "wasm32-unknown-unknown", - "-p", - "lyracore-module", - "--features=debug_reducers", - "--message-format=json-render-diagnostics", - ]) - .output() - .expect("failed to run the Wasm preflight build"); - assert!( - output.status.success(), - "the Wasm preflight build failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let wasm = String::from_utf8(output.stdout) - .expect("Cargo artifact output was not UTF-8") - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|message| message["reason"] == "compiler-artifact") - .filter(|message| message["target"]["name"] == "lyracore_module") - .filter_map(|message| message["filenames"].as_array().cloned()) - .flatten() - .filter_map(|filename| filename.as_str().map(PathBuf::from)) - .find(|path| { - path.extension() - .is_some_and(|extension| extension == "wasm") - }) - .expect("Cargo did not report the built Module Wasm artifact"); - let bytes = std::fs::read(&wasm).expect("the Wasm preflight output is missing"); - assert!(bytes.starts_with(b"\0asm"), "the built module is not Wasm"); - bytes -}