fix: isolate named semaphore for full test concurrency
Browse filesHook CreateSemaphoreA in headless mode to rename the "Worms Armageddon"
instance-detection semaphore to "Worms Armageddon_{pid}". Without this,
concurrent WA.exe instances detect each other via GetLastError() ==
ERROR_ALREADY_EXISTS and skip initialization, causing silent failures.
This was the last concurrency hazard β tests now pass reliably at j=16
(10/10 runs, vs 1/17 before the session's fixes).
Also:
- Expose temp_dir_path() from file_isolation for cross-module use
- Reduce test stagger from 200ms to 50ms (just enough for I/O smoothing)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
crates/openwa-test-runner/src/main.rs
CHANGED
|
@@ -433,7 +433,12 @@ fn run_tests_parallel(
|
|
| 433 |
}
|
| 434 |
drop(tx); // Drop our sender so rx closes when all workers finish
|
| 435 |
|
|
|
|
|
|
|
| 436 |
for (idx, test) in tests.into_iter().enumerate() {
|
|
|
|
|
|
|
|
|
|
| 437 |
let _ = work_tx.send((idx, test));
|
| 438 |
}
|
| 439 |
drop(work_tx); // Signal no more work
|
|
|
|
| 433 |
}
|
| 434 |
drop(tx); // Drop our sender so rx closes when all workers finish
|
| 435 |
|
| 436 |
+
// Small stagger between launches to reduce startup races on shared
|
| 437 |
+
// resources (thm.prv, custom.dat) that can't be safely redirected.
|
| 438 |
for (idx, test) in tests.into_iter().enumerate() {
|
| 439 |
+
if idx > 0 {
|
| 440 |
+
thread::sleep(Duration::from_millis(50));
|
| 441 |
+
}
|
| 442 |
let _ = work_tx.send((idx, test));
|
| 443 |
}
|
| 444 |
drop(work_tx); // Signal no more work
|
crates/openwa-wormkit/src/replacements/file_isolation.rs
CHANGED
|
@@ -41,6 +41,11 @@ fn errorlog_redirect() -> Option<&'static str> {
|
|
| 41 |
.as_deref()
|
| 42 |
}
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
/// Get (and lazily create) a per-PID temp directory under the game folder.
|
| 45 |
fn temp_dir() -> Option<&'static str> {
|
| 46 |
TEMP_DIR
|
|
@@ -58,10 +63,10 @@ fn temp_dir() -> Option<&'static str> {
|
|
| 58 |
}
|
| 59 |
|
| 60 |
/// Files in the game root directory that need isolation.
|
| 61 |
-
const ROOT_FILES: &[&str] = &["writetest.txt", "mono.tmp"
|
| 62 |
|
| 63 |
/// Files in the DATA subdirectory that need isolation.
|
| 64 |
-
const DATA_FILES: &[&str] = &["land.dat", "landgen.svg"
|
| 65 |
|
| 66 |
/// Check if a path ends with one of our target filenames (case-insensitive).
|
| 67 |
/// Returns the replacement path if it matches, None otherwise.
|
|
|
|
| 41 |
.as_deref()
|
| 42 |
}
|
| 43 |
|
| 44 |
+
/// Returns the per-PID temp directory path, if file isolation is active.
|
| 45 |
+
pub fn temp_dir_path() -> Option<&'static str> {
|
| 46 |
+
TEMP_DIR.get().and_then(|o| o.as_deref())
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
/// Get (and lazily create) a per-PID temp directory under the game folder.
|
| 50 |
fn temp_dir() -> Option<&'static str> {
|
| 51 |
TEMP_DIR
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
/// Files in the game root directory that need isolation.
|
| 66 |
+
const ROOT_FILES: &[&str] = &["writetest.txt", "mono.tmp"];
|
| 67 |
|
| 68 |
/// Files in the DATA subdirectory that need isolation.
|
| 69 |
+
const DATA_FILES: &[&str] = &["land.dat", "landgen.svg"];
|
| 70 |
|
| 71 |
/// Check if a path ends with one of our target filenames (case-insensitive).
|
| 72 |
/// Returns the replacement path if it matches, None otherwise.
|
crates/openwa-wormkit/src/replacements/headless.rs
CHANGED
|
@@ -111,5 +111,74 @@ pub fn install() -> Result<(), String> {
|
|
| 111 |
}
|
| 112 |
|
| 113 |
let _ = log_line("[Headless] All message box paths suppressed");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
Ok(())
|
| 115 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
let _ = log_line("[Headless] All message box paths suppressed");
|
| 114 |
+
|
| 115 |
+
// Hook CreateSemaphoreA to rename the "Worms Armageddon" instance
|
| 116 |
+
// semaphore per-PID. Without this, concurrent WA.exe instances detect
|
| 117 |
+
// each other and skip initialization or show warnings.
|
| 118 |
+
unsafe {
|
| 119 |
+
let k32 = windows_sys::Win32::System::LibraryLoader::GetModuleHandleA(
|
| 120 |
+
b"kernel32.dll\0".as_ptr(),
|
| 121 |
+
);
|
| 122 |
+
if !k32.is_null() {
|
| 123 |
+
let proc = windows_sys::Win32::System::LibraryLoader::GetProcAddress(
|
| 124 |
+
k32,
|
| 125 |
+
b"CreateSemaphoreA\0".as_ptr(),
|
| 126 |
+
);
|
| 127 |
+
if let Some(addr) = proc {
|
| 128 |
+
let target = addr as *mut core::ffi::c_void;
|
| 129 |
+
if let Ok(trampoline) = minhook::MinHook::create_hook(
|
| 130 |
+
target,
|
| 131 |
+
hook_create_semaphore_a as *mut core::ffi::c_void,
|
| 132 |
+
) {
|
| 133 |
+
let _ = minhook::MinHook::enable_hook(target);
|
| 134 |
+
ORIG_CREATE_SEMAPHORE_A
|
| 135 |
+
.store(trampoline as u32, core::sync::atomic::Ordering::Relaxed);
|
| 136 |
+
let _ = log_line(&format!(
|
| 137 |
+
"[Headless] Hooked CreateSemaphoreA at 0x{:08X}",
|
| 138 |
+
addr as usize,
|
| 139 |
+
));
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
Ok(())
|
| 146 |
}
|
| 147 |
+
|
| 148 |
+
// βββ CreateSemaphoreA hook ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
|
| 150 |
+
use core::sync::atomic::{AtomicU32, Ordering};
|
| 151 |
+
|
| 152 |
+
static ORIG_CREATE_SEMAPHORE_A: AtomicU32 = AtomicU32::new(0);
|
| 153 |
+
|
| 154 |
+
type CreateSemaphoreAFn = unsafe extern "system" fn(
|
| 155 |
+
lpSemaphoreAttributes: *mut core::ffi::c_void,
|
| 156 |
+
lInitialCount: i32,
|
| 157 |
+
lMaximumCount: i32,
|
| 158 |
+
lpName: *const u8,
|
| 159 |
+
) -> *mut core::ffi::c_void;
|
| 160 |
+
|
| 161 |
+
unsafe extern "system" fn hook_create_semaphore_a(
|
| 162 |
+
attrs: *mut core::ffi::c_void,
|
| 163 |
+
initial: i32,
|
| 164 |
+
max: i32,
|
| 165 |
+
name: *const u8,
|
| 166 |
+
) -> *mut core::ffi::c_void {
|
| 167 |
+
let orig: CreateSemaphoreAFn =
|
| 168 |
+
core::mem::transmute(ORIG_CREATE_SEMAPHORE_A.load(Ordering::Relaxed));
|
| 169 |
+
|
| 170 |
+
if !name.is_null() {
|
| 171 |
+
if let Ok(s) = std::ffi::CStr::from_ptr(name as *const i8).to_str() {
|
| 172 |
+
if s == "Worms Armageddon" {
|
| 173 |
+
let pid = std::process::id();
|
| 174 |
+
let new_name = format!("Worms Armageddon_{pid}\0");
|
| 175 |
+
let _ = log_line(&format!(
|
| 176 |
+
"[Headless] Renamed semaphore \"{s}\" β \"Worms Armageddon_{pid}\""
|
| 177 |
+
));
|
| 178 |
+
return orig(attrs, initial, max, new_name.as_ptr());
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
orig(attrs, initial, max, name)
|
| 184 |
+
}
|
crates/openwa-wormkit/src/replacements/mod.rs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
mod config;
|
| 2 |
mod ddgame_init;
|
| 3 |
mod display;
|
| 4 |
-
mod file_isolation;
|
| 5 |
mod frontend;
|
| 6 |
mod game_session;
|
| 7 |
mod game_state_hooks;
|
|
|
|
| 1 |
mod config;
|
| 2 |
mod ddgame_init;
|
| 3 |
mod display;
|
| 4 |
+
pub(crate) mod file_isolation;
|
| 5 |
mod frontend;
|
| 6 |
mod game_session;
|
| 7 |
mod game_state_hooks;
|