Skip to content

Commit 3da87a8

Browse files
committed
probe: measure the GitCode upload path (TEMPORARY — do not merge)
`publish-ecosystem`'s GitCode leg has blown its 180s per-asset cap on four releases now (0.0.94 / 0.0.97 / 0.0.105 / 2026.7.28.2), always on the two biggest tarballs, always costing ~5min of manual re-upload afterwards. Every fix so far has been a guess about WHY (raise the cap, skip-don't-retry, batch verify). This branch measures it instead. The path under test (tools/gtc `_upload_one`): GET a presigned OBS PUT URL from the GitCode API, then PUT the whole file body to file.gitcode.com. A server-side OBS->GitCode callback registers the asset; when only that callback fails we get `obs_callback ... code:400 ... EOF` for an upload that actually landed, which is why the probe always re-checks the public download URL and reports put_ok and serves independently. Four parallel jobs, one hypothesis each: baseline network + direction control (down/up, gitcode vs github) sizes-urllib is throughput flat (bandwidth wall) or collapsing (stall)? sizes-curl is Python's read-all-then-PUT the bottleneck, or the wire? concurrency per-connection or per-host cap? mirror_res.sh uploads assets SERIALLY within a host leg, so if it is per-connection then parallelising that loop is the entire fix. Local reference measured before pushing (mainland-CN host -> file.gitcode.com): 8MB in 4.35s = 1.84 MB/s, put=200, serves=206. At that rate the 34.8MB asset takes ~19s; CI cannot finish it in 180s, so the runner side is >=10x slower. That is the number these jobs exist to confirm and localise. Every other workflow is deleted on this branch on purpose: the probe is the only thing that should run here, and this branch is never merged.
1 parent f6ef9b9 commit 3da87a8

14 files changed

Lines changed: 397 additions & 2921 deletions

