Fix Marshal mutating *Node input #371 - #375
Open
truffle-dev wants to merge 1 commit into
Open
truffle-dev wants to merge 1 commit into
truffle-dev wants to merge 1 commit into
Conversation
Marshal is meant to treat its input as read-only, but passing a *Node left the caller's tree with all resolvable tags stripped. The representer returns a user-supplied document node as-is, and nodev returns nested *Node values as-is, so the caller's node flows straight into the desolver, which clears inferable tags (!!str, !!int, !!map, ...) in place to keep output clean. The mutation surfaces on the caller's own node after the call returns. Fix by deep-copying the user-supplied node in the representer before it enters the rest of the pipeline. A deepCopyNode helper copies Content, Alias, and Stream, using a seen map so shared anchors keep their identity and cyclic alias graphs do not recurse forever. It is wired at both *Node entry points: the document-node branch in Represent and nodev (which also covers *Node values nested in a struct, map, or slice). Verified: - go test . -run TestMarshalDoesNotMutateNode: passes with the fix, fails without it (tags wiped on the input node). - go test ./... : main, internal, and cmd suites green; yaml-test-suite unchanged at 1383 pass / 225 known-fail.
ingydotnet
force-pushed
the
fix-marshal-mutates-node-371
branch
from
July 11, 2026 18:13
2f46a60 to
178b61d
Compare
There was a problem hiding this comment.
Pull request overview
Fixes a mutation bug where yaml.Marshal (and related encode paths) modified caller-supplied *yaml.Node trees in-place by ensuring user-provided nodes are deep-copied before entering the dump pipeline.
Changes:
- Add a regression test asserting
yaml.Marshal(&node)does not mutate the input node tree. - Introduce
deepCopyNodeand use it for both document-node inputs (Represent) and nested node values (nodev) so downstream stages (notably the desolver) never touch caller-owned nodes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| node_test.go | Adds TestMarshalDoesNotMutateNode to prevent regressions where Marshal strips resolved tags (and other fields) from the input tree. |
| internal/libyaml/representer.go | Deep-copies user-supplied nodes at representer entry points (document nodes and nested node values) via a cycle-safe, identity-preserving deepCopyNode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Contributor
|
I have asked Copilot to review in a separate agent discussion here is what was reported I reviewed PR **#375**’s file changes and here are the likely remaining edge cases / risks:
### What looks solid
- Deep copy is applied at both `*Node` ingress points:
- `Represent` when input is already a `DocumentNode`
- `nodev` for nested `*Node` inside structs/maps/slices
- `seen map[*Node]*Node` correctly preserves shared identity and prevents infinite recursion on cycles/aliases.
- Test `TestMarshalDoesNotMutateNode` covers the core regression.
### Potential edge cases / risks
1) **`Stream.Encoding` copy style**
- In `deepCopyNode`, `s := &Stream{Encoding: n.Stream.Encoding}`.
- If `Encoding` is a pointer/slice/map/interface-with-pointer-semantics (not sure without `Stream` type definition), this may still be shallow for that field.
- If it’s scalar/enum, no issue.
2) **Line/Column copied even though representer-built trees might ignore them**
- Copy includes `Line`/`Column`. Usually harmless.
- But if any downstream behavior unexpectedly depends on these positions for represented nodes, copying preserves original positions (probably desired, but worth noting).
3) **Performance on very large node graphs**
- This is O(N) memory/time per marshal for `*Node` inputs.
- Acceptable for correctness, but workloads marshaling huge prebuilt node graphs repeatedly may notice overhead.
- A benchmark would be useful (`Marshal(*Node)` before/after with large anchors/aliases).
4) **Test coverage breadth**
- Current test validates tag preservation on a simple map/int/string shape.
- Missing targeted tests for:
- alias cycles
- shared subtrees (same node referenced twice)
- comments/style preservation after marshal
- nested `*Node` inside struct/map/slice path (the `nodev` hook)
5) **Semantic expectation: identity no longer preserved through marshal pipeline**
- Previously (buggy) behavior mutated original nodes.
- Now marshal operates on clone, so any internal side-effect expectations (if anyone relied on them) disappear.
- That is the correct API behavior, but could surprise anyone depending on old side effects.
### Overall assessment
The fix direction is correct and likely robust for the reported bug (#371).
The main things I’d still add are **2–4 focused tests** (aliases/shared nodes/nested `*Node`) plus an optional micro-benchmark. |
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 #371.
Marshalis documented to treat its input as read-only, but passing a*Nodeleaves the caller's tree with every resolvable tag stripped:Cause
The representer hands a user-supplied
*Nodestraight into the rest ofthe dump pipeline:
Representreturns a document node as-is, andnodevreturns nested*Nodevalues as-is. The desolver then clearsinferable tags (
!!str,!!int,!!map, ...) in place to keep theoutput clean, so the mutation lands on the caller's own node.
Fix
Deep-copy the user-supplied node in the representer before it enters the
pipeline. A
deepCopyNodehelper copiesContent,Alias, andStream, using aseenmap so shared anchors keep their identity andcyclic alias graphs don't recurse forever. It's wired at both
*Nodeentry points: the document-node branch in
Representandnodev(whichalso covers
*Nodevalues nested inside a struct, map, or slice).Only
*Nodeinputs pay the copy cost; representer-built trees (frommaps, structs, slices) are already fresh and untouched.
Verification
go test . -run TestMarshalDoesNotMutateNodepasses with the fix andfails without it (tags wiped on the input node).
go test ./...: main, internal, and cmd suites green; theyaml-test-suite results are unchanged at 1383 pass / 225 known-fail.