Skip to content

SerializationContext.modify mutates in place, so a caller-supplied context cannot be shared across threads #344

Description

@livingstaccato

Summary

SerializationContext.modify is a context manager that mutates the context in
place and restores the previous values on exit (hcl2/utils.py:134 at
v8.1.3). Every rule that needs to change the context does so through it, on
whatever object it was handed.

That makes a caller-supplied context unsafe to share. serialize() accepts one,
threads it through the whole tree, and the rules underneath write to it, so two
concurrent parses sharing a single context observe each other's flags exactly as
two parses sharing the module-level default used to.

This is not the defect in #327. That one is about the implicit shared object
created by context=SerializationContext() as a default argument, and removing
it does nothing for a context the caller creates and shares deliberately.

Reproduction

from concurrent.futures import ThreadPoolExecutor

from hcl2.api import parses
from hcl2.utils import SerializationContext, SerializationOptions

TOGGLES = "z = f([1, 2, 3], {a = 1})\n"
PLAIN = "x = [1, 2, 3]\ny = {a = 1}\n"
EXPECTED = {"x": [1, 2, 3], "y": {"a": 1}}

shared = SerializationContext()
options = SerializationOptions()


def work(index):
    tree = parses(TOGGLES if index % 2 else PLAIN)
    result = tree.serialize(options, shared)
    return None if index % 2 else result


with ThreadPoolExecutor(max_workers=8) as pool:
    results = [r for r in pool.map(work, range(6000)) if r is not None]
print(sum(r != EXPECTED for r in results), "of", len(results), "corrupted")

Three consecutive runs on the branch that fixes #327: 1635, 2228 and 2591 of
3000
parses corrupted. A tuple comes back as its inline HCL source
('[1, 2, 3]') rather than a list, with no exception raised. On released 8.1.3
the same script reports 31 of 400 at the smaller size the shared default already
made observable.

Suggested shape of the fix

Make the context immutable and have modify return a new one rather than
writing to the caller's. The class already carries exactly that method, and it
has no callers anywhere in the package:

def replace(self, **kwargs) -> "SerializationContext":
    """Return a new context with the given fields overridden."""
    return replace(self, **kwargs)

Every with context.modify(...) block would become a rebind that is passed to
the children it wraps, which the rules already do for the context itself. With
@dataclass(frozen=True) on top, a shared context stops being a hazard by
construction rather than by convention, and the fix for #327 becomes an
optimisation rather than a correctness fix.

It is a bigger change than #327 -- every mutation site moves -- which is why
this is filed separately rather than folded into that PR.


This issue, 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions