Skip to content

Commit 34a51fc

Browse files
committed
fix(reports): finish a submission even when its caller is cancelled
Lookup submits from a composition scope, so leaving the screen cancels the submission. The send wraps a blocking OkHttp call, which runs to the end, and the cancellation then threw away its 200: the report stayed in the outbox and went out again two minutes later. The deployed Worker has no id dedup, so it stores that twice. Cancelled just after the claim, a report was marked sent for a day with nothing queued. CommunityReportSubmitter.submit() now runs claim, enqueue, send and dequeue under NonCancellable. A send torn down by its own transport with no queued copy gives the claim back, since nothing will deliver it. The report client gains a 60 s call timeout, so the direct attempt, now uncancellable, always ends inside the outbox's two-minute hold. The CHANGELOG bullets that described the Worker as deployed with its duplicate marker and id support now say the id is used by the updated Worker, which isn't deployed yet. Tests: CommunityReportSubmitterTest gains a report delivered after its caller was cancelled (dequeued), a claim whose caller was cancelled (still queued and sent), a torn-down send with no queue (claim released), and the call timeout staying under the outbox hold. Three mutants are each killed by name.
1 parent ff916c8 commit 34a51fc

5 files changed

Lines changed: 147 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,14 @@ All notable changes to CallShield will be documented in this file.
165165
as a robocall counts as one vote a day, not two. A report the server turns
166166
down, or one the outbox gives up on, can be made again the same day. Lookup
167167
now says when a report was queued or already made today instead of always
168-
saying it was sent. Each report carries an id, so a resend can be told apart
169-
from a second report.
168+
saying it was sent. Leaving the screen mid-send doesn't send a report twice
169+
either, since one that got through takes its queued copy back out. Each
170+
report now carries an id, which the updated Worker below uses to tell a
171+
resend from a second report.
170172
- The app treats the Worker's "already submitted" answer as delivered only when
171-
the Worker says it stored the report. The deployed Worker writes that marker
172-
before storing, so its bare answer can mean the report was lost, and the app
173-
now tries again later instead of calling it sent.
173+
the Worker says it stored the report. A bare "already submitted" from an
174+
older Worker can mean the report was never stored, so the app now tries again
175+
later instead of calling it sent.
174176
- The app's own unit tests no longer send reports. One of them had posted two
175177
fictional numbers, +1 555-123-4567 and +1 555-987-6543, to the report service
176178
on every run since July, and each landed in this repository's report queue.

