Skip to content

Commit 0f7ff0a

Browse files
jdicorpocursoragent
andcommitted
config: surface real make-uncompress failures, drop dead try/except
Previously uncompress_gds() called subprocess.run without check=True so its except CalledProcessError block was dead code, and stderr/stdout went into PIPE and were thrown away. When `make uncompress` failed (e.g. on Fargate under disk pressure for a large repo) cf-precheck silently swallowed the real error and emitted only the misleading downstream "A single valid GDS was not found" message — see chipfoundry/cf-cli#20 where users blamed LFS. Now: judge by post-condition (gds/*.gds files in the working tree) since caravel-lite's Makefile legitimately exits non-zero on the mag/ step *after* the gds/ step completes, and we don't want that benign failure to fail the whole precheck. If gds/ ends up populated, log INFO with the file list + make's exit code and continue. If empty, log CRITICAL with returncode + stderr tail (4000 chars) + stdout tail (200 chars) so the operator can see exactly why make couldn't produce the layouts. Fail with sys.exit(252). Per .cursor/rules/no-fallback-code.mdc. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e205f7d commit 0f7ff0a

2 files changed

Lines changed: 153 additions & 8 deletions

File tree

src/cf_precheck/config.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,53 @@
1313

1414

1515
def uncompress_gds(project_path: Path, caravel_root: Path) -> None:
16+
"""Run the caravel-lite `uncompress` make target to gunzip gds/*.gds.gz.
17+
18+
Surfaces real failures instead of silently swallowing them. We deliberately
19+
do NOT use ``check=True`` because the caravel-lite Makefile's ``uncompress``
20+
target also touches ``mag/*.mag.gz``; if a project has both ``mag/foo.mag``
21+
and ``mag/foo.mag.gz`` committed (common), gunzip exits non-zero on that
22+
*after* the gds step has already produced ``gds/*.gds`` files. Failing the
23+
whole precheck on that benign mag-step exit would be wrong. Instead we
24+
judge by the post-condition cf-precheck actually cares about: at least one
25+
``gds/*.gds`` file in the working tree.
26+
"""
1627
cmd = f"make -f {caravel_root}/Makefile uncompress;"
17-
try:
18-
logging.info(f"Extracting compressed files in: {project_path}")
19-
subprocess.run(
20-
cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
21-
shell=True, cwd=str(project_path),
28+
logging.info(f"Extracting compressed files in: {project_path}")
29+
result = subprocess.run(
30+
cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
31+
shell=True, cwd=str(project_path),
32+
)
33+
34+
gds_dir = project_path / "gds"
35+
produced_gds = sorted(gds_dir.glob("*.gds")) if gds_dir.is_dir() else []
36+
37+
if produced_gds:
38+
names = ", ".join(p.name for p in produced_gds)
39+
logging.info(
40+
f"make uncompress produced {len(produced_gds)} gds/*.gds file(s): {names} "
41+
f"(make exit {result.returncode})"
42+
)
43+
return
44+
45+
stderr_text = (result.stderr or b"").decode(errors="replace").strip()
46+
stdout_text = (result.stdout or b"").decode(errors="replace").strip()
47+
stderr_tail = stderr_text[-4000:] if stderr_text else "(empty)"
48+
stdout_tail = stdout_text[-200:] if stdout_text else "(empty)"
49+
50+
if result.returncode != 0:
51+
logging.critical(
52+
"make uncompress failed and produced no gds/*.gds files "
53+
f"(exit {result.returncode}). "
54+
f"stderr tail: {stderr_tail} | stdout tail: {stdout_tail}"
55+
)
56+
else:
57+
logging.critical(
58+
"make uncompress reported success (exit 0) but produced no "
59+
f"gds/*.gds files in {gds_dir}. stderr tail: {stderr_tail} | "
60+
f"stdout tail: {stdout_tail}"
2261
)
23-
except subprocess.CalledProcessError as error:
24-
logging.critical(f"Make 'uncompress' error: {error}")
25-
sys.exit(252)
62+
sys.exit(252)
2663

2764

2865
def is_binary_file(filename: str | Path) -> bool:

tests/test_uncompress_gds.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Regression: uncompress_gds must surface real make failures, not swallow them.
2+
3+
The previous implementation captured stderr/stdout into PIPE and threw them
4+
away, used ``subprocess.run`` without ``check=True`` (so its
5+
``except CalledProcessError`` block was dead code), and returned silently on
6+
ANY make failure. That meant Fargate-only failures (e.g. disk pressure during
7+
gunzip on a heavy repo) bubbled up as the misleading downstream error
8+
"A single valid GDS was not found" with no information about why. See
9+
chipfoundry/cf-cli#20.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import subprocess
16+
from pathlib import Path
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from cf_precheck.config import uncompress_gds
22+
23+
24+
def _completed(returncode: int, stderr: bytes = b"", stdout: bytes = b"") -> subprocess.CompletedProcess:
25+
return subprocess.CompletedProcess(
26+
args="make ... uncompress",
27+
returncode=returncode,
28+
stderr=stderr,
29+
stdout=stdout,
30+
)
31+
32+
33+
def test_success_when_gds_files_present_regardless_of_exit_code(
34+
tmp_path: Path, caplog: pytest.LogCaptureFixture
35+
) -> None:
36+
"""Caravel-lite Makefile legitimately exits 2 on the mag/ step after the
37+
gds/ step succeeds. As long as gds/*.gds is on disk, treat as success."""
38+
(tmp_path / "gds").mkdir()
39+
(tmp_path / "gds" / "user_project_wrapper.gds").write_bytes(b"GDSII")
40+
(tmp_path / "gds" / "other.gds").write_bytes(b"GDSII")
41+
42+
with patch.object(
43+
subprocess, "run",
44+
return_value=_completed(2, stderr=b"gzip: mag/user_project_wrapper.mag already exists"),
45+
), caplog.at_level(logging.INFO):
46+
uncompress_gds(tmp_path, Path("/opt/caravel"))
47+
48+
info_msgs = [r.message for r in caplog.records if r.levelno == logging.INFO]
49+
assert any("produced 2 gds/*.gds file" in m for m in info_msgs), info_msgs
50+
assert any("user_project_wrapper.gds" in m for m in info_msgs), info_msgs
51+
assert any("other.gds" in m for m in info_msgs), info_msgs
52+
assert any("make exit 2" in m for m in info_msgs), info_msgs
53+
54+
55+
def test_critical_with_stderr_when_make_fails_and_no_gds(
56+
tmp_path: Path, caplog: pytest.LogCaptureFixture
57+
) -> None:
58+
"""Real make failure (e.g. disk full, missing tool) on a repo with no
59+
pre-existing gds/*.gds — surface stderr + returncode, then exit 252."""
60+
(tmp_path / "gds").mkdir()
61+
stderr = b"gunzip: write error: No space left on device\n"
62+
63+
with patch.object(
64+
subprocess, "run", return_value=_completed(2, stderr=stderr),
65+
), caplog.at_level(logging.CRITICAL):
66+
with pytest.raises(SystemExit) as excinfo:
67+
uncompress_gds(tmp_path, Path("/opt/caravel"))
68+
69+
assert excinfo.value.code == 252
70+
crit_msgs = [r.message for r in caplog.records if r.levelno == logging.CRITICAL]
71+
assert any("make uncompress failed" in m for m in crit_msgs), crit_msgs
72+
assert any("No space left on device" in m for m in crit_msgs), crit_msgs
73+
assert any("exit 2" in m for m in crit_msgs), crit_msgs
74+
75+
76+
def test_critical_when_make_succeeds_but_no_gds_produced(
77+
tmp_path: Path, caplog: pytest.LogCaptureFixture
78+
) -> None:
79+
"""make exits 0 (e.g. no .gds.gz inputs to operate on) but no .gds files
80+
appear: cf-precheck cannot proceed; surface a clear "produced no gds"
81+
message instead of the misleading downstream "no valid GDS" error."""
82+
(tmp_path / "gds").mkdir()
83+
84+
with patch.object(
85+
subprocess, "run", return_value=_completed(0, stdout=b"Nothing to do for 'uncompress'."),
86+
), caplog.at_level(logging.CRITICAL):
87+
with pytest.raises(SystemExit) as excinfo:
88+
uncompress_gds(tmp_path, Path("/opt/caravel"))
89+
90+
assert excinfo.value.code == 252
91+
crit_msgs = [r.message for r in caplog.records if r.levelno == logging.CRITICAL]
92+
assert any("reported success" in m for m in crit_msgs), crit_msgs
93+
assert any("produced no" in m for m in crit_msgs), crit_msgs
94+
95+
96+
def test_missing_gds_dir_treated_as_no_gds(
97+
tmp_path: Path, caplog: pytest.LogCaptureFixture
98+
) -> None:
99+
"""If gds/ doesn't exist at all (malformed repo), still surface a clear
100+
error rather than silently passing or KeyError'ing later."""
101+
with patch.object(
102+
subprocess, "run",
103+
return_value=_completed(0, stdout=b"make: *** No rule to make target 'uncompress'."),
104+
), caplog.at_level(logging.CRITICAL):
105+
with pytest.raises(SystemExit) as excinfo:
106+
uncompress_gds(tmp_path, Path("/opt/caravel"))
107+
108+
assert excinfo.value.code == 252

0 commit comments

Comments
 (0)