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. 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 3b2fb819..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) @@ -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( @@ -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) } @@ -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]) @@ -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..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 @@ -282,4 +281,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/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 984a4a5d..77320651 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 @@ -638,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 } 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