Skip to content

Commit ecbe06d

Browse files
jigar-fatavism
andauthored
Notification: gate changes. (#9087)
Co-authored-by: atavism <atavism@users.noreply.github.com>
1 parent c08c124 commit ecbe06d

4 files changed

Lines changed: 106 additions & 14 deletions

File tree

‎android/app/src/main/kotlin/org/getlantern/lantern/notification/NotificationManager.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,16 @@ class NotificationHelper {
159159
/**
160160
* Shows the starting VPN notification as a foreground notification.
161161
* Also starts the service in the foreground and promotes it to a foreground service.
162+
*
163+
* @return true if this call promoted the service; false if it was already in the foreground.
162164
*/
163165
@Synchronized
164-
fun showStartingVPNConnectedNotification(vpnService: LanternVpnService) {
166+
fun showStartingVPNConnectedNotification(vpnService: LanternVpnService): Boolean {
165167
// Duplicate starts must not replace an existing connected notification.
166-
if (foregroundStarted) return
168+
if (foregroundStarted) return false
167169
showForegroundNotification(vpnService, VPN_CONNECTED, buildStartingVpnNotification())
168170
foregroundStarted = true
171+
return true
169172
}
170173

171174
/**

‎android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,15 +349,27 @@ class LanternVpnService :
349349
) = withContext(Dispatchers.IO) {
350350
// A duplicate may also arrive via startForegroundService, so promote the
351351
// service before deciding which request owns startup.
352+
var promotedHere = false
352353
val foregroundFailure = try {
353-
notificationHelper.showStartingVPNConnectedNotification(this@LanternVpnService)
354+
promotedHere = notificationHelper.showStartingVPNConnectedNotification(this@LanternVpnService)
354355
null
355356
} catch (e: CancellationException) {
356357
throw e
357358
} catch (e: Exception) {
358359
e
359360
}
360-
val accepted = vpnStartGate.run(waitForIdle = restart) { attempt ->
361+
val accepted = vpnStartGate.run(
362+
waitForIdle = restart,
363+
onRejected = { rejection ->
364+
AppLogger.i(TAG, "VPN operation ($errorCode) ignored: $rejection")
365+
// Another start owns the notification; a stop leaves nobody to clear
366+
// a promotion made here (e.g. a fresh instance during teardown).
367+
if (rejection == VpnStartGate.Rejection.STOPPING && promotedHere) {
368+
notificationHelper.stopVPNConnectedNotification(this@LanternVpnService)
369+
stopSelf()
370+
}
371+
},
372+
) { attempt ->
361373
try {
362374
if (prepare(this@LanternVpnService) != null) {
363375
attempt.publish { VpnStatusManager.postVPNStatus(VPNStatus.MissingPermission) }
@@ -410,9 +422,6 @@ class LanternVpnService :
410422
if (e is CancellationException) throw e
411423
}
412424
}
413-
if (!accepted) {
414-
AppLogger.i(TAG, "VPN operation ($errorCode) ignored: another start or stop is in progress")
415-
}
416425
accepted
417426
}
418427

‎android/app/src/main/kotlin/org/getlantern/lantern/service/VpnStartGate.kt‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,39 @@ internal class VpnStartGate(
2626
private var pendingStops = 0
2727
private var stopGeneration = 0L
2828

29-
/** Ordinary starts are rejected while busy; a restart may wait for the current attempt. */
29+
/** Why [run] declined a start. Decided atomically with the gate state. */
30+
enum class Rejection {
31+
/** Another start owns the gate and will publish its own result. */
32+
START_IN_PROGRESS,
33+
/** A stop is running or completed while this start waited; nothing owns startup. */
34+
STOPPING,
35+
}
36+
37+
/**
38+
* Ordinary starts are rejected while busy; a restart may wait for the current attempt.
39+
* [onRejected] runs, outside the lock, with the reason when the start is declined.
40+
*/
3041
suspend fun run(
3142
waitForIdle: Boolean = false,
43+
onRejected: (Rejection) -> Unit = {},
3244
block: suspend (Attempt) -> Unit,
3345
): Boolean = coroutineScope {
3446
val job = coroutineContext.job
3547
val generation = synchronized(stateLock) {
36-
if (pendingStops != 0) return@coroutineScope false
37-
stopGeneration
38-
}
48+
if (pendingStops != 0) null else stopGeneration
49+
} ?: return@coroutineScope reject(Rejection.STOPPING, onRejected)
3950
if (waitForIdle) operationMutex.lock()
40-
synchronized(stateLock) {
51+
val rejection = synchronized(stateLock) {
4152
// A restart queued before Stop must not reconnect after teardown.
4253
if (pendingStops != 0 || generation != stopGeneration) {
4354
if (waitForIdle) operationMutex.unlock()
44-
return@coroutineScope false
55+
return@synchronized Rejection.STOPPING
4556
}
46-
if (!waitForIdle && !operationMutex.tryLock()) return@coroutineScope false
57+
if (!waitForIdle && !operationMutex.tryLock()) return@synchronized Rejection.START_IN_PROGRESS
4758
activeStart = job
59+
null
4860
}
61+
if (rejection != null) return@coroutineScope reject(rejection, onRejected)
4962
try {
5063
job.ensureActive()
5164
block(Attempt(job))
@@ -58,6 +71,11 @@ internal class VpnStartGate(
5871
}
5972
}
6073

74+
private fun reject(rejection: Rejection, onRejected: (Rejection) -> Unit): Boolean {
75+
onRejected(rejection)
76+
return false
77+
}
78+
6179
suspend fun stop(
6280
onStopping: () -> Unit = {},
6381
block: suspend () -> Unit,

‎android/app/src/test/kotlin/org/getlantern/lantern/service/VpnStartGateTest.kt‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,68 @@ class VpnStartGateTest {
434434
assertTrue(gate.run {})
435435
}
436436

437+
@Test(timeout = 10_000)
438+
fun duplicateRejectedByInFlightStartReportsStartInProgress() = runBlocking {
439+
val gate = VpnStartGate()
440+
val prepared = CompletableDeferred<Unit>()
441+
val finish = CompletableDeferred<Unit>()
442+
val rejections = mutableListOf<VpnStartGate.Rejection>()
443+
val first = launch {
444+
gate.run {
445+
prepared.complete(Unit)
446+
finish.await()
447+
}
448+
}
449+
prepared.await()
450+
451+
assertFalse(gate.run(onRejected = { rejections.add(it) }) { error("duplicate must not run") })
452+
assertEquals(listOf(VpnStartGate.Rejection.START_IN_PROGRESS), rejections)
453+
454+
finish.complete(Unit)
455+
first.join()
456+
assertTrue(gate.run(onRejected = { error("accepted start must not report a rejection") }) {})
457+
}
458+
459+
@Test(timeout = 10_000)
460+
fun startsRejectedByStopReportStopping() = runBlocking {
461+
val gate = VpnStartGate()
462+
val entered = CompletableDeferred<Unit>()
463+
val finish = CompletableDeferred<Unit>()
464+
val rejections = mutableListOf<VpnStartGate.Rejection>()
465+
val stop = launch {
466+
gate.stop {
467+
entered.complete(Unit)
468+
finish.await()
469+
}
470+
}
471+
entered.await()
472+
473+
// Rejected up front: a stop is already pending.
474+
assertFalse(gate.run(onRejected = { rejections.add(it) }) { error("stop is running") })
475+
finish.complete(Unit)
476+
stop.join()
477+
478+
// Rejected after waiting: a restart queued before Stop finished must not reconnect.
479+
val prepared = CompletableDeferred<Unit>()
480+
val busy = launch {
481+
gate.run {
482+
prepared.complete(Unit)
483+
CompletableDeferred<Unit>().await()
484+
}
485+
}
486+
prepared.await()
487+
val restart = async(start = CoroutineStart.UNDISPATCHED) {
488+
gate.run(waitForIdle = true, onRejected = { rejections.add(it) }) { error("restart must not undo Stop") }
489+
}
490+
val secondStop = launch { gate.stop {} }
491+
busy.join()
492+
assertFalse(restart.await())
493+
secondStop.join()
494+
495+
assertEquals(listOf(VpnStartGate.Rejection.STOPPING, VpnStartGate.Rejection.STOPPING), rejections)
496+
assertTrue(gate.run {})
497+
}
498+
437499
@Test(timeout = 10_000)
438500
fun cancellingStopDoesNotAbandonTeardown() = runBlocking {
439501
val gate = VpnStartGate()

0 commit comments

Comments
 (0)