Skip to content

Commit bf8cc84

Browse files
fryanpanclaude
andcommitted
ADFA-4128: record the retained set's invalidation where a read-only dir cannot swallow it
purge() emptied meta.json inside a runCatching and then returned the delete's result, so a store whose metadata write AND recursive delete both failed left meta.json intact and readable. load() parsed it and handed a superseded baseline's bytes to a reconnect re-send - the one thing this store exists not to do. Emptying the metadata and unlinking it need different permissions, which is why the class treated them as independent, but a read-only meta.json under a read-only directory refuses both at once. The invalidation is now recorded beside the directory when neither half took, and load() refuses a set carrying that marker. The marker needs only the parent, which retain() must be able to write anyway to rename its staging dir into place, and it is cleared as soon as a purge removes the set. Without the fix the new test reports: value of: load() expected: null but was : RetainedPayload(generation=1, metadataJson={"gen":1}, ...) Also corrects the purge() KDoc, which claimed a failed delete always leaves the set unreadable (board task t-jwpRC46uvGrH). It now states the one case that is still only logged - a store whose whole tree has gone read-only, where there is nowhere left to record anything - and clear()'s warning no longer asserts an invalidation it cannot guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2
1 parent 0f6ab44 commit bf8cc84

2 files changed

Lines changed: 61 additions & 7 deletions

File tree

‎quickbuild/core/src/main/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStore.kt‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ internal class RetainedPayloadStore(
5454
* The swap goes through a staging dir: a crash at any point leaves either the previous
5555
* set, or nothing - never a half-written mix that [load] could hand to a re-send. A
5656
* delete that fails is the same case: [purge] invalidates the old set before removing it,
57-
* so this deploy failing to retain leaves nothing to load rather than the older set.
57+
* so this deploy failing to retain leaves nothing to load rather than the older set - as
58+
* far as [purge] can still record it, which its own contract bounds.
5859
*
5960
* @param generation the generation the confirmed deploy claimed
6061
* @param dexFile the deployed classes, or null when the build moved no code
@@ -105,6 +106,10 @@ internal class RetainedPayloadStore(
105106
* (missing part, corrupt metadata) - either way the caller falls back to rebuilding
106107
*/
107108
fun load(): RetainedPayload? {
109+
if (invalidMarker().isFile) {
110+
log.warn("Retained payload under {} could not be invalidated in place; refusing it", dir)
111+
return null
112+
}
108113
val meta = File(dir, META_NAME)
109114
if (!meta.isFile) return null
110115
return try {
@@ -129,7 +134,7 @@ internal class RetainedPayloadStore(
129134
*/
130135
fun clear() {
131136
if (!purge()) {
132-
log.warn("Could not fully drop the retained payload under {}; it is invalidated but its parts remain", dir)
137+
log.warn("Could not fully drop the retained payload under {}; its parts remain", dir)
133138
}
134139
stagingDir().deleteRecursively()
135140
}
@@ -145,17 +150,38 @@ internal class RetainedPayloadStore(
145150
* closed in the case where the recursive delete cannot finish at all. [load] keys off the
146151
* metadata alone, so once it is unreadable the rest of the tree is inert.
147152
*
148-
* @return true when the directory is gone; false when parts of it survive, in which case
149-
* the set is still unreadable but the caller cannot rename a new one into place
153+
* Both can fail together, though - a read-only [META_NAME] under a read-only [dir] refuses
154+
* the write and the unlink alike - and then nothing inside [dir] has changed, so [load]
155+
* would parse the superseded metadata and re-send a baseline the session has moved past.
156+
* The marker beside [dir] is the third place to record it: it needs only the parent, which
157+
* [retain] must be able to write anyway to rename its staging dir into place.
158+
*
159+
* @return true when the directory is gone; false when parts of it survive, in which case the
160+
* caller cannot rename a new set into place. A false still leaves the old set unreadable -
161+
* by the emptied [META_NAME], or by the marker when that write was refused too - except
162+
* where neither could be written at all, which is a store whose whole tree has gone
163+
* read-only and which [clear] can then only log
150164
*/
151165
private fun purge(): Boolean {
152166
val meta = File(dir, META_NAME)
153-
if (meta.isFile) {
154-
runCatching { meta.writeText("") }
167+
val invalidated = !meta.isFile || runCatching { meta.writeText("") }.isSuccess
168+
if (!invalidated) {
169+
runCatching { invalidMarker().writeText(dir.name) }
155170
}
156-
return dir.deleteRecursively()
171+
if (!dir.deleteRecursively()) return false
172+
// The set is gone, so the marker has nothing left to refuse; leaving it would refuse
173+
// whatever the next retain puts here.
174+
invalidMarker().delete()
175+
return true
157176
}
158177

178+
/**
179+
* The marker that makes [load] refuse a retained set [purge] could not invalidate in place.
180+
* It sits beside [dir] rather than inside it, because the case it exists for is [dir] being
181+
* unwritable.
182+
*/
183+
private fun invalidMarker(): File = File(dir.parentFile, dir.name + ".invalid")
184+
159185
/**
160186
* One payload part of the retained set.
161187
*

‎quickbuild/core/src/test/java/org/appdevforall/cotg/quickbuild/service/deploy/RetainedPayloadStoreTest.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ class RetainedPayloadStoreTest {
137137
assertThat(store.load()).isNull()
138138
}
139139

140+
@Test
141+
fun `a set that can be neither emptied nor deleted is refused rather than re-sent`() {
142+
store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""")
143+
assumeTrue(blockMetadataWrite(), "the filesystem ignored the permission change")
144+
assumeTrue(blockDeletion(), "the filesystem ignored the permission change")
145+
146+
store.clear()
147+
148+
// Both halves of the in-place invalidation are refused here, so nothing inside the
149+
// retention directory changed at all. Reading the untouched metadata back would
150+
// re-send generation 1 onto a baseline the session has already moved past.
151+
assertThat(store.load()).isNull()
152+
}
153+
140154
/**
141155
* Makes the retention directory's entries impossible to unlink while leaving the entries
142156
* themselves writable - the shape a failed `deleteRecursively` takes, since removing a name
@@ -151,9 +165,23 @@ class RetainedPayloadStoreTest {
151165
return dir.setWritable(false) && !dir.canWrite()
152166
}
153167

168+
/**
169+
* Makes the metadata impossible to rewrite - the other half of the invalidation, which fails
170+
* independently of [blockDeletion] because it turns on the file's own mode rather than the
171+
* directory's.
172+
*
173+
* @return false when the platform ignored the permission change, in which case the caller
174+
* must skip
175+
*/
176+
private fun blockMetadataWrite(): Boolean {
177+
val meta = File(File(workDir, "last-deployed"), "meta.json")
178+
return meta.setWritable(false) && !meta.canWrite()
179+
}
180+
154181
@AfterEach
155182
fun restoreRetentionDirPermissions() {
156183
// @TempDir cleanup fails on a directory it cannot empty.
157184
File(workDir, "last-deployed").setWritable(true)
185+
File(File(workDir, "last-deployed"), "meta.json").setWritable(true)
158186
}
159187
}

0 commit comments

Comments
 (0)