Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion bin/local-dev/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,17 @@ svc_source_hash() {
}

# Per-service dirty check (the SRC * indicator). Two-stage:
# Set a file's mtime one second into the past, in whichever `touch` dialect is
# present. Used to build the comparison reference for the fast path below; see
# there for why the second of slack is needed. A missing path is a quiet no-op.
_stamp_backdate() {
[[ -f "${1:-}" ]] || return 0
# GNU coreutils, then BSD/macOS `-A` (adjust the timestamps by -1 second).
touch -d '1 second ago' "$1" 2>/dev/null \
|| touch -A -01 "$1" 2>/dev/null \
|| true
}

# Fast path (~22 ms): is any tracked source newer than the stamp file's
# mtime? If not, definitely clean.
# Slow path (~100 ms): compute current source hash and compare to the hash
Expand Down Expand Up @@ -2003,10 +2014,26 @@ svc_src_changed() {
while IFS= read -r d; do
[[ -n "$d" ]] && dirs+=("$d")
done < <(_svc_src_dirs "$svc")
# `find -newer` is *strictly* newer, and the stamp is written at the
# end of a build — right before you edit the file you were just
# building. An edit inside the filesystem's timestamp granularity
# therefore shares the stamp's mtime exactly and used to be
# invisible here, so `auto` skipped the rebuild (#7075). Compare
# against a throwaway marker one second behind the stamp instead.
# The slack only widens the candidate set; the content hash below
# still decides. The stamp itself keeps its real mtime, so the
# refresh at the end of the slow path converges as before.
local cmp_ref="$stamp"
local marker="$BUILD_STAMP_DIR/.${svc}.cmp"
if touch -r "$stamp" "$marker" 2>/dev/null; then
_stamp_backdate "$marker"
cmp_ref="$marker"
fi
local newer=""
newer=$(find "${dirs[@]}" \
\( -name "*.scala" -o -name "*.java" -o -name "*.proto" \) \
-newer "$stamp" -type f -print 2>/dev/null | head -1)
-newer "$cmp_ref" -type f -print 2>/dev/null | head -1)
rm -f "$marker"
if [[ -z "$newer" ]]; then
return 1 # nothing changed since last stamp → clean
fi
Expand Down
67 changes: 67 additions & 0 deletions bin/local-dev/tests/test_local_dev_sh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -593,5 +593,72 @@ else
_fail "changelog references missing files:$missing_files"
fi

# 29) Regression for #7075: `find -newer` is *strictly* newer, so a source whose
# mtime equals the build stamp's is invisible to the fast filter and `auto`
# reports "everything up-to-date" without rebuilding it. The read side
# therefore compares against a throwaway marker one second behind the stamp.
# Deterministic: the colliding mtime is forced with `touch -r`, not raced.
stamp_fn=$(awk 'index($0, "_stamp_backdate()") == 1 {f=1} f{print} f && /^}/{exit}' "$MAIN_SH")
if [[ -z "$stamp_fn" ]]; then
_fail "_stamp_backdate helper missing"
else
_sd=$(mktemp -d 2>/dev/null || mktemp -d -t ldsd)
mkdir -p "$_sd/src"
# Older.scala must end up comfortably behind the stamp, further back than
# the one second of slack the marker adds — hence a real wait rather than a
# computed timestamp, which has no portable spelling.
: > "$_sd/src/Older.scala"
sleep 2
: > "$_sd/stamp"
: > "$_sd/src/Same.scala"
touch -r "$_sd/stamp" "$_sd/src/Same.scala" # exactly the stamp's mtime

# The bug itself, characterised: comparing against the stamp misses it.
naive=$(find "$_sd/src" -name '*.scala' -newer "$_sd/stamp" -print 2>/dev/null)
if [[ "$naive" != *"Same.scala"* ]]; then
_pass "stamp backdate: bare \`-newer \$stamp\` misses an equal mtime (the bug)"
else
_fail "stamp backdate: premise no longer holds — -newer saw an equal mtime" \
"found: $naive"
fi

