Files
OpenFUT/scripts/sold-staging-down.py
T
funman300 468bc0fba9 feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.

PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.

  OPENFUT_FIFA17_SOLD_EXPERIMENT      = highest | buyNow   (else OFF)
  OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1                  (else 0)
  OPENFUT_FIFA17_SOLD_COUNT_MODE      = active_plus_sold    (else active)

WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.

CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.

FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.

STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
  * scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
    utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
    own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
    responder and asserts every substitution applied, so a silent no-op cannot leave
    it pointing at production. Kills only recorded pids whose cmdline contains the
    staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
  * docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
  * src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
    (CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
    leaves the listing live with nothing moved. Refuses any path containing
    openfut-promotion or the production ports.
  * scripts/sold-wire-check.py -- proves the whole flow headless before any operator
    time is spent.

WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.

Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.

Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.

The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
2026-08-18 02:14:18 +00:00

253 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tear down the isolated FIFA-17 SOLD staging stack brought up by
`scripts/sold-staging-up.py` -- and NOTHING else.
Kill safety is the whole point of this file. `openfut-utas-host` and `openfut-core`
each name TWO live processes on this machine: the staging ones and the PRODUCTION
ones. So there is no pattern matching here at all:
* every pid comes from the manifest the up script wrote;
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
directory -- production's cmdline never can, because staging runs binaries
copied into that directory;
* the known production pids are refused explicitly, as a second gate;
* only the process GROUP the up script created (pgid == pid, via
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
* afterwards every staging port is proven free and production is proven alive.
python3 scripts/sold-staging-down.py
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import sys
import time
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
# Production processes that MUST be alive before and after this script runs. These
# two are the ones the batch contract names, and they live in the host pid view.
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
# Reported but not gated: container pids change when the operator restarts the
# container, and a stale entry here would turn a successful teardown into a FATAL.
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
2090888: "prod pow"}
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
class Fatal(RuntimeError):
pass
def banner(title: str) -> None:
print()
print("=" * 78)
print(f"== {title}")
print("=" * 78)
def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
def step(msg: str) -> None:
print(f" {msg}")
def safe_path(path: str) -> str:
real = os.path.realpath(path)
for bad in FORBIDDEN_PATHS:
if real == bad or real.startswith(bad + os.sep):
raise Fatal(f"REFUSING to touch production state: {path} -> {real}")
return path
def pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def cmdline_of(pid: int) -> str:
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
return fh.read().replace(b"\0", b" ").decode(errors="replace").strip()
except OSError:
return ""
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
teardown failed."""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
def port_free(port: int) -> bool:
return port not in listening_ports()
def stop_one(rec: dict, staging_dir: str) -> str:
"""Stop exactly one recorded process. Returns a human-readable outcome."""
name, pid = rec["name"], int(rec["pid"])
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
if pid in known_prod:
raise Fatal(
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
"REFUSING to signal anything from this manifest."
)
if not pid_alive(pid):
return f"{name} pid {pid}: already gone"
live = cmdline_of(pid)
if staging_dir not in live:
raise Fatal(
f"{name} pid {pid} is alive but its cmdline does NOT contain "
f"{staging_dir!r} -- pid reuse, or the wrong manifest. REFUSING to "
f"signal it.\n cmdline: {live!r}"
)
try:
pgid = os.getpgid(pid)
except OSError:
pgid = pid
recorded_pgid = int(rec.get("pgid", pid))
if pgid != recorded_pgid:
raise Fatal(
f"{name} pid {pid} is in process group {pgid} but the manifest recorded "
f"{recorded_pgid} -- REFUSING to signal a group we did not create."
)
if pgid != pid:
raise Fatal(
f"{name} pid {pid} is not its own group leader (pgid {pgid}) -- the up "
"script always starts a new session, so this is not our process."
)
os.killpg(pgid, signal.SIGTERM)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline and pid_alive(pid):
time.sleep(0.1)
if pid_alive(pid):
os.killpg(pgid, signal.SIGKILL)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline and pid_alive(pid):
time.sleep(0.1)
if pid_alive(pid):
raise Fatal(f"{name} pid {pid} survived SIGKILL")
return f"{name} pid {pid} (pgid {pgid}): stopped and verified gone"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
DEFAULT_STAGING_DIR),
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
ap.add_argument("--purge", action="store_true",
help="delete the staging directory after stopping (default: keep "
"the databases and logs as evidence)")
args = ap.parse_args()
staging_dir = safe_path(os.path.abspath(args.dir))
manifest_path = safe_path(os.path.join(staging_dir, "manifest.json"))
try:
banner("STOPPING THE STAGING STACK (recorded pids only)")
step(f"staging dir : {staging_dir}")
if not os.path.exists(manifest_path):
print(f" no manifest at {manifest_path} -- nothing was recorded, so "
"nothing will be signalled.")
print(" If a staging process is somehow still running, find it with "
"its cmdline (it contains the staging dir) and stop it by pid.")
return 0
with open(manifest_path) as fh:
manifest = json.load(fh)
if manifest.get("staging_dir") != staging_dir:
raise Fatal(
f"manifest staging_dir {manifest.get('staging_dir')!r} != "
f"{staging_dir!r} -- REFUSING to act on a foreign manifest."
)
step(f"variant : {manifest.get('variant')}")
for rec in manifest.get("processes", []):
ok(stop_one(rec, staging_dir))
banner("PROVE STAGING IS GONE")
ports = manifest.get("ports", {})
for name, port in sorted(ports.items(), key=lambda kv: kv[1]):
if port in PROD_PORTS:
raise Fatal(f"manifest port {name}={port} is a PRODUCTION port")
if not port_free(port):
raise Fatal(f"staging port {name}={port} is STILL listening")
ok(f"staging port {name} {port} free")
leftovers = []
for rec in manifest.get("processes", []):
pid = int(rec["pid"])
if pid_alive(pid) and staging_dir in cmdline_of(pid):
leftovers.append(f"{rec['name']} pid {pid}")
if leftovers:
raise Fatal("staging processes still alive: " + ", ".join(leftovers))
ok("no recorded staging process is alive")
banner("PROVE PRODUCTION IS STILL UP")
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
if not pid_alive(pid)]
for pid, what in PROD_PIDS.items():
if pid_alive(pid):
ok(f"{what} pid {pid} alive")
if dead:
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
for pid, what in PROD_PIDS_INFO.items():
state = "alive" if pid_alive(pid) else "not found (informational only)"
step(f"{what} pid {pid} {state}")
if args.purge:
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
ok(f"purged {staging_dir}")
else:
os.replace(manifest_path, safe_path(manifest_path + ".stopped"))
ok(f"kept {staging_dir} (manifest renamed to manifest.json.stopped so a "
"fresh `up` is allowed)")
banner("REMINDER: REVERT THE CLIENT")
print(' On 10.10.0.105, restore "/mnt/games/FIFA 17/openfut.cfg" to:')
print()
print(" host=10.10.0.120")
print(" https_port=8443")
print(" blaze_redirector_port=42127")
print(" blaze_main_port=42130")
print()
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
return 0
except Fatal as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())