‎app/src/main/java/com/sysadmindoc/callshield/data/CommunityContributor.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,20 @@ object CommunityContributor {
4747
private val reportDomainPattern = Regex("^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$")
4848
private val urlIndicatorPattern = Regex("^[a-z_]{3,40}$")
4949

50-
private val client =
50+
// The call timeout bounds the whole attempt (DNS, every route tried, the
51+
// write, OkHttp's own retry), so it always ends before the outbox's queued
52+
// copy is due to go out, and a submission that can't be cancelled can't
53+
// hang either.
54+
internal val client =
5155
HttpClient.shared
5256
.newBuilder()
5357
.connectTimeout(10, TimeUnit.SECONDS)
5458
.readTimeout(10, TimeUnit.SECONDS)
59+
.callTimeout(REPORT_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
5560
.build()
5661

62+
internal const val REPORT_CALL_TIMEOUT_SECONDS = 60L
63+
5764
/**
5865
* Typed outcome so the UI can localize and color-code without sniffing
5966
* English substrings out of [ContributeResult.message] (which is

‎app/src/main/java/com/sysadmindoc/callshield/data/CommunityReportSubmitter.kt‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,17 @@ internal data class CommunityReport(
2727
*
2828
* The claim comes first, so a second tap while the first is in flight is
2929
* suppressed too. The report goes into the outbox before the direct attempt:
30-
* if the caller is cancelled or the process dies mid-send, the queued copy
31-
* still goes out, carrying the same id. A delivered report, or one refused
32-
* for good, takes its queued copy back out, and a refusal also releases the
33-
* claim so the user isn't told it was already sent.
30+
* if the process dies mid-send, the queued copy still goes out, carrying the
31+
* same id. A delivered report, or one refused for good, takes its queued copy
32+
* back out, and a refusal also releases the claim so the user isn't told it
33+
* was already sent.
34+
*
35+
* Once started, a submission runs to the end even if its caller is cancelled,
36+
* the way a user leaving Lookup mid-send cancels it. Cancelled after the send,
37+
* a delivered report would keep its queued copy, and the outbox would send it
38+
* again, which a Worker without id dedup stores twice. Cancelled after the
39+
* claim, the report would be marked sent for a day with nothing queued. Every
40+
* step is bounded, the send by the report client's call timeout.
3441
*/
3542
internal class CommunityReportSubmitter(
3643
private val claim: suspend (number: String, vote: String, now: Long) -> Boolean,
@@ -41,11 +48,17 @@ internal class CommunityReportSubmitter(
4148
private val clock: () -> Long = System::currentTimeMillis,
4249
private val newId: () -> String = { UUID.randomUUID().toString() },
4350
) {
44-
@Suppress("TooGenericExceptionCaught")
4551
suspend fun submit(
4652
number: String,
4753
type: String,
4854
indicators: SmsReportIndicators?,
55+
): ContributeResult = withContext(NonCancellable) { submitToTheEnd(number, type, indicators) }
56+
57+
@Suppress("TooGenericExceptionCaught")
58+
private suspend fun submitToTheEnd(
59+
number: String,
60+
type: String,
61+
indicators: SmsReportIndicators?,
4962
): ContributeResult {
5063
val normalized =
5164
CommunityContributor.normalizeForReport(number)
@@ -61,26 +74,29 @@ internal class CommunityReportSubmitter(
6174
try {
6275
enqueue(report)
6376
true
64-
} catch (e: CancellationException) {
65-
withContext(NonCancellable) { release(normalized, vote) }
66-
throw e
6777
} catch (_: Exception) {
6878
// WorkManager couldn't take it. The direct attempt below still
6979
// runs; it just has no safety net.
7080
false
7181
}
72-
val result = transport(report)
82+
val result =
83+
try {
84+
transport(report)
85+
} catch (e: CancellationException) {
86+
// The send itself was torn down. The queued copy goes out
87+
// later with the same id, so the claim stands.
88+
if (!queued) release(normalized, vote)
89+
throw e
90+
}
7391
return when {
7492
result.success -> {
75-
withContext(NonCancellable) { if (queued) dequeue(report) }
93+
if (queued) dequeue(report)
7694
result
7795
}
7896

7997
!result.outcome.isTransient || !queued -> {
80-
withContext(NonCancellable) {
81-
if (queued) dequeue(report)
82-
release(normalized, vote)
83-
}
98+
if (queued) dequeue(report)
99+
release(normalized, vote)
84100
result
85101
}
86102

‎app/src/main/java/com/sysadmindoc/callshield/service/CommunityReportWorker.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class CommunityReportWorker internal constructor(
7575
private const val KEY_SMS_URL_INDICATORS = "sms_url_indicators"
7676
private const val BACKOFF_MINUTES = 5L
7777

78-
/** Longer than the direct attempt's connect and read timeouts together. */
78+
/** Longer than the direct attempt's whole-call timeout, so the two never overlap. */
7979
internal const val QUEUED_DELAY_MINUTES = 2L
8080

8181
/** Attempts that ran with a network; waiting offline doesn't use them up. */

‎app/src/test/java/com/sysadmindoc/callshield/data/CommunityReportSubmitterTest.kt‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
55
import com.sysadmindoc.callshield.data.CommunityContributor.ContributeOutcome
66
import com.sysadmindoc.callshield.data.CommunityContributor.ContributeResult
77
import com.sysadmindoc.callshield.data.repository.SettingsRepository
8+
import com.sysadmindoc.callshield.service.CommunityReportWorker
89
import kotlinx.coroutines.CancellationException
10+
import kotlinx.coroutines.CompletableDeferred
911
import kotlinx.coroutines.CoroutineScope
1012
import kotlinx.coroutines.Dispatchers
1113
import kotlinx.coroutines.SupervisorJob
1214
import kotlinx.coroutines.cancelAndJoin
15+
import kotlinx.coroutines.launch
1316
import kotlinx.coroutines.runBlocking
1417
import org.junit.After
1518
import org.junit.Assert.assertEquals
@@ -185,6 +188,104 @@ class CommunityReportSubmitterTest {
185188
assertEquals(ContributeOutcome.ALREADY_SUBMITTED, store.submit("+12122340101").outcome)
186189
}
187190

191+
@Test
192+
fun `a report delivered after its caller was cancelled still takes its queued copy back out`() {
193+
// Leaving Lookup mid-send used to throw the Worker's 200 away, and the
194+
// outbox then sent the report again two minutes later.
195+
val store = store()
196+
val sending = CompletableDeferred<Unit>()
197+
val answered = CompletableDeferred<Unit>()
198+
val submitter =
199+
gatedSubmitter(store, transport = { report ->
200+
sent += report
201+
sending.complete(Unit)
202+
answered.await()
203+
ContributeResult(true, "stored", ContributeOutcome.REPORTED_SPAM)
204+
})
205+
206+
runBlocking {
207+
val job = launch(Dispatchers.Default) { submitter.submit("+12122340101", "spam", null) }
208+
sending.await()
209+
job.cancel()
210+
answered.complete(Unit)
211+
job.join()
212+
}
213+
214+
assertEquals(listOf("report-1"), queued.map { it.id })
215+
assertEquals(listOf("report-1"), dequeued.map { it.id })
216+
}
217+
218+
@Test
219+
fun `a claim is never left without its queued copy when the caller is cancelled`() {
220+
val store = store()
221+
val claimed = CompletableDeferred<Unit>()
222+
val resume = CompletableDeferred<Unit>()
223+
val submitter =
224+
gatedSubmitter(store, claim = { number, vote, time ->
225+
store.settings.claimCommunityReport(number, vote, time).also {
226+
claimed.complete(Unit)
227+
resume.await()
228+
}
229+
})
230+
231+
runBlocking {
232+
val job = launch(Dispatchers.Default) { submitter.submit("+12122340101", "spam", null) }
233+
claimed.await()
234+
job.cancel()
235+
resume.complete(Unit)
236+
job.join()
237+
}
238+
239+
assertEquals(listOf("report-1"), queued.map { it.id })
240+
assertEquals(listOf("report-1"), sent.map { it.id })
241+
}
242+
243+
@Test
244+
fun `a send torn down with no queued copy gives the claim back`() {
245+
val store = store()
246+
cancelSend = true
247+
val submitter = gatedSubmitter(store, enqueue = { error("WorkManager is unavailable") })
248+
249+
runBlocking {
250+
try {
251+
submitter.submit("+12122340101", "spam", null)
252+
fail("the cancellation should reach the caller")
253+
} catch (_: CancellationException) {
254+
// Nothing will deliver it now, so it mustn't read as sent.
255+
}
256+
}
257+
258+
cancelSend = false
259+
assertEquals(ContributeOutcome.REPORTED_SPAM, store.submit("+12122340101").outcome)
260+
}
261+
262+
@Test
263+
fun `the direct attempt always ends before the queued copy is due`() {
264+
val callTimeout = TimeUnit.SECONDS.toMillis(CommunityContributor.REPORT_CALL_TIMEOUT_SECONDS)
265+
266+
assertEquals(callTimeout.toInt(), CommunityContributor.client.callTimeoutMillis)
267+
assertTrue(callTimeout < TimeUnit.MINUTES.toMillis(CommunityReportWorker.QUEUED_DELAY_MINUTES))
268+
}
269+
270+
private fun gatedSubmitter(
271+
store: Store,
272+
claim: suspend (String, String, Long) -> Boolean = store.settings::claimCommunityReport,
273+
enqueue: suspend (CommunityReport) -> Unit = { report -> queued += report },
274+
transport: suspend (CommunityReport) -> ContributeResult = { report ->
275+
sent += report
276+
if (cancelSend) throw CancellationException("the send was torn down")
277+
ContributeResult(true, "stored", ContributeOutcome.REPORTED_SPAM)
278+
},
279+
) = CommunityReportSubmitter(
280+
claim = claim,
281+
release = store.settings::releaseCommunityReport,
282+
transport = transport,
283+
enqueue = enqueue,
284+
dequeue = { report -> dequeued += report },
285+
clock = { now },
286+
newId = { "report-${++nextId}" },
287+
)
288+
188289
@Test
189290
fun `a refused report leaves nothing queued and can be made again`() {
190291
val store = store()

0 commit comments

Comments
 (0)