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
22 changes: 1 addition & 21 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,27 +400,7 @@ enum DatabaseDriverFactory {
passwordOverride: String? = nil,
awaitPlugins: Bool
) async throws -> DatabaseDriver {
let pluginId = connection.type.pluginTypeId
if PluginManager.shared.driverPlugin(for: connection.type) == nil,
!PluginManager.shared.hasFinishedInitialLoad {
logger.info("Plugin '\(pluginId)' not loaded yet, waiting for background load")
await PluginManager.shared.waitForInitialLoad()
}
if PluginManager.shared.driverPlugin(for: connection.type) == nil,
PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) {
logger.info("Plugin '\(pluginId)' is installed but outdated, updating it before connect")
await PluginManager.shared.ensurePluginReady(forTypeId: pluginId)
}
if PluginManager.shared.driverPlugin(for: connection.type) == nil,
connection.type.isDownloadablePlugin,
!PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) {
logger.info("Plugin '\(pluginId)' not installed, installing on demand before connect")
do {
try await PluginManager.shared.installMissingPlugin(for: connection.type) { _ in }
} catch {
logger.warning("On-demand install for '\(pluginId)' did not complete: \(error.localizedDescription)")
}
}
await PluginManager.shared.prepareForConnecting(to: connection.type)
return try await createDriverFromPlugin(for: connection, passwordOverride: passwordOverride)
}

Expand Down
22 changes: 22 additions & 0 deletions TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,26 @@ extension PluginManager {
rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason
}

func prepareForConnecting(to type: DatabaseType) async {
let typeId = type.pluginTypeId
if driverPlugin(for: type) == nil, !hasFinishedInitialLoad {
Self.logger.info("Plugin '\(typeId)' not loaded yet, waiting for background load")
await waitForInitialLoad()
}
if driverPlugin(for: type) == nil, hasOutdatedRejectedPlugin(forTypeId: typeId) {
Self.logger.info("Plugin '\(typeId)' is installed but outdated, updating it before connect")
await ensurePluginReady(forTypeId: typeId)
}
if driverPlugin(for: type) == nil, type.isDownloadablePlugin, !hasOutdatedRejectedPlugin(forTypeId: typeId) {
Self.logger.info("Plugin '\(typeId)' not installed, installing on demand before connect")
do {
try await installMissingPlugin(for: type) { _ in }
} catch {
Self.logger.warning("On-demand install for '\(typeId)' did not complete: \(error.localizedDescription)")
}
}
}

