WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541)

* Fix webhost argparse with extra args

* accidentally added line

* WebHost: fix some typing

B64 url conversion is used in test/hosting,
so it felt appropriate to include this here.

* Test: Hosting: also test autogen

* Test: Hosting: simplify stop_* and leave a note about Windows compat

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

There are actually some differences with MP on Windows
that make it impossible to run this in CI.

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
This commit is contained in:
NewSoupVi
2025-10-20 17:40:32 +02:00
committed by GitHub
parent 708df4d1e2
commit 7cd73e2710
7 changed files with 136 additions and 43 deletions

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(): def mystery_argparse(argv: list[str] | None = None):
from settings import get_settings from settings import get_settings
settings = get_settings() settings = get_settings()
defaults = settings.generator defaults = settings.generator
@@ -57,7 +57,7 @@ def mystery_argparse():
parser.add_argument("--spoiler_only", action="store_true", parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. " help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
args = parser.parse_args() args = parser.parse_args(argv)
if args.skip_output and args.spoiler_only: if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only") parser.error("Cannot mix --skip_output and --spoiler_only")

View File

@@ -1,6 +1,7 @@
import base64 import base64
import os import os
import socket import socket
import typing
import uuid import uuid
from flask import Flask from flask import Flask
@@ -61,20 +62,21 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value): def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value): def to_url(value: uuid.UUID) -> str:
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value): def to_python(self, value: str) -> uuid.UUID:
return to_python(value) return to_python(value)
def to_url(self, value): def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
return to_url(value) return to_url(value)
@@ -84,7 +86,7 @@ app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def register() -> None:
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib import importlib

View File

@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event() _stop_event = Event()
def stop(): def stop() -> None:
"""Stops previously launched threads""" """Stops previously launched threads"""
global _stop_event global _stop_event
stop_event = _stop_event stop_event = _stop_event

View File

@@ -137,7 +137,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse() args = mystery_argparse([]) # Just to set up the Namespace with defaults
args.multi = playercount args.multi = playercount
args.seed = seed args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery

View File

@@ -3,6 +3,7 @@
# Run with `python test/hosting` instead, # Run with `python test/hosting` instead,
import logging import logging
import traceback import traceback
from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from time import sleep from time import sleep
from typing import Any from typing import Any
@@ -11,7 +12,7 @@ from test.hosting.client import Client
from test.hosting.generate import generate_local from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata) stop_autogen, stop_autohost, upload_multidata, generate_remote)
from test.hosting.world import copy as copy_world, delete as delete_world from test.hosting.world import copy as copy_world, delete as delete_world
failure = False failure = False
@@ -56,35 +57,62 @@ else:
if __name__ == "__main__": if __name__ == "__main__":
import sys
import warnings import warnings
warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", DeprecationWarning)
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = [] p1_games: list[str] = []
data_paths = [] data_paths: list[Path | None] = []
rooms = [] rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "Temp World") copy_world("VVVVVV", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}") print(f"Generating [{n}] {', '.join(games)} offline")
multidata = generate_local(games, tempdir) multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata) data_paths.append(multidata)
p1_games.append(games[0])
finally: finally:
delete_world("Temp World") delete_world("Temp World")
webapp = get_app(tempdir) webapp = get_app(tempdir)
webhost_client = webapp.test_client() webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1): for n, multidata in enumerate(data_paths, 1):
assert multidata
seed = upload_multidata(webhost_client, multidata) seed = upload_multidata(webhost_client, multidata)
print(f"Uploaded [{n}] {multidata} as {seed}\n")
room = create_room(webhost_client, seed) room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n") print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room)
# Generate 1 extra game on WebHost
from WebHostLib.autolauncher import autogen
for n, games in enumerate(multis[:1], len(multis) + 1):
multis.append(games)
try:
print(f"Generating [{n}] {', '.join(games)} online")
autogen(webapp.config)
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
seed = generate_remote(webhost_client, games)
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
finally:
stop_autogen()
data_paths.append(None) # WebHost-only
room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room) rooms.append(room)
print("Starting autohost") print("Starting autohost")
@@ -96,31 +124,10 @@ if __name__ == "__main__":
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games) involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3): for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
prev_host_adr = host.address prev_host_adr = host.address
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages web_data_packages = client.games_packages
@@ -134,6 +141,7 @@ if __name__ == "__main__":
autohost(webapp.config) # this will spin the room right up again autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below # if saving failed, the next iteration will fail below
sleep(2) # work around issue #5571
# verify server shut down # verify server shut down
try: try:
@@ -156,6 +164,31 @@ if __name__ == "__main__":
"customserver did not load or save correctly during/after " "customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit")) + ("Ctrl+C" if collected_items == 2 else "/exit"))
if not multidata:
continue # games rolled on WebHost can not be tested against MultiServer
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datapackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
# compare customserver to MultiServer # compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages, expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer") "customserver datapackage differs from MultiServer")
@@ -176,10 +209,12 @@ if __name__ == "__main__":
print(f"Restoring multidata for {room}") print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data) set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2, assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver") "Save was destroyed during exception in customserver")
print("Save file is not busted 🥳") print("Save file is not busted 🥳")
sleep(2) # work around issue #5571
finally: finally:
print("Stopping autohost") print("Stopping autohost")

