Skip to content

Commit 6b82794

Browse files
committed
fix(text-json): a JsonElement reports its document's disposal (#2117)
A JsonElement captured before JsonDocument::Dispose() kept answering GetInt32()=10. It now raises ObjectDisposedException, as .NET does. sizeof(JsonElement) 48 -> 56; consumers rebuild. THE GATE WAS SA-3 ALL ALONG. #2117's blocker was "enforcing it needs shared disposal state reachable from every element, i.e. an OBJECT-LAYOUT CHANGE to JsonElement ... gated exactly as modules/io's #2098 is" -- and #2098 landed under SA-3 (Approval IO-1) on 2026-08-18. SA-3 covers this shape, so the gate is discharged rather than waived. The review's framing correction stands: this was never a use-after-free. JsonElement held an OWNING aliasing shared_ptr, so a captured element kept the tree alive and read LIVE storage. The defect was a disposed document still serving data. THE DESIGN IS .NET'S, NOT A WORKAROUND. .NET's JsonElement holds a JsonDocument _parent and an index, and delegates every accessor to _parent.GetXxx(_idx); each of those begins with CheckNotDisposed() -- some twenty call sites in JsonDocument.cs. The flag lives with the DOCUMENT, and the element reaches it through the reference it already holds. This port's element now points at a shared detail::JsonDocumentState and carries the node as a raw pointer into it -- the direct counterpart of _parent plus _idx. The separate `bool disposed_` on JsonDocument is DELETED rather than mirrored: two flags for one fact is what let the document and its elements disagree. Deriving a child element no longer builds an aliasing shared_ptr at all, so five construction sites got simpler. TWO BOUNDARIES ARE .NET'S AND BOTH ARE PINNED: - A DEFAULT element is undefined, not disposed. .NET keeps them apart -- CheckValidInstance() raises InvalidOperationException for a null parent, CheckNotDisposed() raises ObjectDisposedException. Writing the guard as `if (!node_) throw` would fail that. - A Clone() taken BEFORE disposal survives, because .NET's Clone() delegates to _parent.CloneElement(_idx), producing an element rooted in a NEW document. ValueKind throws too, which is easy to leave out and is not optional: .NET's reads _parent.GetJsonTokenType, which begins with CheckNotDisposed(). Dispose() still retains the tree, and now buys something for it. .NET frees its buffer there, but .NET's elements reference the DOCUMENT and are told they are disposed; this port's elements reference the STATE and would read freed storage if it vanished under them. Retention is what makes the diagnostic safe -- the cost the class note has recorded since #2110, now paid for a check rather than for nothing. Mutations: 5, all caught. Two pins inverted, each of which asserted the defect verbatim. The layout pin uses shadow structs and asserts the two differ by EXACTLY one pointer, so a change to any member's own size shows up as a mismatch rather than a number to re-guess. Gate: 17,388 run, 17,388 passed, 0 failed, 0 skipped across 38 executables (+2, in SharpRuntimeTests_Text_Json, 298 -> 300). Module graph unchanged at 41/93. No vtable, base-class, signature or noexcept change. Built in build/ with --parallel 2. Downstream: zero JsonElement/JsonDocument code sites in cna and mobile-eggbert. docs/Migration-JsonElementDisposalGuard.md
1 parent cd45b1a commit 6b82794

8 files changed