# The fix: a marker one second behind the stamp sees it.
marker="$_sd/marker"
( eval "$stamp_fn"
touch -r "$_sd/stamp" "$marker" && _stamp_backdate "$marker" ) 2>/dev/null
fixed=$(find "$_sd/src" -name '*.scala' -newer "$marker" -print 2>/dev/null)
if [[ "$fixed" == *"Same.scala"* ]]; then
_pass "stamp backdate: backdated marker sees the equal-mtime edit"
else
_fail "stamp backdate: backdated marker still misses the edit" "found: '$fixed'"
fi
# Negative: the slack must not drag genuinely older sources in, or every
# tick pays the content hash forever.
if [[ "$fixed" != *"Older.scala"* ]]; then
_pass "stamp backdate: a clearly older source stays clean"
else
_fail "stamp backdate: slack flagged an older source" "found: $fixed"
fi
# Negative: a missing path must be a quiet no-op, not an error spray.
err=$( ( eval "$stamp_fn"; _stamp_backdate "$_sd/nope" ) 2>&1 ); rc=$?
if (( rc == 0 )) && [[ -z "$err" ]]; then
_pass "stamp backdate: missing file is a quiet no-op"
else
_fail "stamp backdate: missing file was noisy" "rc=$rc err='$err'"
fi
rm -rf "$_sd"
fi

# 30) Wiring: the jvm dirty check must compare against the backdated marker
# rather than the stamp, or #7075 is only half fixed (the shell path is the
# one that gates the rebuild; tui.py only colours the SRC column).
src_changed_body=$(awk 'index($0, "svc_src_changed()") == 1 {f=1} f{print} f && /^}/{exit}' "$MAIN_SH")
if [[ "$src_changed_body" == *"_stamp_backdate"* ]] \
&& ! printf '%s\n' "$src_changed_body" | grep -qE '\-newer "\$stamp"'; then
_pass "svc_src_changed compares against the backdated marker"
else
_fail "svc_src_changed still compares directly against \$stamp"
fi

printf "\n%d passed, %d failed\n" "$PASS" "$FAIL"
(( FAIL == 0 ))
32 changes: 32 additions & 0 deletions bin/local-dev/tests/test_local_dev_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,38 @@ def test_is_dirty_after_seed_then_edit(tmp_path, monkeypatch, tui):
assert tui.is_dirty(svc) is True


def test_is_dirty_when_edit_shares_the_stamp_mtime(tmp_path, monkeypatch, tui):
"""An edit landing in the same filesystem timestamp tick as the stamp write
must still be seen.

`test_is_dirty_after_seed_then_edit` above hits this by accident wherever
the filesystem's granularity is coarser than its two consecutive writes;
here the collision is forced with os.utime, so it holds on every platform.
The fast mtime filter must not answer "definitely clean" without consulting
the content hash, or `auto` silently skips the rebuild."""
monkeypatch.setattr(tui, "REPO_ROOT", tmp_path)
monkeypatch.setattr(tui, "BUILD_STAMP_DIR", tmp_path / "stamps")
(tmp_path / "stamps").mkdir()
_seed_jvm_layout(tmp_path, "config-service/src")

svc = tui.SERVICES_BY_NAME["config-service"]
jar = tmp_path / svc.artifact_jar
jar.parent.mkdir(parents=True, exist_ok=True)
jar.write_bytes(b"fake-jar-bytes")

assert tui.is_dirty(svc) is False
stamp = tmp_path / "stamps" / svc.name

# Change the content, then force the source's mtime to exactly the stamp's.
src = tmp_path / "config-service/src/Main.scala"
src.write_text("object Main { def y = 2 }\n")
st = stamp.stat()
os.utime(src, ns=(st.st_atime_ns, st.st_mtime_ns))
assert src.stat().st_mtime_ns == stamp.stat().st_mtime_ns

assert tui.is_dirty(svc) is True


def test_is_dirty_mtime_bump_without_content_change_stays_clean(tmp_path, monkeypatch, tui):
"""Robustness against `git checkout` touching mtimes — the whole reason we
moved off pure-mtime detection. After seeding the stamp, simulating a
Expand Down
14 changes: 13 additions & 1 deletion bin/local-dev/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,9 +592,21 @@ def source_hash(svc: Service, files: Optional[list[Path]] = None) -> str:


def _newest_mtime_after(files: list[Path], stamp_mtime: float) -> bool:
"""Any source at or after the stamp's mtime?

`>=`, not `>`: the stamp is written at the end of a build and the natural
next action is to edit the file you were just building, so an edit landing
inside the filesystem's timestamp granularity shares the stamp's mtime
exactly. With a strict `>` the filter answered "definitely clean" and the
content hash was never consulted, so the rebuild was skipped (#7075).

Equality costs at most one extra hash comparison: when it finds the content
unchanged, `_jvm_is_dirty` bumps the stamp's mtime past the sources, and
later ticks take the cheap path again.
"""
for f in files:
try:
if f.stat().st_mtime > stamp_mtime:
if f.stat().st_mtime >= stamp_mtime:
return True
except OSError:
continue
Expand Down
Loading