.github/tools/probe_gtc_upload.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""probe_gtc_upload.py — instrumented single-asset upload to a GitCode release.
3+
4+
TEMPORARY diagnostic tool for the recurring `publish-ecosystem` failure where
5+
the GitCode leg of tools/mirror_res.sh blows its 180s per-asset cap on the two
6+
largest tarballs (0.0.94 / 0.0.97 / 0.0.105 / 2026.7.28.2). It is a measurement
7+
harness, not a mirror: it reproduces exactly what tools/gtc `release upload`
8+
does, but times every stage separately and reports one JSON line per upload so
9+
the stages can be compared across transports and concurrency levels.
10+
11+
The upload path under test (identical to tools/gtc `_upload_one`):
12+
13+
1. GET {api}/repos/{repo}/releases/{tag}/upload_url?file_name=X
14+
-> {"url": <presigned OBS PUT URL>, "headers": {...}}
15+
2. PUT <presigned url> with the whole file as the body
16+
3. (server-side) OBS calls back into the GitCode API to register the asset
17+
18+
Stage 3 is invisible to the client except when it fails, which is where the
19+
`obs_callback ... code:400 ... EOF` false negative comes from: the object is
20+
in OBS but the registration callback errored, so the PUT reports failure for
21+
an upload that actually landed. This tool always re-probes the public download
22+
URL afterwards, so `put_ok` and `serves` are reported independently and that
23+
false negative shows up as put_ok=false + serves=true.
24+
25+
Usage:
26+
probe_gtc_upload.py --repo xlings-res/mcpp --tag probe-x --file f.bin \
27+
[--method urllib|curl] [--label NAME]
28+
29+
Auth: GITCODE_TOKEN env var (same as tools/gtc).
30+
Output: one JSON object per line on stdout; human notes on stderr.
31+
"""
32+
import argparse
33+
import json
34+
import os
35+
import subprocess
36+
import sys
37+
import time
38+
import urllib.error
39+
import urllib.parse
40+
import urllib.request
41+
from pathlib import Path
42+
43+
API_BASE = os.environ.get("GITCODE_API_BASE", "https://api.gitcode.com/api/v5")
44+
DL_HOST = "https://gitcode.com"
45+
46+
47+
def get_upload_url(repo, tag, fname, token):
48+
"""Stage 1 — ask GitCode for a presigned OBS PUT URL."""
49+
url = (f"{API_BASE}/repos/{repo}/releases/{tag}/upload_url"
50+
f"?file_name={urllib.parse.quote(fname)}")
51+
req = urllib.request.Request(
52+
url, headers={"PRIVATE-TOKEN": token, "Accept": "application/json"})
53+
t0 = time.monotonic()
54+
with urllib.request.urlopen(req, timeout=120) as r:
55+
body = r.read()
56+
dt = time.monotonic() - t0
57+
info = json.loads(body)
58+
return info["url"], info.get("headers") or {}, dt
59+
60+
61+
def put_urllib(put_url, headers, path):
62+
"""Stage 2, transport A — exactly what tools/gtc does today: read the
63+
whole file into memory and hand it to urllib as a single PUT body."""
64+
with open(path, "rb") as f:
65+
data = f.read()
66+
req = urllib.request.Request(put_url, data=data, headers=headers, method="PUT")
67+
t0 = time.monotonic()
68+
try:
69+
with urllib.request.urlopen(req, timeout=1800) as r:
70+
status = r.status
71+
resp = r.read().decode(errors="replace")[:300]
72+
except urllib.error.HTTPError as e:
73+
status = e.code
74+
resp = e.read().decode(errors="replace")[:300]
75+
except Exception as e: # noqa: BLE001 - report, don't raise
76+
status = -1
77+
resp = f"{type(e).__name__}: {e}"[:300]
78+
return status, resp, time.monotonic() - t0, {}
79+
80+
81+
def put_curl(put_url, headers, path):
82+
"""Stage 2, transport B — same presigned URL, but curl streams the file
83+
from disk instead of buffering it in the process. curl also reports its own
84+
connect/TLS/first-byte breakdown, which is what separates "the network is
85+
slow" from "the client is slow"."""
86+
fmt = ("%{http_code} %{speed_upload} %{time_namelookup} %{time_connect} "
87+
"%{time_appconnect} %{time_pretransfer} %{time_starttransfer} %{time_total}")
88+
cmd = ["curl", "-sS", "-X", "PUT", "-T", path, "--max-time", "1800", "-o", "/tmp/put_body",
89+
"-w", fmt]
90+
for k, v in headers.items():
91+
cmd += ["-H", f"{k}: {v}"]
92+
cmd.append(put_url)
93+
t0 = time.monotonic()
94+
p = subprocess.run(cmd, capture_output=True, text=True)
95+
dt = time.monotonic() - t0
96+
detail, status, resp = {}, -1, (p.stderr or "")[:300]
97+
if p.returncode == 0 and p.stdout.strip():
98+
parts = p.stdout.split()
99+
try:
100+
status = int(parts[0])
101+
detail = {
102+
"curl_speed_upload_Bps": float(parts[1]),
103+
"curl_t_namelookup": float(parts[2]),
104+
"curl_t_connect": float(parts[3]),
105+
"curl_t_appconnect": float(parts[4]),
106+
"curl_t_pretransfer": float(parts[5]),
107+
"curl_t_starttransfer": float(parts[6]),
108+
"curl_t_total": float(parts[7]),
109+
}
110+
except (IndexError, ValueError):
111+
pass
112+
try:
113+
resp = Path("/tmp/put_body").read_text(errors="replace")[:300]
114+
except OSError:
115+
resp = ""
116+
else:
117+
detail["curl_exit"] = p.returncode
118+
return status, resp, dt, detail
119+
120+
121+
def serves(repo, tag, fname):
122+
"""Independent truth: does the public download URL actually return bytes?
123+
Ranged GET — HEAD lies on GitCode (returns a redirect stub)."""
124+
url = f"{DL_HOST}/{repo}/releases/download/{tag}/{urllib.parse.quote(fname)}"
125+
p = subprocess.run(
126+
["curl", "-sSL", "-o", "/dev/null", "-w", "%{http_code}", "-r", "0-0", url],
127+
capture_output=True, text=True)
128+
return p.stdout.strip(), url
129+
130+
131+
def main():
132+
ap = argparse.ArgumentParser()
133+
ap.add_argument("--repo", required=True)
134+
ap.add_argument("--tag", required=True)
135+
ap.add_argument("--file", required=True)
136+
ap.add_argument("--method", choices=("urllib", "curl"), default="urllib")
137+
ap.add_argument("--label", default="")
138+
ap.add_argument("--no-verify", action="store_true")
139+
args = ap.parse_args()
140+
141+
token = os.environ.get("GITCODE_TOKEN", "")
142+
if not token:
143+
sys.exit("GITCODE_TOKEN is empty")
144+
145+
path = Path(args.file)
146+
size = path.stat().st_size
147+
fname = path.name
148+
149+
put_url, headers, t_url = get_upload_url(args.repo, args.tag, fname, token)
150+
obs_host = urllib.parse.urlparse(put_url).netloc
151+
152+
put = put_urllib if args.method == "urllib" else put_curl
153+
status, resp, t_put, detail = put(put_url, headers, str(path))
154+
155+
rec = {
156+
"label": args.label or fname,
157+
"method": args.method,
158+
"file": fname,
159+
"size_bytes": size,
160+
"size_mb": round(size / 1048576, 2),
161+
"obs_host": obs_host,
162+
"t_upload_url_s": round(t_url, 3),
163+
"t_put_s": round(t_put, 2),
164+
"put_status": status,
165+
"put_ok": status == 200,
166+
"throughput_MBps": round(size / 1048576 / t_put, 3) if t_put > 0 else None,
167+
"put_response_head": resp.replace("\n", " ")[:200],
168+
}
169+
rec.update(detail)
170+
171+
if not args.no_verify:
172+
# Deliberately independent of put_ok: this is the check that exposes
173+
# the obs_callback false negative (put_ok=false while serves=200/206).
174+
code, url = serves(args.repo, args.tag, fname)
175+
rec["verify_code"] = code
176+
rec["serves"] = code in ("200", "206")
177+
rec["download_url"] = url
178+
179+
print(json.dumps(rec), flush=True)
180+
sys.stderr.write(
181+
f"[probe] {rec['label']}: {rec['size_mb']}MB via {args.method} "
182+
f"-> put={rec['put_status']} in {rec['t_put_s']}s "
183+
f"({rec['throughput_MBps']} MB/s) serves={rec.get('serves')}\n")
184+
185+
186+
if __name__ == "__main__":
187+
main()

.github/workflows/aur-publish.yml

Lines changed: 0 additions & 102 deletions
This file was deleted.

0 commit comments

Comments
 (0)