Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MySQL and MariaDB queries no longer fail after about a minute when a longer query timeout is set. The client read timeout now follows the configured query timeout, so a long query or stored procedure runs for the full time you allow. (#1921)
- A dropped MySQL or MariaDB connection no longer silently re-runs a statement that changes data, so a lost connection can no longer run an insert, update, or stored procedure twice. (#1921)
- The MongoDB, Oracle, Cassandra, and Elasticsearch plugins failed to install on 0.58 with "Bundle failed to load executable". (#1917)
- Plugin install and load failures now name the real cause (wrong architecture, missing dependency, or incompatibility with this version of TablePro) instead of a generic error. A plugin that fails to load on demand is now reported instead of silently disappearing. (#1915)

## [0.58.0] - 2026-07-18

Expand Down
44 changes: 44 additions & 0 deletions TablePro/Core/Plugins/PluginBundleLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// PluginBundleLoader.swift
// TablePro
//

import Foundation
import os

enum PluginBundleLoader {
private static let logger = Logger(subsystem: "com.TablePro", category: "PluginBundleLoader")

static func load(_ bundle: Bundle) throws {
do {
try bundle.loadAndReturnError()
} catch {
let nsError = error as NSError
let reason = describeLoadFailure(nsError)
let detail = nsError.userInfo[NSDebugDescriptionErrorKey] as? String ?? nsError.localizedDescription
logger.error(
"Bundle load failed for \(bundle.bundleURL.lastPathComponent, privacy: .public) [\(nsError.domain, privacy: .public) \(nsError.code, privacy: .public)]: \(reason, privacy: .public) [\(detail, privacy: .public)]"
)
throw PluginError.invalidBundle(reason)
}
}

static func describeLoadFailure(_ error: NSError) -> String {
switch error.code {
case NSFileNoSuchFileError:
return String(localized: "The plugin's executable file is missing.")
case NSExecutableNotLoadableError:
return String(localized: "The plugin's executable couldn't be loaded. It may be damaged or improperly signed.")
case NSExecutableArchitectureMismatchError:
return String(localized: "The plugin doesn't include a build for this Mac's processor architecture.")
case NSExecutableRuntimeMismatchError:
return String(localized: "The plugin was built for an incompatible runtime.")
case NSExecutableLoadError:
return String(localized: "The plugin depends on a component that's missing or incompatible with this Mac.")
case NSExecutableLinkError:
return String(localized: "The plugin isn't compatible with this version of TablePro. Update the app or reinstall the plugin.")
default:
return error.localizedFailureReason ?? error.localizedDescription
}
}
}
11 changes: 6 additions & 5 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,11 @@ final class PluginManager {
}
}

guard bundle.load() else {
Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent)")
do {
try PluginBundleLoader.load(bundle)
} catch {
Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent): \(error.localizedDescription)")
recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit the rejection event after a lazy load failure

When a lazy bundle fails after initial startup, this only appends to rejectedPlugins; no AppEvents.shared.pluginsRejected event is sent. PluginNotificationService subscribes exclusively to that event (set up in AppDelegate), while the only sender is the initial reconciliation path that has already run, so the newly added rejected-plugin notification is never delivered for this on-demand failure despite the intended reporting behavior.

Useful? React with 👍 / 👎.

return
}

Expand Down Expand Up @@ -546,9 +549,7 @@ final class PluginManager {

try validateBundleVersions(bundle)

guard bundle.load() else {
throw PluginError.invalidBundle("Bundle failed to load executable")
}
try PluginBundleLoader.load(bundle)

return bundle
}
Expand Down
63 changes: 63 additions & 0 deletions TableProTests/Core/Plugins/PluginBundleLoaderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// PluginBundleLoaderTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("PluginBundleLoader.describeLoadFailure")
struct PluginBundleLoaderDescribeLoadFailureTests {
private func makeError(_ code: Int, debug: String? = nil, failureReason: String? = nil) -> NSError {
var userInfo: [String: Any] = [:]
if let debug { userInfo[NSDebugDescriptionErrorKey] = debug }
if let failureReason { userInfo[NSLocalizedFailureReasonErrorKey] = failureReason }
return NSError(domain: NSCocoaErrorDomain, code: code, userInfo: userInfo)
}

@Test("missing executable file reports a missing-file reason")
func missingFile() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSFileNoSuchFileError))
#expect(reason.localizedCaseInsensitiveContains("missing"))
#expect(!reason.contains("Bundle failed to load executable"))
}

