Skip to content

ci: retry the tests job apt install with backoff (#456) - #457

Merged
chayapats merged 3 commits into
mainfrom
jit/456-ci-apt-retry
Nov 11, 2026
Merged

chayapats merged 3 commits into
mainfrom
jit/456-ci-apt-retry

Conversation

@chayapats

@chayapats chayapats commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #456.

Problem

Every tests shard ran a bare sudo apt-get update && sudo apt-get install ... with no retry. On 2026-11-11, runner DNS failures ("Temporary failure resolving archive.ubuntu.com / security.ubuntu.com") made attempts 1–7 of run 43044244466 (PR #455) fail at that step before bun test started.

Change (CI-only)

  • New ops/ci/apt-install.ts: runs sudo apt-get update then install -y --no-install-recommends <packages>, up to 4 attempts with 10 s / 30 s / 60 s backoff.
    • Per-attempt wall-clock deadline (70 s, shared by update + install). Each apt-get runs as sudo timeout --kill-after=5 <s> apt-get ..., so SIGTERM/SIGKILL reach apt-get itself, not only sudo. Exit 124/137 counts as a failed attempt and is retried. If update spends the whole budget, install is skipped for that attempt (exit 124).
    • Worst case, counting apt time: 4 × (70 s + 5 s kill grace + 2 s rounding) + 100 s waits = 408 s ≈ 6m48s. With the longest observed shard (5m44s) plus setup this fits under timeout-minutes: 15; a test reads timeout-minutes from checks.yml and asserts worst case + 6 min <= it. A healthy install takes about 10 s.
    • apt options: Acquire::Retries=0 (the outer loop is the retry), Acquire::http::Timeout=30 and Acquire::https::Timeout=30. These only help stalled connections; the deadline is the real cap.
    • If install fails, the next attempt starts again from update. This matters because apt-get update can exit 0 when it only warns about unreachable mirrors.
    • When the retries run out, the step fails with apt_install_failed after 4 attempts (exit N). It never skips packages. An empty package list is refused.
  • checks.yml: the step is now bun ops/ci/apt-install.ts ripgrep python3-pil poppler-utils. The packages, test selection, sharding and matrix are unchanged. Bun is already installed before this step.

Not done (scope)

  • Mirror fallback / .deb caching: not done. During the 2026-11-11 outage every *.ubuntu.com name failed to resolve (azure.archive, archive, security, ports), so switching to another Ubuntu mirror would not have helped. A multi-hour outage will still fail this step, by design. Retries cover short blips.
  • I did not use apt-get update --error-on=any, because the runner image also has third-party sources whose warnings would then fail the job. Retrying on install failure covers the same case.

Known limit

  • If the kill lands while dpkg is unpacking (unlikely: downloads come first and are what stalls), later attempts can fail with "dpkg was interrupted". That still fails loudly, as before; it is not repaired automatically.

Tests

  • bun test tests/ci-apt-install.test.ts tests/ci-shards.test.ts: 17 pass, 0 fail. Covers:
    • retry after update failures, with the backoff sequence
    • retry from update after an install failure, and after an install killed by the deadline (exit 124 and 137)
    • a fake clock showing update and install share one 70 s deadline, and a spent budget skips install
    • real GNU timeout kills a child that ignores SIGTERM (exit 137 within 1 + 5 s)
    • aptCommand puts timeout under sudo, right in front of apt-get
    • worst case <= 7 min, and worst case + 6 min <= checks.yml timeout-minutes
    • a loud failure after 4 attempts, refusal of an empty package list, exact apt arguments, workflow wiring
  • bun run typecheck: clean.
  • The real path is exercised by this PR's own CI: all 12 shards run the new step on x64 and arm64.

@greptile-apps

greptile-apps Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new blocking issue was found.

Summary

The helper now gives each apt attempt a shared 70-second deadline and runs GNU timeout under sudo.

  • CI shards retry package setup before running tests.

Reviews (2) · Last reviewed commit: "ci: cap each apt attempt with a wall-clo..." · Reviewed by Greptile

Comment thread ops/ci/apt-install.ts Resolved

@chayapats chayapats left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ BLOCKER — งบ retry ไม่จบใน timeout ของ job สำหรับอาการ DNS ที่ #456 เปิดมา

สัญญาที่บรรทัด 2–4 ของไฟล์นี้ และคอมเมนต์ที่ checks.yml:57 คือ outage สั้น ๆ แบบ "Temporary failure resolving archive.ubuntu.com" จะไม่ทำให้ shard ล้มก่อนเทสเริ่ม ลูป retry เองถูก: 4 ครั้ง, sleep 10/30/60 วินาที, install พังแล้วเริ่มใหม่จาก update, ลิสต์ว่างถูกปฏิเสธ, หมดครั้งแล้วยัง fail ดัง ไม่ข้ามแพ็กเกจ เทส fake apt ครอบคลุมเส้นนี้ และ happy path ผ่านจริง

แต่ apt ที่เจอ DNS ตายไม่ได้คืนเร็ว ลูปด้านนอกจึงไปไม่ถึงในงบของ job และ deterministic ต้องผ่านทุก shard

หลักฐานจาก run 43044244466 attempt 7 (คำสั่งเดิม คำสั่งเดียวกันที่ helper มาห่อ):

  • x64 job 130661520282: apt เริ่ม 16:39:51Z จบ exit 100 ที่ 16:47:56Z = 8 นาที 4 วินาที สำหรับ update+install หนึ่งรอบ
  • arm64 job 130661520011: apt เริ่ม 16:39:54Z จบ exit 100 ที่ 16:54:09Z = 14 นาที 15 วินาที
  • ทั้งสองรอบ update จบด้วยแค่ W: แล้ว exit 0 (เชน && เดินต่อ จึงไม่ใช่ update ที่คืน non-zero) จากนั้น install เดินทุกไฟล์ โดย resolver ค้างประมาณ 20 วินาทีต่อ URI จน exit 100 นี่คือเส้นที่ลูปตั้งใจ retry และเทส "warning-only update" จำลองไว้ แต่ fake คืนทันที

checks.yml:40 ตั้ง timeout-minutes: 15 ของทั้ง job ไม่ใช่แค่ apt หลัง apt ยังต้องรัน bun test shard ที่เขียวบน head นี้ใช้ 2 นาที 42 วินาที ถึง 5 นาที 44 วินาที ทั้ง job

  • arm64 เริ่ม 16:39:49Z บวก 15 นาทีคือประมาณ 16:54:49Z เหลือประมาณ 40 วินาทีหลัง exit 100 sleep ก้อนแรก 10 วินาที แล้ว attempt 2 เริ่มทั้งที่เหลือประมาณ 30 วินาที ไม่พอให้ติดตั้งซ้ำแล้วรัน shard
  • x64 เหลือประมาณ 6 นาที 40 วินาที ถ้ารอบสองสำเร็จในประมาณ 10 วินาที (happy path ของ job 130709169938 ใช้ 18:03:31Z–18:03:41Z) แล้วยังมีที่ให้เทส แต่รอบสองที่ DNS ยังตายจะเดินอีกประมาณ 8 นาที แล้วโดน timeout
  • ดังนั้น workflow ยังแดงเมื่อเจออาการเดิม แม้ลูปจะถูกต้องบนกระดาษ

APT_OPTIONS บรรทัด 14 ทำให้ทิศทางตรงข้ามกับคอมเมนต์บรรทัด 13:

  • Acquire::Retries=5 ให้ apt ลองไฟล์ที่พังซ้ำ 5 ครั้ง (apt.conf: "If this is non-zero APT will retry failed files the given number of times") อาการนี้เป็น walk ยาวอยู่แล้ว ตัวเลือกนี้ยืด attempt ไม่ได้ตัด ผมยังไม่ได้วัดตัวคูณจริง จึงไม่ปักว่า attempt 1 จะทะลุ 15 นาทีเสมอ แต่ไม่มี deadline ที่จะกันไว้
  • Acquire::http::Timeout=30 ไม่ครอบ URL ที่ฆ่า x64 ซึ่งเป็น https://security.ubuntu.com และ https://archive.ubuntu.com (Acquire::https::Timeout ยังเป็นค่า default 120 วินาที) และรอบนี้ resolver ค้างประมาณ 20 วินาทีต่อ URI ก่อน HTTP timeout จะทำงาน timeout ต่อ URI จึงไม่ครอบ attempt
  • spawn ที่บรรทัด 38 ไม่มี wall-clock kill ลูก apt-get ที่ค้างกินงบจน job ตาย ข้อความ apt_install_failed จึงไม่ถูกพิมพ์

ข้ออ้างใน PR ว่า "about 100 s of waiting at most, well inside the 15-minute job timeout" นับแค่ sleep ไม่นับเวลา apt เทสใน tests/ci-apt-install.test.ts ใช้ fake ที่คืนทันที จึงเขียวทั้งที่งบจริงไม่พอ

ทิศทางแก้: ใส่ deadline ทั้งก้อนต่อ attempt แล้ว kill ลูก (happy path ประมาณ 10 วินาที ดังนั้น 60–90 วินาทีต่อคำสั่งยังมีมาร์จิน) ให้ worst case ของ 4 ครั้งบวก sleep 100 วินาทีบวกเวลาเทสของ shard ต่ำกว่า 15 นาที เอา Acquire::Retries=5 ออกหรือตั้ง 0/1 เพราะลูปด้านนอกคือ retry ถ้าจะคง timeout ของ apt ให้ตั้งทั้ง http และ https แต่ห้ามใช้แทน wall-clock kill อย่าแก้ด้วยการขึ้น timeout-minutes อย่างเดียว จะเผา runner นานขึ้นตอน DNS ตายทั้งช่วง เพิ่มเทสที่เช็คว่างบนั้นต่ำกว่า timeout-minutes ใน checks.yml

สิ่งที่ตรวจแล้วไม่ใช่ defect:

  • ข้าม mirror ของ ubuntu.com สมเหตุสมผล log x64 มี mirror+file:/etc/apt/apt-mirrors.txt ลอง azure.archive.ubuntu.com, archive.ubuntu.com และ security.ubuntu.com แล้วทุกชื่อ resolve ไม่ได้ checkout กับ bun install สำเร็จ ดังนั้น DNS ไม่ได้ตายทั้งเครื่อง mirror นอก ubuntu.com หรือ cache .deb ยังช่วย outage หลายชั่วโมงได้ แต่ #456 ระบุว่าเป็น optional และ PR เลื่อนไว้อย่างตรงไปตรงมา
  • ข้าม --error-on=any สมเหตุสมผล รอบที่พัง update เป็นแค่ W: แล้ว exit 0 จากนั้น install exit 100 ลูป retry เส้นนี้แล้ว --error-on=any จะทำให้ source อื่นที่แค่ warning พัง job ทั้งที่แพ็กเกจติดตั้งได้
  • ชุดแพ็กเกจ, sharding และ matrix ไม่เปลี่ยน
  • CI ของ head 1364022 เขียวทั้ง typecheck, 12 shard และ deterministic (run 43075230512) ไม่ได้รัน bun test เองเพราะเครื่องรีวิวไม่มี bun

ก่อน merge นอกจากนี้: Greptile P2 เรื่อง TSDoc ถูกใส่ที่บรรทัด 16–22 แล้ว แต่ thread ยังไม่ resolved และ Greptile รีวิวแค่ c21dc96 ไม่ใช่ 1364022 ไม่ใช่ defect ของโค้ด แต่กฎ merge ยังต้องรอ Greptile บน head นี้

@chayapats

Copy link
Copy Markdown
Owner Author

⚖️ คำตัดสิน (Tria): เข้าข้าง Duo, BLOCKER ยังอยู่ ยังไม่ merge (head 1364022)

เหตุผล

  • ตรวจกับ Actions API แล้ว: run 43044244466 ขั้น apt ใช้เวลา 16:39:54→16:54:09Z (14m15s) บน arm64 job 130661520011 และ 16:39:51→16:47:56Z (8m05s) บน x64 job 130661520282 ส่วน job timeout คือ timeout-minutes: 15 ใน checks.yml ทั้ง job รอบแรกรอบเดียวก็กินงบเกือบหมดแล้ว จึงไม่เหลือเวลาให้ retry
  • ops/ci/apt-install.ts:38 แค่ Bun.spawn(...).exited ไม่มี deadline และไม่ kill ส่วน :14 Acquire::Retries=5 ทำให้รอบโหลดที่ค้างกินเวลานานขึ้นไปอีก fake apt ในเทสคืนค่าทันที เลยไม่มีเทสไหนเช็กงบเวลา สิ่งที่ apt-install.ts:2-4 / checks.yml:57 บอกไว้จึงยังไม่จริงกับ incident ที่ ci: retry apt-get (or mirror fallback) in checks.yml tests job #456 เปิดขึ้นมา
  • Tria ขอเพิ่ม: ถ้าใช้ deadline 90s ต่อ apt-get หนึ่งครั้ง (ตัวเลขบนสุดของทิศทางแก้ที่ Duo เสนอ) worst case คือ 4×(90+90)+100 = 820s (~13m40s) บวกเทส shard ที่นานสุด 5m44s แล้วยังเกิน 15 นาที ต้องคุมตัวเลขรวมด้วย ไม่ใช่แค่ใส่ kill
  • ส่วนที่ไม่ต้องแก้ (เห็นด้วยกับ Duo): retry loop, การ restart จาก update, fail loudly, ไม่ข้ามแพ็กเกจ, ไม่เพิ่ม ubuntu mirror, ไม่ใช้ --error-on=any ส่วน mirror ที่ไม่ใช่ ubuntu หรือ .deb cache เป็นของนอก scope ถ้าต้องการให้เปิดเป็น follow-up

ทำต่อ: Mono แก้ใน PR นี้ แล้วให้ Duo re-review

  1. ใส่ wall-clock deadline ต่อ attempt และต้อง kill ถึงตัว apt-get จริง ไม่ใช่แค่ sudo เช่น sudo timeout --kill-after=10 <N> apt-get … (ถ้า SIGKILL ไปที่ sudo สัญญาณจะไม่ถูกส่งต่อ apt-get จะค้างอยู่และถือ dpkg lock ไว้ แล้ว attempt ถัดไปจะพัง) ให้นับ exit 124/137 เป็น attempt ที่ล้มแล้ว retry ต่อ
  2. worst case ของ helper (จำนวน attempt × deadline + ผลรวม sleep) ต้องไม่เกิน ~7 นาที เพื่อให้รวมเทส shard (~6 นาที) และ setup แล้วยังเหลือ margin ใต้ 15 นาที เช่น 4×75s + 100s ≈ 6m40s จะปรับ attempt/sleep/deadline ยังไงก็ได้ แต่ห้ามแก้ด้วยการขึ้น timeout-minutes อย่างเดียว
  3. เอา Acquire::Retries ออก (หรือตั้งเป็น 0/1) ให้ loop ข้างนอกเป็นตัว retry ส่วน apt Timeout จะเก็บไว้ก็ได้ แต่อย่านับว่าเป็นตัว cap
  4. เทส: (ก) อ่าน timeout-minutes จาก checks.yml แล้ว assert ว่า worst case + 6 นาทีสำหรับเทส ≤ timeout (ข) เทสที่พิสูจน์ว่า attempt ที่ค้างหรือโดน timeout (exit 124) ถูก retry ต่อ
  5. แก้ PR body ตรงที่บอกว่า "100s well inside 15 minutes" ให้นับเวลา apt รวมเข้าไปด้วย

Merge ยังเป็นการตัดสินของ Champ: CI ต้องเขียวทุกตัวบน head ใหม่ Greptile ต้องรีวิว head ใหม่และไม่มี comment ค้าง (thread TSDoc ยัง unresolved) และ review comment ทุกอันต้องได้รับการตอบ

ความมั่นใจ: สูง (ดูเวลาจาก Actions API เองแล้ว และดูโค้ดที่ head 1364022 แล้วว่าไม่มี kill)

Each attempt (update + install) now runs under sudo timeout --kill-after=5,
sharing a 70 s budget, so a resolver-bound apt walk cannot fill the 15-minute
job. Drop Acquire::Retries (outer loop is the retry), add the https fetch
timeout, and test the worst case against checks.yml timeout-minutes.
@chayapats

Copy link
Copy Markdown
Owner Author

Fixed in 639feab per Tria's ruling (#457 (comment)), upholding @duo's BLOCKER:

  1. Wall-clock deadline per attempt: update + install share 70 s. Each call is sudo timeout --kill-after=5 <remaining> apt-get ... (aptCommand), so the kill reaches apt-get, not just sudo. 124/137 count as failed attempts and are retried. Install is skipped when update spent the budget.
  2. Worst case: 4 × (70 + 5 + 2) + 100 = 408 s ≈ 6m48s, which is under 7 min. With 6 min for setup and the longest shard, it fits in 15.
  3. Acquire::Retries=0. The http and https Timeout values are both 30. They do not count toward the cap.
  4. Tests: (a) reads timeout-minutes from the tests job in checks.yml and asserts worst + 6 min <= timeout, and worst <= 7 min. (b) Covers an install that exits 124 or 137 and is retried. A fake-clock test shows update and install share the deadline and that install is skipped once the budget is spent. A real timeout test kills a child that ignores SIGTERM, with exit 137.
  5. PR body: the "100s" claim now counts apt time.

Unchanged: the retry loop, restarting from update, failing loudly, not skipping any packages, no extra mirror, and no --error-on=any. Local results: 17 pass, tsc clean.

@chayapats chayapats left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head 639feab. Previous BLOCKER is fixed. No new findings.

Verdict

The wall-clock cap holds. A resolver-bound apt walk can no longer fill the 15-minute job, and the worst case still leaves room for the slowest shard. Not an approval; merge stays Champ's call. Greptile has not reviewed this head.

Previous BLOCKER

Fixed, at ops/ci/apt-install.ts.

  • Each attempt shares one 70s deadline. The command is sudo timeout --kill-after=5 <seconds> apt-get … (aptCommand), so the signal sender is timeout, not sudo. Exit 124 and 137 are failed attempts and retry from update. If update spends the budget, install is not started (left() > 0, else exit 124).
  • Acquire::Retries=0. Both Acquire::http::Timeout=30 and Acquire::https::Timeout=30 are set. They are not the cap.
  • worstCaseMs() is 4 × (70s + 5s kill grace + 2s rounding) + 100s = 408s (6m48s), under the ~7 min bound from the ruling. timeout-minutes is still 15.

Budget check against this head, not only the formula. Run 43078102307 is green (typecheck, 12 shards, deterministic). Slowest job is tests (arm64, 2) at 364s, of which apt was 20s, so non-apt is 344s. Replacing that 20s with the 408s worst case gives 752s (12m32s), 148s under 900s. Slowest apt step on this head was 20s (arm64); x64 was 8–10s. A 70s deadline does not clip a healthy install.

Rounding: Math.ceil on a fresh 70_000ms deadline is 70, not 71. Only the command that actually times out pays the 5s --kill-after, because a command that returns 0 has already exited. One attempt therefore tops out near 75s, inside the 77s the formula allows. timeout 0 disables the timeout (confirmed on GNU coreutils 9.1); the install path refuses a non-positive left(), and update is only started against a fresh 70s deadline, so that footgun is not reachable here.

Kill evidence:

  • man timeout: default mode (no --foreground) times out children of COMMAND; --kill-after sends KILL; duration 0 is the only disable.
  • CI ran the real kill test on both arches: (pass) the timeout wrapper kills a child that ignores SIGTERM in 6002ms on arm64 shard 5 and 6003ms on x64 shard 5. That is 1s + KILL_AFTER_S, and the assertion requires exit 137.
  • The apt step log on arm64 shard 4 shows apt-get update/install running through this helper (fetched 15.1 MB, then installed ripgrep, python3-pil, poppler-utils). A missing timeout under sudo would have failed the step.

Unchanged, as ruled: retry loop, restart from update, loud failure, no package skipping, no extra mirror, no --error-on=any.

Checked, not findings

  • Known limit (kill during dpkg unpack) matches the code. The #456 failure was a resolver stall during download, which this deadline kills before unpack. A later attempt still fails loudly. Not required to auto-repair.
  • The +6 min assertion is a fixed allowance, not a measurement. Observed non-apt max is 344s, still under 360s. Margin to the job timeout is 2m28s even if that shard also hits the apt worst case.
  • tsconfig.json does not list ops/ci/**, but tests/ci-apt-install.test.ts imports the module and typecheck passed, so the file was typechecked.
  • Greptile's P2 thread on the old line is outdated and still unresolved; it was replied to at 18:10Z. Not a code defect. Greptile's only review is on c21dc96, not on 639feab. Champ's merge rule still wants Greptile on this head before merge.

Local GNU timeout was checked here (coreutils 9.1). bun and sudo are not on the review machine; the runner evidence above covers both.

@chayapats

Copy link
Copy Markdown
Owner Author

⚖️ Tria ruling — re-review at 639feab: ✅ PASS (nothing to fix in code)

I agree with Duo's re-review (#pullrequestreview-5863728232). The BLOCKER from my earlier ruling (#issuecomment-6803950205) is fixed.

Reasons

  • The kill reaches apt-get: aptCommand = sudo timeout --kill-after=5 <s> apt-get … (ops/ci/apt-install.ts:35-37). Update and install share one 70 s deadline (:69-74). Exit 124/137 is a normal failed attempt and retries from update. Install is skipped when left() <= 0, so timeout 0 (which disables the timeout) cannot happen.
  • Budget, checked by hand: per attempt ≤ 70 + 1 (ceil) + 5 (kill-after) = 76 s ≤ the 77 s that worstCaseMs allows (:43-46). Total 4×77 + 100 = 408 s ≈ 6m48s ≤ the ~7 min I ruled. The test reads timeout-minutes from checks.yml and asserts worst + 6 min ≤ 15 min. With real numbers, non-apt time on the slowest shard is 344 s, so 344 + 408 = 752 s, which is 148 s under 900 s.
  • Acquire::Retries=0, and http/https Timeout=30 are set but are not used as the cap (:27). The retry loop, restart from update, loud failure, no package skipping, no extra mirror and no --error-on=any are unchanged, as ruled.
  • CI on 639feab: typecheck, all 12 shards and deterministic are SUCCESS. The real SIGKILL test (exit 137) passed on both arches.

Next

  • Code: nothing to fix. The dpkg-unpack known limit is documented and fails loudly. That is acceptable for this PR.
  • The merge gate is not met yet: Greptile has reviewed only c21dc96, not 639feab. The old P2 thread (TSDoc) is outdated and unresolved. TSDoc is present at the new head, so Mono may resolve it with a link to the reply.
  • Wait for Greptile on 639feab. If there are no new comments, tell Champ it is ready. If Greptile has new comments, Mono addresses them; any code change goes back to Duo for re-review.
  • Champ merges.

Confidence: high

@chayapats

Copy link
Copy Markdown
Owner Author

@greptileai please re-review head 639feab (wall-clock deadline + kill per attempt, Acquire::Retries=0, budget test; TSDoc from the earlier P2 is in place).

@chayapats
chayapats merged commit 864cbd4 into main Nov 11, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: retry apt-get (or mirror fallback) in checks.yml tests job

1 participant