File size: 13,840 Bytes
acd280c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
"""通用「带哈希闸门」的模型取数。

为什么不用 `hf_hub_download` / `snapshot_download`:
  2026-10-09 事故 —— `models/rnafm/model.safetensors` 大小 398,117,588 与上游**逐字节
  相同**,但 205 个张量里 155 个全零(aria2 预分配 + 中断留下的零填充空洞),
  `from_pretrained` 一声不响。**唯一能挡住它的是 sha256。**

规矩(写死在这里,别在调用点上重复实现):
  1) 下到 `.part`;
  2) **强制** sha256 比对上游声明值,不符绝不改名;
  3) 大小已满但哈希错 => 判定为「预分配空洞」,连 .part.aria2 一起清掉
     (这种情况续传救不了:控制文件会说"分片都完成了",每次续传立刻返回同一个坏文件);
  4) 通过后才原子改名。

跑法:
    python scripts/00d_fetch_verified.py \
        --repo multimolecule/ernierna --dest models/ernierna \
        --sha256 02274cb7... --size 345071800
不给 --sha256 时**拒绝大文件下载**(防止又走回"下来就用"的老路)。
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
UA = {"User-Agent": "Mozilla/5.0"}

# 大文件之外要一起拉的小文件(缺了模型加载不起来)
SMALL = ["config.json", "tokenizer_config.json", "vocab.txt",
         "special_tokens_map.json", "configuration.json"]


def sha256_of(path: Path, chunk: int = 8 << 20) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for b in iter(lambda: f.read(chunk), b""):
            h.update(b)
    return h.hexdigest()


def real_progress(path: Path, chunk: int = 1 << 20) -> tuple[int, int, int]:
    """数「有多少 MB 块真有数据」。

    ⚠️ **逻辑大小不能当进度。** aria2 多连接按偏移写文件,逻辑大小会跳到最高已写位置,
    中间可能是零。实测一份 325 MB 的 .part 里 255/311 块是空洞,真实数据只有 56 MB。
    这个函数才是进度。

    返回 (有数据块数, 总块数, 空洞位置前若干)。
    """
    have = tot = 0
    holes: list[int] = []
    with open(path, "rb") as f:
        off = 0
        while True:
            b = f.read(chunk)
            if not b:
                break
            tot += 1
            if any(b):
                have += 1
            elif len(holes) < 20:
                holes.append(off // chunk)
            off += len(b)
    return have, tot, len(holes)


def hf_declared(repo: str, fname: str, host: str = "huggingface.co") -> tuple[str | None, int | None]:
    """问 HF 要官方声明的 sha256 / size。"""
    url = f"https://{host}/api/models/{repo}?blobs=true"
    req = urllib.request.Request(url, headers={**UA, "Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=40) as r:
        d = json.load(r)
    for s in d.get("siblings", []):
        if s["rfilename"] == fname:
            lfs = s.get("lfs") or {}
            return lfs.get("sha256"), lfs.get("size")
    return None, None


def fetch_small(repo: str, fname: str, dest: Path, rev: str,
                host: str = "huggingface.co") -> bool:
    url = f"https://{host}/{repo}/resolve/{rev}/{fname}"
    try:
        req = urllib.request.Request(url, headers=UA)
        with urllib.request.urlopen(req, timeout=60) as r:
            data = r.read()
        (dest / fname).write_bytes(data)
        print(f"  {fname:<28} {len(data):>8} B  OK")
        return True
    except Exception as e:
        print(f"  {fname:<28} 跳过 ({type(e).__name__}: {str(e)[:50]})")
        return False


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--repo", required=True, help="例如 multimolecule/ernierna")
    ap.add_argument("--file", default="model.safetensors")
    ap.add_argument("--dest", type=Path, required=True)
    ap.add_argument("--sha256", default=None, help="上游声明的 sha256;不给就自己去问 HF")
    ap.add_argument("--size", type=int, default=None)
    ap.add_argument("--rev", default="main")
    ap.add_argument("--host", default="huggingface.co",
                    help="下载主机。hf-mirror.com 是镜像;**哈希用的是同一份声明值**,"
                         "所以从哪个 host 下都不影响正确性,只影响速度。")
    ap.add_argument("--api-host", default="huggingface.co",
                    help="查元数据用哪个 host(声明 sha256 从这里拿)")
    ap.add_argument("--connections", type=int, default=8,
                    help="aria2 连接数。16 在这条链路上握手失败率太高,默认降到 8")
    ap.add_argument("--method", default="aria2", choices=["aria2", "curl"],
                    help="aria2=多连接续传(快,靠控制文件记进度);"
                         "curl=单连接续传(慢,但写出来的一定是连续前缀)")
    ap.add_argument("--max-attempts", type=int, default=20,
                    help="失败重试次数。**重试不会删 .part**,所以进度不丢")
    ap.add_argument("--no-verify", action="store_true",
                    help="**危险**:跳过 sha256。只有元数据接口拿不到哈希时才用,且要写进 PROGRESS")
    args = ap.parse_args()

    dest: Path = args.dest if args.dest.is_absolute() else (ROOT / args.dest)
    dest.mkdir(parents=True, exist_ok=True)
    big = dest / args.file
    part = dest / (args.file + ".part")
    url = f"https://{args.host}/{args.repo}/resolve/{args.rev}/{args.file}"

    # ---- 单写者锁 ----
    # 2026-10-09 真踩了:杀 aria2 **没杀它的父 python**,那个 python 的重试循环
    # 立刻又拉起一个 aria2;与此同时我又起了第二个驱动器。
    # 结果两个 aria2 写同一个 .part / .part.aria2。
    # (数据本身没事 —— 两边写的是同样的字节、同样的偏移;但控制文件会互相踩,
    #   进度读数变得不可信。)这个锁就是为这件事加的。
    lock = dest / ".fetch.lock"
    if lock.exists():
        try:
            old = int(lock.read_text(encoding="utf-8").split()[0])
        except Exception:
            old = -1
        alive = False
        if old > 0:
            alive = subprocess.run(["tasklist", "/FI", f"PID eq {old}"],
                                   capture_output=True, text=True).stdout.find(str(old)) >= 0
        if alive:
            print(f"[STOP] {dest} 已经被 PID {old} 的驱动器占着(单写者锁)。")
            print("       两个 aria2 写同一个 .part 会把控制文件搞乱。")
            print("       要么等它结束,要么先杀掉**父 python 进程**再重来。")
            return 3
        print(f"[note] 发现过期锁(PID {old} 已不在),接管")
    lock.write_text(f"{os.getpid()} {time.strftime('%Y-%m-%d %H:%M:%S')}", encoding="utf-8")

    want_sha, want_size = args.sha256, args.size
    if want_sha is None:
        print(f"[meta] 问 {args.api_host} 要 {args.repo} 的声明值 ...")
        try:
            want_sha, api_size = hf_declared(args.repo, args.file, args.api_host)
            want_size = want_size or api_size
        except Exception as e:
            print(f"[meta] 失败: {e}")

    print("=" * 74)
    print(f"repo   : {args.repo}")
    print(f"file   : {args.file}")
    print(f"host   : {args.host}   (元数据来自 {args.api_host})")
    print(f"dest   : {dest}")
    print(f"期望   : size={want_size}  sha256={want_sha}")
    print("=" * 74)

    if not want_sha and not args.no_verify:
        print("[STOP] 拿不到上游 sha256,拒绝下载大文件。")
        print("       真要跳过请显式加 --no-verify,并把原因写进 PROGRESS.md。")
        return 2

    # 已经好了?
    if big.exists():
        h = sha256_of(big)
        if want_sha and h == want_sha:
            print(f"[skip] 已存在且哈希正确 ({h[:16]}...)")
            for f in SMALL:
                if not (dest / f).exists():
                    fetch_small(args.repo, f, dest, args.rev, args.host)
            return 0
        print(f"[note] 已存在的文件哈希不符 ({h[:16]}... != {want_sha[:16] if want_sha else '?'}...)")
        bad = dest / (args.file + ".corrupt")
        if bad.exists():
            bad.unlink()
        big.rename(bad)
        print(f"[keep] 留档 -> {bad.name}")

    # 下
    exe = shutil.which("aria2c")
    print(f"[down] {url}")

    if args.method == "aria2":
        if not exe:
            print("[STOP] 没有 aria2c。用 --method curl 或先装 aria2。")
            return 2
        # ⚠️ 这里必须循环重试,而且**不能删 .part / .part.aria2**。
        #
        # 为什么(2026-10-09 实测):
        #   aria2 多连接是按偏移写文件的,`--file-allocation=none` 下逻辑大小会跳到
        #   "最高已写位置",中间全是零。实测一份 .part:
        #       逻辑大小 325,391,316 B(看起来 94%),311 个 MB 块里 **255 个是空洞**,
        #       真实数据只有 56 MB(16%)。
        #   所以**逻辑大小完全不能当作进度**。
        #   而 `.part.aria2` 控制文件准确记录了哪些分片已完成 —— 它才是进度的真相。
        #   一次失败就删文件,等于把真进度也扔了(上一版就是这么干的)。
        #
        # 正确性最后由 sha256 兜底:只要它没通过,就绝不改名。
        for attempt in range(1, args.max_attempts + 1):
            cmd = [exe, "-x", str(args.connections), "-s", str(args.connections),
                   "-k", "1M",
                   "--continue=true",                 # 用控制文件续传
                   "--file-allocation=none",          # 不预分配
                   "--disable-ipv6=true",
                   "--max-tries=0", "--retry-wait=10", "--timeout=60",
                   "--lowest-speed-limit=1K",         # 死连接快点丢掉换新的
                   "--summary-interval=60", "--console-log-level=warn",
                   "-d", str(dest), "-o", part.name, url]
            print(f"\n[aria2] 第 {attempt}/{args.max_attempts} 次 "
                  f"(.part 逻辑大小 {part.stat().st_size if part.exists() else 0:,} B "
                  f"—— 注意这不是进度)", flush=True)
            print("[aria2] " + " ".join(cmd), flush=True)
            t0 = time.time()
            rc = subprocess.call(cmd)
            el = time.time() - t0
            size_now = part.stat().st_size if part.exists() else 0
            print(f"[aria2] rc={rc} 用时 {el/60:.1f} min  逻辑大小 {size_now:,} B")
            if part.exists():
                have, tot, nh = real_progress(part)
                print(f"[真进度] 有数据块 {have}/{tot}  (约 {have} MB / "
                      f"{args.size//(1<<20) if args.size else '?'} MB),空洞 {nh}")
            if rc == 0:
                print("[aria2] 退出码 0")
                break
            print(f"[aria2] 非零退出 —— **保留 .part 与控制文件**,重试会接着下")
            time.sleep(5)
        else:
            print(f"[warn] {args.max_attempts} 次都没成功。.part 还在,再跑本命令即可。")
    else:
        # 单连接 curl 续传。慢,但写出来的一定是**连续前缀**,没有空洞歧义。
        if not shutil.which("curl"):
            print("[STOP] 没有 curl。")
            return 2
        for attempt in range(1, args.max_attempts + 1):
            print(f"\n[curl] 第 {attempt}/{args.max_attempts} 次续传 "
                  f"(已有 {part.stat().st_size if part.exists() else 0:,} B)", flush=True)
            rc = subprocess.call(["curl", "-sS", "-L", "-C", "-",
                                  "--max-time", "1800",
                                  "--speed-limit", "1024", "--speed-time", "60",
                                  "-A", "curl/8.0", "-o", str(part), url])
            print(f"[curl] rc={rc}  "
                  f"大小 {part.stat().st_size if part.exists() else 0:,} B")
            if rc == 0:
                break
            time.sleep(5)

    if not part.exists():
        print("[FAIL] 没有 .part,下载没起来")
        return 1

    # 校验
    size = part.stat().st_size
    print()
    print(f"[verify] 大小 {size:,} (期望 {want_size}) "
          f"{'OK' if want_size and size == want_size else '不符' if want_size else '未声明'}")
    got = sha256_of(part)
    print(f"[verify] sha256 {got}")
    if want_sha and got != want_sha:
        print("[FAIL] 哈希不符 —— 绝不改名")
        if want_size and size >= want_size:
            ctl = part.with_name(part.name + ".aria2")
            print("[FAIL] 大小已满但内容错 -> 判定为预分配空洞,清掉重来")
            part.unlink(missing_ok=True)
            if ctl.exists():
                ctl.unlink()
            print("       重跑本命令即可(会从头下)")
        else:
            print(f"[FAIL] 只下了 {size/1e6:.1f} MB,未完成 -> 保留 .part,重跑会接着下")
        return 1

    print("[verify] 哈希一致")
    if big.exists():
        big.unlink()
    part.rename(big)          # 同盘 rename 原子
    print(f"[done] -> {big}")

    print()
    print("[meta] 小文件")
    for f in SMALL:
        if not (dest / f).exists():
            fetch_small(args.repo, f, dest, args.rev, args.host)

    print()
    print("下一步(**必须**):")
    print(f"  & $PY scripts\\01c_check_encoder_sanity.py --model {dest} \\")
    print(f"      --expect-sha256 {want_sha} --expect-size {want_size}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())