Skip to content

Commit 804c743

Browse files
fryanpanclaude
andcommitted
ADFA-4128: absorb a mid-rebuild save only when its mtime proves it predates the rebuild
A filesystem that truncates mtimes to whole seconds can stamp a save made just after a proxy app rebuild started with an mtime just before it. The echo split then folded it into the absorbed set and onBaselineReset dropped it: the edit never built and nothing said so. An mtime on a whole-second boundary is now taken as truncated and must predate the rebuild start by 2 s (the FAT step) to absorb. A flat margin on every mtime would instead strand a genuinely pre-start save on every filesystem, because the watcher's debounce (150 ms quiet, 1 s cap) puts such a save's mtime within a second of the start; four of the existing F4 echo tests go red under it. A file the rebuild already holds keeps the exact cutoff, since its arrival is the echo the split exists to absorb. Review thread: #1718 (comment) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2
1 parent bffbc5b commit 804c743

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestrator.kt‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ class LiveReloadOrchestrator(
493493
* start, files with no readable mtime (nothing proves they predate the read), removals (no
494494
* mtime left to date them), and [ChangedFiles.Unknown].
495495
*
496+
* A filesystem may truncate mtimes to whole seconds (ext4 without extended timestamps,
497+
* two seconds on FAT), so a save landing just after the start can report an mtime just
498+
* before it. An mtime on a whole-second boundary is taken as truncated, and a file with
499+
* one is absorbed only when it predates the start by [MTIME_GRANULARITY_MARGIN_MILLIS]:
500+
* on a coarse filesystem that errs toward one extra build rather than a silently dropped
501+
* edit, and on a precise one it misfires on the one save in a thousand that lands on an
502+
* exact second, at the same cost. Applying the margin to every mtime instead would strand
503+
* a genuinely pre-start save on every filesystem: the watcher's debounce (150 ms quiet,
504+
* 1 s cap) puts such a save's mtime within a second of the start.
505+
*
506+
* A file the rebuild already holds keeps the exact cutoff even when truncated: its
507+
* arrival is the echo of the save the rebuild is absorbing, and stranding that echo is
508+
* the spurious invalidation this split exists to prevent. The residual is a re-edit of a
509+
* held file within the margin after the start on a coarse filesystem, which absorbs.
510+
*
496511
* The absorbed part joins [awaitingAbsorption] via [ChangedFiles.plus], NOT
497512
* [unionPendingLocked]: this rebuild IS the Gradle build these files would demand, so
498513
* latching a sticky verdict from them would re-report the invalidation it is resolving.
@@ -506,7 +521,16 @@ class LiveReloadOrchestrator(
506521
if (changes !is ChangedFiles.Known) return changes
507522
val absorbed =
508523
changes.files.filterTo(mutableSetOf()) { file ->
509-
fileLastModified(file) in 1..absorptionStartedAtMillis
524+
val mtime = fileLastModified(file)
525+
val heldAlready = held !is ChangedFiles.Known || file in held.files
526+
val truncated = mtime % 1_000L == 0L
527+
val cutoff =
528+
if (heldAlready || !truncated) {
529+
absorptionStartedAtMillis
530+
} else {
531+
absorptionStartedAtMillis - MTIME_GRANULARITY_MARGIN_MILLIS
532+
}
533+
mtime in 1..cutoff
510534
}
511535
if (absorbed.isEmpty()) return changes
512536
awaitingAbsorption = held + ChangedFiles.Known(absorbed)
@@ -863,6 +887,12 @@ class LiveReloadOrchestrator(
863887
* full Gradle build.
864888
*/
865889
const val ESCALATE_AFTER_IDENTICAL_FAILURES = 2
890+
891+
/**
892+
* Coarsest mtime granularity a project tree is expected to sit on: FAT stamps mtimes in
893+
* 2 s steps, ext4 without extended timestamps in 1 s. See [absorbEchoesLocked].
894+
*/
895+
const val MTIME_GRANULARITY_MARGIN_MILLIS = 2_000L
866896
}
867897
}
868898

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/domain/reload/LiveReloadOrchestratorTest.kt‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,35 @@ class LiveReloadOrchestratorTest {
726726
assertThat(executor.requests).isEmpty()
727727
}
728728

729+
@Test
730+
fun `a mid-rebuild edit the rebuild does not hold stays pending when its coarse mtime lands inside the granularity margin`() =
731+
runTest {
732+
// A filesystem with 1 s mtimes stamps a save made just after the rebuild started
733+
// with an mtime a second before it, on a whole-second boundary. Absorbing on that
734+
// mtime would lose the edit: onBaselineReset drops the absorbed set and nothing
735+
// builds it. The precise 9_900 echoes in the tests above sit inside the same
736+
// margin and must still absorb - the margin is for truncated mtimes only.
737+
val executor = GatedExecutor()
738+
val orchestrator =
739+
LiveReloadOrchestrator(
740+
executor,
741+
ChangeClassifier(),
742+
backgroundScope,
743+
wallClock = { 10_000L },
744+
fileLastModified = { file -> if (file.path == srcA) 9_000L else 0L },
745+
) {}
746+
747+
orchestrator.onFilesChanged(known("app/src/main/AndroidManifest.xml"))
748+
runCurrent()
749+
orchestrator.onProxyAppRebuildStarted()
750+
orchestrator.onFilesChanged(known(srcA))
751+
orchestrator.onBaselineReset()
752+
runCurrent()
753+
754+
assertThat(executor.requests).hasSize(1)
755+
assertThat(executor.requests[0].changes).isEqualTo(known(srcA))
756+
}
757+
729758
@Test
730759
fun `a mid-rebuild file with no readable mtime stays pending - nothing proves it predates the read`() =
731760
runTest {

0 commit comments

Comments
 (0)