fix: prevent stale module binder deliveries - #902
Conversation
Invalidate asynchronous delivery work when module UIDs disappear or the module cache is reset, and scope death recipients to the Binder they watch. Bound the delivery workers and add regression coverage for duplicate, stale, and reset attempts. Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
|
Thanks for the writeup — the post-send 1. The fixed 4-thread pool globalizes the stall it was meant to bound. The comment kept just above 2. 3. Failures during uid churn are no longer counted. In the worker, 4. The generation check compares against a stale copy. 5. The tests cover the tracker, not the machinery. The four added tests exercise |
Scope cache invalidation to changed module generations, preserve failure throttles, and keep delivery attempts from blocking unrelated modules. Add deterministic coverage for failure churn, binder death identity, scoped invalidation, and generation detection. Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
liyw0205
left a comment
There was a problem hiding this comment.
The latest commit addresses the global fixed-pool starvation and narrows cache invalidation, but the delivery commit is still not atomic with UID/cache invalidation. Two races can leave a live replacement without a binder until another UID transition occurs.
Validation: git diff --check passed. I could not complete the Gradle tests locally because downloading Gradle 9.6.1 repeatedly timed out; the current head also has no successful reported check (action_required).
| val belongsToChangedModule = { uid: Int -> uid % PER_USER_RANGE in moduleAppIds } | ||
| val invalidatedUids = mutableSetOf<Int>() | ||
|
|
||
| invalidatedUids += deliveryAttempts.invalidateMatching(belongsToChangedModule) |
There was a problem hiding this comment.
Blocking race: invalidating attempts and clearing uidSet/deliveries are separate operations. After invalidateMatching() returns, a new-generation uidStarts() can publish a delivery; the following snapshots/removals can then delete that new delivery. In particular, if removeMatching() removes the new entry after the worker added the UID, this method unlinks it and removes uidSet, while the worker's post-link check sees that its registry entry is already gone and cannot distinguish this from a stale commit. More importantly, the returned UID set may trigger another attempt concurrently with the first new-generation attempt. Please make lifecycle invalidation and delivery commit share one synchronization/ownership boundary rather than independently sweeping three stores.
| uidSet.remove(uid) | ||
| } | ||
| } | ||
| deliveries.put(uid, provider, recipient)?.let { previous -> |
There was a problem hiding this comment.
Blocking race: the isCurrent() check at line 207 is not atomic with publishing deliveries and uidSet. A possible ordering is: old worker passes line 207; uidGone() invalidates and clears the UID; old worker executes lines 215-218; the replacement process emits its only uidStarts() callback and returns at line 149 because the stale worker temporarily set uidSet; then the old worker notices invalidation at lines 221-224 and rolls itself back. Final state is empty, but the replacement process remains alive without receiving the binder and may emit no further UID edge. The attempt validation and registry/UID publication need to be committed atomically, or a failed stale commit must explicitly schedule the current replacement.
Tiacoo
left a comment
There was a problem hiding this comment.
Found one additional ordering race in the retry accounting. The two existing atomicity concerns around delivery publication/invalidation are valid; independently, stale attempt completions can still mutate BinderFailureTracker after a replacement attempt has completed.
recordFailure is deliberately allowed after an attempt has been invalidated, but its update is not ordered against a newer attempt's binderFailures.clear(uid). For example: attempt A starts and blocks; uidGone() invalidates A; replacement B starts and succeeds, clearing the failure run; then A returns null and records a failure after that success. Repeating this churn can throttle a healthy replacement. The inverse is also possible around lines 178-180: A passes isCurrent, B replaces it and records a failure, then stale A clears B's run before linkDelivery notices it is stale.
Please carry the attempt/generation into the failure tracker (or commit result accounting under the same ownership lock) so an older completion cannot overwrite a newer result.
Validation: git diff --check passed. I could not start :daemon:testDebugUnitTest locally because this environment has no JDK; the current head's Core workflow is action_required, so there is no CI test result yet.
Tiacoo
left a comment
There was a problem hiding this comment.
Found another gap in cache-generation redelivery: uidClear() can only rediscover UIDs that currently have an active attempt or a successful delivery.
A failed send (sendBinder() == null) finishes its attempt and never enters uidSet or deliveries, even though the module process/provider can remain alive—for example, provider.call may return null without killing the UID. If a new module generation is then published, that live UID is absent from invalidatedUids, so ConfigCache does not call uidStarts(uid). No later UID observer edge is guaranteed, leaving the new generation without its Binder indefinitely.
This contradicts the stated reason for immediate redelivery in ConfigCache (a running module may not emit another UID transition). Please track active module UIDs independently of attempt/delivery success and redeliver matching active UIDs on a generation change. A regression test should cover: failed send → attempt finishes while UID remains active → generation swap → a new attempt is scheduled.
|
This PR, it commits the ultimate faux pas of concurrent state management: "State Fragmentation". Regard closely the core variables maintaining the state in private val uidSet = ConcurrentHashMap.newKeySet<Int>()
private val sending = ConcurrentHashMap.newKeySet<Int>()
private val deliveries = ConcurrentHashMap<Int, Pair<IBinder, IBinder.DeathRecipient>>()
private val binderFailures = ConcurrentHashMap<Int, FailureRun>()
This is exactly the root cause why the previous AIs could never catch all the Race Conditions. The lifecycle of a single UID (Idle -> Sending -> Delivered / Throttled) is forcibly divided into four different To compensate for the time differential between these four collections, the author (or the AI that generated this code) has accumulated unnecessary complexity, creating three classes— But voilà, with the Kotlin philosophy, this should be clean and elegant: sealed class DeliveryState {
object Idle : DeliveryState()
data class Sending(val attemptId: Long) : DeliveryState()
data class Delivered(val provider: IBinder, val recipient: IBinder.DeathRecipient) : DeliveryState()
data class Throttled(val count: Int, val cooldownUntil: Long) : DeliveryState()
}
As long as one ensures the use of an atomic There is absolutely no need to maintain three extra Tracker classes, and we certainly will not have the problem of being interrupted the exact second after an This PR expends a massive amount of effort constructing complex workarounds, without realizing that the underlying architectural design itself is fundamentally flawed. |
|
The “state fragmentation” criticism is valid for the current revision. The lifecycle of one UID is split between The PR still has a concrete purpose independent of that design flaw: The four-state sketch is directionally right but needs a little more state than shown: an active UID after a failed send must retain its failure run, an in-flight send invalidated by I am restructuring this around one |
Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
|
Follow-up: the refactor is now pushed in |
Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
Tiacoo
left a comment
There was a problem hiding this comment.
I re-reviewed the latest head, d68a0c4, after the state-machine rewrite. Centralizing the state fixes the previously reported split-commit races, but three lifecycle gaps remain:
[P1] A replacement attempt overwrites the uidGone() tombstone before the old failure returns.
begin() replaces an inactive Sending state, while invalidateGone() only flips active to false. Sequence: A starts; uidGone() tombstones A; B starts and replaces the state; A then returns null. recordFailure(uid, A) sees B and ignores A, so repeated crash/restart churn can still evade the three-failure throttle. The tests split the two halves: one covers replacement plus stale success, and one covers stale failure without replacement (tests). Please retain outstanding/tombstoned attempt IDs separately from the current attempt, accept an old failure only while it is registered and no newer success has superseded it, and add begin A -> uidGone -> begin B -> recordFailure(A) coverage.
[P1/P2] An active throttled UID has no wake-up after cooldown.
begin() returns no claim before cooldownUntil, and generation invalidation returns the active UID but preserves Throttled. ConfigCache calls uidStarts() once; if that lands during cooldown, nothing schedules another attempt when the deadline expires. With no later UID observer edge, the new generation can remain without a Binder indefinitely. Return a retry deadline from the transition and schedule one keyed/epoch-checked retry, or permit one generation-change attempt while retaining the failure run. Add a clock-driven test that advances past the deadline without another observer callback.
[P1/P2] A start callback racing provider death is discarded.
For Delivered, begin() only copies active=true and records no pending start; later removeIfCurrentDelivery() changes it to Idle but schedules nothing. If a replacement/start callback arrives just before the old death recipient, that only callback is lost and the replacement may stay alive without a Binder. Store a pendingStart/start epoch in Delivered, and have removal return whether one retry must be scheduled. Add commit -> begin while Delivered -> death removal -> new attempt coverage.
|
Let us speak actual English, not AI style non-sense. I do appericiate your efforts of spoting errors in my PR #893 and driving your AI agents to improve current PR, bothing showing your strong interest of contributing to Vector. To me, only the part of uid delivery ownership is vakuable, which I summarize in commit 231822a now. In current PR, I don't understand why we should link uid delivery with module cache changes. Please justifity it with concrete senario, otherwise, remove this part. |
|
Fair point, and sorry — my earlier replies were stiff and over-written. The case I had in mind is simple: a module app is already running while the module is disabled. Vector sees the UID start, but If Vector intentionally requires a restart after enabling a module, then the hook is unnecessary and I will remove it. |
zhuxi99
left a comment
There was a problem hiding this comment.
This is a solid rewrite — centralizing the UID lifecycle in DeliveryStateStore with per-UID compute transitions closes the stale-worker / old-death-callback races the old three-map design could not. The state machine is well documented and the regression tests map 1:1 to the failure modes in the summary. A few things I found while reading:
1. linkDelivery can leak a DeathRecipient when an invalidation races the link (ModuleAppService.kt:152-166)
commitSuccess publishes Delivered(provider, recipient) before linkToDeath registers the recipient. If uidGone() or uidClear() lands in that window:
- The invalidating thread sees
Delivered, removes the state, and callsunlinkToDeath(recipient)— which fails (recipient not registered yet) and is swallowed byrunCatching. - Our thread then calls
provider.linkToDeath(recipient, 0)— succeeds, since the binder is still alive. isCurrentDeliveryis now false, andremoveIfCurrentDeliveryreturns false (state is no longerDelivered), so the inner unlink is skipped.
Result: the recipient stays registered on the provider binder with no path that ever unregisters it, until the provider process dies. Each hot-reload (uidClear) or uidGone that wins this race leaks one. Note that in that branch removeIfCurrentDelivery can only return false once isCurrentDelivery is false — the nested check is effectively dead code, which is exactly why the leak is silent.
Suggested fix — unlink unconditionally once linkToDeath succeeded and we no longer own the delivery (unlinkToDeath only unregisters this recipient, so it cannot touch a replacement pair):
provider.linkToDeath(recipient, 0)
if (!deliveryState.isCurrentDelivery(uid, provider, recipient)) {
runCatching { provider.unlinkToDeath(recipient, 0) }
}(Linking first and committing after would reopen the mirror-image window where a provider dying between the two steps publishes a dead binder with no death callback left to clean it up, so the current order seems intentional — the minimal fix above is the safer direction.)
2. A generation change can run two concurrent sendBinder calls for the same UID — the module app sees the duplicate
invalidateMatching turns Sending(active) into Idle(active = true), and uidClear returns that UID for immediate uidStarts → begin() grants a new attempt while the old worker is still blocked inside sendBinder (getContentProviderExternal / provider.call can block for seconds). The stale result is correctly discarded by the isCurrentSending re-check, but the module app has already received a second SEND_BINDER provider call — the side effect happened even though the outcome is dropped. The old sending set serialized this; it is the deliberate price of not waiting for the stale lookup, but it might be worth a comment (or a module-side idempotency note), since a module handling onServiceBind twice concurrently could do surprising things.
3. Test coverage gaps
finish()'s tombstone deletion path (!active && failureCount == 0→null, DeliveryState.kt:138) is untested — e.g.begin→invalidateGone→finishshould leave no entry.- The store-level half of race #1 (invalidate removes
Delivered, a subsequentcommitSuccessis rejected) is covered bystaleSuccessCannotPublishAfterUidGoneAndReplacement, but the ModuleAppService-side unlink behavior isn't. A test would need injectable binder fakes, so this may be acceptable — flagging it mainly so the leak above doesn't ship unnoticed.
Positive: moduleGenerationAppIds's referential comparison is consistent with the reuse path in ConfigCache.kt:256-275 (unchanged APK path → same LoadedModule instance → no invalidation), so the "only changed generations" claim holds in practice, not just in the unit test where instances are manually shared.
|
Fair enough — this is a rewrite rather than a patch-up. On JingMatrix's question, the concrete scenario for tying delivery to cache changes: a module is disabled while its app process is still running. Disabling doesn't kill the process, so nothing fires afterward — no uidGone (process is alive), no death callback (the provider binder is alive), no uidStarts (no uid transition). The uid stays Delivered and the framework keeps serving a module that left the cache: requestScope can still prompt, remote files can still be written, and a later uidStarts for that uid gets swallowed by the Delivered no-op. The invalidation is what ends that service and, for an update (new LoadedModule, same app id), hands the running process the new generation immediately instead of waiting for its next restart. Scope of the value: uninstall is already covered — force-stop kills the process, so uidGone and the death callback clean up — which means the hook only earns its keep in the "process outlives the cache change" window. If that's judged too narrow, it can be reduced to removals only: invalidate just the app ids that left the module map. That keeps the disabled-module case and drops the update re-delivery. |
|
Read through the rewrite — centralizing the UID lifecycle in One actual issue though:
The recipient is then registered on a live binder with no code path left to unregister it — it leaks until the provider process dies. The nested Fix: unlink unconditionally once we no longer own the delivery: provider.linkToDeath(recipient, 0)
if (!deliveryState.isCurrentDelivery(uid, provider, recipient)) {
runCatching { provider.unlinkToDeath(recipient, 0) }
}
Smaller notes:
And for what it's worth: |
|
@LIghtJUNction Please remove the test code, and simplify your changes to be minimal. |
Signed-off-by: LIghtJUNction <lightjunction.me@gmail.com>
Summary
Prevent a stale module Binder delivery from publishing after the UID it belongs to has gone away.
Changes
Validation:
git diff --check./gradlew :daemon:ktfmtCheck --rerun-tasks --no-daemon --console=plain./gradlew :daemon:testDebugUnitTest --no-daemon --console=plain