View File

@@ -1,6 +1,10 @@
import io
import json
import re import re
import time
import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Iterable, Optional, cast
from WebHostLib import to_python from WebHostLib import to_python
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
"get_app", "get_app",
"generate_remote",
"upload_multidata", "upload_multidata",
"create_room", "create_room",
"start_room", "start_room",
@@ -17,6 +22,7 @@ __all__ = [
"set_room_timeout", "set_room_timeout",
"get_multidata_for_room", "get_multidata_for_room",
"set_multidata_for_room", "set_multidata_for_room",
"stop_autogen",
"stop_autohost", "stop_autohost",
] ]
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
"TESTING": True, "TESTING": True,
"HOST_ADDRESS": "localhost", "HOST_ADDRESS": "localhost",
"HOSTERS": 1, "HOSTERS": 1,
"GENERATORS": 1,
"JOB_THRESHOLD": 1,
}) })
return get_app() return get_app()
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
data = io.BytesIO()
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for n, game in enumerate(games, 1):
name = f"{n}.yaml"
zip_file.writestr(name, json.dumps({
"name": f"Player{n}",
"game": game,
game: {},
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
}))
data.seek(0)
response = app_client.post("/generate", content_type="multipart/form-data", data={
"file": (data, "yamls.zip"),
})
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
assert "Location" in response.headers, f"Starting gen failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
for attempt in range(10):
response = app_client.get(location)
if "Location" in response.headers:
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
return location[6:]
time.sleep(1)
raise TimeoutError("WebHost gen did not finish")
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={ response = app_client.post("/uploads", data={
"file": multidata.open("rb"), "file": multidata.open("rb"),
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
room.seed.multidata = data room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None: def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
import os import os
import signal import signal
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
stop() stop()
proc: multiprocessing.process.BaseProcess proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
# and ungraceful may not save the game
if proc.pid == os.getpid():
continue
if graceful and proc.pid: if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else: else:
proc.kill() proc.kill()
try: try:
try:
proc.join(30)
except TimeoutError:
raise
except KeyboardInterrupt:
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
proc.join(30) proc.join(30)
except TimeoutError: except TimeoutError:
proc.kill() proc.kill()
proc.join() proc.join()
def stop_autogen(graceful: bool = True) -> None:
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
_stop_webhost_mp("SpawnPoolWorker-", graceful)
def stop_autohost(graceful: bool = True) -> None:
_stop_webhost_mp("MultiHoster", graceful)

View File

@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None: def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name from Utils import get_file_safe_name
from worlds import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
assert dst not in _new_worlds, "World already created" assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape if '"' in dst or "\\" in dst: # easier to reject than to escape