Skip to content

Commit 8c22be3

Browse files
committed
Upgrade: Invalidate a dead billing connection from refreshStrict too
refreshStrict() throws its incomplete-result error AFTER useConnection already returned: refreshPurchases hands back a partial result instead of throwing, so useConnection's dead-binder detection never sees it. A gate that ran against a connection whose binder died mid-query (partialError cause chain carrying SERVICE_DISCONNECTED / SERVICE_TIMEOUT) left that connection installed, and every later purchase check kept talking to the corpse until something else tore it down. processReconciliation() already compensated for the refresh()/connect-loop paths; the strict path had no such call. Extract that dead-connection block into invalidateOnDeadConnection() and call it from both. The strict path deliberately does NOT feed the episode clock — a gate the user aborted mid-purchase is not a reconciliation outcome. Fixes review finding F1.
1 parent 7ea74b4 commit 8c22be3

2 files changed

Lines changed: 58 additions & 11 deletions

File tree

app/src/gplay/java/eu/darken/amply/upgrade/core/billing/BillingManager.kt

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -432,17 +432,12 @@ open class BillingManager @Inject constructor(
432432
}
433433
}
434434

435-
// Everything a COMPLETED refresh owes the rest of the app, in one place: both the connect loop's
436-
// initial refresh and manual refresh() calls run through it, so a Restore tap during an outage
437-
// feeds the same bookkeeping the connect loop does. Only reached when refreshPurchases returned
438-
// (it still throws when it found nothing AND a query failed — that path is the connect loop's /
439-
// useConnection's).
440-
private fun processReconciliation(refresh: BillingConnection.PurchaseRefresh) {
441-
// A partial refresh no longer reaches useConnection's dead-binder detection (it returns
442-
// instead of throwing), so the teardown that used to ride the throw path happens here. Cause
443-
// chain, not the exception itself: the failure arrives user-friendly-mapped. Deliberately no
444-
// holder CAS: the failing connection may already have been replaced, and the accepted cost of
445-
// that rare race is one extra failed action while the loop reconnects.
435+
// A partial refresh no longer reaches useConnection's dead-binder detection (it returns instead
436+
// of throwing), so the teardown that used to ride the throw path happens here. Cause chain, not
437+
// the exception itself: the failure arrives user-friendly-mapped. Deliberately no holder CAS:
438+
// the failing connection may already have been replaced, and the accepted cost of that rare race
439+
// is one extra failed action while the loop reconnects.
440+
private fun invalidateOnDeadConnection(refresh: BillingConnection.PurchaseRefresh) {
446441
val clientError = refresh.partialError?.let {
447442
(it as? BillingClientException) ?: (it.cause as? BillingClientException)
448443
}
@@ -452,6 +447,15 @@ open class BillingManager @Inject constructor(
452447
}
453448
invalidations.trySend(Unit)
454449
}
450+
}
451+
452+
// Everything a COMPLETED refresh owes the rest of the app, in one place: both the connect loop's
453+
// initial refresh and manual refresh() calls run through it, so a Restore tap during an outage
454+
// feeds the same bookkeeping the connect loop does. Only reached when refreshPurchases returned
455+
// (it still throws when it found nothing AND a query failed — that path is the connect loop's /
456+
// useConnection's).
457+
private fun processReconciliation(refresh: BillingConnection.PurchaseRefresh) {
458+
invalidateOnDeadConnection(refresh)
455459

456460
if (!refresh.isComplete && !refresh.hasConfirmedProPurchase) {
457461
// A reconciliation that couldn't confirm the upgrade. Stamped with the refresh's COMMIT
@@ -529,6 +533,11 @@ open class BillingManager @Inject constructor(
529533
open suspend fun refreshStrict(): BillingData {
530534
log(TAG) { "refreshStrict()" }
531535
val fresh = useConnection { refreshPurchases() }
536+
// A gate that hit a dying connection must still trigger the reconnect (the throw below
537+
// bypasses useConnection's detection, which already returned), or every later purchase check
538+
// keeps reusing the corpse. No-op on a complete refresh. The episode clock stays out of this
539+
// path: an aborted gate is not a reconciliation outcome.
540+
invalidateOnDeadConnection(fresh)
532541
if (!fresh.isComplete) {
533542
// partialError is set for every incomplete refresh; the fallback only exists so a future
534543
// incompleteness without a captured cause still fails closed instead of passing.

app/src/testGplay/java/eu/darken/amply/upgrade/core/billing/BillingManagerTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,44 @@ class BillingManagerTest {
248248
withTimeout(TIMEOUT_MS) { attempts.receive() }
249249
}
250250

251+
@Test fun `a strict gate failure on a dead connection still tears the connection down`(): Unit = runBlocking {
252+
// The gate's throw happens AFTER useConnection already returned (the refresh hands back a
253+
// partial result instead of throwing), so its dead-binder detection never sees it. Without
254+
// the explicit invalidation the dying connection stays installed and every later purchase
255+
// check keeps talking to the corpse.
256+
val pending = TestPurchases.purchase(iapId, pending = true)
257+
val attempts = Channel<Unit>(Channel.UNLIMITED)
258+
val dying = client(iap = FakeBillingClient.Answer(purchases = listOf(pending)))
259+
val subQueries = AtomicInteger(0)
260+
// The binder dies BETWEEN the two refreshes: the connect loop's initial one is complete (so
261+
// its own reconciliation invalidates nothing), and only the gate's own round-trip loses SUBS.
262+
dying.beforeAnswer = { type ->
263+
if (type == BillingClient.ProductType.SUBS && subQueries.incrementAndGet() == 2) {
264+
dying.answers = dying.answers +
265+
(BillingClient.ProductType.SUBS to failing(BillingResponseCode.SERVICE_DISCONNECTED))
266+
}
267+
}
268+
val manager = connectedManager(dying, client(), attempts = attempts)
269+
270+
// Connection 1 is established and its initial refresh committed.
271+
withTimeout(TIMEOUT_MS) { manager.billingData.first() }
272+
withTimeout(TIMEOUT_MS) { attempts.receive() }
273+
274+
withTimeout(TIMEOUT_MS) {
275+
shouldThrow<GplayServiceUnavailableException> { manager.refreshStrict() }
276+
}
277+
278+
// The dead connection was uninstalled and the loop reconnected, instead of the next caller
279+
// being handed the same corpse.
280+
withTimeout(TIMEOUT_MS) { attempts.receive() }
281+
// That torn-down connection is a failed loop iteration and signals as one…
282+
withTimeout(TIMEOUT_MS) { manager.connectionFailures.first() }
283+
// …but the gate's own partial refresh must never reach the feed — a gate the user aborted
284+
// mid-purchase is not a reconciliation outcome. This refresh confirmed nothing (only a
285+
// payment in progress), so running it through the reconciliation path WOULD have signalled.
286+
withTimeoutOrNull(QUIET_MS) { manager.connectionFailures.first() } shouldBe null
287+
}
288+
251289
// endregion
252290

253291
// region pending payments

0 commit comments

Comments
 (0)