func ensurePluginReady(forTypeId typeId: String) async {
if reconciliationActive, let task = reconciliationTask {
await task.value
Expand All @@ -287,6 +307,8 @@ extension PluginManager {
private func reconcileOutdated(matchingTypeId typeId: String) async {
let targets = rejectedPlugins.filter { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }
guard !targets.isEmpty else { return }
reconciliationActive = true

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 Avoid marking connect reconciles active without tracking the task

When a background reconciliation has scheduled a delayed retry, reconciliationTask still points at that sleeping retry while reconciliationActive is false. A connect-time reconcile now flips reconciliationActive to true without replacing reconciliationTask, so a second connection attempt during this reconcile enters ensurePluginReady, sees reconciliationActive, and awaits the stale sleeper instead of the in-flight connect reconcile. In the transient-failure path this can unnecessarily block connecting for the configured retry delay (30s/300s/600s) even if the direct reconcile finishes quickly and installs the driver.

Useful? React with 👍 / 👎.

defer { reconciliationActive = false }
await RegistryClient.shared.fetchManifest(forceRefresh: true)
guard let manifest = RegistryClient.shared.manifest else { return }
for target in targets {
Expand Down
16 changes: 9 additions & 7 deletions TablePro/Core/Plugins/Registry/RegistryModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,18 @@ extension RegistryPlugin {
currentKitVersion: Int,
minimumKitVersion: Int
) throws -> RegistryBinary {
let compatible = binaries
let highestInRange = binaries
.filter { $0.architecture == arch }
.filter { binary in
guard let kit = binary.pluginKitVersion else { return false }
return kit >= minimumKitVersion && kit <= currentKitVersion
.compactMap { binary -> (binary: RegistryBinary, kit: Int)? in
guard let kit = binary.pluginKitVersion, kit >= minimumKitVersion, kit <= currentKitVersion else {
return nil
}
return (binary, kit)
}
.max { ($0.pluginKitVersion ?? 0) < ($1.pluginKitVersion ?? 0) }
.max { $0.kit < $1.kit }

if let compatible {
return compatible
if let highestInRange {
return highestInRange.binary
}

throw PluginError.noCompatibleBinary
Expand Down
24 changes: 12 additions & 12 deletions TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ struct PluginManagerReconciliationTests {
)
}

private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) -> RegistryPlugin {
private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) throws -> RegistryPlugin {
let arch = PluginArchitecture.current.rawValue
let binaries = kitVersions
.map { "{\"architecture\": \"\(arch)\", \"downloadURL\": \"https://x\", \"sha256\": \"deadbeef\", \"pluginKitVersion\": \($0)}" }
Expand All @@ -65,7 +65,7 @@ struct PluginManagerReconciliationTests {
"binaries": [\(binaries)]
}
"""
return try! JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8))
return try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8))
}

private func kind(_ action: RejectedPluginAction) -> String {
Expand All @@ -78,8 +78,8 @@ struct PluginManagerReconciliationTests {
}

@Test("rejectedAction awaits while the manifest is still loading")
func rejectedActionAwaitsWithoutManifest() {
let plugin = makeRegistryPlugin(kitVersions: [18])
func rejectedActionAwaitsWithoutManifest() throws {
let plugin = try makeRegistryPlugin(kitVersions: [18])
let action = PluginManager.rejectedAction(
registryPlugin: plugin, manifestLoaded: false, currentKitVersion: 18, minimumKitVersion: 18
)
Expand All @@ -95,35 +95,35 @@ struct PluginManagerReconciliationTests {
}

@Test("rejectedAction offers an update when a current-kit binary exists")
func rejectedActionUpdateAvailable() {
let plugin = makeRegistryPlugin(kitVersions: [17, 18])
func rejectedActionUpdateAvailable() throws {
let plugin = try makeRegistryPlugin(kitVersions: [17, 18])
let action = PluginManager.rejectedAction(
registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18, minimumKitVersion: 18
)
#expect(kind(action) == "updateAvailable")
}

@Test("rejectedAction offers an update for a resilient older-kit binary under a newer app")
func rejectedActionUpdateAvailableForwardCompat() {
let plugin = makeRegistryPlugin(kitVersions: [18])
func rejectedActionUpdateAvailableForwardCompat() throws {
let plugin = try makeRegistryPlugin(kitVersions: [18])
let action = PluginManager.rejectedAction(
registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 19, minimumKitVersion: 18
)
#expect(kind(action) == "updateAvailable")
}

@Test("rejectedAction asks for an app update when only a newer-kit binary exists")
func rejectedActionRequiresAppUpdate() {
let plugin = makeRegistryPlugin(kitVersions: [18, 19])
func rejectedActionRequiresAppUpdate() throws {
let plugin = try makeRegistryPlugin(kitVersions: [18, 19])
let action = PluginManager.rejectedAction(
registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 17, minimumKitVersion: 17
)
#expect(kind(action) == "requiresAppUpdate")
}

@Test("rejectedAction awaits when only pre-floor binaries are published")
func rejectedActionAwaitsForOlderKits() {
let plugin = makeRegistryPlugin(kitVersions: [16, 17])
func rejectedActionAwaitsForOlderKits() throws {
let plugin = try makeRegistryPlugin(kitVersions: [16, 17])
let action = PluginManager.rejectedAction(
registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18, minimumKitVersion: 18
)
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-registry-readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
plugin binaries. With range-aware binary selection an additive PluginKit bump
needs no re-publish (an older resilient binary still serves), so this only fails
after a breaking bump that raised the floor and left the registry behind.

The gate reads the raw GitHub origin, not the jsDelivr CDN that clients use, so
it sees the manifest the moment the registry push lands rather than waiting out
(or racing) the CDN edge cache.
"""

import argparse
import json
import sys
import urllib.request

DEFAULT_MANIFEST_URL = "https://cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json"
DEFAULT_MANIFEST_URL = "https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json"


def fetch_manifest(url, retries=4):
Expand Down
Loading