@@ -34,14 +34,21 @@ open class PassthroughCoreMock: DatadogCoreProtocol, FeatureScope, @unchecked Se
3434 /// Current context that will be passed to feature-scopes.
3535 @ReadWriteLock
3636 public var context : DatadogContext {
37- didSet { send ( message: . context( context) ) }
37+ didSet {
38+ _messageBus. send ( message: context)
39+ _ = messageReceiver. receive ( message: . context( context) , from: self )
40+ }
3841 }
3942
4043 let writer = FileWriterMock ( )
4144
42- /// The message receiver.
45+ /// The legacy `FeatureMessage` receiver.
4346 public var messageReceiver : FeatureMessageReceiver
4447
48+ /// The typed message bus. Owns its own dispatcher table and holds a weak reference
49+ /// to this core so that feature objects storing the bus do not create retain cycles.
50+ private lazy var _messageBus = PassthroughMessageBusMock ( core: self )
51+
4552 /// Callback called when `eventWriteContext` closure is executed.
4653 public var onEventWriteContext : ( ( Bool ) -> Void ) ?
4754
@@ -59,7 +66,7 @@ open class PassthroughCoreMock: DatadogCoreProtocol, FeatureScope, @unchecked Se
5966 self . dataStore = dataStore
6067 self . messageReceiver = messageReceiver
6168
62- messageReceiver. receive ( message: . context( context) , from: self )
69+ messageReceiver. receive ( message: . context( context) , from: NOPDatadogCore ( ) )
6370
6471 PassthroughCoreMock . referenceCount += 1
6572 }
@@ -68,24 +75,65 @@ open class PassthroughCoreMock: DatadogCoreProtocol, FeatureScope, @unchecked Se
6875 PassthroughCoreMock . referenceCount -= 1
6976 }
7077
78+ /// The typed message bus — delegates to `_messageBus`.
79+ public var messageBus : MessageBus { _messageBus }
80+
81+ /// Subscribes `receiver` to the typed bus and, for `DatadogContext` receivers,
82+ /// delivers the current context immediately (mirrors `DatadogCore.add(messageReceiver:forKey:)`).
83+ public func subscribe< Receiver> ( receiver: Receiver ) where Receiver: BusMessageReceiver {
84+ _messageBus. subscribe ( receiver: receiver)
85+ if let currentContext = context as? Receiver . Message {
86+ receiver. receive ( message: currentContext, from: self )
87+ }
88+ }
89+
90+ /// Removes `receiver` from the typed bus.
91+ public func unsubscribe< Receiver> ( receiver: Receiver ) where Receiver: BusMessageReceiver {
92+ _messageBus. unsubscribe ( receiver: receiver)
93+ }
94+
95+ /// Delivers `message` to the typed bus.
96+ public func send< Message> ( message: Message , else fallback: @escaping ( ) -> Void ) where Message: BusMessage {
97+ _messageBus. send ( message: message, else: fallback)
98+ }
99+
71100 /// no-op
72101 public func register< T> ( feature: T ) throws where T: DatadogFeature { }
73102 /// no-op
74103 public func feature< T> ( named name: String , type: T . Type ) -> T ? { nil }
75104
76- /// Always returns a feature-scope.
105+ /// Returns a feature-scope backed by a weak reference to avoid retain cycles .
77106 public func scope< T> ( for featureType: T . Type ) -> FeatureScope where T: DatadogFeature {
78- self
107+ PassthroughScopeMock ( core : self )
79108 }
80109
81110 public func set< Context> ( context: @escaping ( ) -> Context ? ) where Context: AdditionalContext {
82111 self . context. set ( additionalContext: context ( ) )
83112 }
84113
85- public func send( message: FeatureMessage , else fallback: ( ) -> Void ) {
86- if !messageReceiver. receive ( message: message, from: self ) {
87- fallback ( )
114+ public func send( message: FeatureMessage , else fallback: @escaping ( ) -> Void ) {
115+ var handled = messageReceiver. receive ( message: message, from: self )
116+
117+ // Bridge legacy FeatureMessage senders to the typed bus so BusMessageReceiver
118+ // subscribers receive messages during the gradual migration to the typed bus.
119+ switch message {
120+ case let . telemetry( msg) :
121+ _messageBus. send ( message: msg, else: { } )
122+ handled = true
123+ case let . payload( value) :
124+ if let crash = value as? Crash {
125+ // Deliver to typed bus; if no subscriber is registered, preserve the
126+ // original fallback so callers can detect the unhandled case.
127+ _messageBus. send ( message: crash, else: {
128+ if !handled { fallback ( ) }
129+ } )
130+ return
131+ }
132+ default :
133+ break
88134 }
135+
136+ if !handled { fallback ( ) }
89137 }
90138
91139 /// no-op
@@ -137,3 +185,84 @@ open class PassthroughCoreMock: DatadogCoreProtocol, FeatureScope, @unchecked Se
137185 return nil
138186 }
139187}
188+
189+ /// A `FeatureScope` backed by a **weak** reference to a `PassthroughCoreMock`.
190+ ///
191+ /// Returned by `PassthroughCoreMock.scope(for:)` so that receivers holding the scope
192+ /// do not retain the mock core, preventing retain cycles in tests.
193+ public final class PassthroughScopeMock : FeatureScope , @unchecked Sendable {
194+ private weak var core : PassthroughCoreMock ?
195+
196+ public init ( core: PassthroughCoreMock ) {
197+ self . core = core
198+ }
199+
200+ public func eventWriteContext( bypassConsent: Bool , _ block: @escaping ( DatadogContext , Writer ) -> Void ) {
201+ core? . eventWriteContext ( bypassConsent: bypassConsent, block)
202+ }
203+
204+ public func context( _ block: @escaping ( DatadogContext ) -> Void ) {
205+ core? . context ( block)
206+ }
207+
208+ public var dataStore : DataStore { core? . dataStore ?? NOPDataStore ( ) }
209+
210+ public var telemetry : Telemetry { core? . telemetry ?? NOPTelemetry ( ) }
211+
212+ public func send( message: FeatureMessage , else fallback: @escaping ( ) -> Void ) {
213+ if let core = core { core. send ( message: message, else: fallback) } else { fallback ( ) }
214+ }
215+
216+ public func set< Context> ( context: @escaping ( ) -> Context ? ) where Context: AdditionalContext {
217+ core? . set ( context: context)
218+ }
219+
220+ public func set( anonymousId: String ? ) {
221+ core? . set ( anonymousId: anonymousId)
222+ }
223+ }
224+
225+ /// A standalone `MessageBus` returned by `PassthroughCoreMock.messageBus`.
226+ ///
227+ /// Owns its own dispatcher table and holds the core **weakly** so that feature objects
228+ /// (e.g. `FatalErrorContextNotifier`) that store the bus do not create retain cycles
229+ /// back to the mock core.
230+ public final class PassthroughMessageBusMock : MessageBus , @unchecked Sendable {
231+ private typealias Dispatch = ( any BusMessage , DatadogCoreProtocol ) -> Void
232+
233+ private weak var core : ( any DatadogCoreProtocol ) ?
234+
235+ /// Per-message-key dispatchers, keyed by receiver identity.
236+ private var dispatchers : [ String : [ ObjectIdentifier : Dispatch ] ] = [ : ]
237+
238+ public init ( core: DatadogCoreProtocol ) {
239+ self . core = core
240+ }
241+
242+ public func subscribe< Receiver> ( receiver: Receiver ) where Receiver: BusMessageReceiver {
243+ let id = ObjectIdentifier ( receiver)
244+ dispatchers [ Receiver . Message. key, default: [ : ] ] [ id] = { message, core in
245+ guard let typed = message as? Receiver . Message else {
246+ return
247+ }
248+ receiver. receive ( message: typed, from: core)
249+ }
250+ }
251+
252+ public func unsubscribe< Receiver> ( receiver: Receiver ) where Receiver: BusMessageReceiver {
253+ let id = ObjectIdentifier ( receiver)
254+ dispatchers [ Receiver . Message. key] ? . removeValue ( forKey: id)
255+ }
256+
257+ public func send< Message> ( message: Message , else fallback: @escaping ( ) -> Void ) where Message: BusMessage {
258+ guard let core = core else {
259+ fallback ( )
260+ return
261+ }
262+ guard let bucket = dispatchers [ Message . key] , !bucket. isEmpty else {
263+ fallback ( )
264+ return
265+ }
266+ bucket. values. forEach { $0 ( message, core) }
267+ }
268+ }
0 commit comments