Lines changed: 272 additions & 36 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — a `JsonElement` reports its document's disposal, and `sizeof` grows 48 → 56 (ticket #2117)
5+
6+
*2026-08-19.* A `JsonElement` captured before `JsonDocument::Dispose()` kept answering. It now
7+
raises `System::ObjectDisposedException`, as .NET does.
8+
9+
Landed under `docs/StandingApprovals.md` **SA-3** (private data members; `sizeof` pinned by a
10+
layout test, no vtable, base-class, signature or `noexcept` change). **Downstream consumers must
11+
be recompiled**; no source change is needed.
12+
13+
---
14+
15+
## 1. The gate was SA-3 all along
16+
17+
#2117's recorded blocker: *"enforcing it needs shared disposal state reachable from every
18+
element, i.e. an OBJECT-LAYOUT CHANGE to `JsonElement`. No design is guessed here; it is gated
19+
exactly as `modules/io`'s #2098 is."*
20+
21+
#2098 landed under SA-3 (Approval IO-1) on 2026-08-18. SA-3 covers exactly this shape, so the
22+
gate is discharged rather than waived.
23+
24+
**And the review's framing correction stands:** this was never a use-after-free. `JsonElement`
25+
held an owning aliasing `shared_ptr`, so a captured element kept the tree alive and read **live**
26+
storage. The defect was a *disposed document still serving data*.
27+
28+
## 2. The design is .NET's, not a workaround
29+
30+
.NET's `JsonElement` holds a `JsonDocument _parent` and an index, and delegates every accessor to
31+
`_parent.GetXxx(_idx)`; each of those begins with `CheckNotDisposed()` — some twenty call sites in
32+
`JsonDocument.cs`. **The flag lives with the document, and the element reaches it through the
33+
reference it already holds.**
34+
35+
This port now does the same: the element points at a shared `detail::JsonDocumentState` and
36+
carries the node as a raw pointer into it — the direct counterpart of `_parent` plus `_idx`.
37+
38+
| | Was | Is |
39+
|---|---|---|
40+
| `JsonElement` | `shared_ptr<const ordered_json>` aliasing the node | `shared_ptr<JsonDocumentState>` + `const ordered_json*` |
41+
| `JsonDocument` | `shared_ptr<const ordered_json>` + a separate `bool disposed_` | one `shared_ptr<JsonDocumentState>` |
42+
| `sizeof(JsonElement)` | **48** | **56** |
43+
44+
Two flags for one fact is what let the document and its elements disagree, so the separate
45+
`disposed_` bool is **gone** rather than mirrored.
46+
47+
A side effect worth noting: deriving a child element no longer builds an aliasing `shared_ptr` at
48+
all — `JsonElement(state_, &(*it))` — so five construction sites got simpler.
49+
50+
## 3. What changed observably
51+
52+
| Call after `doc->Dispose()` | Was | Is |
53+
|---|---|---|
54+
| `captured.GetInt32()` | `10` | `ObjectDisposedException` |
55+
| `captured.getValueKindProperty()` | the kind | `ObjectDisposedException` |
56+
| `captured.GetRawText()`, `ToString()` | the text | `ObjectDisposedException` |
57+
| `array[1]`, `GetArrayLength()`, `GetProperty(…)` | answered | `ObjectDisposedException` |
58+
| `captured.Clone()` | a copy | `ObjectDisposedException` |
59+
| `doc->getRootElementProperty()` | already threw | **unchanged** |
60+
| double `Dispose()` | already safe | **unchanged** |
61+
| a **default** `JsonElement` | `Undefined` | **unchanged** (§4) |
62+
| a `Clone()` taken **before** disposal || **still works** (§4) |
63+
64+
`ValueKind` throwing is easy to leave out and is not optional: .NET's reads
65+
`_parent.GetJsonTokenType`, which begins with `CheckNotDisposed()`. A mutation skipping it there
66+
is caught.
67+
68+
## 4. Two boundaries that are .NET's, not conveniences
69+
70+
**A default element is undefined, not disposed.** .NET keeps them apart:
71+
`CheckValidInstance()` raises `InvalidOperationException` for a null parent, `CheckNotDisposed()`
72+
raises `ObjectDisposedException`. A default element here has no document, so it must keep
73+
answering "undefined" rather than claiming a disposal that never happened. Writing the guard as
74+
`if (!node_) throw` would fail that, and the mutation is caught.
75+
76+
**A clone survives.** `Clone()` gives the copy its own state, so it outlives the original
77+
document — .NET's `Clone()` delegates to `_parent.CloneElement(_idx)`, producing an element rooted
78+
in a **new** document. A mutation that shares the original state instead is caught.
79+
80+
## 5. `Dispose()` still retains the tree, and now buys something for it
81+
82+
`Dispose()` does not free the parsed tree. .NET frees its buffer there, but .NET's elements hold a
83+
reference to the *document* and are simply told they are disposed; this port's elements hold a
84+
reference to the **state** and would read freed storage if it vanished under them.
85+
86+
Retention is what makes the diagnostic safe. It is the cost the class note has recorded since
87+
#2110 — now paid for a check rather than for nothing.
88+
89+
## 6. Evidence
90+
91+
Five mutations, **all caught**:
92+
93+
| Mutation | Caught by |
94+
|---|---|
95+
| the guard never fires | 2 cases |
96+
| the guard also fires for a default element | `Fix2117_ADefaultElementIsUndefinedNotDisposed` |
97+
| `Dispose()` does not set the flag | 2 cases |
98+
| `Clone()` shares the original state | `Fix2117_DisposalReachesElementsHandedOutEarlier` |
99+
| `ValueKind` skips the guard | 2 cases |
100+
101+
Two pins were inverted — `JsonReviewPinTests.DisposalGuardsThatALREADYWorkAndTheOneThatDoesNot`
102+
and `JsonGatedBehaviourPins.PIN2117…` — each of which asserted the defect verbatim.
103+
104+
The layout pin uses shadow structs rather than bare numbers, and asserts that the two shadows
105+
**differ by exactly one pointer**, so a change to any member's own size shows up as a mismatch
106+
instead of a number to re-guess.
107+
108+
## 7. Downstream, measured
109+
110+
`cna` and `mobile-eggbert` reference `JsonElement` or `JsonDocument` in **zero** code sites.
111+
Neither was modified. The full-rebuild requirement is recorded here for any future consumer.

