fix: stop concurrent parses from sharing one serialization context (#327) - #334
Open
livingstaccato wants to merge 6 commits into
Open
Conversation
`serialize()` declared `context=SerializationContext()` as a default
argument. Python evaluates a default once, at import, so every rule in
the process shared a single mutable context -- and expressions.py,
functions.py and indexing.py mutate it in place through
`context.modify(inside_dollar_string=True)`.
Nothing supplies a context at any public entry point: `api.serialize`
calls `tree.serialize()`, and `NodeView.to_dict` calls
`self._node.serialize(options=...)`. Both landed on that shared object.
So a thread serializing a function call set `inside_dollar_string` for
every other thread, and any tuple or object those threads were
serializing came back as its inline HCL source -- `[1, 2, 3]` as the
string `'[1, 2, 3]'`, `{a = 1}` as `'{a = 1}'`. No exception, just a
different type, which a caller doing schema validation downstream then
reports as a type error in the user's configuration.
The structural rules in base.py now build a fresh context when called
without one, and thread the context they were given into every child
call -- eight sites that were dropping it and re-defaulting to the
shared object. The rules below them already threaded it.
The regression test interleaves 800 parses across 8 threads. Before this
change, 400 of the 400 plain parses came back corrupted; the effect needs
roughly 400 interleaved parses to show at all, which is why the count is
what it is rather than a token handful.
Other `serialize()` signatures keep the mutable default. They are no
longer reachable with it -- every caller passes a context now -- but the
defaults remain a trap for a future call site and would be worth removing
separately.
Contributor
Author
|
Please hold off on merging this one for now — I want to do another review pass over it before it goes in. Opened as a draft for that reason; I will mark it ready and say so here once I am done. |
Threading a context through the four structural rules fixed every parse the public API performs, because all of them enter at `StartRule`. It left the same declaration standing on 46 other methods: a default argument is evaluated once at import, `SerializationContext.modify` mutates in place, and so a caller serializing a rule directly -- not through `loads` -- still shared one object with every other thread doing the same. Nothing reachable from `loads` used those defaults, so this changes no value the library returns; it removes a trap rather than a live defect. Each of them now takes `context=None` and builds its own when it is not given one. The nine methods that never read the context keep the parameter but skip the construction. The new test walks the shipped rule modules and asserts every `context` parameter defaults to None, so a rule added later is covered without anyone remembering, and a second test asserts the walk actually found methods rather than passing over an empty list.
The existing regression test submits 800 parses and trusts that their critical sections overlap. That is how the defect was found and it is worth keeping, but it can only ever be evidence: on a single-core or differently-scheduled worker the same run passes over unfixed code because the two halves never meet. These force the overlap. One thread holds a mutated context open on a barrier while another serializes, and a second test asserts two serializations are handed different context objects at all. Both fail against the unfixed structural rules rather than depending on the scheduler to reveal it. Also pins why `options` may keep the shared default the context could not: nothing in the package assigns to it. If something ever does, the new test fails rather than the default quietly becoming a second cross-thread channel.
`options=SerializationOptions()` is the same construct as the context was, in the same position: one object evaluated at import and handed to every caller that omits the argument. It was not a live defect, because nothing in the package assigns to a `SerializationOptions` -- but that is a property of today's code, not of the design, and it is reachable by any subclass or hook a consumer writes. Keeping it meant defending a distinction that rests on nobody ever writing to it. The earlier tests here asserted exactly that distinction. They are replaced by the guard the context already has: a walk over the shipped rule modules asserting every `options` parameter defaults to None. It immediately earned it, catching one the change had missed -- `NewLineOrCommentRule.to_list` declares its default with an annotation, which the mechanical pass did not match.
The walk backing both guards missed three things. It used `iter_modules`, so a future subpackage would have been invisible; it looked at `hcl2.rules` alone, though the pattern is package-wide; and it tested `inspect.isfunction` against what `vars(cls)` returns, which is the descriptor for a staticmethod or classmethod rather than a function. That last one was not hypothetical: `StringRule._serialize_part_as_value` takes a context and was never inspected. It walks `hcl2` recursively and unwraps `__func__` now, taking the count from 50 to 51 and 59. The floors were `> 30` against real counts of 51 and 59, so a 40% loss of coverage would have passed. They now sit just under the true numbers. Defaulting to None was also only half the invariant: a rule that takes `None` and dereferences it without constructing one passes every guard and then raises `AttributeError` for exactly the direct caller this protects. Both parameters are annotated `Optional[...]`, which makes that a mypy error -- confirmed by deleting a guard and watching three `union-attr` errors appear -- and a test asserts the annotation is there, for the parameters that default to None rather than the required ones. Finally, thirteen test doubles still declared the mutable defaults. They are the nearest template anyone copies, so they now match.
Removing the shared default fixed the contexts the package builds for itself. A consumer that builds one and hands it to concurrent calls still had several writers: modify() was a context manager that set fields on whatever context it was given and restored them on exit, so two threads sharing one object raced on it and produced the same silent corruption -- a tuple or object serialized as its inline HCL source -- with nothing to warn them. A traversal now descends by building a child with replace(), the dataclass is frozen, and modify() is gone. The post-block checks are unchanged in meaning: they already read the outer value, which is now simply the context that was never written to. Racing two threads does not demonstrate the old defect -- modify() restored the field within a single call, so a repetition test came back green against the unfixed code. The regression test reads the caller's own context from inside the scope that used to mutate it, and sees True there before this change.
livingstaccato
marked this pull request as ready for review
September 3, 2026 02:01
Contributor
Author
|
Review pass done, so the hold above no longer applies — this is ready for review now. Rebased on current 🤖 Drafted with Claude Code. |
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.
Fixes #327. Fixes #344.
What
serialize()declaredcontext=SerializationContext()as a default argument. Python evaluates that once, at import, so every rule in the process shared one mutable context -- andexpressions.py,functions.pyandindexing.pymutate it in place throughcontext.modify(inside_dollar_string=True).A thread serializing a function call therefore set
inside_dollar_stringfor every other thread, and any tuple or object those threads were serializing came back as its inline HCL source ('[1, 2, 3]') instead of a list. Silently -- no exception, a different type. Downstream that is worse than a crash: code expecting a list gets a string and iterates its characters, so a tuple of resource names can become a sequence of single characters with nothing to indicate anything went wrong.The four structural rules now take
context=None, build a freshSerializationContextwhen called without one, and thread the context they were given into their children, so one parse can no longer observe another's state.That fixes the contexts the package builds for itself, and not the other half of the same defect: a consumer who builds a context and hands it to concurrent calls still had several writers on one object, because
modifyset fields on whatever context it was given. SoSerializationContextis nowfrozen=True,modifyis gone, and a traversal descends by building a child withreplace. The checks that follow each of those seventeen scopes are unchanged in meaning -- they already read the outer value, which is now simply the context nothing wrote to.Every other rule's
serializecarried the same declaration and is changed with them, and so doesoptions. Nothing in the package assigns to aSerializationOptions, so that one was never a live defect -- but it is the same construct in the same position, one mutable object handed to every caller that omits the argument and reachable by any subclass or hook a consumer writes, and keeping it meant defending a distinction that rests on nobody ever writing to it. Nothing reachable fromloadsused those defaults -- the public API always enters atStartRule-- so that half removes a trap rather than a live defect: a caller serializing a rule directly, outside the public API, still shared one context object with every other thread doing the same. The nine methods that never read the context keep the parameter and skip the construction.Reproduction
Interleaving a document that toggles the flag with one that reads it, across 8 threads: 400 of 400 plain parses came back corrupted before the fix, 0 of 400 after. Below roughly 400 iterations the threads do not overlap enough to observe it at all -- at 100 iterations the unfixed code corrupts none -- which is why the regression test in
test/unit/test_thread_safety.pyruns 800.Tests
The 800-parse reproduction is kept, because it is how the defect was found, but it can only ever be evidence: on a single-core or differently-scheduled worker its two halves may never meet. Two deterministic tests sit alongside it -- one holds a mutated context open on a barrier while another thread serializes, one asserts two serializations are handed different context objects at all -- and both fail against the unfixed rules without depending on the scheduler.
A third pins why
optionsmay keep the shared default that the context could not: nothing in the package assigns to it. If something ever does, that test fails rather than the default quietly becoming a second cross-thread channel.For the immutability half, racing two threads proves nothing:
modifyset the field and restored it inside one call, so the window is far too small to land on by repetition -- a test that tried came back green against the unfixed code, and was thrown away rather than kept as false assurance. What discriminates is reading the caller's own context from inside the scope that used to mutate it:[True]before the change,[False]after.Compatibility
The parameter keeps its name and stays optional, so the calling convention is preserved and a caller passing its own context still works exactly as before -- and now sharing one across concurrent parses is safe rather than merely permitted, which is what #344 asked for. Single-threaded behaviour is unchanged.
What freezing costs
Three things stop working, all of them on
SerializationContextitself:with ctx.modify(x=True):AttributeError-- usereplacectx.inside_dollar_string = TrueFrozenInstanceError@dataclass class Sub(SerializationContext)TypeError: cannot inherit non-frozen dataclass from a frozen oneThe third is the one that fires furthest from its cause: at class definition rather than at use, so it surfaces on import with nothing pointing at the upgrade. The remedy is
@dataclass(frozen=True)on the subclass. (A context also becomes hashable, which nothing needs but nothing minds.)How much that costs depends on whether any of it was ever offered, and by this repository's own reckoning it was not.
hcl2/__init__.pylistsSerializationOptionsin__all__and does not listSerializationContext. The word "context" does not occur anywhere indocs/. Every documented serialization path ishcl2.serialize(tree, serialization_options=...), whose context parameter is not reachable at all -- the options are keyword-only and there is no context keyword. Reaching one meansfrom hcl2.utils import SerializationContext, an unexported name from an undocumented module;StartRuleis exported and takes a context positionally, but there is still no offered way to construct the argument. Inside the tree,modifyhad no callers outsidehcl2/rules/and its own tests.So for anyone using the documented API this is invisible, and for anyone who went into
hcl2.utilsfor a name the package declines to export, it is three loud failures rather than a silent change of behaviour.The alternative, considered and not taken: thread
replacethrough the seventeen call sites and leave the dataclass mutable. Nothing inhcl2/assigns to a context, so that removes every actual mutation and breaks nothing --modifycould stay, unused. It also leaves the guarantee resting on nobody writing one back, which is the property that made #344 hard to see in the first place. Happy to take that version instead if you would rather not spend the break; it is a policy call rather than a technical one.Merging
It touches the same code as #348 (
hcl2/rules/expressions.py). Whichever of those lands first, this one needs a rebase rather than a merge — the overlaps are real edits to the same methods, not adjacent lines, so resolving them by hand risks losing one of the two fixes. Say the word and I will rebase and re-run the suite.This pull request, and the investigation behind it, were produced by an AI assistant (Claude) working on behalf of the author. Please review with that provenance in mind.