Repository navigation
fix: base-branch follow-ups — funding gate height rule, replay wake lock, bind-heal lifetime, flaky tests #1632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
HashEngineering
merged 21 commits into
feat/kotlin-sdk-phase-1
from
fix/phase1-base-followups
Oct 27, 2026
Merged
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
29e683b
fix(sdk): keep the L1 funding gate on the height rule
HashEngineering 4f85b93
fix(sync): release the replay wake lock while the replay cannot progress
HashEngineering 92feae1
fix(sync): gate the bind-heal L1 start on the issuing service lifetime
HashEngineering 96c95a1
test(sync): fix the race-dependent restart and cutover UI tests
HashEngineering ba72d27
docs: drop the obsolete correction request from the issues record
HashEngineering 6138ffd
fix(sync): re-take the replay wake lock when the filter wedge clears
HashEngineering 4637405
fix(sync): seed the NETWORK impediment from connectivity at service s…
HashEngineering 505eb30
refactor(sync): one definition of the replay wake-lock hold rule
HashEngineering 047acc3
fix(sync): an ended lifetime's kick cannot replace or cancel the curr…
HashEngineering da7e80f
fix(sync): seed connectivity after registering the network callback
HashEngineering 0a9b03f
fix(sync): re-take the replay wake lock even when the observer misses…
HashEngineering 3238b3a
fix(sync): give a restarted engine its own filter-stall window
HashEngineering c17f388
fix(sync): default-network impediment, guarded wake-lock rechecks, bi…
HashEngineering 32a837b
fix(sdk): close the L1 funding gate while the engine waits for peers
HashEngineering b55f1ca
fix(sdk): close the L1 funding gate while the wallet cursor is unknown
HashEngineering 5d6926a
fix(sync): ignore capability updates for a network that is not the de…
HashEngineering 712e1a1
fix(sync): serialize the watchdog's event-driven clears with its stal…
HashEngineering 0445d94
fix(sync): let only a network-callback verdict supersede the offline …
HashEngineering a9dd29c
fix(sync): read the replay wake lock's other holds live, not from the…
HashEngineering ddc76e2
Merge feat/kotlin-sdk-phase-1 (#1616) into fix/phase1-base-followups
HashEngineering 5197263
test(sdk): an unknown wallet cursor is refused before a deferred buil…
HashEngineering File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 112 additions & 78 deletions
190
wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt
Large diffs are not rendered by default.
Oops, something went wrong.
142 changes: 142 additions & 0 deletions
142
wallet/src/de/schildbach/wallet/service/NetworkAvailability.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| /* | ||
| * Copyright 2026 Dash Core Group. | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package de.schildbach.wallet.service | ||
|
|
||
| import android.net.ConnectivityManager | ||
| import android.net.NetworkCapabilities | ||
|
|
||
| /** | ||
| * Whether connectivity AS IT STANDS NOW offers a network the blockchain | ||
| * service can sync over: the default network, with internet and validated — | ||
| * the same rule the service's default-network callback applies | ||
| * ([DefaultNetworkTracker]). | ||
| * | ||
| * The service seeds its NETWORK impediment from this once that callback is | ||
| * registered ([ImpedimentGate.seedOffline]). The callback reports nothing | ||
| * while there is no default network (no `onAvailable`, `onLost` or | ||
| * `onCapabilitiesChanged`; `onUnavailable` is for `requestNetwork`), so a | ||
| * service created while already offline used to read as online until some | ||
| * network came and went. Never throws: an unreadable state answers "usable", | ||
| * the old assumption, so this can only ever add an impediment the platform | ||
| * confirmed. | ||
| */ | ||
| fun hasUsableNetworkNow(connectivityManager: ConnectivityManager): Boolean = try { | ||
| val capabilities = connectivityManager.activeNetwork?.let(connectivityManager::getNetworkCapabilities) | ||
| capabilities != null && | ||
| capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && | ||
| capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) | ||
| } catch (t: Throwable) { | ||
| true | ||
| } | ||
|
|
||
| /** | ||
| * The NETWORK impediment from a DEFAULT-network callback | ||
| * (`registerDefaultNetworkCallback`): offline unless the default network — the | ||
| * route the service's own traffic takes — has internet and is validated. | ||
| * | ||
| * Review on #1632 (CodeRabbit, PastaClaw): the callback used to be registered | ||
| * for ANY network with internet + validated. A validated secondary network | ||
| * then cleared the impediment while the default route was unusable, and the | ||
| * replay kept its no-timeout wake lock offline. The reverse also happened: | ||
| * one matching network losing validation set the impediment while another | ||
| * usable one was still up. The default network is one network, so each event | ||
| * about it is the whole answer. | ||
| * | ||
| * Each method returns the verdict: true offline, false online, null when the | ||
| * event decides nothing: a loss of, or a capability update for, a network | ||
| * that is not the tracked default (its successor's `onAvailable` may already | ||
| * have been delivered), or an `onAvailable` whose capabilities could not be | ||
| * read (`onCapabilitiesChanged` follows it). Only `onAvailable` changes which | ||
| * network is the default. A late update for the former default must not take | ||
| * it back (PastaClaw on #1632): its later `onLost` would then match and leave | ||
| * the tracker offline with the successor still up. Called in delivery order, | ||
| * one at a time. | ||
| */ | ||
| internal class DefaultNetworkTracker<N : Any> { | ||
| private var current: N? = null | ||
|
|
||
| fun onAvailable(network: N, usable: Boolean?): Boolean? { | ||
| current = network | ||
| return usable?.not() | ||
| } | ||
|
|
||
| fun onCapabilitiesChanged(network: N, usable: Boolean): Boolean? { | ||
| if (network != current) return null | ||
| return !usable | ||
| } | ||
|
|
||
| fun onLost(network: N): Boolean? { | ||
| if (network != current) return null | ||
| current = null | ||
| return true | ||
| } | ||
|
|
||
| fun clear() { | ||
| current = null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serializes every change to the blockchain service's impediment set, and | ||
| * decides the one-off connectivity seed at service start. | ||
| * | ||
| * The set is a plain `EnumSet` changed from the network callback's coroutines | ||
| * (IO) and the keystore health observer (main); [update] and | ||
| * [networkCallbackUpdate] make each change atomic. | ||
| * | ||
| * The seed: reading connectivity BEFORE registering the callback left a gap — | ||
| * a network lost between the read and the registration was never reported | ||
| * (nothing matched any more), and the service read as online. So the read | ||
| * comes AFTER registration, where it must not overwrite what the callback has | ||
| * already said: every callback update bumps a counter, the token is taken | ||
| * just before registering, and [seedOffline] marks the service offline only if | ||
| * no callback update landed since. A default network that is present after | ||
| * the registration is reported by the callback (`onAvailable`, later | ||
| * `onLost`), so the seed only ever decides the "no default network" case the | ||
| * callback stays silent on. | ||
| */ | ||
| internal class ImpedimentGate { | ||
| private val lock = Any() | ||
| private var networkCallbackUpdates = 0L | ||
|
|
||
| /** A change that is not a network-callback update (the keystore health observer). */ | ||
| fun <T> update(block: () -> T): T = synchronized(lock, block) | ||
|
|
||
| /** A network-callback update; it supersedes the start-up seed. */ | ||
| fun <T> networkCallbackUpdate(block: () -> T): T = synchronized(lock) { | ||
| networkCallbackUpdates++ | ||
| block() | ||
| } | ||
|
|
||
| /** Taken just BEFORE `registerNetworkCallback`. */ | ||
| fun seedToken(): Long = synchronized(lock) { networkCallbackUpdates } | ||
|
|
||
| /** | ||
| * After registration, with [usableNow] read after it too: mark the service | ||
| * offline ([markOffline], under the lock) unless a network is usable or a | ||
| * callback update has landed since [token]. A stale "not usable" read is | ||
| * harmless: a network that appeared before it is reported by the callback, | ||
| * and its update either landed first (the seed is skipped) or lands after | ||
| * (and clears the impediment). | ||
| */ | ||
| fun seedOffline(token: Long, usableNow: Boolean, markOffline: () -> Unit): Boolean = synchronized(lock) { | ||
| if (usableNow || networkCallbackUpdates != token) return false | ||
| markOffline() | ||
| true | ||
| } | ||
| } | ||
167 changes: 167 additions & 0 deletions
167
wallet/src/de/schildbach/wallet/service/ReplayWakeLock.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| /* | ||
| * Copyright 2026 Dash Core Group. | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package de.schildbach.wallet.service | ||
|
|
||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.filter | ||
| import kotlinx.coroutines.launch | ||
| import org.slf4j.LoggerFactory | ||
|
|
||
| /** The slice of `PowerManager.WakeLock` the replay hold uses — a seam for host tests. */ | ||
| interface ReplayWakeLockHandle { | ||
| val isHeld: Boolean | ||
| fun acquire() | ||
| fun release() | ||
| } | ||
|
|
||
| /** | ||
| * The SDK replay's own acquisition of the blockchain service's partial wake | ||
| * lock (no timeout). | ||
| * | ||
| * On the SDK path no peergroup start acquires the lock, so a replay that the | ||
| * idle rule keeps alive could still be dozed. The lock is held for the span | ||
| * the service is kept alive for, minus the stretches the replay cannot | ||
| * progress ([replayHoldsWakeLock]: offline, or wedged with the filter-stall | ||
| * watchdog's restarts spent). | ||
| * | ||
| * Who decides: everyone, the same way. The minute tick ([tick]) and every | ||
| * event that can change the answer ([recheck]: an impediment change, the | ||
| * filter wedge clearing via [observeWedgeClears]) read the decision NOW and | ||
| * acquire or release to match, under one monitor. ACTION_TIME_TICK is not | ||
| * delivered while the CPU sleeps, so a replay that can move again must not | ||
| * wait for the tick to get the lock back. | ||
| * | ||
| * The read and the change happen under the same monitor, and so does each | ||
| * later recheck. A recheck that read "due" just before a concurrent network | ||
| * loss can still acquire. But the loss's own recheck then runs after it, reads | ||
| * "not due", and releases. (An acquire-only re-check used to leave that lock | ||
| * held until the next tick, so the replay held the CPU while offline.) | ||
| * | ||
| * The tick also knows holds the events cannot see (an engine start, a durable | ||
| * save in flight). It records them, and a recheck honours the last recorded | ||
| * value. A stale one only keeps the lock, and a held lock keeps the CPU awake, | ||
| * so the next tick comes and corrects it. | ||
| * | ||
| * Tracked apart from `isHeld` because the lock is reference-counted and shared | ||
| * with the dashj path ([releaseForTeardown] releases only this owner's count). | ||
| * | ||
| * @param lock the service's wake lock; null before onCreate assigns it. | ||
| * @param sdkPath whether the SDK owns L1 (the dashj path manages its own hold). | ||
| * @param replayDue the replay half of the hold decision, read now. | ||
| */ | ||
| internal class ReplayWakeLock( | ||
| private val lock: () -> ReplayWakeLockHandle?, | ||
| private val sdkPath: () -> Boolean, | ||
| private val replayDue: () -> Boolean | ||
| ) { | ||
| private val log = LoggerFactory.getLogger(ReplayWakeLock::class.java) | ||
| private val guard = Any() | ||
|
|
||
| @Volatile | ||
| var held: Boolean = false | ||
| private set | ||
|
|
||
| /** | ||
| * Set first thing in onDestroy: from then on the lock is only ever released. | ||
| * A network-callback coroutine launched just before the callback was | ||
| * unregistered can still run, and its re-acquire landing after the | ||
| * teardown's release would hold a no-timeout lock until the process died. | ||
| */ | ||
| @Volatile | ||
| private var retired = false | ||
|
|
||
| /** The tick's holds the events cannot read; written and read under [guard]. */ | ||
| private var tickHolds = false | ||
|
|
||
| /** | ||
| * The minute tick. [otherHolds]: an engine start or a durable save needs | ||
| * the CPU (the replay's own part is read by [recheck]). | ||
| */ | ||
| fun tick(otherHolds: Boolean) { | ||
| synchronized(guard) { tickHolds = otherHolds } | ||
| recheck() | ||
| } | ||
|
|
||
| /** Hold the lock iff the replay (or the tick's last other holds) needs the CPU now. */ | ||
| fun recheck() { | ||
| if (!sdkPath()) return | ||
| val lock = lock() ?: return | ||
| synchronized(guard) { | ||
| try { | ||
| val due = tickHolds || replayDue() | ||
| if (due && !held && !retired) { | ||
| log.info("replay or engine start in progress on the SDK path — acquiring the wake lock") | ||
| lock.acquire() | ||
| held = true | ||
| } else if (!due && held) { | ||
| log.info("replay / engine start complete or unable to progress — releasing the wake lock") | ||
| held = false | ||
| if (lock.isHeld) lock.release() | ||
| } | ||
| } catch (t: Throwable) { | ||
| log.warn("wake lock hold/release for the replay failed", t) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Re-check every time the filter wedge clears: the cursor moved, wallet | ||
| * events resumed, the scan caught up, or the engine stopped. | ||
| * | ||
| * [clears] COUNTS the clears rather than mirroring the flag. A collector | ||
| * that is slow to run can miss intermediate values of a StateFlow, and on a | ||
| * Boolean that loses events: clear → re-wedge → clear arrives as the | ||
| * "not wedged" it already saw, while the minute tick released the lock | ||
| * during the re-wedge — and no re-check came. A count it has not seen | ||
| * always means a clear happened, so it re-checks. The baseline is read when | ||
| * this is CALLED, not when the collector first runs, so a clear in between | ||
| * is not taken for the starting value. [recheck] reads the current | ||
| * state, so a clear that was followed by a new wedge acquires nothing. | ||
| */ | ||
| fun observeWedgeClears(scope: CoroutineScope, clears: StateFlow<Long>): Job { | ||
| val baseline = clears.value | ||
| return scope.launch { | ||
| clears.filter { it != baseline } | ||
| .collect { | ||
| log.info("the filter wedge cleared — re-checking the replay wake lock") | ||
| recheck() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** onDestroy, first thing: no acquisition after this. */ | ||
| fun retire() { | ||
| retired = true | ||
| } | ||
|
|
||
| /** onDestroy, cleanup: release this owner's count, if any. */ | ||
| fun releaseForTeardown() { | ||
| val lock = lock() ?: return | ||
| synchronized(guard) { | ||
| if (held) { | ||
| held = false | ||
| if (lock.isHeld) { | ||
| log.debug("releasing the replay's wake-lock acquisition") | ||
| lock.release() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.