Skip to content

Commit 0f6ab44

Browse files
fryanpanclaude
andcommitted
ADFA-4128: invalidate the retained payload when its directory cannot be deleted
retain() and clear() discarded deleteRecursively's result, so a partial delete could leave a readable meta.json behind - contradicting this store's own "replaced wholesale by every retain" contract. Both now go through a private purge() that empties meta.json before the recursive delete and returns the delete's result. The order is the load-bearing part: unlinking a name needs write permission on the directory, rewriting a file's bytes needs it only on the file, so a truncated meta.json still fails load() closed in the case where the delete cannot proceed at all. load() keys off the metadata alone, so once that is unreadable the rest of the tree is inert. retain() checks the result into its existing catch; clear() warns when parts survive. Not propagated to callers: both are post-deploy bookkeeping on the success path, with the payload already live in the app. Failing a build for a retention housekeeping error would turn a cheaper-recovery optimisation into a build failure, and the documented fallback - the catch-up rebuild - still happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XkGof8cLt23LkxZ8MKzin2
1 parent 89bab5a commit 0f6ab44

2 files changed

Lines changed: 76 additions & 3 deletions

File tree

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ internal class RetainedPayloadStore(
5252
* confirmed the payload, so what is retained is always something known to have run.
5353
*
5454
* The swap goes through a staging dir: a crash at any point leaves either the previous
55-
* set, or nothing - never a half-written mix that [load] could hand to a re-send.
55+
* set, or nothing - never a half-written mix that [load] could hand to a re-send. A
56+
* 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.
5658
*
5759
* @param generation the generation the confirmed deploy claimed
5860
* @param dexFile the deployed classes, or null when the build moved no code
@@ -84,7 +86,7 @@ internal class RetainedPayloadStore(
8486
addProperty("hasAssets", assetsZip != null)
8587
}.toString(),
8688
)
87-
dir.deleteRecursively()
89+
check(purge()) { "could not clear ${dir.absolutePath}" }
8890
check(staging.renameTo(dir)) { "could not move staging into ${dir.absolutePath}" }
8991
} catch (e: Exception) {
9092
staging.deleteRecursively()
@@ -126,10 +128,34 @@ internal class RetainedPayloadStore(
126128
* generation supersedes the retained one but must never be replayed as a hot swap.
127129
*/
128130
fun clear() {
129-
dir.deleteRecursively()
131+
if (!purge()) {
132+
log.warn("Could not fully drop the retained payload under {}; it is invalidated but its parts remain", dir)
133+
}
130134
stagingDir().deleteRecursively()
131135
}
132136

137+
/**
138+
* Removes the retained set, invalidating it before its parts go so a failed delete cannot
139+
* leave a readable [META_NAME] behind. [File.deleteRecursively] reports failure by
140+
* returning false and may already have deleted part of the tree, so its result is what
141+
* decides whether the set is gone.
142+
*
143+
* The metadata is emptied rather than unlinked first: unlinking needs write permission on
144+
* [dir] and writing needs it only on the file, so an emptied [META_NAME] still fails [load]
145+
* closed in the case where the recursive delete cannot finish at all. [load] keys off the
146+
* metadata alone, so once it is unreadable the rest of the tree is inert.
147+
*
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
150+
*/
151+
private fun purge(): Boolean {
152+
val meta = File(dir, META_NAME)
153+
if (meta.isFile) {
154+
runCatching { meta.writeText("") }
155+
}
156+
return dir.deleteRecursively()
157+
}
158+
133159
/**
134160
* One payload part of the retained set.
135161
*

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package org.appdevforall.cotg.quickbuild.service.deploy
22

33
import com.google.common.truth.Truth.assertThat
4+
import org.junit.jupiter.api.AfterEach
5+
import org.junit.jupiter.api.Assumptions.assumeTrue
46
import org.junit.jupiter.api.Test
57
import org.junit.jupiter.api.io.TempDir
68
import java.io.File
@@ -109,4 +111,49 @@ class RetainedPayloadStoreTest {
109111

110112
assertThat(store.load()).isNull()
111113
}
114+
115+
@Test
116+
fun `a retain that cannot delete the old set leaves nothing loadable`() {
117+
store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""")
118+
assumeTrue(blockDeletion(), "the filesystem ignored the permission change")
119+
120+
store.retain(2L, artifact("next.dex", "new-dex"), null, null, """{"gen":2}""")
121+
122+
// Generation 1's bytes must not outlive a failed generation-2 swap: a re-send would
123+
// replay them at a generation the session no longer deploys, leaving the app behind
124+
// with nothing left to notice it.
125+
assertThat(store.load()).isNull()
126+
}
127+
128+
@Test
129+
fun `a clear that cannot delete the set still makes it unloadable`() {
130+
store.retain(1L, artifact("built.dex", "old-dex"), null, null, """{"gen":1}""")
131+
assumeTrue(blockDeletion(), "the filesystem ignored the permission change")
132+
133+
store.clear()
134+
135+
// clear() runs when the baseline changed or a restart deploy landed; either way the
136+
// old bytes must never come back as a hot swap.
137+
assertThat(store.load()).isNull()
138+
}
139+
140+
/**
141+
* Makes the retention directory's entries impossible to unlink while leaving the entries
142+
* themselves writable - the shape a failed `deleteRecursively` takes, since removing a name
143+
* needs write permission on the directory and rewriting a file's bytes needs it only on the
144+
* file.
145+
*
146+
* @return false when the platform ignored the permission change (a root test runner, or a
147+
* filesystem without POSIX permissions), in which case the caller must skip
148+
*/
149+
private fun blockDeletion(): Boolean {
150+
val dir = File(workDir, "last-deployed")
151+
return dir.setWritable(false) && !dir.canWrite()
152+
}
153+
154+
@AfterEach
155+
fun restoreRetentionDirPermissions() {
156+
// @TempDir cleanup fails on a directory it cannot empty.
157+
File(workDir, "last-deployed").setWritable(true)
158+
}
112159
}

0 commit comments

Comments
 (0)