468bc0fba9
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.
376 lines
17 KiB
Python
Executable File
376 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Wire-level verification of the seller-facing SOLD flow, in isolation.
|
|
|
|
Proves the harness produces a correct, authentic sold row BEFORE any operator time
|
|
is spent driving a real FIFA client. Brings up its own Core + utas-host on ephemeral
|
|
ports against throwaway databases, runs the real settlement through the
|
|
`staging_sell` binary, then reads every seller-facing surface under BOTH A/B
|
|
variants and exercises the bulk clear verb.
|
|
|
|
ISOLATION: production ports 8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216,
|
|
8080, 8081 and 8094 are in a hard deny-list checked before every bind and every
|
|
request, and nothing under /home/alex/openfut-promotion/state/ is opened.
|
|
|
|
python3 scripts/sold-wire-check.py [--keep]
|
|
"""
|
|
import argparse
|
|
import http.client
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
|
|
TS = "2026-01-01T00:00:00Z"
|
|
PERSONA = "33068179"
|
|
SELLER_CLUB = "club-seller-a"
|
|
BUYER_CLUB = "club-buyer-b"
|
|
ITEM = "core-disposable-x"
|
|
CARD = "def-disposable"
|
|
TRADE_ID = "900500150"
|
|
GROSS = 150
|
|
|
|
checks = []
|
|
|
|
|
|
def check(label, ok, detail=""):
|
|
checks.append((label, bool(ok), detail))
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
|
|
return ok
|
|
|
|
|
|
def free_port():
|
|
for _ in range(200):
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
p = s.getsockname()[1]
|
|
s.close()
|
|
if p not in FORBIDDEN and p > 1024:
|
|
return p
|
|
raise RuntimeError("no free port")
|
|
|
|
|
|
def req(port, method, path, body=None):
|
|
assert port not in FORBIDDEN, f"refusing to contact production port {port}"
|
|
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
|
headers = {"X-OpenFUT-Game": "fifa17"}
|
|
if body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
c.request(method, path, body=json.dumps(body) if body is not None else None,
|
|
headers=headers)
|
|
r = c.getresponse()
|
|
raw = r.read()
|
|
c.close()
|
|
try:
|
|
return r.status, json.loads(raw)
|
|
except Exception:
|
|
return r.status, raw.decode("utf-8", "replace")
|
|
|
|
|
|
def wait_http(port, path, timeout=45, proc=None, log=None):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if proc is not None and proc.poll() is not None:
|
|
tail = ""
|
|
if log and os.path.exists(log):
|
|
tail = open(log).read()[-1500:]
|
|
raise RuntimeError(f"process exited {proc.returncode}\n{tail}")
|
|
try:
|
|
st, _ = req(port, "GET", path)
|
|
if st < 500:
|
|
return
|
|
except Exception:
|
|
time.sleep(0.25)
|
|
tail = open(log).read()[-1500:] if log and os.path.exists(log) else ""
|
|
raise RuntimeError(f"{path} on {port} never became ready\n{tail}")
|
|
|
|
|
|
def seed(db):
|
|
"""Two identities by direct SQL: Seller A (the FIFA persona) and synthetic Buyer B."""
|
|
con = sqlite3.connect(db)
|
|
for prof, club, coins, game in (
|
|
("prof-seller-a", SELLER_CLUB, 1_000, "fifa17"),
|
|
("prof-buyer-b", BUYER_CLUB, 20_000, "buyer-game"),
|
|
):
|
|
con.execute(
|
|
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?)", (prof, prof, game, TS, TS))
|
|
con.execute(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)", (club, prof, club, coins, TS, TS))
|
|
con.execute(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
|
|
"VALUES (?, ?, ?, 0, ?)", (ITEM, SELLER_CLUB, CARD, TS))
|
|
con.commit()
|
|
con.close()
|
|
|
|
|
|
def owner_of(db, item):
|
|
con = sqlite3.connect(db)
|
|
row = con.execute("SELECT club_id FROM owned_cards WHERE id = ?", (item,)).fetchone()
|
|
n = con.execute("SELECT COUNT(*) FROM owned_cards WHERE id = ?", (item,)).fetchone()[0]
|
|
coins = dict(con.execute("SELECT id, coins FROM clubs").fetchall())
|
|
con.close()
|
|
return (row[0] if row else None), n, coins
|
|
|
|
|
|
def start_core(tmp, port, log):
|
|
db = os.path.join(tmp, "core.db")
|
|
pack = os.path.join(tmp, "pack.json")
|
|
with open(pack, "w") as f:
|
|
# A top-level ARRAY: Core's content-pack loader expects a sequence, not a
|
|
# map. Needed because the preflight refuses to start when an owned card
|
|
# references a CardDefinitionId no pack defines.
|
|
json.dump([{
|
|
"id": CARD, "name": "Disposable", "overall": 75, "position": "ST",
|
|
"nation": "Nation", "league": "League", "club": "Club",
|
|
"pace": 75, "shooting": 75, "passing": 75, "dribbling": 75,
|
|
"defending": 40, "physical": 70, "rarity": "gold",
|
|
"image_path": None,
|
|
}], f)
|
|
env = dict(os.environ,
|
|
LISTEN_ADDR=f"127.0.0.1:{port}",
|
|
DATABASE_URL=f"sqlite://{db}",
|
|
# Core's real data dir (read-only): it needs chemistry_styles.json
|
|
# and friends. The throwaway DB and the content pack stay in tmp.
|
|
DATA_DIR=os.path.join(REPO, "openfut-core", "data"),
|
|
OPENFUT_CONTENT_PACKS=pack)
|
|
# Migrate-only pass first: Core owns its schema, so the fixture cannot be
|
|
# seeded into an empty file. Stop it before the external writer touches the db.
|
|
with open(log, "w") as lf:
|
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
|
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
|
wait_http(port, "/health", proc=p, log=log)
|
|
p.terminate()
|
|
p.wait(timeout=20)
|
|
seed(db)
|
|
with open(log, "a") as lf:
|
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
|
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
|
wait_http(port, "/health", proc=p, log=log)
|
|
return p, db
|
|
|
|
|
|
def start_host(tmp, port, core_port, log, variant, coins_processed="0",
|
|
count_mode="active"):
|
|
with open(os.path.join(tmp, "catalog.json"), "w") as f:
|
|
# Minimal STAGING catalog. Deliberately NOT the production catalog, which
|
|
# lives under /home/alex/openfut-promotion/state/ and must never be opened.
|
|
json.dump({"schema_version": 1, "game": "fifa17",
|
|
"cards": {CARD: {"asset_id": 212188, "version": 0,
|
|
"rareflag": 1, "kind": "player"}}}, f)
|
|
env = dict(os.environ,
|
|
OPENFUT_UTAS_HOST_ADDR=f"127.0.0.1:{port}",
|
|
OPENFUT_CORE_URL=f"http://127.0.0.1:{core_port}",
|
|
# Deliberately dead: any Python fallback must fail closed and be
|
|
# visible, never silently serve production data.
|
|
OPENFUT_UTAS_PYTHON_URL="http://127.0.0.1:9",
|
|
OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon/data/tables"),
|
|
OPENFUT_IDENTITY_STORE=os.path.join(tmp, "identity.json"),
|
|
OPENFUT_PERSONA_ID=PERSONA,
|
|
OPENFUT_MARKET_DB=os.path.join(tmp, "market.db"),
|
|
OPENFUT_PILE_DB=os.path.join(tmp, "pile.db"),
|
|
OPENFUT_FIFA17_SOLD_EXPERIMENT=variant,
|
|
OPENFUT_FIFA17_SOLD_COINS_PROCESSED=coins_processed,
|
|
OPENFUT_FIFA17_SOLD_COUNT_MODE=count_mode,
|
|
OPENFUT_FIFA17_CATALOG=os.path.join(tmp, "catalog.json"),
|
|
RUST_LOG="info")
|
|
with open(log, "w") as lf:
|
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-utas-host")],
|
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
|
wait_http(port, "/ut/game/fifa17/tradePile/counts", proc=p, log=log)
|
|
return p
|
|
|
|
|
|
def banner(t):
|
|
print("\n" + "=" * 72)
|
|
print(f"== {t}")
|
|
print("=" * 72)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--keep", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
tmp = tempfile.mkdtemp(prefix="openfut-sold-wire-")
|
|
procs = []
|
|
try:
|
|
core_port, host_port = free_port(), free_port()
|
|
core_log = os.path.join(tmp, "core.log")
|
|
host_log = os.path.join(tmp, "host.log")
|
|
banner("ISOLATED STAGING (production untouched)")
|
|
print(f" tmp : {tmp}")
|
|
print(f" core : 127.0.0.1:{core_port}")
|
|
print(f" utas-host : 127.0.0.1:{host_port}")
|
|
print(f" forbidden : {sorted(FORBIDDEN)}")
|
|
|
|
core, core_db = start_core(tmp, core_port, core_log)
|
|
procs.append(core)
|
|
host = start_host(tmp, host_port, core_port, host_log, "highest")
|
|
procs.append(host)
|
|
print(" both ready")
|
|
|
|
bann = [l for l in open(host_log) if "sold-experiment" in l]
|
|
check("host banner names the variant", any("bidState=highest" in l for l in bann),
|
|
(bann[0].strip() if bann else "no banner"))
|
|
|
|
banner("BEFORE — seller A owns the item, nothing listed")
|
|
own, n, coins = owner_of(core_db, ITEM)
|
|
print(f" owner={own} instances={n} coins={coins}")
|
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
|
print(f" /tradePile total={pile.get('total')} counts={json.dumps(counts)}")
|
|
check("seller owns the item", own == SELLER_CLUB, str(own))
|
|
check("no rows before listing", pile.get("total") == 0)
|
|
check("sold counter starts at 0", counts.get("sold") == 0)
|
|
|
|
banner(f"LIST — authentic active listing at {GROSS} coins")
|
|
# Seed the listing directly into the staging market db: the client normally
|
|
# does this via POST /auctionhouse, which needs a wire-id mapping we do not
|
|
# have in this headless check. The LISTING SHAPE is identical either way.
|
|
mdb = os.path.join(tmp, "market.db")
|
|
con = sqlite3.connect(mdb)
|
|
con.execute(
|
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
|
(TRADE_ID, CARD, ITEM, 100000178, 212188, GROSS, GROSS, "CAGE",
|
|
str(int(time.time() * 1000)), json.dumps({
|
|
"id": 100000178, "resourceId": 212188, "rating": 75,
|
|
"preferredPosition": "ST", "itemState": "forSale",
|
|
"untradeable": False, "assetId": 212188}), 3600))
|
|
con.commit()
|
|
con.close()
|
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
|
row = pile["auctionInfo"][0]
|
|
print(f" active row: tradeState={row['tradeState']} bidState={row['bidState']} "
|
|
f"expires={row['expires']} counts={json.dumps(counts)}")
|
|
check("active row is active/none", row["tradeState"] == "active" and row["bidState"] == "none")
|
|
check("counts.selling == 1 while active", counts.get("selling") == 1)
|
|
check("counts.sold still 0 while active", counts.get("sold") == 0)
|
|
|
|
banner("PURCHASE — synthetic Buyer B, through the REAL settlement path")
|
|
out = subprocess.run(
|
|
[os.path.join(REPO, "target/release/staging_sell"),
|
|
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{core_port}",
|
|
"--trade-id", TRADE_ID, "--item", ITEM,
|
|
"--seller", SELLER_CLUB, "--buyer", BUYER_CLUB, "--gross", str(GROSS)],
|
|
capture_output=True, text=True, timeout=120)
|
|
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
|
|
check("staging_sell succeeded", out.returncode == 0, f"exit {out.returncode}")
|
|
|
|
own, n, coins = owner_of(core_db, ITEM)
|
|
fee = GROSS * 5 // 100
|
|
print(f" owner={own} instances={n} coins={coins} fee={fee}")
|
|
check("ownership transferred to buyer", own == BUYER_CLUB, str(own))
|
|
check("exactly ONE authoritative instance", n == 1, str(n))
|
|
check("buyer debited gross", coins.get(BUYER_CLUB) == 20_000 - GROSS,
|
|
str(coins.get(BUYER_CLUB)))
|
|
check("seller credited net", coins.get(SELLER_CLUB) == 1_000 + GROSS - fee,
|
|
str(coins.get(SELLER_CLUB)))
|
|
check("economy shrank by exactly the fee",
|
|
21_000 - sum(coins.values()) == fee, str(21_000 - sum(coins.values())))
|
|
|
|
banner("SOLD ROW — variant A: closed / highest")
|
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
|
st3, status = req(host_port, "GET", f"/ut/game/fifa17/trade/status?tradeIds={TRADE_ID}")
|
|
a_row = pile["auctionInfo"][0] if pile.get("auctionInfo") else {}
|
|
print(" " + json.dumps(a_row, indent=2).replace("\n", "\n "))
|
|
print(f" counts={json.dumps(counts)}")
|
|
check("sold row is present in the pile", pile.get("total") == 1)
|
|
check("tradeState closed", a_row.get("tradeState") == "closed")
|
|
check("bidState highest (variant A)", a_row.get("bidState") == "highest")
|
|
check("currentBid == sale price", a_row.get("currentBid") == GROSS)
|
|
check("expires 0", a_row.get("expires") == 0)
|
|
check("twelve atoms exactly", len(a_row) == 12, str(len(a_row)))
|
|
check("counts.sold == 1", counts.get("sold") == 1)
|
|
check("counts.selling == 0", counts.get("selling") == 0)
|
|
check("/trade/status agrees", status["auctionInfo"][0]["tradeState"] == "closed"
|
|
and status["auctionInfo"][0]["bidState"] == "highest")
|
|
|
|
banner("VARIANT B — same state, restart host with closed / buyNow")
|
|
host.terminate(); host.wait(timeout=20); procs.remove(host)
|
|
host = start_host(tmp, host_port, core_port, host_log, "buyNow",
|
|
coins_processed="1", count_mode="active_plus_sold")
|
|
procs.append(host)
|
|
st, pileb = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
|
st2, countsb = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
|
b_row = pileb["auctionInfo"][0]
|
|
print(f" bidState={b_row['bidState']} coinsProcessed={b_row['coinsProcessed']} "
|
|
f"counts={json.dumps(countsb)}")
|
|
check("bidState buyNow (variant B)", b_row.get("bidState") == "buyNow")
|
|
check("coinsProcessed 1 when asked", b_row.get("coinsProcessed") == 1)
|
|
check("count_mode active_plus_sold counts the sold row",
|
|
countsb.get("count") == 1 and countsb.get("sold") == 1,
|
|
json.dumps(countsb))
|
|
differing = sorted(k for k in a_row if a_row.get(k) != b_row.get(k))
|
|
check("A/B differ ONLY in bidState and coinsProcessed",
|
|
differing == ["bidState", "coinsProcessed"], str(differing))
|
|
|
|
banner("CLEAR — the PE-proven bulk verb DELETE .../trade/sold")
|
|
pre_coins = owner_of(core_db, ITEM)[2]
|
|
st, body = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
|
|
print(f" HTTP {st} body={json.dumps(body)}")
|
|
st2, pilec = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
|
st3, countsc = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
|
own2, n2, post_coins = owner_of(core_db, ITEM)
|
|
print(f" after clear: total={pilec.get('total')} counts={json.dumps(countsc)} "
|
|
f"owner={own2} instances={n2}")
|
|
check("clear acks 200 {}", st == 200 and body == {})
|
|
check("sold row gone from the pile", pilec.get("total") == 0)
|
|
check("counts.sold back to 0", countsc.get("sold") == 0)
|
|
check("clear moved NO coins", pre_coins == post_coins, f"{pre_coins} -> {post_coins}")
|
|
check("buyer still owns the item after clear", own2 == BUYER_CLUB, str(own2))
|
|
check("still exactly one instance", n2 == 1, str(n2))
|
|
st, again = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
|
|
check("clearing again is a safe no-op", st == 200)
|
|
cleared = [l for l in open(host_log) if "market-clear-sold" in l]
|
|
check("clear is logged for capture", bool(cleared),
|
|
cleared[-1].strip() if cleared else "no log line")
|
|
|
|
banner("PRODUCTION UNTOUCHED")
|
|
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
check("prod-host pid 3631953 still alive", alive == "3631953", alive or "gone")
|
|
opened = subprocess.run(
|
|
["bash", "-lc",
|
|
"ls -l /proc/*/fd 2>/dev/null | grep -c openfut-promotion || true"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
print(f" staging fds referencing production state: (informational) {opened}")
|
|
|
|
banner("RESULT")
|
|
passed = sum(1 for _, ok, _ in checks if ok)
|
|
print(f" {passed}/{len(checks)} checks passed")
|
|
failed = [l for l, ok, _ in checks if not ok]
|
|
if failed:
|
|
print(" FAILED: " + "; ".join(failed))
|
|
print("\n " + ("ALL CHECKS PASSED" if not failed else "FAILURES PRESENT"))
|
|
return 0 if not failed else 1
|
|
finally:
|
|
for p in procs:
|
|
try:
|
|
p.terminate(); p.wait(timeout=15)
|
|
except Exception:
|
|
try:
|
|
p.kill()
|
|
except Exception:
|
|
pass
|
|
if args.keep:
|
|
print(f"\n kept {tmp}")
|
|
else:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
print(f"\n removed {tmp}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|