fix: publish the hook list safely across threads - #382
Open
abelonogov-ld wants to merge 1 commit into
Open
Conversation
HookRunner held its hooks in a plain ArrayList, which addHook mutates and which every series reads, both from any thread. A hook registered on one thread could stay invisible to another, be seen half written, or be lost to a concurrent registration; and because a series read the field once per stage, a hook arriving in between left the after stage reaching past the series data it was paired with, which was caught and logged as that hook having failed a stage it was never in. The list is now replaced rather than modified, and each series reads it once into a local, so it runs the hooks it began with. A hook registered while a series is under way joins the next one. Co-authored-by: Cursor <cursoragent@cursor.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Describe the bug
HookRunnerkeeps its hooks in a plainArrayListwith no synchronization:LDClient.addHookis public API and registering a hook after the client starts is its documented purpose, whilewithEvaluation,identifyandafterTrackall read that list and may all be called from any thread. Four things follow:addand the read.ArrayList.addstores the element and incrementssize, and a reader may observe the new size before the element, sohooks.get(i)returns null. TheNullPointerExceptionis then caught by the stage's own handler and logged as the hook having reported an error, andgetHookNametrips over the same null and logs that it could not read the hook's metadata. Two error lines about a hook that did nothing.addHookcalls can lose one of the hooks or throw from insideArrayListas it grows.seriesDataListreaches, andseriesDataList.get(i)threwIndexOutOfBoundsException. That call sits inside the try, so it was caught and logged as the new hook failingafterEvaluation, a stage it was never in.identifyholds that window open for a whole round trip, since its after stage runs from theAfterIdentifyMethodit returns.No flag value or event is affected. What it costs is hooks that quietly miss work they should have seen, and error logs blaming hooks for failures that were not theirs.
Describe the solution you've provided
The list is replaced rather than modified, and every series reads it once:
withEvaluation,identifyandafterTrackeach takeList<Hook> hooks = this.hooks;as their first statement and work from that, so a series runs the hooks it began with and its stages stay paired. The methodidentifyreturns closes over the snapshot rather than the field, which is what keeps a hook registered during the round trip out of the second half of a series it was never in. A hook registered while a series is under way joins the next one.The volatile read is the only cost on the evaluation path; the copy happens per
addHook, a handful of times in an application's life.One deliberate behavior change
The before loop re-read
hooks.size()on every iteration, so a hook registered by an earlier hook'sbeforeEvaluationwas picked up by the very series it was registered during. Against a snapshot it joins the next one instead. Nothing documented the old behavior, and it is the same mid-series registration this PR is about: a hook that observes half a series is worse off than one that starts cleanly at the next.Describe alternatives you've considered
CopyOnWriteArrayList. One line, and it fixes visibility and concurrent registration, but eachgetconsults the current array, so the reads within a single series can still disagree and the unbalanced stages survive. Once the reads are snapshotted the list needs no concurrency of its own.addHook, which is what the iOS SDK does — its hooks are fixed at initialization and there is no equivalent method, which is why none of this arises there. Not available to us without removing published API.Additional context
Three tests, each of which fails against the current implementation:
Found while working on evaluation exposure deduplication, but independent of it and older than it. Not related to the other Android branch in flight,
andrey/one-flag-read-per-evaluation; the two touch different files and can land in either order.Note
Overview
HookRunnerno longer mutates a sharedArrayListfor hooks. It keeps avolatileimmutable list,addHookcopies and publishes a new list undersynchronized, andwithEvaluation,identify, andafterTrackeach read the list once and run a fixed hook snapshot for the whole series.Identify’s returned
AfterIdentifyMethodcloses over that snapshot so hooks registered during the round trip do not runafterIdentifyfor a series they never entered. Hooks added mid-evaluation (including from another hook’sbeforeEvaluation) take effect on the next series, not the current one.Three unit tests cover snapshot semantics for evaluation and identify and concurrent
addHookwithout lost registrations.Reviewed by Cursor Bugbot for commit fd34a85. Bugbot is set up for automated code reviews on this repo. Configure here.