From 233a08abecf0f5db24f1f36c35e20c5b9df55fef Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 24 Jul 2026 15:31:46 +0800 Subject: [PATCH 1/5] chore: add manifest cache isolation marker for worktree builds SwiftPM caches manifest evaluations keyed by file content hash. The main checkout and this worktree share identical Package.swift bytes but resolve ../../ local-dependency candidates to different paths, so either side could be served the other's cached evaluation (observed as local path dependencies silently degrading to remote tags mid-session). A trailing comment makes the content hashes differ. --- RuntimeViewerCore/Package.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index a8c8f368..991c2b43 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -245,3 +245,8 @@ extension SwiftSetting { static let nonisolatedNonsendingByDefault: Self = .enableUpcomingFeature("NonisolatedNonsendingByDefault") // SE-0461, Swift 6.2, SwiftPM 6.2+ static let immutableWeakCaptures: Self = .enableUpcomingFeature("ImmutableWeakCaptures") // SE-0481, Swift 6.2, SwiftPM 6.2+ } + +// NOTE: This branch intentionally diverges from main in this manifest so that +// SwiftPM's content-hash-keyed manifest cache cannot serve an evaluation made +// in the main checkout (where relative local-dependency candidates resolve to +// sibling checkouts) to builds running in this worktree, or vice versa. From a4f44a98a97385ff6ac9f6c839ffa86efa7d99b3 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Fri, 24 Jul 2026 18:38:10 +0800 Subject: [PATCH 2/5] refactor(RuntimeViewerCore): bridge NodeReference type names in specialization node building MachOSwiftSection's declaration values now hold NodeReference (Stage 5a); the two tree-building sites materialize the small type-name subtrees on demand. All mangleAsString call sites pass through the generic DemanglingNode bridge unchanged. --- .../Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index 3b2fb819..588478b6 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -909,7 +909,7 @@ extension RuntimeSwiftSection { switch runtimeArgument { case .candidate(let runtimeCandidate): let matched = try matchUpstreamCandidate(runtimeCandidate, in: parameter) - return (.candidate(matched), matched.typeName.node) + return (.candidate(matched), matched.typeName.node.materialize()) case .boundGeneric(let runtimeBase, let innerRuntimeArguments): guard depth < Self.maxSpecializationDepth else { throw RuntimeEngine.EngineError.boundGenericInnerFailed( @@ -996,7 +996,7 @@ extension RuntimeSwiftSection { case .class: boundKind = .boundGenericClass case .enum: boundKind = .boundGenericEnum } - let baseNode = wrappedAsType(base.typeName.node) + let baseNode = wrappedAsType(base.typeName.node.materialize()) let normalizedInners = innerNodes.map(wrappedAsType) let typeList = Node.create(kind: .typeList, children: normalizedInners) let boundNode = Node.create(kind: boundKind, children: [baseNode, typeList]) From f41648a8444097b1bc00e1b32d40f3afd60ad85d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 25 Jul 2026 20:33:13 +0800 Subject: [PATCH 3/5] fix: release per-image index state instead of pinning it for the engine's lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeSection(for:)` dropped its `sections` entry and reclaimed nothing: the per-image indexer is also registered with the aggregate via `setupForFactory` → `indexer.addSubIndexer(...)`, and *that* reference is what keeps the image's declaration graph — including the `NodeStore` its definitions hold — resident. The aggregate lives as long as its factory, i.e. as long as the owning `RuntimeEngine`, and `addSubIndexer` had no inverse at all, so per-image state accumulated monotonically while browsing and could never be released. - Add `removeSubIndexer(_:)` to `RuntimeSwiftInterfaceIndexer` (undoing both the local registration and the upstream one) and to `RuntimeObjCInterfaceIndexer`. - Detach the sub-indexer in both factories' `removeSection` / `removeAllSections` before dropping the entry, so those methods now actually free memory. - Call the teardown from `RuntimeEngine.stop()`. A stopped engine is usually deallocated right after, which would free this anyway — but anything that outlives the stop while holding the engine (an abandoned probe task, a suspended request) would otherwise keep the full indexed graph of every image the user ever opened resident. Releasing explicitly bounds that to the engine object itself. - Capture `engine` weakly in `pollUntilPeerAnswers`'s probe task. The function deliberately abandons rather than awaits a stuck probe, and `cancel()` cannot interrupt an XPC send that ignores cancellation, so a strong capture pinned the engine indefinitely. The engine stays alive across a normal poll because the caller awaits the function. Deliberately NOT changed: `RuntimeMessageChannel`'s optional request timeout. `finishReceiving` already drains every pending request and resumes each with an error, so a dead peer unblocks its awaiters on channel teardown; the only remaining hang needs a healthy channel plus a silent peer, and it holds small Codable values rather than any index state. Forcing a default timeout would break legitimately long forwarded operations for no memory benefit. Verified by building RuntimeViewerCore and RuntimeViewerPackages; the runtime effect (memory actually dropping after an engine stops) still wants a real memory-graph check in the app. --- .../Core/RuntimeObjCSection.swift | 10 ++++++++- .../Core/RuntimeSwiftSection.swift | 12 ++++++++++- .../RuntimeObjCInterfaceIndexer.swift | 11 ++++++++++ .../RuntimeSwiftInterfaceIndexer.swift | 15 +++++++++++++ .../RuntimeViewerCore/RuntimeEngine.swift | 21 +++++++++++++++++++ .../Engine/RuntimeEngineManager.swift | 11 +++++++++- 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index a86f529e..5b711fd8 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -584,10 +584,18 @@ actor RuntimeObjCSectionFactory { } func removeSection(for imagePath: String) { - sections.removeValue(forKey: imagePath) + // Detach the per-image indexer from the aggregate as well: the + // `addSubIndexer` registration above, not the `sections` entry, is what + // keeps the image's indexed state alive. + if let section = sections.removeValue(forKey: imagePath) { + objcInterfaceIndexer.removeSubIndexer(section.objcIndexer) + } } func removeAllSections() { + for section in sections.values { + objcInterfaceIndexer.removeSubIndexer(section.objcIndexer) + } sections.removeAll() } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index 588478b6..94775a86 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -1427,7 +1427,14 @@ actor RuntimeSwiftSectionFactory { } func removeSection(for imagePath: String) { - sections.removeValue(forKey: imagePath) + // Detach the per-image indexer from the aggregate first. `setupForFactory` + // registered it via `indexer.addSubIndexer`, and that registration — not + // the `sections` entry — is what keeps the image's declaration graph (and + // the `NodeStore` its definitions reference) alive. Dropping only the + // `sections` entry reclaimed nothing. + if let section = sections.removeValue(forKey: imagePath) { + indexer.removeSubIndexer(section.indexer) + } // Drop any mangledID entries originating from this image so a // subsequent `addSubIndexer`-driven re-register can repopulate them. indexedTypeByCandidateID = indexedTypeByCandidateID.filter { _, value in @@ -1439,6 +1446,9 @@ actor RuntimeSwiftSectionFactory { } func removeAllSections() { + for section in sections.values { + indexer.removeSubIndexer(section.indexer) + } sections.removeAll() indexedTypeByCandidateID.removeAll() indexedProtocolByCandidateID.removeAll() diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift index 86c30e4f..91cacd77 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift @@ -605,6 +605,17 @@ public final class RuntimeObjCInterfaceIndexer: @unchecked Sendable { public func addSubIndexer(_ subIndexer: RuntimeObjCInterfaceIndexer) { _subIndexers.withLock { $0.append(subIndexer) } } + + /// Detaches a per-image indexer registered by `addSubIndexer(_:)`. No-op + /// when it was never registered. + /// + /// The inverse was missing, so registration pinned every per-image indexer + /// for the aggregate's lifetime and `RuntimeObjCSectionFactory.removeSection` + /// reclaimed nothing. Mirrors + /// `RuntimeSwiftInterfaceIndexer.removeSubIndexer(_:)`. + public func removeSubIndexer(_ subIndexer: RuntimeObjCInterfaceIndexer) { + _subIndexers.withLock { $0.removeAll { $0 === subIndexer } } + } } // MARK: - Events diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift index 3b92e1cd..91ffbef9 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift @@ -282,4 +282,19 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { upstream.addSubIndexer(subIndexer.upstream) _subIndexers.withLock { $0.append(subIndexer) } } + + /// Detach a per-image indexer registered by `addSubIndexer(_:)`, undoing + /// both halves of that registration. No-op when it was never registered. + /// + /// Without this inverse, registration was permanent: the aggregate — which + /// lives as long as its `RuntimeSwiftSectionFactory`, i.e. as long as the + /// owning `RuntimeEngine` — kept every per-image indexer and its entire + /// declaration graph alive, so `RuntimeSwiftSectionFactory.removeSection` + /// could drop its `sections` entry and still reclaim nothing. Detaching here + /// releases the last reference, letting the sub-indexer deinit and evict its + /// `SymbolIndexStore` entry. + func removeSubIndexer(_ subIndexer: RuntimeSwiftInterfaceIndexer) { + upstream.removeSubIndexer(subIndexer.upstream) + _subIndexers.withLock { $0.removeAll { $0 === subIndexer } } + } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index 984a4a5d..eb96ee30 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -343,9 +343,30 @@ public actor RuntimeEngine { connectionStateCancellable = nil connection?.stop() stateSubject.send(.disconnected(error: nil)) + releaseIndexedSections() #log(.info, "RuntimeEngine stopped") } + /// Drops every per-image section this engine indexed, releasing the + /// declaration graphs (and the `NodeStore`s their definitions reference). + /// + /// A stopped engine is normally deallocated right after, which would free + /// this anyway — but "normally" is doing a lot of work: anything that + /// outlives the stop while holding the engine (an abandoned probe task, a + /// suspended request) would otherwise keep the *entire* indexed graph of + /// every image the user ever opened resident. Releasing explicitly bounds + /// that damage to the engine object itself. The factories are actors, so + /// this hops off the synchronous `stop()`; the tasks capture only the + /// factories, never `self`. + private func releaseIndexedSections() { + let swiftSectionFactory = swiftSectionFactory + let objcSectionFactory = objcSectionFactory + Task { + await swiftSectionFactory.removeAllSections() + await objcSectionFactory.removeAllSections() + } + } + /// Defensive backstop for paths that drop an engine without calling `stop()`. /// /// The primary teardown is the explicit `stop()` above (invoked by diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift index 31d51ff2..a6098dd9 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Engine/RuntimeEngineManager.swift @@ -485,8 +485,17 @@ public final class RuntimeEngineManager { private static func pollUntilPeerAnswers(engine: RuntimeEngine, timeout: TimeInterval) async -> Bool { let (stream, continuation) = AsyncStream.makeStream() - let probeTask = Task { + // `engine` is captured weakly on purpose. As the comment above says, a + // stuck probe is abandoned rather than awaited, and `probeTask.cancel()` + // below cannot interrupt an XPC send that ignores cancellation — so this + // task can outlive the poll indefinitely. Holding the engine strongly + // there would pin it (and everything it indexed) for the rest of the + // process; weakly, the abandoned probe simply exits once the manager + // releases the engine. The engine stays alive for the duration of a + // normal poll because the caller is awaiting this function. + let probeTask = Task { [weak engine] in while !Task.isCancelled { + guard let engine else { return } if (try? await engine.requestEngineList(timeout: 3)) != nil { continuation.yield(true) return From 751c1b6ddc95bdaf4903316783a8bbd64f8b6e91 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 21:11:14 +0800 Subject: [PATCH 4/5] refactor(core): adopt the descriptor-backed TypeDefinition API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MachOSwiftSection's memory work (its evolution 0002) slims TypeDefinition and ExtensionDefinition down to descriptor-backed storage: the eagerly materialized TypeContextWrapper stored property is gone and typeContextDescriptorWrapper is exposed directly on the definition. Update the eight consuming sites (generic-flag checks, class hierarchy dumping, specialization request building, and the interface indexer) to the direct accessor; every call is mechanical, no behavior change. Requires a MachOSwiftSection release that includes that work — the exact: pin in RuntimeViewerCore/Package.swift must be bumped alongside merging this, since 0.14.1 still ships the stored-wrapper shape. --- .../Core/RuntimeSwiftSection.swift | 18 +++++++++--------- .../RuntimeSwiftInterfaceIndexer.swift | 3 +-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift index 94775a86..cd61e878 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift @@ -225,7 +225,7 @@ actor RuntimeSwiftSection { let allChildren = typeChildren + protocolChildren + specializedChildren var properties: RuntimeObject.Properties = [] - if typeDefinition.type.contextDescriptorWrapper.contextDescriptor.layout.flags.isGeneric { + if typeDefinition.typeContextDescriptorWrapper.contextDescriptor.layout.flags.isGeneric { properties.insert(.isGeneric) } let isSpecialized = typeDefinition.isSpecialized @@ -374,12 +374,12 @@ extension RuntimeSwiftSection { guard case .swift(.type(.class)) = object.kind, let classDefinitionName = interfaceDefinitionNameByObject[object.key]?.typeName, let classDefinition = indexer.allTypeDefinitions[classDefinitionName], - case .class(let `class`) = classDefinition.type + case .class(let classDescriptor) = classDefinition.typeContextDescriptorWrapper else { #log(.debug, "No class hierarchy found") return [] } - let hierarchy = try ClassHierarchyDumper(machO: machO).dump(for: `class`.descriptor) + let hierarchy = try ClassHierarchyDumper(machO: machO).dump(for: classDescriptor) #log(.debug, "Class hierarchy: \(hierarchy.count, privacy: .public) levels") return hierarchy } @@ -543,7 +543,7 @@ extension RuntimeSwiftSection { let typeDefinition = indexer.allTypeDefinitions[typeName] else { return nil } var properties: RuntimeObject.Properties = [] - if typeDefinition.type.contextDescriptorWrapper.contextDescriptor.layout.flags.isGeneric { + if typeDefinition.typeContextDescriptorWrapper.contextDescriptor.layout.flags.isGeneric { properties.insert(.isGeneric) } let isSpecialized = typeDefinition.isSpecialized @@ -618,7 +618,7 @@ extension RuntimeSwiftSection { func specializationRequest(for object: RuntimeObject) async throws -> RuntimeSpecializationRequest { do { let typeDefinition = try requireGenericTypeDefinition(for: object) - let upstreamRequest = try specializer.makeRequest(for: typeDefinition.type.typeContextDescriptorWrapper) + let upstreamRequest = try specializer.makeRequest(for: typeDefinition.typeContextDescriptorWrapper) return try makeRuntimeSpecializationRequest(from: upstreamRequest) } catch let error as GenericSpecializer.SpecializerError { throw Self.translate(error) @@ -656,7 +656,7 @@ extension RuntimeSwiftSection { ) candidateSpecializer.maxBindingDepth = Self.maxSpecializationDepth do { - let upstreamRequest = try candidateSpecializer.makeRequest(for: matched.entry.value.type.typeContextDescriptorWrapper) + let upstreamRequest = try candidateSpecializer.makeRequest(for: matched.entry.value.typeContextDescriptorWrapper) return try makeRuntimeSpecializationRequest(from: upstreamRequest) } catch let error as GenericSpecializer.SpecializerError { throw Self.translate(error) @@ -672,7 +672,7 @@ extension RuntimeSwiftSection { ) async throws -> RuntimeObject { try Task.checkCancellation() let baseTypeDefinition = try requireGenericTypeDefinition(for: object) - let upstreamRequest = try specializer.makeRequest(for: baseTypeDefinition.type.typeContextDescriptorWrapper) + let upstreamRequest = try specializer.makeRequest(for: baseTypeDefinition.typeContextDescriptorWrapper) let resolved = try resolveUpstreamArguments(selection.arguments, against: upstreamRequest) try Task.checkCancellation() let upstreamSelection = SpecializationSelection(arguments: resolved.arguments) @@ -730,7 +730,7 @@ extension RuntimeSwiftSection { ) async throws -> RuntimeSpecializationValidation { try Task.checkCancellation() let typeDefinition = try requireGenericTypeDefinition(for: object) - let upstreamRequest = try specializer.makeRequest(for: typeDefinition.type.typeContextDescriptorWrapper) + let upstreamRequest = try specializer.makeRequest(for: typeDefinition.typeContextDescriptorWrapper) let resolved = try resolveUpstreamArguments(selection.arguments, against: upstreamRequest) try Task.checkCancellation() let upstreamSelection = SpecializationSelection(arguments: resolved.arguments) @@ -944,7 +944,7 @@ extension RuntimeSwiftSection { innerSpecializer.maxBindingDepth = Self.maxSpecializationDepth let innerRequest: SpecializationRequest do { - innerRequest = try innerSpecializer.makeRequest(for: innerEntry.value.type.typeContextDescriptorWrapper) + innerRequest = try innerSpecializer.makeRequest(for: innerEntry.value.typeContextDescriptorWrapper) } catch let error as GenericSpecializer.SpecializerError { throw Self.translate(error) } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift index 91ffbef9..131b9e90 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift @@ -166,8 +166,7 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { guard let childKey = try? await mangleAsString(typeName.node) else { continue } typeNameTable[childKey] = typeName - guard case .class(let classWrapper) = typeDefinition.type else { continue } - let classDescriptor = classWrapper.descriptor + guard case .class(let classDescriptor) = typeDefinition.typeContextDescriptorWrapper else { continue } guard let superclassMangled = try? classDescriptor.superclassTypeMangledName(in: machO) else { continue } // Round-trip through demangle + remangle so the superclass key From bea27fb6a5e7282e70dae7f5bd123012c566a103 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 21:11:14 +0800 Subject: [PATCH 5/5] perf(core): demangle one-shot symbols transiently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demangleAsNode interns every node it produces into swift-demangling's global NodeStore, which never evicts — so demangling a symbol just to read one identifier or to immediately remangle it keeps the whole tree resident for the rest of the process. Both such call sites (the ObjC reference probe in RuntimeEngine and the stable-Swift-class bridge in RuntimeRelationshipsResolver) now use demangleAsNodeTransient. A transient tree is not canonical, but remangling stays sound: the remangler's substitution table compares nodes structurally (SubstitutionEntry uses deepEquals), with identity only as a memoization fast path, so the mangled output stays in the same key space as makeRuntimeObject(forMangledTypeName:). Requires a swift-demangling release with the transient API (present on its main since the node-store work merged); the MachOSwiftSection pin bump that accompanies this branch's merge brings it in. --- .../RuntimeRelationshipsResolver.swift | 24 +++++++++++++++---- .../RuntimeViewerCore/RuntimeEngine.swift | 7 +++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift index effc32e8..7f43b701 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift @@ -163,10 +163,26 @@ actor RuntimeRelationshipsResolver { /// than fall back to `.objc(.type(.class))`. private func materializeRelationshipReference(_ reference: ObjCClassReference) async -> RuntimeObject? { if reference.isSwiftStable { - // `demangleAsNode` / `mangleAsString` each ship a sync and an async - // overload; the compiler picks the async one inside this `async` - // context, so the `try?` needs an `await` for the implicit choice. - if let node = try? await demangleAsNode(reference.className, isType: false), + // The tree is discarded the moment it has been remangled, so + // demangle it transiently: `demangleAsNode` interns every node it + // produces into the library's global cache, which never evicts, so + // a one-shot use like this one keeps the whole tree resident for + // the rest of the process. `demangleAsNodeTransient` ships sync + // only — a single-symbol demangle costs microseconds, so there is + // nothing worth hopping for. `mangleAsString` still has both a + // sync and an async overload and the compiler picks the async one + // inside this `async` context, which is why only that line awaits. + // + // A transient tree is not canonical — structurally equal nodes are + // not guaranteed to be distinct instances, and the reverse does not + // hold either — so remangling it would be unsound if the remangler + // keyed its substitution table by node identity. It does not: + // `SubstitutionEntry` compares nodes structurally (`deepEquals`), + // and identity is only ever a memoization fast path that falls back + // to recomputation on a miss. So `swiftMangled` comes out identical + // to what an interned tree would produce, which is what keeps it in + // the same key space as `makeRuntimeObject(forMangledTypeName:)`. + if let node = try? demangleAsNodeTransient(reference.className, isType: false), let swiftMangled = try? await mangleAsString(node), let swiftSection = await swiftSectionFactory.existingSection(for: reference.imagePath), let runtimeObject = await swiftSection.makeRuntimeObject(forMangledTypeName: swiftMangled) { diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift index eb96ee30..77320651 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift @@ -659,8 +659,13 @@ public actor RuntimeEngine { /// name whose nominal node lives in the `__C` (Objective-C) module. /// Returns `nil` for anything that is not an ObjC-imported class or /// protocol — the fallback then declines rather than fabricating a target. + /// + /// Demangles transiently: only the identifier text is read out and the + /// tree is dropped. `demangleAsNode` would intern every node into the + /// library's global cache, which never evicts, so each click on a type + /// would leave a whole tree resident for the rest of the process. private static func objcReference(forSwiftMangledName mangledName: String) -> (name: String, kind: RuntimeObjectKind)? { - guard let node = try? demangleAsNode(mangledName, isType: true), + guard let node = try? demangleAsNodeTransient(mangledName, isType: true), let nominal = firstObjCNominalNode(in: node), let identifier = nominal.children.first(where: { $0.kind == .identifier })?.text else { return nil }