Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions RuntimeViewerCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<MachOImage>.SpecializerError {
throw Self.translate(error)
Expand Down Expand Up @@ -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<MachOImage>.SpecializerError {
throw Self.translate(error)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<MachOImage>.SpecializerError {
throw Self.translate(error)
}
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand All @@ -1439,6 +1446,9 @@ actor RuntimeSwiftSectionFactory {
}

func removeAllSections() {
for section in sections.values {
indexer.removeSubIndexer(section.indexer)
}
sections.removeAll()
indexedTypeByCandidateID.removeAll()
indexedProtocolByCandidateID.removeAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 27 additions & 1 deletion RuntimeViewerCore/Sources/RuntimeViewerCore/RuntimeEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,8 +485,17 @@ public final class RuntimeEngineManager {
private static func pollUntilPeerAnswers(engine: RuntimeEngine, timeout: TimeInterval) async -> Bool {
let (stream, continuation) = AsyncStream<Bool>.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
Expand Down