From 58f1df57fb1ba46d897941684be5e3631fefb3ec Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:48:28 +0000 Subject: [PATCH 1/2] fix(bridge): defer dylib init off the constructor (macOS 26 dyld init-order) Move all ObjC/Foundation/IMCore work out of the __attribute__((constructor)) into a bridgeBootstrap() dispatched on the main queue. macOS 26 tightened dyld initializer ordering for platform apps; touching Foundation at constructor time can run before libSystem finishes bootstrapping and abort Messages on launch. The constructor now only enqueues (a libdispatch call, no synchronous ObjC). Based on v0.11.0; constructor change only. --- Sources/IMsgHelper/IMsgInjected.m | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 356d63f..9988dff 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -4371,8 +4371,13 @@ static void startV2InboxWatcher(void) { #pragma mark - Dylib Entry Point -__attribute__((constructor)) -static void injectedInit(void) { +// Bridge bootstrap. Intentionally NOT run from the dylib constructor: macOS 26 +// tightened dyld initializer ordering for platform/system apps, so touching +// ObjC/Foundation/IMCore at constructor time can execute before libSystem has +// finished bootstrapping ("dyld initialized but libSystem has not") and abort +// Messages.app on launch. injectedInit() defers this onto the main queue, which +// only drains once the process is fully initialized. +static void bridgeBootstrap(void) { NSLog(@"[imsg-bridge] Dylib injected into %@", [[NSProcessInfo processInfo] processName]); // Connect to IMDaemon for full IMCore access @@ -4413,6 +4418,15 @@ static void injectedInit(void) { }); } +__attribute__((constructor)) +static void injectedInit(void) { + // Keep the constructor tiny: only enqueue onto the main queue (a libdispatch + // call, no synchronous ObjC message dispatch). All ObjC/Foundation/IMCore + // work happens later in bridgeBootstrap, after the runloop is live — see the + // comment there for the macOS 26 dyld init-order rationale. + dispatch_async(dispatch_get_main_queue(), ^{ bridgeBootstrap(); }); +} + __attribute__((destructor)) static void injectedCleanup(void) { NSLog(@"[imsg-bridge] Cleaning up..."); From 9b3bdaebf1f2e3ee6f6de99245f74c87ced7bd8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 08:18:18 +0100 Subject: [PATCH 2/2] fix(bridge): delay injected bootstrap after main queue starts --- CHANGELOG.md | 3 + Sources/IMsgHelper/IMsgInjected.m | 100 +++++++++++------- .../BridgeCommandRegistrationTests.swift | 31 ++++++ 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3414fae..f6fad4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ ### Local Lookups - feat: add `--local` modes for `account`, `whois`, and `nickname` so common introspection can read local history or Contacts without launching the IMCore bridge (#132, thanks @ranaroussi). +### Advanced IMCore +- fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine). + ## 0.10.0 - 2026-05-28 ### Watch diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 9988dff..e72c930 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -4375,56 +4375,74 @@ static void startV2InboxWatcher(void) { // tightened dyld initializer ordering for platform/system apps, so touching // ObjC/Foundation/IMCore at constructor time can execute before libSystem has // finished bootstrapping ("dyld initialized but libSystem has not") and abort -// Messages.app on launch. injectedInit() defers this onto the main queue, which -// only drains once the process is fully initialized. +// Messages.app on launch. injectedInit() only schedules this delayed bootstrap; +// the lock file is written after the watchers are installed below. static void bridgeBootstrap(void) { - NSLog(@"[imsg-bridge] Dylib injected into %@", [[NSProcessInfo processInfo] processName]); - - // Connect to IMDaemon for full IMCore access - Class daemonClass = NSClassFromString(@"IMDaemonController"); - if (daemonClass) { - id daemon = [daemonClass performSelector:@selector(sharedInstance)]; - if (daemon && [daemon respondsToSelector:@selector(connectToDaemon)]) { - [daemon performSelector:@selector(connectToDaemon)]; - NSLog(@"[imsg-bridge] Connected to IMDaemon"); - } else { - NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect"); - } - } else { - NSLog(@"[imsg-bridge] IMDaemonController class not found"); - } - - // Delay initialization to let Messages.app fully start - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ - NSLog(@"[imsg-bridge] Initializing after delay..."); + static dispatch_once_t once; + dispatch_once(&once, ^{ + @autoreleasepool { + initFilePaths(); + NSLog(@"[imsg-bridge] Dylib injected into %@", + [[NSProcessInfo processInfo] processName]); + debugLog(@"bootstrap starting in process=%@ pid=%d", + [[NSProcessInfo processInfo] processName], getpid()); + + // Connect to IMDaemon for full IMCore access + Class daemonClass = NSClassFromString(@"IMDaemonController"); + if (daemonClass) { + id daemon = [daemonClass performSelector:@selector(sharedInstance)]; + if (daemon && [daemon respondsToSelector:@selector(connectToDaemon)]) { + [daemon performSelector:@selector(connectToDaemon)]; + NSLog(@"[imsg-bridge] Connected to IMDaemon"); + debugLog(@"connected to IMDaemon"); + } else { + NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect"); + debugLog(@"IMDaemonController available but couldn't connect"); + } + } else { + NSLog(@"[imsg-bridge] IMDaemonController class not found"); + debugLog(@"IMDaemonController class not found"); + } - // Log IMCore status - Class registryClass = NSClassFromString(@"IMChatRegistry"); - if (registryClass) { - id registry = [registryClass performSelector:@selector(sharedInstance)]; - if ([registry respondsToSelector:@selector(allExistingChats)]) { - NSArray *chats = [registry performSelector:@selector(allExistingChats)]; - NSLog(@"[imsg-bridge] IMChatRegistry available with %lu chats", - (unsigned long)chats.count); + NSLog(@"[imsg-bridge] Initializing after delay..."); + + // Log IMCore status + Class registryClass = NSClassFromString(@"IMChatRegistry"); + if (registryClass) { + id registry = [registryClass performSelector:@selector(sharedInstance)]; + if ([registry respondsToSelector:@selector(allExistingChats)]) { + NSArray *chats = [registry performSelector:@selector(allExistingChats)]; + NSLog(@"[imsg-bridge] IMChatRegistry available with %lu chats", + (unsigned long)chats.count); + debugLog(@"IMChatRegistry available chats=%lu", + (unsigned long)chats.count); + } + } else { + NSLog(@"[imsg-bridge] IMChatRegistry NOT available"); + debugLog(@"IMChatRegistry not available"); } - } else { - NSLog(@"[imsg-bridge] IMChatRegistry NOT available"); - } - probeSelectors(); - startFileWatcher(); - startV2InboxWatcher(); - registerEventObservers(); + probeSelectors(); + startFileWatcher(); + startV2InboxWatcher(); + registerEventObservers(); + debugLog(@"bootstrap complete"); + } }); } __attribute__((constructor)) static void injectedInit(void) { - // Keep the constructor tiny: only enqueue onto the main queue (a libdispatch - // call, no synchronous ObjC message dispatch). All ObjC/Foundation/IMCore - // work happens later in bridgeBootstrap, after the runloop is live — see the - // comment there for the macOS 26 dyld init-order rationale. - dispatch_async(dispatch_get_main_queue(), ^{ bridgeBootstrap(); }); + // Keep the constructor tiny: only enqueue onto the main queue with + // libdispatch. Start the startup delay from that first main-queue turn so + // bridgeBootstrap cannot become ready to run before Messages services the + // queue for the first time. + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + bridgeBootstrap(); + }); + }); } __attribute__((destructor)) diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index 65da985..3f0bc11 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -148,6 +148,37 @@ func injectedHelperWiresNativePollSend() throws { )) } +@Test +func injectedHelperConstructorOnlySchedulesDelayedBootstrap() throws { + let testFile = URL(fileURLWithPath: #filePath) + let repoRoot = + testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let helper = repoRoot.appendingPathComponent("Sources/IMsgHelper/IMsgInjected.m") + let source = stripObjectiveCComments(try String(contentsOf: helper, encoding: .utf8)) + let constructorBody = try #require(functionBody(named: "injectedInit", in: source)) + let bootstrapBody = try #require(functionBody(named: "bridgeBootstrap", in: source)) + + #expect(constructorBody.contains("dispatch_after")) + #expect(constructorBody.contains("dispatch_async")) + #expect(constructorBody.contains("bridgeBootstrap();")) + #expect(!constructorBody.contains("NSLog")) + #expect(!constructorBody.contains("NSProcessInfo")) + #expect(!constructorBody.contains("NSClassFromString")) + #expect(!constructorBody.contains("IMDaemonController")) + #expect(!constructorBody.contains("startFileWatcher")) + #expect(!constructorBody.contains("startV2InboxWatcher")) + + #expect(bootstrapBody.contains("dispatch_once")) + #expect(bootstrapBody.contains("@autoreleasepool")) + #expect(bootstrapBody.contains("connectToDaemon")) + #expect(bootstrapBody.contains("startFileWatcher()")) + #expect(bootstrapBody.contains("startV2InboxWatcher()")) + #expect(bootstrapBody.contains("registerEventObservers()")) +} + private func stripObjectiveCComments(_ source: String) -> String { source .replacingOccurrences(of: #"/\*[\s\S]*?\*/"#, with: "", options: .regularExpression)