fix(windows): avoid repeated firewall prompts in fixtures and launcher updates

Keep same-machine test sockets on loopback and stage launcher-managed games
at a stable executable path per game home. Preserve runtime dependencies and
protect running installed/native versions from replacement and cleanup.

Refs #9685.
This commit is contained in:
GabrielNichols committed 2026-11-05 04:42:32 -03:00
1 parent 3c945f9d30
commit 1a6a403884
21 files changed
+414 -27

No files matched your search

@@ -944,9 +944,16 @@ impl Game<'_> {
let port = match ctx.get("port") {
Some(Arg::Int(p)) => *p,
// HttpUtil.getAvailablePort.
_ => std::net::TcpListener::bind(("0.0.0.0", 0))
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.port())),
_ => std::net::TcpListener::bind((
if std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1") {
"127.0.0.1"
} else {
"0.0.0.0"
},
0,
))
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.port())),
};
let published = if self.serving() {
self.published_port()
+1 -1
View File
@@ -3702,7 +3702,7 @@ mod tests {
let sync = std::sync::Arc::new(data::SyncData::empty());
let config = NetConfig {
announce: false,
..NetConfig::lan(0, name.into(), 2)
..NetConfig::loopback(0, name.into(), 2)
};
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
@@ -917,7 +917,7 @@ mod tests {
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
..NetConfig::loopback(0, "ShulkerBulletTest".into(), 2)
};
let mut sync = data::SyncData::empty();
sync.builtin.registries.insert(
+1 -1
View File
@@ -578,7 +578,7 @@ mod hub_tests {
fn hub() -> Hub {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ArrivalsTest".into(), 4)
..NetConfig::loopback(0, "ArrivalsTest".into(), 4)
};
let mut sync = data::SyncData::empty();
// The entity types the tracker names: the player, as id 0.
+2 -2
View File
@@ -1686,7 +1686,7 @@ mod tests {
announce: false,
listen: false,
accepts_transfers: false,
..crate::lan::NetConfig::lan(0, "Transfers".into(), 8)
..crate::lan::NetConfig::loopback(0, "Transfers".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
@@ -1771,7 +1771,7 @@ mod tests {
let _ = never.recv();
Vec::new()
}))),
..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8)
..crate::lan::NetConfig::loopback(0, "Owner - World".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
+5 -1
View File
@@ -136,7 +136,11 @@ impl Scanner {
pub fn start() -> Self {
let games: Games = Arc::default();
let stop = Arc::new(AtomicBool::new(false));
if let Ok(socket) = bind_listener() {
// Constructing multiplayer screens in unit tests must not open the LAN.
if !cfg!(test)
&& !super::test_loopback()
&& let Ok(socket) = bind_listener()
{
let (games, stop) = (games.clone(), stop.clone());
let _ = std::thread::Builder::new()
.name("horizon-lan-scan".into())
+1 -1
View File
@@ -4578,7 +4578,7 @@ mod tests {
fn hub_with_sync(sync: SyncData) -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
..crate::lan::NetConfig::loopback(0, "EffectSyncTest".into(), 2)
};
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
+50 -7
View File
@@ -218,6 +218,15 @@ pub struct NetConfig {
pub levels: crate::chunk::LevelShapes,
}
impl NetConfig {
/// A real TCP server confined to this machine, without multicast announcements.
/// Use this for socket tests; `lan` retains the public LAN behaviour.
pub fn loopback(port: u16, motd: String, max_players: usize) -> Self {
Self {
bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
announce: false,
..Self::lan(port, motd, max_players)
}
}
/// MinecraftServer.enforceSecureProfile.
pub fn enforces_secure_chat(&self) -> bool {
self.secure_chat.as_ref().is_some_and(|s| s.enforced())
@@ -635,7 +644,13 @@ impl Lan {
/// The socket [`Lan::listen`] accepts on, bound ahead so that a failure
/// can be reported before the hub is at hand.
pub fn bind(address: Option<std::net::IpAddr>, port: u16) -> io::Result<TcpListener> {
let bind = address.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
let bind = address.unwrap_or_else(|| {
if test_loopback() {
std::net::Ipv4Addr::LOCALHOST.into()
} else {
std::net::Ipv4Addr::UNSPECIFIED.into()
}
});
let listener = TcpListener::bind((bind, port))?;
listener.set_nonblocking(true)?;
Ok(listener)
@@ -654,7 +669,7 @@ impl Lan {
let thread = std::thread::Builder::new()
.name("horizon-lan-accept".into())
.spawn(move || accept_loop(listener, accept_shared, &accept_stop))?;
self._pinger = if announce {
self._pinger = if announce && !test_loopback() {
discovery::Pinger::start(self.shared.motd(), port).ok()
} else {
None
@@ -931,13 +946,41 @@ fn handle(stream: TcpStream, addr: SocketAddr, counted: Counted) {
/// HttpUtil.getAvailablePort: a free local port.
pub fn available_port() -> u16 {
TcpListener::bind(("0.0.0.0", 0))
let bind = if cfg!(test) || test_loopback() {
"127.0.0.1"
} else {
"0.0.0.0"
};
TcpListener::bind((bind, 0))
.and_then(|l| l.local_addr())
.map_or(25565, |a| a.port())
}
/// Same-machine harnesses opt in; real LAN runs keep the normal network interfaces.
pub(super) fn test_loopback() -> bool {
std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
}
#[cfg(test)]
mod tests {
#[test]
fn a_local_server_accepts_only_the_requested_loopback_address() {
let lan = super::Lan::start_with(
std::sync::Arc::new(super::data::SyncData::empty()),
super::NetConfig::loopback(0, "Local test".into(), 1),
)
.unwrap();
let local = std::net::TcpStream::connect(("127.0.0.1", lan.port)).unwrap();
let other = "127.0.0.2".parse::<std::net::IpAddr>().unwrap();
assert!(
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::new(other, lan.port),
std::time::Duration::from_millis(500),
)
.is_err()
);
drop(local);
}
use super::conn::MEMORY_ADDRESS;
use super::*;
use std::{io::Read, time::Instant};
@@ -949,7 +992,7 @@ mod tests {
fn a_flood_of_silent_sockets_is_turned_away() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "Flood".into(), 1)
..NetConfig::loopback(0, "Flood".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let address = (std::net::Ipv4Addr::LOCALHOST, lan.port);
@@ -994,7 +1037,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Status".into(), 8)
..NetConfig::loopback(0, "Status".into(), 8)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
@@ -1012,7 +1055,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Share".into(), 20)
..NetConfig::loopback(0, "Share".into(), 20)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
@@ -1046,7 +1089,7 @@ mod tests {
lang: Arc::new(Default::default()),
log_ips: true,
}),
..NetConfig::lan(0, "Room".into(), 1)
..NetConfig::loopback(0, "Room".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let shared = &lan.shared;
+4 -4
View File
@@ -208,7 +208,7 @@ impl Server {
}
fn server(resources: &Path) -> Server {
server_with(resources, NetConfig::lan(0, "Test - World".into(), 8))
server_with(resources, NetConfig::loopback(0, "Test - World".into(), 8))
}
fn server_with(resources: &Path, net: NetConfig) -> Server {
@@ -295,7 +295,7 @@ fn a_lan_guest_sees_the_signed_in_owners_skin() {
let net = NetConfig {
announce: false,
owner_profile: Some(profile.clone()),
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let mut s = server_with(&resources, net);
s.hub.singleplayer_owner = Some(offline_uuid("Owner"));
@@ -484,7 +484,7 @@ fn neoforge_shapes_items_reach_two_guests() {
sync.id("minecraft:item", "minecraft:raw_copper").unwrap(),
sync.id("minecraft:item", "minecraft:redstone").unwrap(),
);
let mut s = server_sync(sync, NetConfig::lan(0, "Test - World".into(), 8));
let mut s = server_sync(sync, NetConfig::loopback(0, "Test - World".into(), 8));
let port = s.hub.lan.port;
// Guest one joins first and stays while guest two joins and reads its
// own join packets, then guest one reads its own.
@@ -741,7 +741,7 @@ fn a_signed_in_hosts_lan_authenticates_guests_but_not_its_own_player() {
let net = NetConfig {
online: Some(Arc::new(OnlineMode::generate(false).unwrap())),
announce: false,
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let lan = Lan::start_with(sync, net).unwrap();
let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap();
+6
View File
@@ -12,6 +12,12 @@ in vanilla's Anvil format.
in the README scores each subsystem against vanilla and lists what is still
missing; [Rust rewrite status](rust-parity.md) has the details.
On Windows, the launcher uses a stable executable path for each game home across
release and native-mod updates. Its private runtime copy lives under the launcher's
data folder in `run/<home key>/game/`; installed versions remain intact. Opening a
world to other machines can still require an initial Windows Firewall permission.
The launcher does not create firewall rules or change network-profile settings.
## musl Linux: manual installation
On x86-64 musl distributions, download `horizon-linux-musl-x86_64.tar.gz` from the
+7
View File
@@ -4,6 +4,13 @@ How parity work is run, checked and merged. The harnesses themselves are describ
## Running the vanilla harnesses
Same-machine fixtures created with `lab.game_env` use `HORIZON_TEST_LOOPBACK=1`: Horizon
listens on loopback and does not scan or announce LAN worlds. This avoids Windows Firewall
permission prompts for newly compiled fixture executables. Set `HORIZON_TEST_LOOPBACK=0`
explicitly when testing real network interfaces or multicast discovery. Local vanilla server
fixtures also set `server-ip=127.0.0.1`; use one installed JDK via `JAVA_HOME` and its `bin`
on `PATH`, rather than copying Java to each fixture directory. Keep the firewall enabled.
`tools/parity.py` runs the `tools/test_*_parity.py` harnesses in parallel and reruns only what a change can affect:
```sh
+37 -4
View File
@@ -2761,6 +2761,8 @@ pub fn client_command(tag: &str) -> Command {
let home = game_home();
let _ = fs::create_dir_all(&home);
let mut cmd = Command::new(client_program(tag));
#[cfg(windows)]
crate::windows_runtime::mark(&mut cmd, &version_dir(tag));
strip_launcher_render_env(&mut cmd);
cmd.current_dir(&home)
.env("HORIZON_HOME", &home)
@@ -2882,7 +2884,7 @@ fn stale_versions(installed: &[String], keep: usize, protected: &[String]) -> Ve
/// their program and `--resources` from it. Windows refuses to delete a running program's files,
/// so there the delete itself fails. With no answer from `ps` the version counts as running.
#[cfg(unix)]
fn in_use(tag: &str) -> bool {
pub(crate) fn in_use(tag: &str) -> bool {
let dir = format!("{}/", version_dir(tag).display());
Command::new("ps")
.args(["-A", "-ww", "-o", "args="])
@@ -2891,8 +2893,13 @@ fn in_use(tag: &str) -> bool {
.is_none_or(|out| String::from_utf8_lossy(&out.stdout).contains(&dir))
}
#[cfg(not(unix))]
fn in_use(_tag: &str) -> bool {
#[cfg(windows)]
pub(crate) fn in_use(tag: &str) -> bool {
crate::windows_runtime::in_use(&version_dir(tag))
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn in_use(_tag: &str) -> bool {
false
}
@@ -4021,7 +4028,32 @@ impl Running {
}
}
pub fn spawn(job: Job, mut cmd: Command, tx: Sender<Msg>) -> Result<Running, String> {
/// Prepare a command's Windows runtime and keep its guard alive through process exit.
pub struct RuntimeGuard {
pub(crate) _slot: fs::File,
pub(crate) _source: fs::File,
}
pub fn prepare_command(cmd: Command) -> Result<(Command, Option<RuntimeGuard>), String> {
#[cfg(windows)]
let cmd = crate::windows_runtime::redirect(cmd);
#[cfg(windows)]
let guard = crate::windows_runtime::prepare(&cmd).map_err(|e| {
crate::i18n::trf(
"Could not run {program}: {error}",
&[
("program", &cmd.get_program().to_string_lossy()),
("error", &e.to_string()),
],
)
})?;
#[cfg(not(windows))]
let guard = None;
Ok((cmd, guard))
}
pub fn spawn(job: Job, cmd: Command, tx: Sender<Msg>) -> Result<Running, String> {
let (mut cmd, runtime) = prepare_command(cmd)?;
crate::process::hide_console(&mut cmd);
let mut child = cmd
.stdin(Stdio::piped())
@@ -4064,6 +4096,7 @@ pub fn spawn(job: Job, mut cmd: Command, tx: Sender<Msg>) -> Result<Running, Str
Ok(s) => Err(format!("Exited with {s}")),
Err(e) => Err(e.to_string()),
};
drop(runtime);
let _ = tx.send(Msg::Exit(job, result));
});
Ok(Running { child, stdin })
+2 -1
View File
@@ -1212,7 +1212,8 @@ fn json(value: serde_json::Value) {
}
/// Runs a game process in this terminal and reports how it ended.
fn attached(mut cmd: Command) -> Result<(), String> {
fn attached(cmd: Command) -> Result<(), String> {
let (mut cmd, _runtime) = backend::prepare_command(cmd)?;
let status = cmd
.status()
.map_err(|e| format!("Could not start {:?}: {e}", cmd.get_program()))?;
+2
View File
@@ -459,6 +459,8 @@ pub fn plan(launch: &Launch, words: Vec<OsString>, windows: bool, found: &Found)
/// The game's command run with `launch`: a new command with the same folder and variables,
/// plus the instance's variables. Call it last, after every argument is added.
pub fn wrap(cmd: Command, launch: &Launch) -> Command {
#[cfg(windows)]
let cmd = crate::windows_runtime::redirect(cmd);
if launch.is_empty() {
return cmd;
}
+2
View File
@@ -54,4 +54,6 @@ pub mod themes;
pub mod uninstall_aside;
pub mod update_handoff;
pub mod wardrobe;
#[cfg(windows)]
mod windows_runtime;
pub mod worlds;
+3
View File
@@ -2542,6 +2542,9 @@ pub fn prune(keep: &str) {
})
.collect();
for old in stale(builds, keep, &played) {
if backend::in_use(&old) {
continue;
}
let _ = backend::remove(&old);
}
}
+7 -1
View File
@@ -552,8 +552,14 @@ impl Default for NewServer {
/// A port nobody listens on, from `from` up, and not one of `taken`.
pub fn free_port(from: u16, taken: &[u16]) -> u16 {
let bind = if cfg!(test) || std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
{
"127.0.0.1"
} else {
"0.0.0.0"
};
(from..u16::MAX)
.find(|p| !taken.contains(p) && std::net::TcpListener::bind(("0.0.0.0", *p)).is_ok())
.find(|p| !taken.contains(p) && std::net::TcpListener::bind((bind, *p)).is_ok())
.unwrap_or(from)
}
+263
View File
@@ -0,0 +1,263 @@
//! Keep the game's Windows executable path stable across installed versions.
//! A slot belongs to one game home; its lock is held until the child exits.
use sha2::{Digest, Sha256};
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::{fs, io, path::Path, process::Command};
const SOURCE: &str = "HORIZON_WINDOWS_RUNTIME_SOURCE";
const SLOT: &str = "HORIZON_WINDOWS_RUNTIME_SLOT";
pub(crate) fn mark(cmd: &mut Command, source: &Path) {
cmd.env(SOURCE, source);
}
fn value<'a>(cmd: &'a Command, key: &str) -> Option<&'a std::ffi::OsStr> {
cmd.get_envs().find(|(k, _)| *k == key).and_then(|(_, v)| v)
}
/// Called before adding a pre-launch command or wrapper, after applying the instance.
pub(crate) fn redirect(cmd: Command) -> Command {
redirect_in(cmd, &crate::backend::data_dir().join("run"))
}
fn redirect_in(cmd: Command, root: &Path) -> Command {
if value(&cmd, SOURCE).is_none() || value(&cmd, SLOT).is_some() {
return cmd;
}
let home = value(&cmd, "HORIZON_HOME")
.map(Path::new)
.or(cmd.get_current_dir())
.expect("the game command has a home");
let home = fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
let key = format!("{:x}", Sha256::digest(home.as_os_str().as_encoded_bytes()));
let slot = root.join(key);
let mut out = Command::new(slot.join("game").join(crate::backend::CLIENT));
out.args(cmd.get_args());
if let Some(dir) = cmd.get_current_dir() {
out.current_dir(dir);
}
for (key, value) in cmd.get_envs() {
match value {
Some(value) => out.env(key, value),
None => out.env_remove(key),
};
}
out.env(SLOT, slot);
out
}
/// Prepare under an exclusive lock. Never fall back to a version-specific executable:
/// a locked or incomplete slot is an error, rather than another firewall identity.
pub(crate) fn prepare(cmd: &Command) -> io::Result<Option<crate::backend::RuntimeGuard>> {
prepare_in(cmd, &crate::backend::data_dir().join("run"))
}
fn prepare_in(cmd: &Command, root: &Path) -> io::Result<Option<crate::backend::RuntimeGuard>> {
let (Some(source), Some(slot)) = (value(cmd, SOURCE), value(cmd, SLOT)) else {
return Ok(None);
};
let slot = Path::new(slot);
let valid_key = slot
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()));
if slot.parent() != Some(root) || !valid_key {
return Err(io::ErrorKind::InvalidInput.into());
}
stage(Path::new(source), slot).map(Some)
}
fn plain_directory(path: &Path) -> io::Result<()> {
fs::create_dir_all(path)?;
// A replaced runtime slot or game folder must never redirect cleanup to player data.
if fs::symlink_metadata(path)?.file_attributes() & 0x400 != 0 {
return Err(io::ErrorKind::InvalidInput.into());
}
Ok(())
}
fn stage(source: &Path, slot: &Path) -> io::Result<crate::backend::RuntimeGuard> {
plain_directory(slot)?;
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(slot.join("run.lock"))?;
lock.try_lock().map_err(io::Error::other)?;
// Retain the original exe's protection against deletion/rebuild while playing.
let source_file = fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(source.join(crate::backend::CLIENT))?;
let game = slot.join("game");
plain_directory(&game)?;
// Windows refuses this write while an orphaned game still executes the old binary,
// even if its launcher (and thus the advisory lock) has already exited.
fs::copy(
source.join(crate::backend::CLIENT),
game.join(crate::backend::CLIENT),
)?;
for entry in fs::read_dir(&game)? {
let entry = entry?;
if entry.file_name() == crate::backend::CLIENT {
continue;
}
if entry.file_type()?.is_dir() {
fs::remove_dir_all(entry.path())?;
} else {
fs::remove_file(entry.path())?;
}
}
// Runtime files (DLLs, native-data and native-mods.json) stay beside the exe.
// --resources still names the installed version's own resources.
for entry in fs::read_dir(source)? {
let entry = entry?;
if entry.file_name() == crate::backend::CLIENT || entry.file_name() == ".local" {
continue;
}
let to = game.join(entry.file_name());
if entry.file_type()?.is_dir() {
crate::backend::copy_dir(&entry.path(), &to)?;
} else {
fs::copy(entry.path(), to)?;
}
}
fs::write(
slot.join("source.json"),
serde_json::to_vec(&fs::canonicalize(source)?)?,
)?;
Ok(crate::backend::RuntimeGuard {
_slot: lock,
_source: source_file,
})
}
pub(crate) fn in_use(source: &Path) -> bool {
in_use_in(source, &crate::backend::data_dir().join("run"))
}
fn in_use_in(source: &Path, root: &Path) -> bool {
let source = fs::canonicalize(source).unwrap_or_else(|_| source.to_path_buf());
let slots = match fs::read_dir(root) {
Ok(slots) => slots,
Err(e) => return e.kind() != io::ErrorKind::NotFound,
};
slots.filter_map(Result::ok).any(|slot| {
let recorded = fs::read(slot.path().join("source.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<std::path::PathBuf>(&bytes).ok());
if recorded.as_ref() != Some(&source) {
return false;
}
let Ok(lock) = fs::OpenOptions::new()
.read(true)
.write(true)
.open(slot.path().join("run.lock"))
else {
return true;
};
if lock.try_lock().is_err() {
return true;
}
// If the launcher exited first, Windows still protects the running image.
fs::OpenOptions::new()
.write(true)
.open(slot.path().join("game").join(crate::backend::CLIENT))
.is_err()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_orphaned_process_keeps_its_runtime_and_source_version_protected() {
let root = std::env::temp_dir().join(format!("horizon-orphan-{}", std::process::id()));
let source = root.join("version");
fs::create_dir_all(&source).unwrap();
fs::copy(
Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/cmd.exe"),
source.join(crate::backend::CLIENT),
)
.unwrap();
let mut cmd = Command::new(source.join(crate::backend::CLIENT));
cmd.current_dir(&root).args(["/D", "/C", "set /p answer="]);
mark(&mut cmd, &source);
let mut cmd = redirect_in(cmd, &root.join("run"));
let guard = prepare_in(&cmd, &root.join("run")).unwrap().unwrap();
crate::process::hide_console(&mut cmd);
let mut child = cmd
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
drop(guard); // Simulate a launcher exiting before its game.
let running = child.try_wait().unwrap().is_none();
let protected = in_use_in(&source, &root.join("run"));
let replacement = prepare_in(&cmd, &root.join("run"));
let _ = child.kill();
child.wait().unwrap();
assert!(running && protected);
assert!(replacement.is_err());
assert!(!in_use_in(&source, &root.join("run")));
drop(prepare_in(&cmd, &root.join("run")).unwrap());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn versions_reuse_a_slot_keep_native_files_and_cannot_replace_a_running_game() {
let root = std::env::temp_dir().join(format!("horizon-runtime-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let home = root.join("home");
fs::create_dir_all(&home).unwrap();
let command = |version: &str| {
let source = root.join(version);
fs::create_dir_all(source.join("native-data/mod")).unwrap();
fs::write(source.join(crate::backend::CLIENT), version).unwrap();
fs::write(source.join("native-data/mod/table"), version).unwrap();
let mut cmd = Command::new(source.join(crate::backend::CLIENT));
cmd.current_dir(&home)
.env("HORIZON_HOME", &home)
.arg("--offscreen");
mark(&mut cmd, &source);
redirect_in(cmd, &root.join("run"))
};
let first = command("v1");
let lock = prepare_in(&first, &root.join("run")).unwrap().unwrap();
assert!(in_use_in(&root.join("v1"), &root.join("run")));
assert!(fs::remove_file(root.join("v1").join(crate::backend::CLIENT)).is_err());
let game = Path::new(first.get_program()).parent().unwrap();
fs::write(game.join("removed.dll"), "old").unwrap();
let second = command("v2");
assert_eq!(first.get_program(), second.get_program());
assert!(prepare_in(&second, &root.join("run")).is_err());
assert_eq!(fs::read_to_string(first.get_program()).unwrap(), "v1");
drop(lock);
assert!(!in_use_in(&root.join("v1"), &root.join("run")));
let lock = prepare_in(&second, &root.join("run")).unwrap().unwrap();
assert!(!in_use_in(&root.join("v1"), &root.join("run")));
assert!(in_use_in(&root.join("v2"), &root.join("run")));
assert_eq!(fs::read_to_string(second.get_program()).unwrap(), "v2");
assert_eq!(
fs::read_to_string(game.join("native-data/mod/table")).unwrap(),
"v2"
);
assert!(!game.join("removed.dll").exists());
assert_eq!(second.get_current_dir(), Some(home.as_path()));
assert_eq!(second.get_args().collect::<Vec<_>>(), ["--offscreen"]);
drop(lock);
let unrelated = root.join("unrelated");
fs::create_dir_all(&unrelated).unwrap();
fs::write(unrelated.join("keep.txt"), "player data").unwrap();
let mut injected = command("v3");
injected.env(SLOT, &unrelated);
assert!(prepare_in(&injected, &root.join("run")).is_err());
assert_eq!(
fs::read_to_string(unrelated.join("keep.txt")).unwrap(),
"player data"
);
fs::remove_dir_all(root).unwrap();
}
}
+6
View File
@@ -21,6 +21,12 @@ class IntegrationEnvironmentTests(unittest.TestCase):
self.assertEqual(env['HORIZON_TELEMETRY_URL'], 'off')
self.assertEqual(env['HORIZON_LAUNCHER_NO_SELF_UPDATE'], '1')
self.assertEqual(env['HORIZON_NO_AUDIO'], '1')
self.assertEqual(env['HORIZON_TEST_LOOPBACK'], '1')
def test_real_lan_suites_can_keep_the_network_interfaces(self):
with tempfile.TemporaryDirectory() as root:
env = game_environment({'HORIZON_TEST_LOOPBACK': '0'}, root)
self.assertEqual(env['HORIZON_TEST_LOOPBACK'], '0')
def test_each_fixture_process_gets_fresh_homes_and_cleanup(self):
script = ('import json, os; from pathlib import Path; '
+3
View File
@@ -80,6 +80,9 @@ def game_env(base=None, **extra):
env.update({key: str(value) for key, value in extra.items()})
env["HORIZON_NO_AUDIO"] = "1"
env["HORIZON_LAUNCHER_NO_SELF_UPDATE"] = "1"
# Local fixtures use real TCP, but need no inbound access from other machines.
# Network/discovery suites can explicitly set HORIZON_TEST_LOOPBACK=0.
env.setdefault("HORIZON_TEST_LOOPBACK", "1")
# Never a real Discord's Rich Presence; tools/discord_presence_e2e.py points the game at a fake one.
if not env.get("HORIZON_DISCORD_IPC_DIR"):
env["HORIZON_NO_DISCORD"] = "1"
+1
View File
@@ -85,6 +85,7 @@ NAME = 'dedicated-server-parity'
SEED = 12345
PLAYER = 'Tester'
PROPERTIES = {
'server-ip': '127.0.0.1',
'online-mode': 'false',
'white-list': 'false',
'level-seed': str(SEED),