@Test("not-loadable executable reports a damaged executable")
func notLoadable() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableNotLoadableError))
#expect(reason.localizedCaseInsensitiveContains("loaded") || reason.localizedCaseInsensitiveContains("damaged"))
}

@Test("architecture mismatch names the processor architecture")
func architectureMismatch() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableArchitectureMismatchError))
#expect(reason.localizedCaseInsensitiveContains("architecture"))
}

@Test("runtime mismatch reports an incompatible runtime")
func runtimeMismatch() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableRuntimeMismatchError))
#expect(reason.localizedCaseInsensitiveContains("runtime"))
}

@Test("missing dependency reports a missing component")
func missingDependency() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableLoadError))
#expect(reason.localizedCaseInsensitiveContains("component"))
}

@Test("link error points at app or plugin incompatibility")
func linkError() {
let reason = PluginBundleLoader.describeLoadFailure(makeError(NSExecutableLinkError))
#expect(reason.contains("TablePro"))
}

@Test("unknown code falls back to the OS-provided reason")
func unknownCodeFallsBack() {
let reason = PluginBundleLoader.describeLoadFailure(
makeError(999_999, failureReason: "Custom underlying reason")
)
#expect(reason == "Custom underlying reason")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing
@testable import TablePro

@Suite("Plugin lazy activation re-verification", .serialized)
@MainActor
Expand Down Expand Up @@ -71,4 +71,65 @@ struct PluginLazyActivationVerificationTests {
#expect(manager.driverPlugins[typeId] == nil)
#expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1)
}

@Test("lazy bundle whose executable fails to load is rejected with a specific reason")
func rejectsBundleThatFailsToLoad() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent("LazyLoadFail-\(UUID().uuidString)", isDirectory: true)
let userPluginsDir = root.appendingPathComponent("Plugins", isDirectory: true)
let bundleURL = userPluginsDir.appendingPathComponent("Broken.tableplugin", isDirectory: true)
let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true)
let macosURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true)
try fm.createDirectory(at: macosURL, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: root) }

let bundleId = "com.TablePro.test.broken.\(UUID().uuidString)"
let typeId = "broken-db-\(UUID().uuidString)"
let info: [String: Any] = [
"CFBundleIdentifier": bundleId,
"CFBundleName": "Broken",
"CFBundleShortVersionString": "1.0.0",
"CFBundleExecutable": "Broken"
]
let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
try infoData.write(to: contentsURL.appendingPathComponent("Info.plist"))
try Data("not a mach-o binary".utf8).write(to: macosURL.appendingPathComponent("Broken"))

let suiteName = "LazyLoadFailTest.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir)
let bundle = try #require(Bundle(url: bundleURL))
manager.plugins = [
PluginEntry(
id: bundleId,
bundle: bundle,
url: bundleURL,
source: .builtIn,
name: "Broken",
version: "1.0.0",
pluginDescription: "",
capabilities: [.databaseDriver],
isEnabled: true,
databaseTypeId: typeId,
additionalTypeIds: [],
pluginIconName: "puzzlepiece",
defaultPort: nil,
exportFormatId: nil,
importFormatId: nil,
inspectorId: nil
)
]

manager.activateLazyBundle(at: bundleURL)

#expect(manager.driverPlugins[typeId] == nil)
let rejection = try #require(manager.rejectedPlugins.first { $0.url == bundleURL })
#expect(!rejection.reason.isEmpty)
#expect(!rejection.reason.contains("Bundle failed to load executable"))

manager.activateLazyBundle(at: bundleURL)
#expect(manager.rejectedPlugins.filter { $0.url == bundleURL }.count == 1)
}
}
Loading