modules/text-json/include/System/Text/Json/JsonDocument.hpp

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <string>
77
#include "System/IDisposable.hpp"
88
#include "System/ObjectDisposedException.hpp"
9+
#include "System/Text/Json/detail/JsonDocumentState.hpp"
910
#include "System/Text/Json/JsonDocumentOptions.hpp"
1011
#include "System/Text/Json/JsonElement.hpp"
1112
#include "System/Text/Json/JsonException.hpp"
@@ -32,24 +33,31 @@ namespace System::Text::Json {
3233
* survives. That is not a leak, and LSan agrees.
3334
*/
3435
class JsonDocument : public System::IDisposable {
35-
std::shared_ptr<const nlohmann::ordered_json> root_;
36-
bool disposed_ = false;
36+
// #2117: one shared state, so every element handed out can see the disposal. The
37+
// separate `disposed_` bool is gone -- two flags for one fact is what let the document
38+
// and its elements disagree.
39+
std::shared_ptr<detail::JsonDocumentState> state_;
3740

38-
explicit JsonDocument(std::shared_ptr<const nlohmann::ordered_json> root) : root_(std::move(root)) {}
41+
explicit JsonDocument(std::shared_ptr<detail::JsonDocumentState> state) : state_(std::move(state)) {}
3942

4043
public:
4144
~JsonDocument() override = default;
4245

4346
/** @brief Releases the root element and marks the document as disposed. */
4447
void Dispose() override {
45-
disposed_ = true;
46-
root_.reset();
48+
if (state_) state_->disposed.store(true, std::memory_order_relaxed);
49+
// The state is NOT released. .NET frees its buffer here, but .NET's elements hold a
50+
// reference to the document and are told they are disposed; this port's elements
51+
// hold a reference to the STATE and would read freed storage if it went away while
52+
// one was alive. Retention is what makes the diagnostic safe -- the cost the class
53+
// note has recorded since #2110, now bought for a check rather than for nothing.
54+
state_.reset();
4755
}
4856

4957
/** @return The root JsonElement of this document. @throws System::ObjectDisposedException if disposed. */
5058
[[nodiscard]] JsonElement getRootElementProperty() const {
51-
if (disposed_) throw System::ObjectDisposedException("JsonDocument");
52-
return JsonElement(root_);
59+
if (!state_) throw System::ObjectDisposedException("JsonDocument");
60+
return JsonElement(state_, &state_->root);
5361
}
5462

5563
/**
@@ -62,7 +70,7 @@ namespace System::Text::Json {
6270
// and the depth check all live in ONE place now, because JsonSerializer had a
6371
// second, drifted copy of this sequence. See detail::ParseDocumentText.
6472
return std::shared_ptr<JsonDocument>(new JsonDocument(
65-
std::make_shared<const nlohmann::ordered_json>(detail::ParseDocumentText(json, options))));
73+
std::make_shared<detail::JsonDocumentState>(detail::ParseDocumentText(json, options))));
6674
// #2111: this caught only parse_error, so a number literal that overflows a
6775
// double -- which raises out_of_range, NOT parse_error -- escaped as a std::
6876
// exception that no caller writing catch(const System::Exception&) could see.

modules/text-json/include/System/Text/Json/JsonElement.hpp

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
#include "SharpRuntime/SharpRuntimeHelper.hpp"
99
#include "System/Text/Json/JsonValueKind.hpp"
1010
#include "nlohmann/json.hpp"
11+
#include "System/ObjectDisposedException.hpp"
12+
#include "System/Text/Json/detail/JsonDocumentState.hpp"
1113

1214
namespace System::Text::Json {
1315

@@ -27,23 +29,55 @@ namespace System::Text::Json {
2729
* — same observable API, simpler implementation.
2830
*/
2931
class JsonElement {
30-
std::shared_ptr<const nlohmann::ordered_json> node_;
32+
// #2117: the element points at the document's shared STATE and carries the node as a raw
33+
// pointer into it, which is .NET's `_parent` plus `_idx`. It used to hold an aliasing
34+
// `shared_ptr` straight to the node, which kept the tree alive -- correct for lifetime,
35+
// and with no route back to the document to ask whether it had been disposed.
36+
//
37+
// sizeof(JsonElement) 48 -> 56 under SA-3; pinned by JsonLayoutPinTests.
38+
std::shared_ptr<detail::JsonDocumentState> state_;
39+
const nlohmann::ordered_json* node_ = nullptr;
3140
std::string propertyName_; // set only for elements obtained via EnumerateObject(); see JsonProperty
3241

3342
friend class JsonProperty;
3443
friend class JsonDocument;
3544

3645
[[nodiscard]] const nlohmann::ordered_json& require(JsonValueKind expected, const char* what) const;
3746

47+
/**
48+
* @brief The node, after checking the owning document has not been disposed.
49+
*
50+
* The single choke point .NET spreads over some twenty `CheckNotDisposed()` calls in
51+
* `JsonDocument`. Every accessor goes through it, so an element handed out before
52+
* `Dispose()` reports the disposal rather than serving data.
53+
*
54+
* A **default** element has no state and is NOT disposed -- it is undefined, and .NET
55+
* distinguishes the two: `CheckValidInstance()` raises `InvalidOperationException` for a
56+
* null parent while `CheckNotDisposed()` raises `ObjectDisposedException`. Callers here
57+
* keep their existing "undefined" behaviour, so this returns nullptr for that case and
58+
* each accessor answers as it always did.
59+
*/
60+
[[nodiscard]] const nlohmann::ordered_json* checkedNode() const {
61+
if (state_ && state_->disposed.load(std::memory_order_relaxed))
62+
throw System::ObjectDisposedException("JsonDocument");
63+
return node_;
64+
}
65+
3866
public:
3967
/** @brief Constructs an undefined JsonElement. */
4068
JsonElement() = default;
41-
/** @brief Wraps a node from a JsonDocument's parsed tree (internal; use JsonDocument::getRootElementProperty()/GetProperty()/etc. instead). */
42-
explicit JsonElement(std::shared_ptr<const nlohmann::ordered_json> node) : node_(std::move(node)) {}
69+
/** @brief Wraps a node in a JsonDocument's shared state (internal; use JsonDocument::getRootElementProperty()/GetProperty()/etc. instead). */
70+
JsonElement(std::shared_ptr<detail::JsonDocumentState> state, const nlohmann::ordered_json* node)
71+
: state_(std::move(state)), node_(node) {}
4372

44-
/** @return The kind of this JSON value. */
73+
/**
74+
* @return The kind of this JSON value.
75+
* @throws System::ObjectDisposedException if the owning document has been disposed
76+
* (#2117). .NET throws here too: `ValueKind` reads `_parent.GetJsonTokenType`,
77+
* which begins with `CheckNotDisposed()`.
78+
*/
4579
[[nodiscard]] JsonValueKind getValueKindProperty() const {
46-
if (!node_) return JsonValueKind::Undefined;
80+
if (!checkedNode()) return JsonValueKind::Undefined;
4781
if (node_->is_object()) return JsonValueKind::Object;
4882
if (node_->is_array()) return JsonValueKind::Array;
4983
if (node_->is_string()) return JsonValueKind::String;
@@ -63,7 +97,7 @@ namespace System::Text::Json {
6397
* null/empty-string distinction matters.
6498
*/
6599
[[nodiscard]] std::string GetString() const {
66-
if (node_ && node_->is_null()) return {};
100+
if (checkedNode() && node_->is_null()) return {};
67101
return require(JsonValueKind::String, "String").get<std::string>();
68102
}
69103

@@ -92,7 +126,7 @@ namespace System::Text::Json {
92126
[[nodiscard]] bool TryGetInt64(longcs& value) const;
93127
/** @brief Tries to get this element's value as a double without throwing on failure. */
94128
[[nodiscard]] bool TryGetDouble(double& value) const {
95-
if (!node_ || !node_->is_number()) { value = 0; return false; }
129+
if (!checkedNode() || !node_->is_number()) { value = 0; return false; }
96130
value = node_->get<double>();
97131
return true;
98132
}
@@ -110,7 +144,7 @@ namespace System::Text::Json {
110144
* **object-layout change to `JsonDocument` and `JsonElement`** — hence blocked, not
111145
* deferred.
112146
*/
113-
[[nodiscard]] std::string GetRawText() const { return node_ ? node_->dump() : std::string(); }
147+
[[nodiscard]] std::string GetRawText() const { return checkedNode() ? node_->dump() : std::string(); }
114148

115149
/**
116150
* @brief Tries to get a named object property.
@@ -124,7 +158,7 @@ namespace System::Text::Json {
124158
const auto& n = require(JsonValueKind::Object, "Object");
125159
auto it = n.find(name);
126160
if (it == n.end()) return false;
127-
out = JsonElement(std::shared_ptr<const nlohmann::ordered_json>(node_, &(*it)));
161+
out = JsonElement(state_, &(*it));
128162
return true;
129163
}
130164

@@ -157,17 +191,26 @@ namespace System::Text::Json {
157191
const auto& arr = require(JsonValueKind::Array, "Array");
158192
std::vector<JsonElement> result;
159193
result.reserve(arr.size());
160-
for (const auto& item : arr) result.emplace_back(std::shared_ptr<const nlohmann::ordered_json>(node_, &item));
194+
for (const auto& item : arr) result.emplace_back(state_, &item);
161195
return result;
162196
}
163197

164198
/** @return The properties of this JSON object, in document order. @throws System::InvalidOperationException if not an object. */
165199
[[nodiscard]] std::vector<JsonProperty> EnumerateObject() const;
166200

167201
/** @return A deep copy of this element that owns its own storage. */
202+
/**
203+
* @return A deep copy of this element that owns its own storage.
204+
*
205+
* The copy has its own state, so it survives the original document's `Dispose()` — which
206+
* is .NET's contract too: `Clone()` delegates to `_parent.CloneElement(_idx)`, producing
207+
* an element rooted in a NEW document (`JsonElement.cs`). Cloning an element whose
208+
* document is already disposed throws, because the check runs first.
209+
*/
168210
[[nodiscard]] JsonElement Clone() const {
169-
if (!node_) return JsonElement();
170-
return JsonElement(std::make_shared<const nlohmann::ordered_json>(*node_));
211+
if (!checkedNode()) return JsonElement();
212+
auto fresh = std::make_shared<detail::JsonDocumentState>(*node_);
213+
return JsonElement(fresh, &fresh->root);
171214
}
172215

173216
/** @return The raw JSON text of this element (same as GetRawText()). */
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) Robert Vokac and contributors
3+
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
4+
#pragma once
5+
6+
#include <atomic>
7+
#include <utility>
8+
9+
#include "nlohmann/json.hpp"
10+
11+
namespace System::Text::Json::detail {
12+
13+
/**
14+
* @brief The state a `JsonDocument` and every `JsonElement` handed out from it share.
15+
*
16+
* Ticket #2117 (SR-AUD-324, cause TJ-H). Before it, an element captured before
17+
* `JsonDocument::Dispose()` kept answering — not a dangling read (the element held an owning
18+
* aliasing `shared_ptr`, so the tree stayed alive) but a **disposed document still serving
19+
* data**, where .NET throws.
20+
*
21+
* @par This is .NET's own structure, not a workaround
22+
* .NET's `JsonElement` holds a `JsonDocument _parent` and an index, and delegates every
23+
* accessor to `_parent.GetXxx(_idx)`; each of those begins with `CheckNotDisposed()`
24+
* (`JsonDocument.cs`, some twenty call sites). So the disposal flag lives with the
25+
* **document**, and the element reaches it through the reference it already holds. This
26+
* struct is that reference: elements point at the state rather than at a bare tree node, and
27+
* carry the node as a raw pointer into it — the direct counterpart of `_parent` plus `_idx`.
28+
*
29+
* The flag is `std::atomic` because `Dispose()` and a reader may be on different threads and
30+
* the previous design gave no guarantee either way; making the answer well-defined costs one
31+
* relaxed load per access.
32+
*/
33+
struct JsonDocumentState {
34+
nlohmann::ordered_json root;
35+
std::atomic<bool> disposed{false};
36+
37+
explicit JsonDocumentState(nlohmann::ordered_json parsed) : root(std::move(parsed)) {}
38+
};
39+
40+
} // namespace System::Text::Json::detail

modules/text-json/src/System/Text/Json/JsonElement.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@ namespace System::Text::Json {
9393
const auto& arr = require(JsonValueKind::Array, "Array");
9494
if (index < 0 || static_cast<size_t>(index) >= arr.size())
9595
throw System::IndexOutOfRangeException("Index was outside the bounds of the array.");
96-
return JsonElement(std::shared_ptr<const nlohmann::ordered_json>(node_, &arr[static_cast<size_t>(index)]));
96+
return JsonElement(state_, &arr[static_cast<size_t>(index)]);
9797
}
9898

9999
std::vector<JsonProperty> JsonElement::EnumerateObject() const {
100100
const auto& obj = require(JsonValueKind::Object, "Object");
101101
std::vector<JsonProperty> result;
102102
result.reserve(obj.size());
103103
for (auto it = obj.begin(); it != obj.end(); ++it) {
104-
JsonElement value(std::shared_ptr<const nlohmann::ordered_json>(node_, &(*it)));
104+
JsonElement value(state_, &(*it));
105105
result.push_back(JsonProperty(it.key(), std::move(value)));
106106
}
107107
return result;

0 commit comments

Comments
 (0)