src/rtti_dissect.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file rtti_dissect.cpp | ||
| 3 | * @brief Reverse-direction RTTI dissection, self-healing offset resolvers, and the frame-scheduled heal runner. | ||
| 4 | * | ||
| 5 | * Builds on top of the verified COL prelude shared with rtti.cpp: | ||
| 6 | * L1 identify_pointee_type: reverse-identify the object behind one slot. | ||
| 7 | * L2 reverse_scan_block: RTTI-label a block of slots (tooling). | ||
| 8 | * L3 heal_landmark: self-heal one field offset after a patch. | ||
| 9 | * L4 solve_fingerprint: recover one uniform shift across many fields. | ||
| 10 | * L5 HealScheduler: drive the heals on a frame cadence, latch per group, warn once on real drift. | ||
| 11 | * | ||
| 12 | * Every L1-L4 entry point is noexcept and fails closed. The hot self-heal path allocates nothing (it reuses one stack | ||
| 13 | * PointeeType); only the explicitly tooling-only block scanner grows a vector. All reads go through the same | ||
| 14 | * SEH-guarded, module-bound-checked prelude the forward walker uses, so an unmapped page or forged COL is a clean | ||
| 15 | * non-match, never a fault. Matching is byte-exact on the MSVC most-derived mangled name (no UnDecorateSymbolName). | ||
| 16 | * | ||
| 17 | * The public surface uses Address and reports failures through the ErrorCategory::Rtti block. Address <-> integer | ||
| 18 | * punning is confined to the raw-slot arithmetic below. | ||
| 19 | */ | ||
| 20 | |||
| 21 | #include "DetourModKit/rtti_dissect.hpp" | ||
| 22 | #include "DetourModKit/logger.hpp" | ||
| 23 | |||
| 24 | #include "internal/memory_guarded.hpp" | ||
| 25 | #include "internal/rtti_shared.hpp" | ||
| 26 | |||
| 27 | #include <cstdint> | ||
| 28 | #include <iterator> | ||
| 29 | #include <memory> | ||
| 30 | #include <utility> | ||
| 31 | |||
| 32 | namespace DetourModKit | ||
| 33 | { | ||
| 34 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 35 | namespace detail | ||
| 36 | { | ||
| 37 | void (*g_rtti_after_heal_evidence_test_hook)() noexcept = nullptr; | ||
| 38 | } // namespace detail | ||
| 39 | #endif | ||
| 40 | |||
| 41 | namespace | ||
| 42 | { | ||
| 43 | /** | ||
| 44 | * @brief Soft policy filter: does a resolved slot's shape (and subobject position) satisfy the landmark's | ||
| 45 | * required indirection? | ||
| 46 | * @details Indirection::Any accepts either shape; PointerToObject and ObjectBase pin the slot to the | ||
| 47 | * pointer-to-object or direct-object form. This is a policy-layer decision deliberately kept out | ||
| 48 | * of L1 so a consumer can record Any when capture and heal may straddle a DLL boundary. | ||
| 49 | * CompleteObject adds a subobject constraint on top of the direct-object shape, which is why | ||
| 50 | * @p col_offset is consulted here. | ||
| 51 | * @param was_pointer The resolved slot's shape (PointeeType::was_pointer). | ||
| 52 | * @param col_offset The resolved object's COL.offset (PointeeType::col_offset): 0 for the primary subobject, | ||
| 53 | * nonzero for a multiple-inheritance secondary base. | ||
| 54 | * @param ind The landmark's required indirection. | ||
| 55 | */ | ||
| 56 | 80 | [[nodiscard]] bool shape_ok(bool was_pointer, std::uint32_t col_offset, rtti::Indirection ind) noexcept | |
| 57 | { | ||
| 58 |
4/5✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 68 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 2 → 6 taken 6 times.
✗ Branch 2 → 11 not taken.
|
80 | switch (ind) |
| 59 | { | ||
| 60 | 2 | case rtti::Indirection::Any: | |
| 61 | 2 | return true; | |
| 62 | 68 | case rtti::Indirection::PointerToObject: | |
| 63 | 68 | return was_pointer; | |
| 64 | 4 | case rtti::Indirection::ObjectBase: | |
| 65 | 4 | return !was_pointer; | |
| 66 | 6 | case rtti::Indirection::CompleteObject: | |
| 67 | // A direct object base pinned to the most-derived (primary) subobject. Under multiple inheritance every | ||
| 68 | // base subobject has its own vtable, and each vtable's COL names the same complete type. COL.offset | ||
| 69 | // distinguishes those subobjects; the primary subobject has col_offset == 0, so its base is the | ||
| 70 | // complete object. Rejecting col_offset != 0 keeps a heal from latching a secondary base's adjacent | ||
| 71 | // vtable and reporting an offset shifted by that subobject delta. | ||
| 72 |
4/4✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 9 taken 1 time.
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 2 times.
|
6 | return !was_pointer && col_offset == 0; |
| 73 | } | ||
| 74 | ✗ | return false; | |
| 75 | } | ||
| 76 | |||
| 77 | /** | ||
| 78 | * @brief Probe one slot: resolve, check shape, byte-exact name match. | ||
| 79 | * @details Fills @p pt whenever the slot resolves (so the caller can read the match details), and reports | ||
| 80 | * whether it also passed the shape filter and the exact mangled-name compare. The name compare reuses | ||
| 81 | * the same semantics as vtable_is_type: a superstring or a differing byte fails. | ||
| 82 | * @return true only on a full resolve + shape + exact-name match. | ||
| 83 | */ | ||
| 84 | 385 | [[nodiscard]] bool slot_matches(std::uintptr_t addr, const rtti::Landmark &lm, rtti::PointeeType &pt) noexcept | |
| 85 | { | ||
| 86 |
2/2✓ Branch 4 → 5 taken 305 times.
✓ Branch 4 → 6 taken 80 times.
|
385 | if (!rtti::identify_pointee_type(Address{addr}, pt)) |
| 87 | 305 | return false; | |
| 88 |
2/2✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 75 times.
|
80 | if (!shape_ok(pt.was_pointer, pt.col_offset, lm.indirection)) |
| 89 | 5 | return false; | |
| 90 | 75 | return pt.name() == lm.expected_mangled; | |
| 91 | } | ||
| 92 | |||
| 93 | /** | ||
| 94 | * @brief Builds a HealHit from a matched slot. | ||
| 95 | * @details healed_offset is the field's offset within the struct base (slot_addr - base), the value a consumer | ||
| 96 | * feeds straight into a pointer chain. It equals nominal_offset when the layout did not drift and | ||
| 97 | * nominal_offset +/- delta after a shift. | ||
| 98 | */ | ||
| 99 | [[nodiscard]] rtti::HealHit | ||
| 100 | 34 | make_hit(std::uintptr_t slot_addr, std::uintptr_t base, const rtti::PointeeType &pt) noexcept | |
| 101 | { | ||
| 102 | 34 | rtti::HealHit h; | |
| 103 | 34 | h.healed_offset = rtti::detail::address_offset(slot_addr, base); | |
| 104 | 34 | h.slot_addr = Address{slot_addr}; | |
| 105 | 34 | h.object_addr = pt.object_base; | |
| 106 | 34 | h.vtable = pt.vtable; | |
| 107 | 34 | h.col_offset = pt.col_offset; | |
| 108 | 34 | h.was_pointer = pt.was_pointer; | |
| 109 | 34 | return h; | |
| 110 | } | ||
| 111 | |||
| 112 | /** | ||
| 113 | * @brief Re-establishes a matched slot's type evidence between two equal image-generation reads. | ||
| 114 | * @details rtti::image_generation folds {live module base, PE identity}, so two equal nonzero reads that | ||
| 115 | * bracket a COL/name walk attest that one image produced the evidence. A token sampled only after the | ||
| 116 | * walk cannot: a fixed-base replacement landing between the walk and the sample would authorize the | ||
| 117 | * previous image's offset under the replacement's token, which is the exact authorization a consumer | ||
| 118 | * reads as proof that the layout still holds. The re-walk costs one extra resolve, paid only on a slot | ||
| 119 | * that already matched. | ||
| 120 | * @return The bracketed generation, or 0 when the image moved, the evidence no longer holds, or the vtable's | ||
| 121 | * image is untracked. | ||
| 122 | */ | ||
| 123 | 5 | [[nodiscard]] std::uint64_t bracketed_generation(const rtti::Landmark &lm, const rtti::HealHit &hit) noexcept | |
| 124 | { | ||
| 125 | 5 | const std::uint64_t before = rtti::image_generation(hit.vtable); | |
| 126 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
|
5 | if (before == 0) |
| 127 | 1 | return 0; | |
| 128 | |||
| 129 | 4 | rtti::PointeeType pt; | |
| 130 |
2/2✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 3 times.
|
4 | if (!slot_matches(hit.slot_addr.raw(), lm, pt)) |
| 131 | 1 | return 0; | |
| 132 | // The same slot must still yield the same object, vtable, and subobject position. A replacement that | ||
| 133 | // happens to publish a same-named type at another address is a different layout, not a re-confirmation. | ||
| 134 |
4/8✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 15 not taken.
✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 15 not taken.
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 15 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 3 times.
|
6 | if (pt.vtable != hit.vtable || pt.object_base != hit.object_addr || pt.col_offset != hit.col_offset || |
| 135 |
1/2✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 3 times.
|
3 | pt.was_pointer != hit.was_pointer) |
| 136 | ✗ | return 0; | |
| 137 | |||
| 138 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 139 |
2/2✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 2 times.
|
3 | if (auto *const hook = DetourModKit::detail::g_rtti_after_heal_evidence_test_hook) |
| 140 | 1 | hook(); | |
| 141 | #endif | ||
| 142 | 3 | const std::uint64_t after = rtti::image_generation(hit.vtable); | |
| 143 |
2/2✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 1 time.
|
3 | return after == before ? before : 0; |
| 144 | } | ||
| 145 | |||
| 146 | /** | ||
| 147 | * @brief Validates a landmark's type/shape descriptor fields. | ||
| 148 | * @details Shared by heal_from and solve_fingerprint. Does not touch @ref rtti::Landmark::base or @ref | ||
| 149 | * rtti::Landmark::window, which the two callers validate differently. | ||
| 150 | * @return true when expected_mangled is a sane length and indirection is a known enumerator. | ||
| 151 | */ | ||
| 152 | 83 | [[nodiscard]] bool descriptor_ok(const rtti::Landmark &lm) noexcept | |
| 153 | { | ||
| 154 |
6/6✓ Branch 3 → 4 taken 82 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 80 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 80 times.
|
83 | if (lm.expected_mangled.empty() || lm.expected_mangled.size() >= rtti::MAX_TYPE_NAME_LEN) |
| 155 | 3 | return false; | |
| 156 |
2/2✓ Branch 10 → 11 taken 79 times.
✓ Branch 10 → 12 taken 1 time.
|
80 | switch (lm.indirection) |
| 157 | { | ||
| 158 | 79 | case rtti::Indirection::PointerToObject: | |
| 159 | case rtti::Indirection::ObjectBase: | ||
| 160 | case rtti::Indirection::CompleteObject: | ||
| 161 | case rtti::Indirection::Any: | ||
| 162 | 79 | return true; | |
| 163 | } | ||
| 164 | 1 | return false; | |
| 165 | } | ||
| 166 | |||
| 167 | /** | ||
| 168 | * @brief Self-heal engine shared by heal_landmark and HealRun::heal_into. | ||
| 169 | * @details Takes the struct @p base explicitly (rather than reading @c lm.base) so a scheduler can heal | ||
| 170 | * from a per-frame live base without copying the landmark. Otherwise identical to the documented | ||
| 171 | * heal_landmark contract: nominal short-circuit, nearest-first widened grid, equidistant tie -> | ||
| 172 | * HealAmbiguous, exhausted window -> HealNoMatch, malformed descriptor -> BadDescriptor. | ||
| 173 | */ | ||
| 174 | 55 | [[nodiscard]] Result<rtti::HealHit> heal_from(const rtti::Landmark &lm, Address base) noexcept | |
| 175 | { | ||
| 176 | // 1. Descriptor validation. Every check below touches no memory. | ||
| 177 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 54 times.
|
55 | if (base.raw() < rtti::detail::MIN_VALID_PTR) |
| 178 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark", base.raw()}); | |
| 179 |
2/2✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 13 taken 50 times.
|
54 | if (!descriptor_ok(lm)) |
| 180 | 4 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark"}); | |
| 181 |
2/2✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 17 taken 49 times.
|
50 | if (lm.window > rtti::MAX_HEAL_WINDOW) |
| 182 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark"}); | |
| 183 |
2/2✓ Branch 17 → 18 taken 48 times.
✓ Branch 17 → 19 taken 1 time.
|
49 | const std::size_t stride = (lm.stride == 0) ? sizeof(std::uintptr_t) : lm.stride; |
| 184 | |||
| 185 | // 2. Single bounds computation. Unsigned arithmetic is wrap-defined; a | ||
| 186 | // nominal_offset that wraps the address space or lands outside the | ||
| 187 | // canonical user-mode window is rejected here, before any read. With | ||
| 188 | // a validated nominal_slot (>= MIN_VALID_PTR) and window <= MAX_HEAL_WINDOW | ||
| 189 | // (4096) << MIN_VALID_PTR (0x10000), the lo/hi derivations below | ||
| 190 | // cannot themselves underflow or wrap. | ||
| 191 | 49 | const std::uintptr_t nominal_slot = base.raw() + static_cast<std::uintptr_t>(lm.nominal_offset); | |
| 192 |
2/2✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 26 taken 46 times.
|
49 | if (!DetourModKit::detail::is_plausible_ptr(nominal_slot)) |
| 193 | 3 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark", nominal_slot}); | |
| 194 | 46 | std::uintptr_t lo = nominal_slot - lm.window; | |
| 195 |
1/2✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 46 times.
|
46 | if (lo < rtti::detail::MIN_VALID_PTR) |
| 196 | ✗ | lo = rtti::detail::MIN_VALID_PTR; | |
| 197 | 46 | const std::uintptr_t hi = nominal_slot + lm.window; | |
| 198 | |||
| 199 | 46 | rtti::PointeeType pt; | |
| 200 | |||
| 201 | // 3. Nominal slot first. An exact-offset match short-circuits before the | ||
| 202 | // window scan, so while the nominal slot still matches, a same-typed | ||
| 203 | // neighbour in the window cannot force an ambiguity verdict. Once the | ||
| 204 | // nominal slot fails, the grid scan below reaches such neighbours and | ||
| 205 | // an equidistant pair still fails closed. | ||
| 206 |
2/2✓ Branch 29 → 30 taken 19 times.
✓ Branch 29 → 34 taken 27 times.
|
46 | if (slot_matches(nominal_slot, lm, pt)) |
| 207 | 19 | return make_hit(nominal_slot, base.raw(), pt); | |
| 208 | |||
| 209 | // 4. Widened grid scan, nearest distance first. Candidate slots are | ||
| 210 | // congruent to nominal_slot modulo stride, so every probe stays pointer-aligned | ||
| 211 | // to the nominal slot. At each distance ring the -d and +d slots are | ||
| 212 | // both evaluated before deciding, so an equidistant tie is detected | ||
| 213 | // rather than silently resolved to one side. | ||
| 214 | 27 | for (std::size_t k = 1;; ++k) | |
| 215 | { | ||
| 216 | 134 | const std::size_t step = k * stride; | |
| 217 | 134 | const bool minus_in = step <= (nominal_slot - lo); | |
| 218 | 134 | const bool plus_in = step <= (hi - nominal_slot); | |
| 219 |
3/4✓ Branch 35 → 36 taken 13 times.
✓ Branch 35 → 39 taken 121 times.
✓ Branch 36 → 37 taken 13 times.
✗ Branch 36 → 39 not taken.
|
134 | if (!minus_in && !plus_in) |
| 220 | 13 | break; | |
| 221 | |||
| 222 | 121 | bool minus_match = false; | |
| 223 | 121 | rtti::HealHit minus_hit{}; | |
| 224 |
5/6✓ Branch 39 → 40 taken 121 times.
✗ Branch 39 → 43 not taken.
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 43 taken 118 times.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 48 taken 118 times.
|
121 | if (minus_in && slot_matches(nominal_slot - step, lm, pt)) |
| 225 | { | ||
| 226 | 3 | minus_match = true; | |
| 227 | 3 | minus_hit = make_hit(nominal_slot - step, base.raw(), pt); | |
| 228 | } | ||
| 229 | |||
| 230 | 121 | bool plus_match = false; | |
| 231 | 121 | rtti::HealHit plus_hit{}; | |
| 232 |
5/6✓ Branch 48 → 49 taken 121 times.
✗ Branch 48 → 52 not taken.
✓ Branch 50 → 51 taken 12 times.
✓ Branch 50 → 52 taken 109 times.
✓ Branch 53 → 54 taken 12 times.
✓ Branch 53 → 57 taken 109 times.
|
121 | if (plus_in && slot_matches(nominal_slot + step, lm, pt)) |
| 233 | { | ||
| 234 | 12 | plus_match = true; | |
| 235 | 12 | plus_hit = make_hit(nominal_slot + step, base.raw(), pt); | |
| 236 | } | ||
| 237 | |||
| 238 | // A uniquely nearest match heals; an equidistant +d/-d pair is the irreducible ambiguity and fails | ||
| 239 | // closed. | ||
| 240 |
4/4✓ Branch 57 → 58 taken 3 times.
✓ Branch 57 → 62 taken 118 times.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 62 taken 2 times.
|
121 | if (minus_match && plus_match) |
| 241 | 1 | return std::unexpected(Error{ErrorCode::HealAmbiguous, "rtti::heal_landmark", nominal_slot}); | |
| 242 |
2/2✓ Branch 62 → 63 taken 2 times.
✓ Branch 62 → 64 taken 118 times.
|
120 | if (minus_match) |
| 243 | 2 | return minus_hit; | |
| 244 |
2/2✓ Branch 64 → 65 taken 11 times.
✓ Branch 64 → 66 taken 107 times.
|
118 | if (plus_match) |
| 245 | 11 | return plus_hit; | |
| 246 | 107 | } | |
| 247 | |||
| 248 | 13 | return std::unexpected(Error{ErrorCode::HealNoMatch, "rtti::heal_landmark", nominal_slot}); | |
| 249 | } | ||
| 250 | } // anonymous namespace | ||
| 251 | |||
| 252 | 426 | Result<void> rtti::identify_pointee_typed(Address slot_addr, PointeeType &out) noexcept | |
| 253 | { | ||
| 254 |
2/2✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 8 taken 422 times.
|
426 | if (slot_addr.raw() < detail::MIN_VALID_PTR) |
| 255 | 4 | return std::unexpected(Error{ErrorCode::BadSlotAddress, "rtti::identify_pointee", slot_addr.raw()}); | |
| 256 | |||
| 257 | 422 | const auto slot_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(slot_addr.raw()); | |
| 258 |
6/6✓ Branch 11 → 12 taken 419 times.
✓ Branch 11 → 14 taken 3 times.
✓ Branch 13 → 14 taken 307 times.
✓ Branch 13 → 15 taken 112 times.
✓ Branch 16 → 17 taken 310 times.
✓ Branch 16 → 21 taken 112 times.
|
422 | if (!slot_opt || *slot_opt < detail::MIN_VALID_PTR) |
| 259 | 310 | return std::unexpected(Error{ErrorCode::UnreadableSlot, "rtti::identify_pointee", slot_addr.raw()}); | |
| 260 | 112 | const std::uintptr_t slot_val = *slot_opt; | |
| 261 | |||
| 262 | 112 | detail::ColSite site; | |
| 263 | 112 | bool was_pointer = false; | |
| 264 | 112 | std::uintptr_t object_base = 0; | |
| 265 | 112 | std::uintptr_t vtable = 0; | |
| 266 | |||
| 267 | // Resolve the candidate vtable's owning-module span once and reuse it across both shape attempts when the | ||
| 268 | // second candidate lives in the same module. resolve_col_site's single-argument overload calls the live-module | ||
| 269 | // resolver (a GetModuleHandleExW loader-lock acquisition) on every call, so the two-attempt probe below | ||
| 270 | // otherwise takes the loader lock up to twice per slot. The common direct-object case (an object's vtable and | ||
| 271 | // its first virtual function both live in the class-defining module) then costs a single acquisition. A | ||
| 272 | // genuinely cross-module second candidate (identify_pointee resolves an object whose vtable lives in a | ||
| 273 | // different DLL than the struct) still falls back to a fresh module_of, so the cross-DLL capability is | ||
| 274 | // preserved rather than regressed. | ||
| 275 | 112 | DetourModKit::detail::ModuleSpan first_span; | |
| 276 | 112 | bool first_span_resolved = false; | |
| 277 | |||
| 278 | // Pointer-to-object first: treat slot_val as a pointer to an object and try to resolve the pointee's vtable | ||
| 279 | // (*slot_val). A direct object would read its own first vtable entry here, which practically never satisfies | ||
| 280 | // the COL signature + pSelf cross-check, so this ordering does not misclassify real direct objects. | ||
| 281 | 112 | const auto vt2_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(slot_val); | |
| 282 |
6/6✓ Branch 24 → 25 taken 103 times.
✓ Branch 24 → 28 taken 9 times.
✓ Branch 26 → 27 taken 73 times.
✓ Branch 26 → 28 taken 30 times.
✓ Branch 29 → 30 taken 73 times.
✓ Branch 29 → 39 taken 39 times.
|
112 | if (vt2_opt && *vt2_opt >= detail::MIN_VALID_PTR) |
| 283 | { | ||
| 284 | 73 | first_span = DetourModKit::detail::module_span(DetourModKit::detail::live_module_region(Address{*vt2_opt})); | |
| 285 | 73 | first_span_resolved = true; | |
| 286 |
1/2✓ Branch 36 → 37 taken 73 times.
✗ Branch 36 → 39 not taken.
|
73 | if (detail::resolve_col_site(*vt2_opt, first_span, site)) |
| 287 | { | ||
| 288 | 73 | was_pointer = true; | |
| 289 | 73 | object_base = slot_val; | |
| 290 | 73 | vtable = *vt2_opt; | |
| 291 | } | ||
| 292 | } | ||
| 293 | // Else direct object base: the slot itself is the object, its value is the vtable. Pinned to ground truth: the | ||
| 294 | // object base is the slot ADDRESS, the vtable is the value READ at it (not a second deref). | ||
| 295 |
2/2✓ Branch 39 → 40 taken 39 times.
✓ Branch 39 → 54 taken 73 times.
|
112 | if (vtable == 0) |
| 296 | { | ||
| 297 | // Reuse the first candidate's span when it also owns slot_val (the common same-module case); otherwise | ||
| 298 | // resolve slot_val's module afresh via the self-resolving overload (the cross-module fallback). | ||
| 299 | ✗ | const bool resolved = (first_span_resolved && first_span.contains(slot_val)) | |
| 300 |
1/2✗ Branch 40 → 41 not taken.
✓ Branch 40 → 45 taken 39 times.
|
39 | ? detail::resolve_col_site(slot_val, first_span, site) |
| 301 | 39 | : detail::resolve_col_site(slot_val, site); | |
| 302 |
2/2✓ Branch 47 → 48 taken 28 times.
✓ Branch 47 → 50 taken 11 times.
|
39 | if (resolved) |
| 303 | { | ||
| 304 | 28 | was_pointer = false; | |
| 305 | 28 | object_base = slot_addr.raw(); | |
| 306 | 28 | vtable = slot_val; | |
| 307 | } | ||
| 308 | else | ||
| 309 | { | ||
| 310 | 11 | return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()}); | |
| 311 | } | ||
| 312 | } | ||
| 313 | |||
| 314 | // Read the name into the output buffer through the same page-bounded copy the forward walker uses. A faulted or | ||
| 315 | // empty name is a non-resolution. site.module_end clamps the copy to the vtable's owning module so a NUL-less | ||
| 316 | // edge-of-module name cannot over-read into an adjacent image. | ||
| 317 | const std::size_t name_len = | ||
| 318 | 101 | detail::read_name_seh(site.name_addr, out.name_buf, sizeof(out.name_buf), site.module_end); | |
| 319 |
1/2✗ Branch 55 → 56 not taken.
✓ Branch 55 → 60 taken 101 times.
|
101 | if (name_len == 0) |
| 320 | ✗ | return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()}); | |
| 321 | |||
| 322 | // read_name_seh returns a boundary-truncated prefix when the name runs to the owning module's end without a NUL | ||
| 323 | // (it fills accum_cap == module_end - name_addr and appends its own output terminator). A name with no | ||
| 324 | // in-module terminator is not a confident identity: a forged descriptor whose non-terminated bytes equal a | ||
| 325 | // landmark's expected string would otherwise pass slot_matches's byte-exact pt.name() compare. Require the | ||
| 326 | // source terminator to sit strictly inside module_end. A genuine name found its NUL below accum_cap, so | ||
| 327 | // name_addr + name_len stays below the boundary, while a boundary-truncated name lands exactly on it. | ||
| 328 |
2/4✓ Branch 60 → 61 taken 101 times.
✗ Branch 60 → 66 not taken.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 66 taken 101 times.
|
101 | if (site.module_end != 0 && site.name_addr + name_len >= site.module_end) |
| 329 | ✗ | return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()}); | |
| 330 | |||
| 331 | 101 | out.vtable = Address{vtable}; | |
| 332 | 101 | out.col_addr = Address{site.col_addr}; | |
| 333 | 101 | out.td_addr = Address{site.td_addr}; | |
| 334 | 101 | out.name_addr = Address{site.name_addr}; | |
| 335 | 101 | out.object_base = Address{object_base}; | |
| 336 | 101 | out.col_offset = site.col_offset; | |
| 337 | 101 | out.pointer_value = Address{slot_val}; | |
| 338 | 101 | out.was_pointer = was_pointer; | |
| 339 | 101 | out.name_len = static_cast<std::uint16_t>(name_len); | |
| 340 | |||
| 341 | // Complete object with underflow clamp: a garbage or forged col_offset larger than object_base must not wrap | ||
| 342 | // the address; report object_base itself in that (non-physical) case. | ||
| 343 |
1/2✓ Branch 72 → 73 taken 101 times.
✗ Branch 72 → 74 not taken.
|
101 | out.complete_obj = Address{(object_base < site.col_offset) ? object_base : object_base - site.col_offset}; |
| 344 | 101 | return {}; | |
| 345 | } | ||
| 346 | |||
| 347 | 410 | bool rtti::identify_pointee_type(Address slot_addr, PointeeType &out) noexcept | |
| 348 | { | ||
| 349 | // The bool primitive is exactly has_value() over the typed core: one probe, one prelude walk, one | ||
| 350 | // implementation. Callers that need the WHY of a miss use identify_pointee_typed / identify_pointee_type_or. | ||
| 351 | 410 | return identify_pointee_typed(slot_addr, out).has_value(); | |
| 352 | } | ||
| 353 | |||
| 354 | 6 | std::size_t rtti::reverse_scan_block( | |
| 355 | Address start, | ||
| 356 | std::size_t slot_count, | ||
| 357 | std::vector<LabeledSlot> &out, | ||
| 358 | std::size_t stride | ||
| 359 | ) noexcept | ||
| 360 | { | ||
| 361 |
3/6✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 6 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 6 times.
|
6 | if (start.raw() < detail::MIN_VALID_PTR || slot_count == 0) |
| 362 | ✗ | return 0; | |
| 363 |
1/2✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 6 times.
|
6 | if (stride == 0) |
| 364 | ✗ | stride = sizeof(std::uintptr_t); | |
| 365 | |||
| 366 | // Overflow guard mirroring find_in_pointer_table: reject a span that overflows size_t or wraps the address | ||
| 367 | // space. | ||
| 368 |
2/2✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 5 times.
|
6 | if (slot_count > SIZE_MAX / stride) |
| 369 | 1 | return 0; | |
| 370 | 5 | const std::uintptr_t start_raw = start.raw(); | |
| 371 | 5 | const std::uintptr_t span = static_cast<std::uintptr_t>(slot_count * stride); | |
| 372 |
1/2✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 5 times.
|
5 | if (start_raw + span < start_raw) |
| 373 | ✗ | return 0; | |
| 374 | |||
| 375 | 5 | std::size_t added = 0; | |
| 376 | 5 | PointeeType pt; | |
| 377 |
2/2✓ Branch 25 → 17 taken 13 times.
✓ Branch 25 → 26 taken 5 times.
|
18 | for (std::size_t i = 0; i < slot_count; ++i) |
| 378 | { | ||
| 379 | 13 | const std::uintptr_t slot_addr = start_raw + i * stride; | |
| 380 |
2/2✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 10 times.
|
13 | if (!identify_pointee_type(Address{slot_addr}, pt)) |
| 381 | 3 | continue; | |
| 382 | try | ||
| 383 | { | ||
| 384 |
1/2✓ Branch 22 → 23 taken 10 times.
✗ Branch 22 → 29 not taken.
|
10 | out.push_back(LabeledSlot{Address{slot_addr}, i, pt}); |
| 385 | } | ||
| 386 | ✗ | catch (...) | |
| 387 | { | ||
| 388 | // A reallocation failure must not escape the noexcept boundary; | ||
| 389 | // stop and report the slots already appended. | ||
| 390 | ✗ | return added; | |
| 391 | ✗ | } | |
| 392 | 10 | ++added; | |
| 393 | } | ||
| 394 | 5 | return added; | |
| 395 | } | ||
| 396 | |||
| 397 | 2 | std::size_t rtti::reverse_scan_block_bytes( | |
| 398 | Address start, | ||
| 399 | std::size_t byte_len, | ||
| 400 | std::vector<LabeledSlot> &out, | ||
| 401 | std::size_t stride | ||
| 402 | ) noexcept | ||
| 403 | { | ||
| 404 |
2/2✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
|
2 | if (stride == 0) |
| 405 | 1 | stride = sizeof(std::uintptr_t); | |
| 406 | 2 | return reverse_scan_block(start, byte_len / stride, out, stride); | |
| 407 | } | ||
| 408 | |||
| 409 | 42 | Result<rtti::HealHit> rtti::heal_landmark(const Landmark &lm) noexcept | |
| 410 | { | ||
| 411 | 42 | return heal_from(lm, lm.base); | |
| 412 | } | ||
| 413 | |||
| 414 | Result<rtti::FingerprintHit> | ||
| 415 | 14 | rtti::solve_fingerprint(Address base, std::span<const Landmark> fp, std::size_t window_bytes) noexcept | |
| 416 | { | ||
| 417 | // Validation. No memory is touched until a delta is probed below. | ||
| 418 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 13 times.
|
14 | if (base.raw() < detail::MIN_VALID_PTR) |
| 419 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint", base.raw()}); | |
| 420 |
6/6✓ Branch 9 → 10 taken 12 times.
✓ Branch 9 → 12 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 11 times.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 18 taken 11 times.
|
13 | if (fp.empty() || fp.size() > MAX_FINGERPRINT_LANDMARKS) |
| 421 | 2 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"}); | |
| 422 |
2/2✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 22 taken 10 times.
|
11 | if (window_bytes > MAX_HEAL_WINDOW) |
| 423 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"}); | |
| 424 | |||
| 425 | 10 | std::size_t required_count = 0; | |
| 426 |
2/2✓ Branch 43 → 23 taken 29 times.
✓ Branch 43 → 44 taken 9 times.
|
38 | for (std::size_t i = 0; i < fp.size(); ++i) |
| 427 | { | ||
| 428 |
1/2✗ Branch 25 → 26 not taken.
✓ Branch 25 → 29 taken 29 times.
|
29 | if (!descriptor_ok(fp[i])) |
| 429 | ✗ | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"}); | |
| 430 |
2/2✓ Branch 37 → 30 taken 29 times.
✓ Branch 37 → 38 taken 28 times.
|
57 | for (std::size_t j = 0; j < i; ++j) |
| 431 | { | ||
| 432 |
2/2✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 36 taken 28 times.
|
29 | if (fp[j].nominal_offset == fp[i].nominal_offset) |
| 433 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"}); | |
| 434 | } | ||
| 435 |
2/2✓ Branch 39 → 40 taken 25 times.
✓ Branch 39 → 41 taken 3 times.
|
28 | if (fp[i].required) |
| 436 | 25 | ++required_count; | |
| 437 | } | ||
| 438 | // A template with no required landmark cannot fail closed against a dense region, so it is rejected rather than | ||
| 439 | // guessed. | ||
| 440 |
2/2✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 48 taken 8 times.
|
9 | if (required_count == 0) |
| 441 | 1 | return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"}); | |
| 442 | |||
| 443 | // Enumerate uniform deltas in [-window, +window] stepping by pointer size (real-world layout shifts are | ||
| 444 | // pointer-granular). A delta is a candidate only when it satisfies every required landmark; among candidates | ||
| 445 | // the most optional hits wins. A tie for the top optional score latches HealAmbiguous (fail closed) only when | ||
| 446 | // it is between two nonzero deltas; a zero-drift candidate (delta 0, the anchor still validating) wins a tie | ||
| 447 | // outright, since the object is exactly where the caller anchored. | ||
| 448 | 8 | constexpr std::ptrdiff_t step = static_cast<std::ptrdiff_t>(sizeof(std::uintptr_t)); | |
| 449 | 8 | const std::ptrdiff_t w = static_cast<std::ptrdiff_t>(window_bytes); | |
| 450 | |||
| 451 | 8 | PointeeType pt; | |
| 452 | 8 | bool have_best = false; | |
| 453 | 8 | bool tie = false; | |
| 454 | 8 | std::ptrdiff_t best_delta = 0; | |
| 455 | 8 | std::size_t best_optional = 0; | |
| 456 | |||
| 457 | 72 | const auto eval_delta = [&](std::ptrdiff_t delta) noexcept | |
| 458 | { | ||
| 459 | 72 | std::size_t opt_hits = 0; | |
| 460 |
2/2✓ Branch 27 → 4 taken 93 times.
✓ Branch 27 → 28 taken 10 times.
|
175 | for (const Landmark &lm : fp) |
| 461 | { | ||
| 462 | const std::uintptr_t addr = | ||
| 463 | 93 | base.raw() + static_cast<std::uintptr_t>(lm.nominal_offset) + static_cast<std::uintptr_t>(delta); | |
| 464 |
3/4✓ Branch 8 → 9 taken 93 times.
✗ Branch 8 → 12 not taken.
✓ Branch 10 → 11 taken 30 times.
✓ Branch 10 → 12 taken 63 times.
|
93 | const bool ok = DetourModKit::detail::is_plausible_ptr(addr) && slot_matches(addr, lm, pt); |
| 465 |
2/2✓ Branch 13 → 14 taken 91 times.
✓ Branch 13 → 16 taken 2 times.
|
93 | if (lm.required) |
| 466 | { | ||
| 467 | // A missing required landmark disqualifies this delta outright; abandon it without scoring the | ||
| 468 | // rest. | ||
| 469 |
2/2✓ Branch 14 → 15 taken 62 times.
✓ Branch 14 → 18 taken 29 times.
|
91 | if (!ok) |
| 470 | 62 | return; | |
| 471 | } | ||
| 472 |
2/2✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 1 time.
|
2 | else if (ok) |
| 473 | { | ||
| 474 | 1 | ++opt_hits; | |
| 475 | } | ||
| 476 | } | ||
| 477 | |||
| 478 | // Every required landmark matched: this delta is a candidate. | ||
| 479 |
3/4✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 30 taken 7 times.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 3 times.
|
10 | if (!have_best || opt_hits > best_optional) |
| 480 | { | ||
| 481 | 7 | have_best = true; | |
| 482 | 7 | best_optional = opt_hits; | |
| 483 | 7 | best_delta = delta; | |
| 484 | 7 | tie = false; | |
| 485 | } | ||
| 486 |
4/4✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 34 taken 1 time.
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 1 time.
|
3 | else if (opt_hits == best_optional && best_delta != 0) |
| 487 | { | ||
| 488 | // An equal-score tie is genuine ambiguity only between two nonzero deltas: neither candidate sits at | ||
| 489 | // the caller's anchor, so there is no principled way to pick and the solve fails closed. When the | ||
| 490 | // incumbent is the zero-drift solution (delta 0 is probed first below, so best_delta == 0 means it | ||
| 491 | // already satisfies every required landmark), the anchor itself still validates: honour it. That is the | ||
| 492 | // "no drift: the object is exactly where the caller anchored" reading, and it correctly resolves an | ||
| 493 | // array of same-typed objects to element 0 (the one at base) instead of refusing because a sibling at | ||
| 494 | // +stride matches equally. A strictly higher optional score at any delta still wins above; only an | ||
| 495 | // equal-score tie against the zero-drift incumbent is suppressed here. | ||
| 496 | 1 | tie = true; | |
| 497 | } | ||
| 498 | 8 | }; | |
| 499 | |||
| 500 | // Iterate magnitudes 0, +step, -step, +2*step, ... so the scan is nearest-first; the decision itself is | ||
| 501 | // score-based, not distance-based. | ||
| 502 |
2/2✓ Branch 53 → 49 taken 40 times.
✓ Branch 53 → 54 taken 8 times.
|
48 | for (std::ptrdiff_t m = 0; m <= w; m += step) |
| 503 | { | ||
| 504 | 40 | eval_delta(m); | |
| 505 |
2/2✓ Branch 50 → 51 taken 32 times.
✓ Branch 50 → 52 taken 8 times.
|
40 | if (m != 0) |
| 506 | 32 | eval_delta(-m); | |
| 507 | } | ||
| 508 | |||
| 509 |
2/2✓ Branch 54 → 55 taken 1 time.
✓ Branch 54 → 59 taken 7 times.
|
8 | if (!have_best) |
| 510 | 1 | return std::unexpected(Error{ErrorCode::HealNoMatch, "rtti::solve_fingerprint", base.raw()}); | |
| 511 |
2/2✓ Branch 59 → 60 taken 1 time.
✓ Branch 59 → 64 taken 6 times.
|
7 | if (tie) |
| 512 | 1 | return std::unexpected(Error{ErrorCode::HealAmbiguous, "rtti::solve_fingerprint", base.raw()}); | |
| 513 | 6 | return FingerprintHit{best_delta, required_count, best_optional}; | |
| 514 | } | ||
| 515 | |||
| 516 | 3 | std::size_t rtti::heal_report(std::span<const Landmark> landmarks, std::span<DriftEntry> out) noexcept | |
| 517 | { | ||
| 518 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 3 times.
|
3 | const std::size_t written = (landmarks.size() < out.size()) ? landmarks.size() : out.size(); |
| 519 |
2/2✓ Branch 21 → 8 taken 5 times.
✓ Branch 21 → 22 taken 3 times.
|
8 | for (std::size_t i = 0; i < written; ++i) |
| 520 | { | ||
| 521 | 5 | const Landmark &landmark = landmarks[i]; | |
| 522 | 5 | DriftEntry &entry = out[i]; | |
| 523 | // Start from a clean entry so a failed heal cannot expose stale healed_offset/delta from a reused | ||
| 524 | // (non-zeroed) output buffer. | ||
| 525 | 5 | entry = DriftEntry{}; | |
| 526 | 5 | entry.name = landmark.expected_mangled; | |
| 527 | 5 | entry.nominal_offset = landmark.nominal_offset; | |
| 528 | |||
| 529 | 5 | const auto heal = heal_landmark(landmark); | |
| 530 |
2/2✓ Branch 13 → 14 taken 4 times.
✓ Branch 13 → 18 taken 1 time.
|
5 | if (heal) |
| 531 | { | ||
| 532 | 4 | entry.ok = true; | |
| 533 | 4 | entry.healed_offset = heal->healed_offset; | |
| 534 | // delta is the realised layout shift: 0 when the field did not move, signed when it did. It is the | ||
| 535 | // reported layout shift, derived purely from the existing heal result. Saturation preserves the | ||
| 536 | // direction of a difference that does not fit in ptrdiff_t. | ||
| 537 | 4 | entry.delta = rtti::detail::saturating_sub(heal->healed_offset, landmark.nominal_offset); | |
| 538 | } | ||
| 539 | else | ||
| 540 | { | ||
| 541 | // ok stays false; healed_offset and delta stay 0 (valid only when ok). | ||
| 542 | 1 | entry.error = heal.error().code; | |
| 543 | } | ||
| 544 | } | ||
| 545 | 3 | return written; | |
| 546 | } | ||
| 547 | |||
| 548 | struct rtti::HealScheduler::Impl | ||
| 549 | { | ||
| 550 | // One independently-latched heal group: its own retry countdown and its own success latch, so a group whose | ||
| 551 | // target comes up late retries on the interval without freezing any sibling group. | ||
| 552 | struct Group | ||
| 553 | { | ||
| 554 | Work work; | ||
| 555 | Gate gate; | ||
| 556 | bool latched = false; | ||
| 557 | std::uint32_t frames_until_retry = 0; | ||
| 558 | }; | ||
| 559 | |||
| 560 | HealConfig config; | ||
| 561 | // The per-scheduler "layout has drifted" latch, claimed by CAS so exactly one Warning is emitted across every | ||
| 562 | // group of this scheduler even when several fields moved on the same frame. | ||
| 563 | std::atomic<bool> drift_warned{false}; | ||
| 564 | std::vector<Group> groups; | ||
| 565 | // Groups registered from within a running tick() (a callback calling add_group) are staged here and merged into | ||
| 566 | // `groups` after the scan loop, so add_group can never reallocate `groups` while tick's range-for holds a | ||
| 567 | // reference into it. | ||
| 568 | std::vector<Group> pending; | ||
| 569 | // Re-entrancy depth of tick(): 0 outside a scan and 1 while one is in flight. A rejected nested tick never | ||
| 570 | // changes the outer scan's in-flight state. | ||
| 571 | unsigned tick_depth = 0; | ||
| 572 | |||
| 573 | // Moves every deferred group into `groups`. Callable only while no scan is in flight, since it may reallocate | ||
| 574 | // `groups`. insert() reserves once up front (so on OOM it throws before moving any element, leaving `pending` | ||
| 575 | // intact for the next attempt) and then move-constructs each element (a std::move_only_function move is | ||
| 576 | // noexcept), so a failed adoption loses no work and keeps tick() noexcept. | ||
| 577 | 890 | void adopt_pending() noexcept | |
| 578 | { | ||
| 579 |
2/2✓ Branch 3 → 4 taken 875 times.
✓ Branch 3 → 5 taken 15 times.
|
890 | if (pending.empty()) |
| 580 | 875 | return; | |
| 581 | try | ||
| 582 | { | ||
| 583 |
4/6✓ Branch 6 → 7 taken 15 times.
✗ Branch 6 → 18 not taken.
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 18 not taken.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 16 taken 10 times.
|
40 | groups.insert( |
| 584 | 25 | groups.end(), | |
| 585 | std::make_move_iterator(pending.begin()), | ||
| 586 | std::make_move_iterator(pending.end()) | ||
| 587 | ); | ||
| 588 | 5 | pending.clear(); | |
| 589 | } | ||
| 590 | 10 | catch (...) | |
| 591 | { | ||
| 592 | // Allocation failed; leave `pending` untouched so the deferred groups are retried on the next tick. | ||
| 593 | 10 | } | |
| 594 | } | ||
| 595 | }; | ||
| 596 | |||
| 597 | 25 | Result<rtti::HealScheduler> rtti::HealScheduler::start(HealConfig config) noexcept | |
| 598 | { | ||
| 599 | // A zero interval would divide-by-nothing the retry budget (every tick is a scan), which is a caller mistake, | ||
| 600 | // not a valid cadence; reject it up front rather than silently reinterpret it. | ||
| 601 |
4/4✓ Branch 2 → 3 taken 24 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 23 times.
|
25 | if (config.interval_frames == 0 || config.drift_warn_threshold < 0) |
| 602 | 2 | return std::unexpected(Error{ErrorCode::InvalidArg, "rtti::HealScheduler::start"}); | |
| 603 | try | ||
| 604 | { | ||
| 605 |
1/2✓ Branch 7 → 8 taken 23 times.
✗ Branch 7 → 19 not taken.
|
23 | auto impl = std::make_unique<Impl>(); |
| 606 | 23 | impl->config = config; | |
| 607 | 23 | return HealScheduler{std::move(impl)}; | |
| 608 | 23 | } | |
| 609 | ✗ | catch (...) | |
| 610 | { | ||
| 611 | ✗ | return std::unexpected(Error{ErrorCode::OutOfMemory, "rtti::HealScheduler::start"}); | |
| 612 | ✗ | } | |
| 613 | } | ||
| 614 | |||
| 615 | 46 | rtti::HealScheduler::HealScheduler(std::unique_ptr<Impl> impl) noexcept : m_impl(std::move(impl)) {} | |
| 616 | 24 | rtti::HealScheduler::HealScheduler(HealScheduler &&) noexcept = default; | |
| 617 | ✗ | rtti::HealScheduler &rtti::HealScheduler::operator=(HealScheduler &&) noexcept = default; | |
| 618 | 47 | rtti::HealScheduler::~HealScheduler() noexcept = default; | |
| 619 | |||
| 620 | 348 | Result<void> rtti::HealScheduler::add_group(Work work, Gate gate) noexcept | |
| 621 | { | ||
| 622 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 348 times.
|
348 | if (!m_impl) |
| 623 | ✗ | return {}; | |
| 624 | // A group with no heal work can never resolve; ignore an empty callback rather than let it reach tick(), where | ||
| 625 | // invoking an empty std::move_only_function would be undefined behavior. | ||
| 626 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 348 times.
|
348 | if (!work) |
| 627 | ✗ | return {}; | |
| 628 | // Defer a group added from within a running tick (a work/gate callback re-entering add_group) so tick's | ||
| 629 | // range-for reference into `groups` is never invalidated by a reallocation mid-iteration; it starts scanning on | ||
| 630 | // the next tick. | ||
| 631 |
2/2✓ Branch 9 → 10 taken 322 times.
✓ Branch 9 → 12 taken 26 times.
|
348 | std::vector<Impl::Group> &target = m_impl->tick_depth != 0 ? m_impl->pending : m_impl->groups; |
| 632 | try | ||
| 633 | { | ||
| 634 |
6/10✓ Branch 20 → 21 taken 346 times.
✓ Branch 20 → 28 taken 2 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 346 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 346 times.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 2 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 2 times.
|
1050 | target.push_back(Impl::Group{std::move(work), std::move(gate), false, 0}); |
| 635 | } | ||
| 636 | 2 | catch (...) | |
| 637 | { | ||
| 638 | // push_back's strong guarantee leaves the vector unchanged, so a failed registration commits no state. | ||
| 639 | 2 | return std::unexpected(Error{ErrorCode::OutOfMemory, "rtti::HealScheduler::add_group"}); | |
| 640 | 2 | } | |
| 641 | 346 | return {}; | |
| 642 | } | ||
| 643 | |||
| 644 | 450 | void rtti::HealScheduler::tick() noexcept | |
| 645 | { | ||
| 646 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 449 times.
|
450 | if (!m_impl) |
| 647 | 1 | return; | |
| 648 | // Reject a re-entrant tick (a work or gate callback calling tick() again on this scheduler). Running the inner | ||
| 649 | // scan would clear the in-flight state on return, after which the outer scan's range-for still holds a | ||
| 650 | // reference into `groups` and a subsequent add_group would push directly into `groups`, reallocating it | ||
| 651 | // mid-iteration. A nested tick is a no-op; the groups it would have scanned run on the next outer tick. Because | ||
| 652 | // the reject returns before the depth is bumped, it cannot clear the outer scan's in-flight marker. | ||
| 653 |
2/2✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 445 times.
|
449 | if (m_impl->tick_depth != 0) |
| 654 | 4 | return; | |
| 655 | // Retry an adoption a previous tick could not complete, before the depth bump so the retry runs with no scan in | ||
| 656 | // flight. A group deferred during tick N therefore scans on tick N + 1 even when tick N's exit adoption hit | ||
| 657 | // OOM; adopting only at exit would spend tick N + 1 moving the queue and not scan it until N + 2, so a | ||
| 658 | // consumer that ticks only once more would adopt the heal work and stop without ever running it. | ||
| 659 | 445 | m_impl->adopt_pending(); | |
| 660 | 445 | ++m_impl->tick_depth; | |
| 661 |
2/2✓ Branch 45 → 14 taken 771 times.
✓ Branch 45 → 46 taken 445 times.
|
1661 | for (Impl::Group &group : m_impl->groups) |
| 662 | { | ||
| 663 |
2/2✓ Branch 16 → 17 taken 249 times.
✓ Branch 16 → 18 taken 522 times.
|
771 | if (group.latched) |
| 664 | 416 | continue; | |
| 665 | |||
| 666 | // Silent pre-gate, evaluated BEFORE the interval countdown: a target that is not constructed yet is polled | ||
| 667 | // cheaply every frame and skipped without spending the retry budget or logging. A throwing gate is treated | ||
| 668 | // as "not ready". | ||
| 669 |
2/2✓ Branch 19 → 20 taken 101 times.
✓ Branch 19 → 24 taken 421 times.
|
522 | if (group.gate) |
| 670 | { | ||
| 671 | 101 | bool ready = false; | |
| 672 | try | ||
| 673 | { | ||
| 674 |
1/2✓ Branch 20 → 21 taken 101 times.
✗ Branch 20 → 50 not taken.
|
101 | ready = group.gate(); |
| 675 | } | ||
| 676 | ✗ | catch (...) | |
| 677 | { | ||
| 678 | ✗ | ready = false; | |
| 679 | ✗ | } | |
| 680 |
2/2✓ Branch 22 → 23 taken 100 times.
✓ Branch 22 → 24 taken 1 time.
|
101 | if (!ready) |
| 681 | 100 | continue; | |
| 682 | } | ||
| 683 | |||
| 684 | // Fixed-interval countdown. The scan frame itself does not decrement: after a scan the counter is reset to | ||
| 685 | // interval_frames and the next interval_frames ticks are skips, so scans land on frames 0, interval+1, | ||
| 686 | // 2*(interval)+2, and so on. The cadence is fixed, never a geometric backoff. | ||
| 687 |
2/2✓ Branch 24 → 25 taken 67 times.
✓ Branch 24 → 26 taken 355 times.
|
422 | if (group.frames_until_retry > 0) |
| 688 | { | ||
| 689 | 67 | --group.frames_until_retry; | |
| 690 | 67 | continue; | |
| 691 | } | ||
| 692 | 355 | group.frames_until_retry = m_impl->config.interval_frames; | |
| 693 | |||
| 694 | 355 | HealRun run{m_impl->config, m_impl->drift_warned}; | |
| 695 | 355 | bool resolved = false; | |
| 696 | try | ||
| 697 | { | ||
| 698 |
1/2✓ Branch 30 → 31 taken 355 times.
✗ Branch 30 → 53 not taken.
|
355 | resolved = group.work(run); |
| 699 | } | ||
| 700 | ✗ | catch (...) | |
| 701 | { | ||
| 702 | // A throwing work callback is treated as "did not resolve this frame"; the group retries next interval. | ||
| 703 | ✗ | resolved = false; | |
| 704 | ✗ | } | |
| 705 |
2/2✓ Branch 32 → 33 taken 342 times.
✓ Branch 32 → 34 taken 13 times.
|
355 | if (resolved) |
| 706 | 342 | group.latched = true; | |
| 707 | } | ||
| 708 | |||
| 709 | // The scan loop is done; drop the in-flight depth, then adopt any groups a callback deferred while ticking. | ||
| 710 | 445 | --m_impl->tick_depth; | |
| 711 | 445 | m_impl->adopt_pending(); | |
| 712 | } | ||
| 713 | |||
| 714 | 27 | bool rtti::HealScheduler::all_resolved() const noexcept | |
| 715 | { | ||
| 716 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 26 times.
|
27 | if (!m_impl) |
| 717 | 1 | return true; | |
| 718 | // A deferred group is registered from the moment add_group returns, so completion cannot be claimed while the | ||
| 719 | // adoption queue still holds work: reporting only over `groups` would answer true for a scheduler whose | ||
| 720 | // end-of-tick adoption ran out of memory and is still holding live, unlatched heal work. | ||
| 721 |
2/2✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 21 times.
|
26 | if (!m_impl->pending.empty()) |
| 722 | 5 | return false; | |
| 723 |
2/2✓ Branch 25 → 12 taken 215 times.
✓ Branch 25 → 26 taken 15 times.
|
251 | for (const Impl::Group &group : m_impl->groups) |
| 724 | { | ||
| 725 |
2/2✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 209 times.
|
215 | if (!group.latched) |
| 726 | 6 | return false; | |
| 727 | } | ||
| 728 | 15 | return true; | |
| 729 | } | ||
| 730 | |||
| 731 | 1 | const rtti::HealConfig &rtti::HealScheduler::config() const noexcept | |
| 732 | { | ||
| 733 | // A moved-from scheduler is inert (m_impl == nullptr), the same contract tick / add_group / all_resolved honor. | ||
| 734 | // config() returns a reference, so it cannot no-op; hand back a reference to a static default rather than | ||
| 735 | // dereferencing null, keeping the accessor safe on an inert instance too. | ||
| 736 |
1/2✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 5 not taken.
|
1 | if (!m_impl) |
| 737 | { | ||
| 738 | static const HealConfig inert_config{}; | ||
| 739 | 1 | return inert_config; | |
| 740 | } | ||
| 741 | ✗ | return m_impl->config; | |
| 742 | } | ||
| 743 | |||
| 744 | 1 | void rtti::HealRun::warn_drift_once(std::string_view label, std::ptrdiff_t delta) noexcept | |
| 745 | { | ||
| 746 | // Compare magnitudes in the unsigned domain: the naive (delta < 0) ? -delta : delta is undefined at | ||
| 747 | // PTRDIFF_MIN, whose negation is not representable. A drift delta can reach that bound for a caller-supplied | ||
| 748 | // nominal, so route both operands through the PTRDIFF_MIN-safe magnitude helper. | ||
| 749 | 1 | const std::uint64_t magnitude = rtti::detail::ptrdiff_magnitude(delta); | |
| 750 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1 time.
|
1 | if (magnitude <= rtti::detail::ptrdiff_magnitude(m_config.drift_warn_threshold)) |
| 751 | ✗ | return; | |
| 752 | // CAS one-shot: the first drift to clear the latch emits the single actionable Warning. The recovered POINTER | ||
| 753 | // offsets self-healed, but the non-healable scalar/flag offsets in the same structs silently rode the same | ||
| 754 | // shift and need a human to re-verify. That is the actionable headline this one line carries. | ||
| 755 | 1 | bool expected = false; | |
| 756 |
1/2✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 11 not taken.
|
1 | if (m_drift_warned.compare_exchange_strong(expected, true, std::memory_order_relaxed)) |
| 757 | { | ||
| 758 | 1 | (void)log().try_log( | |
| 759 | LogLevel::Warning, | ||
| 760 | "Self-heal: layout drifted (first change: {} by {:+#x}); pointer offsets recovered. " | ||
| 761 | "Re-verify non-healable scalars.", | ||
| 762 | label, | ||
| 763 | delta | ||
| 764 | ); | ||
| 765 | } | ||
| 766 | } | ||
| 767 | |||
| 768 | 6 | Result<rtti::HealHit> rtti::HealRun::heal_into( | |
| 769 | std::string_view label, | ||
| 770 | const Landmark &landmark, | ||
| 771 | Address base, | ||
| 772 | std::atomic<std::ptrdiff_t> &slot, | ||
| 773 | bool required | ||
| 774 | ) noexcept | ||
| 775 | { | ||
| 776 | // heal_from takes the base explicitly, so the landmark is not copied. That keeps heal_into allocation-free | ||
| 777 | // and truly noexcept even for a landmark whose owned name would not fit the small-string buffer. | ||
| 778 | 6 | Result<HealHit> result = heal_from(landmark, base); | |
| 779 | 6 | Logger &logger = log(); | |
| 780 |
2/2✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 26 taken 3 times.
|
6 | if (result) |
| 781 | { | ||
| 782 | 3 | slot.store(result->healed_offset, std::memory_order_relaxed); | |
| 783 | 3 | const std::ptrdiff_t delta = rtti::detail::saturating_sub(result->healed_offset, landmark.nominal_offset); | |
| 784 |
2/2✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 22 taken 2 times.
|
3 | if (delta != 0) |
| 785 | { | ||
| 786 | 1 | warn_drift_once(label, delta); | |
| 787 | 1 | (void)logger.try_log( | |
| 788 | LogLevel::Info, | ||
| 789 | "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})", | ||
| 790 | label, | ||
| 791 | delta, | ||
| 792 | 1 | landmark.nominal_offset, | |
| 793 | 1 | result->healed_offset | |
| 794 | ); | ||
| 795 | } | ||
| 796 | else | ||
| 797 | { | ||
| 798 | 2 | (void)logger.try_log( | |
| 799 | LogLevel::Debug, | ||
| 800 | "Self-heal: {} confirmed at nominal {:#x}", | ||
| 801 | label, | ||
| 802 | 2 | landmark.nominal_offset | |
| 803 | ); | ||
| 804 | } | ||
| 805 | 3 | return result; | |
| 806 | } | ||
| 807 | |||
| 808 | // Fail closed: the slot keeps whatever nominal it was seeded with (untouched above). A required miss escalates | ||
| 809 | // to a Warning under WarnRequired; an optional or Quiet miss stays at Debug so a legitimately-absent target | ||
| 810 | // does not spam the log on the frames before it comes up. | ||
| 811 | 3 | const std::string_view reason = to_string(result.error().code); | |
| 812 |
1/4✗ Branch 28 → 29 not taken.
✓ Branch 28 → 32 taken 3 times.
✗ Branch 29 → 30 not taken.
✗ Branch 29 → 32 not taken.
|
3 | if (required && m_config.escalate == HealEscalation::WarnRequired) |
| 813 | { | ||
| 814 | ✗ | (void)logger.try_log( | |
| 815 | LogLevel::Warning, | ||
| 816 | "Self-heal: {} unresolved ({}); kept nominal {:#x} (re-author " | ||
| 817 | "if drifted)", | ||
| 818 | label, | ||
| 819 | reason, | ||
| 820 | ✗ | landmark.nominal_offset | |
| 821 | ); | ||
| 822 | } | ||
| 823 | else | ||
| 824 | { | ||
| 825 | 3 | (void)logger.try_log( | |
| 826 | LogLevel::Debug, | ||
| 827 | "Self-heal: {} not resolvable now ({}); keeping nominal {:#x}", | ||
| 828 | label, | ||
| 829 | reason, | ||
| 830 | 3 | landmark.nominal_offset | |
| 831 | ); | ||
| 832 | } | ||
| 833 | 3 | return result; | |
| 834 | } | ||
| 835 | |||
| 836 | 7 | void rtti::HealedSlot::seed_nominal(std::ptrdiff_t nominal) noexcept | |
| 837 | { | ||
| 838 | // A nominal that has never been confirmed: carry it with generation 0 and Unverified, so a consumer reading the | ||
| 839 | // slot before the first heal gets an explicit best-guess status rather than a Confirmed value it could trust. | ||
| 840 | 7 | publish(nominal, 0, OffsetValidity::Unverified); | |
| 841 | 7 | } | |
| 842 | |||
| 843 | 20018 | void rtti::HealedSlot::publish(std::ptrdiff_t value, std::uint64_t generation, OffsetValidity validity) noexcept | |
| 844 | { | ||
| 845 |
2/2✓ Branch 2 → 3 taken 10006 times.
✓ Branch 2 → 5 taken 10012 times.
|
20018 | if (validity == OffsetValidity::Confirmed) |
| 846 | { | ||
| 847 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 10005 times.
|
10006 | if (generation == 0) |
| 848 | 1 | validity = OffsetValidity::Invalid; | |
| 849 | } | ||
| 850 | else | ||
| 851 | { | ||
| 852 | 10012 | generation = 0; | |
| 853 | } | ||
| 854 | |||
| 855 | // Single-producer seqlock write: bump the sequence to odd (write in progress), store the payload, bump to even | ||
| 856 | // (stable). The release fences pair with the consumer's acquire fence in load() so a reader either sees the | ||
| 857 | // whole new snapshot or retries - never a torn mix of an old and a new field. | ||
| 858 | 20018 | const std::uint32_t seq = m_seq.load(std::memory_order_relaxed); | |
| 859 | 20018 | m_seq.store(seq + 1, std::memory_order_relaxed); | |
| 860 | std::atomic_thread_fence(std::memory_order_release); | ||
| 861 | 20018 | m_value.store(value, std::memory_order_relaxed); | |
| 862 | 20018 | m_generation.store(generation, std::memory_order_relaxed); | |
| 863 | 20018 | m_validity.store(static_cast<std::uint8_t>(validity), std::memory_order_relaxed); | |
| 864 | std::atomic_thread_fence(std::memory_order_release); | ||
| 865 | 20018 | m_seq.store(seq + 2, std::memory_order_relaxed); | |
| 866 | 20018 | } | |
| 867 | |||
| 868 | 18952 | rtti::HealedOffset rtti::HealedSlot::load() const noexcept | |
| 869 | { | ||
| 870 | 18952 | constexpr std::size_t MAX_ATTEMPTS = 16; | |
| 871 |
2/2✓ Branch 44 → 3 taken 144707 times.
✓ Branch 44 → 45 taken 7334 times.
|
152041 | for (std::size_t attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) |
| 872 | { | ||
| 873 | 144707 | const std::uint32_t seq1 = m_seq.load(std::memory_order_acquire); | |
| 874 |
2/2✓ Branch 10 → 11 taken 115681 times.
✓ Branch 10 → 12 taken 29026 times.
|
144707 | if ((seq1 & 1U) != 0U) |
| 875 | 115681 | continue; // A publish is in progress; retry once it completes (single producer, so this is brief). | |
| 876 | 29026 | const std::ptrdiff_t value = m_value.load(std::memory_order_relaxed); | |
| 877 | 29026 | const std::uint64_t generation = m_generation.load(std::memory_order_relaxed); | |
| 878 | 58052 | const std::uint8_t validity = m_validity.load(std::memory_order_relaxed); | |
| 879 | std::atomic_thread_fence(std::memory_order_acquire); | ||
| 880 |
2/2✓ Branch 41 → 42 taken 11618 times.
✓ Branch 41 → 43 taken 17408 times.
|
58052 | if (m_seq.load(std::memory_order_relaxed) == seq1) |
| 881 | 11618 | return HealedOffset{value, generation, static_cast<OffsetValidity>(validity)}; | |
| 882 | } | ||
| 883 | 7334 | return {}; | |
| 884 | } | ||
| 885 | |||
| 886 | 6 | Result<std::ptrdiff_t> rtti::HealedSlot::authorized() const noexcept | |
| 887 | { | ||
| 888 | 6 | const HealedOffset snap = load(); | |
| 889 |
3/4✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 5 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 8 taken 1 time.
|
6 | if (snap.validity != OffsetValidity::Confirmed || snap.generation == 0) |
| 890 | 5 | return std::unexpected(Error{ErrorCode::OffsetNotConfirmed, "rtti::HealedSlot::authorized"}); | |
| 891 | 1 | return snap.value; | |
| 892 | } | ||
| 893 | |||
| 894 | 9 | Result<std::ptrdiff_t> rtti::HealedSlot::authorized(std::uint64_t current_generation) const noexcept | |
| 895 | { | ||
| 896 | 9 | const HealedOffset snap = load(); | |
| 897 |
5/6✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 7 taken 1 time.
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 7 times.
✓ Branch 5 → 7 taken 1 time.
|
9 | if (snap.validity != OffsetValidity::Confirmed || snap.generation == 0 || current_generation == 0 || |
| 898 |
2/2✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 10 taken 5 times.
|
7 | snap.generation != current_generation) |
| 899 | 4 | return std::unexpected( | |
| 900 | 4 | Error{ErrorCode::OffsetNotConfirmed, "rtti::HealedSlot::authorized", snap.generation} | |
| 901 | 4 | ); | |
| 902 | 5 | return snap.value; | |
| 903 | } | ||
| 904 | |||
| 905 | 7 | Result<rtti::HealHit> rtti::HealRun::heal_into( | |
| 906 | std::string_view label, | ||
| 907 | const Landmark &landmark, | ||
| 908 | Address base, | ||
| 909 | HealedSlot &slot, | ||
| 910 | bool required | ||
| 911 | ) noexcept | ||
| 912 | { | ||
| 913 | 7 | Result<HealHit> result = heal_from(landmark, base); | |
| 914 | 7 | Logger &logger = log(); | |
| 915 |
2/2✓ Branch 5 → 6 taken 5 times.
✓ Branch 5 → 27 taken 2 times.
|
7 | if (result) |
| 916 | { | ||
| 917 | // The vtable, unlike the live struct buffer, identifies the image whose RTTI established this layout. The | ||
| 918 | // token is captured across a re-established evidence walk so publication and evidence share one image. | ||
| 919 | 5 | const std::uint64_t generation = bracketed_generation(landmark, *result); | |
| 920 |
2/2✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 22 taken 3 times.
|
5 | if (generation != 0) |
| 921 | { | ||
| 922 | 2 | slot.publish(result->healed_offset, generation, OffsetValidity::Confirmed); | |
| 923 | const std::ptrdiff_t delta = | ||
| 924 | 2 | rtti::detail::saturating_sub(result->healed_offset, landmark.nominal_offset); | |
| 925 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 18 taken 2 times.
|
2 | if (delta != 0) |
| 926 | { | ||
| 927 | ✗ | warn_drift_once(label, delta); | |
| 928 | ✗ | (void)logger.try_log( | |
| 929 | LogLevel::Info, | ||
| 930 | "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})", | ||
| 931 | label, | ||
| 932 | delta, | ||
| 933 | ✗ | landmark.nominal_offset, | |
| 934 | ✗ | result->healed_offset | |
| 935 | ); | ||
| 936 | } | ||
| 937 | else | ||
| 938 | { | ||
| 939 | 2 | (void)logger.try_log( | |
| 940 | LogLevel::Debug, | ||
| 941 | "Self-heal: {} confirmed at nominal {:#x}", | ||
| 942 | label, | ||
| 943 | 2 | landmark.nominal_offset | |
| 944 | ); | ||
| 945 | } | ||
| 946 | 2 | return result; | |
| 947 | } | ||
| 948 | result = | ||
| 949 | 3 | std::unexpected(Error{ErrorCode::OffsetNotConfirmed, "rtti::HealRun::heal_into", result->vtable.raw()}); | |
| 950 | } | ||
| 951 | |||
| 952 | // Miss: keep the retained value (whatever nominal the slot was seeded with) but publish the validity a | ||
| 953 | // slot-only consumer needs: Invalid for a required field (do not consume) or Unverified for an optional one | ||
| 954 | // (best-guess only). A raw atomic slot leaves the dangerous nominal with no in-band signal that the required | ||
| 955 | // heal failed; this channel carries one, so a consumer reading only the slot can still fail closed. | ||
| 956 | 5 | const HealedOffset retained = slot.load(); | |
| 957 |
2/2✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 30 taken 1 time.
|
5 | slot.publish(retained.value, 0, required ? OffsetValidity::Invalid : OffsetValidity::Unverified); |
| 958 | |||
| 959 | 5 | const std::string_view reason = to_string(result.error().code); | |
| 960 |
4/4✓ Branch 34 → 35 taken 4 times.
✓ Branch 34 → 38 taken 1 time.
✓ Branch 35 → 36 taken 3 times.
✓ Branch 35 → 38 taken 1 time.
|
5 | if (required && m_config.escalate == HealEscalation::WarnRequired) |
| 961 | { | ||
| 962 | 3 | (void)logger.try_log( | |
| 963 | LogLevel::Warning, | ||
| 964 | "Self-heal: {} unresolved ({}); kept nominal {:#x} (re-author " | ||
| 965 | "if drifted)", | ||
| 966 | label, | ||
| 967 | reason, | ||
| 968 | 3 | landmark.nominal_offset | |
| 969 | ); | ||
| 970 | } | ||
| 971 | else | ||
| 972 | { | ||
| 973 | 2 | (void)logger.try_log( | |
| 974 | LogLevel::Debug, | ||
| 975 | "Self-heal: {} not resolvable now ({}); keeping nominal {:#x}", | ||
| 976 | label, | ||
| 977 | reason, | ||
| 978 | 2 | landmark.nominal_offset | |
| 979 | ); | ||
| 980 | } | ||
| 981 | 5 | return result; | |
| 982 | } | ||
| 983 | |||
| 984 | ✗ | void rtti::HealRun::note_drift( | |
| 985 | std::string_view label, | ||
| 986 | std::ptrdiff_t nominal_offset, | ||
| 987 | std::ptrdiff_t healed_offset | ||
| 988 | ) noexcept | ||
| 989 | { | ||
| 990 | ✗ | const std::ptrdiff_t delta = rtti::detail::saturating_sub(healed_offset, nominal_offset); | |
| 991 | ✗ | Logger &logger = log(); | |
| 992 | ✗ | if (delta != 0) | |
| 993 | { | ||
| 994 | ✗ | warn_drift_once(label, delta); | |
| 995 | ✗ | (void)logger.try_log( | |
| 996 | LogLevel::Info, | ||
| 997 | "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})", | ||
| 998 | label, | ||
| 999 | delta, | ||
| 1000 | nominal_offset, | ||
| 1001 | healed_offset | ||
| 1002 | ); | ||
| 1003 | } | ||
| 1004 | else | ||
| 1005 | { | ||
| 1006 | ✗ | (void)logger.try_log(LogLevel::Debug, "Self-heal: {} confirmed at nominal {:#x}", label, nominal_offset); | |
| 1007 | } | ||
| 1008 | ✗ | } | |
| 1009 | } // namespace DetourModKit | ||
| 1010 |