Skip to content

Do not cache NSLocale.current if we fail to fetch preferences #1134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 23, 2025
Merged
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
106 changes: 58 additions & 48 deletions Sources/FoundationEssentials/Locale/Locale_Cache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,30 @@ dynamic package func _localeICUClass() -> _LocaleProtocol.Type {
/// Singleton which listens for notifications about preference changes for Locale and holds cached singletons.
struct LocaleCache : Sendable, ~Copyable {
// MARK: - State

struct State {

init() {
#if FOUNDATION_FRAMEWORK
// For Foundation.framework, we listen for system notifications about the system Locale changing from the Darwin notification center.
_CFNotificationCenterInitializeDependentNotificationIfNecessary(CFNotificationName.cfLocaleCurrentLocaleDidChange!.rawValue)
#endif
}

private var cachedFixedLocales: [String : any _LocaleProtocol] = [:]
private var cachedFixedComponentsLocales: [Locale.Components : any _LocaleProtocol] = [:]

#if FOUNDATION_FRAMEWORK
private var cachedFixedIdentifierToNSLocales: [String : _NSSwiftLocale] = [:]

struct IdentifierAndPrefs : Hashable {
let identifier: String
let prefs: LocalePreferences?
}

private var cachedFixedLocaleToNSLocales: [IdentifierAndPrefs : _NSSwiftLocale] = [:]
#endif

mutating func fixed(_ id: String) -> any _LocaleProtocol {
// Note: Even if the currentLocale's identifier is the same, currentLocale may have preference overrides which are not reflected in the identifier itself.
if let locale = cachedFixedLocales[id] {
Expand All @@ -81,7 +81,7 @@ struct LocaleCache : Sendable, ~Copyable {
return locale
}
}

#if canImport(_FoundationICU)
mutating func fixedNSLocale(_ locale: _LocaleICU) -> _NSSwiftLocale {
let id = IdentifierAndPrefs(identifier: locale.identifier, prefs: locale.prefs)
Expand All @@ -102,33 +102,33 @@ struct LocaleCache : Sendable, ~Copyable {
func fixedComponents(_ comps: Locale.Components) -> (any _LocaleProtocol)? {
cachedFixedComponentsLocales[comps]
}

mutating func fixedComponentsWithCache(_ comps: Locale.Components) -> any _LocaleProtocol {
if let l = fixedComponents(comps) {
return l
} else {
let new = _localeICUClass().init(components: comps)

cachedFixedComponentsLocales[comps] = new
return new
}
}
}

let lock: LockedState<State>

static let cache = LocaleCache()
private let _currentCache = LockedState<(any _LocaleProtocol)?>(initialState: nil)

#if FOUNDATION_FRAMEWORK
private var _currentNSCache = LockedState<_NSSwiftLocale?>(initialState: nil)
#endif

fileprivate init() {
lock = LockedState(initialState: State())
}


/// For testing of `autoupdatingCurrent` only. If you want to test `current`, create a custom `Locale` with the appropriate settings using `localeAsIfCurrent(name:overrides:disableBundleMatching:)` and use that instead.
/// This mutates global state of the current locale, so it is not safe to use in concurrent testing.
func resetCurrent(to preferences: LocalePreferences) {
Expand All @@ -150,32 +150,36 @@ struct LocaleCache : Sendable, ~Copyable {
}

var current: any _LocaleProtocol {
return _currentAndCache.locale
}

fileprivate var _currentAndCache: (locale: any _LocaleProtocol, doCache: Bool) {
if let result = _currentCache.withLock({ $0 }) {
return result
return (result, true)
}

// We need to fetch prefs and try again
let (preferences, doCache) = preferences()
let locale = _localeICUClass().init(name: nil, prefs: preferences, disableBundleMatching: false)

// It's possible this was an 'incomplete locale', in which case we will want to calculate it again later.
if doCache {
return _currentCache.withLock {
if let current = $0 {
// Someone beat us to setting it - use existing one
return current
return (current, true)
} else {
$0 = locale
return locale
return (locale, true)
}
}
} else {
return (locale, false)
}

return locale
}

// MARK: Singletons

// This value is immutable, so we can share one instance for the whole process.
static let unlocalized = _LocaleUnlocalized(identifier: "en_001")

Expand All @@ -185,19 +189,19 @@ struct LocaleCache : Sendable, ~Copyable {
static let system : any _LocaleProtocol = {
_localeICUClass().init(identifier: "", prefs: nil)
}()

#if FOUNDATION_FRAMEWORK
static let autoupdatingCurrentNSLocale : _NSSwiftLocale = {
_NSSwiftLocale(Locale(inner: autoupdatingCurrent))
}()

static let systemNSLocale : _NSSwiftLocale = {
_NSSwiftLocale(Locale(inner: system))
}()
#endif

// MARK: -

func fixed(_ id: String) -> any _LocaleProtocol {
lock.withLock {
$0.fixed(id)
Expand All @@ -219,19 +223,25 @@ struct LocaleCache : Sendable, ~Copyable {
if let result = _currentNSCache.withLock({ $0 }) {
return result
}

// Create the current _NSSwiftLocale, based on the current Swift Locale.
// n.b. do not call just `current` here; instead, use `_currentAndCache`
// so that the caching status is honored
let (current, doCache) = _currentAndCache
let nsLocale = _NSSwiftLocale(Locale(inner: current))

// TODO: The current locale has an idea of not caching, which we have never honored here in the NSLocale cache
return _currentNSCache.withLock {
if let current = $0 {
// Someone beat us to setting it, use that one
return current
} else {
$0 = nsLocale
return nsLocale

if doCache {
return _currentNSCache.withLock {
if let current = $0 {
// Someone beat us to setting it, use that one
return current
} else {
$0 = nsLocale
return nsLocale
}
}
} else {
return nsLocale
}
}

Expand All @@ -240,7 +250,7 @@ struct LocaleCache : Sendable, ~Copyable {
func fixedComponents(_ comps: Locale.Components) -> any _LocaleProtocol {
lock.withLock { $0.fixedComponentsWithCache(comps) }
}

#if FOUNDATION_FRAMEWORK && !NO_CFPREFERENCES
func preferences() -> (LocalePreferences, Bool) {
// On Darwin, we check the current user preferences for Locale values
Expand All @@ -249,28 +259,28 @@ struct LocaleCache : Sendable, ~Copyable {

var prefs = LocalePreferences()
prefs.apply(cfPrefs)

if wouldDeadlock.boolValue {
// Don't cache a locale built with incomplete prefs
return (prefs, false)
} else {
return (prefs, true)
}
}

func preferredLanguages(forCurrentUser: Bool) -> [String] {
var languages: [String] = []
if forCurrentUser {
languages = CFPreferencesCopyValue("AppleLanguages" as CFString, kCFPreferencesAnyApplication, kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? [String] ?? []
} else {
languages = CFPreferencesCopyAppValue("AppleLanguages" as CFString, kCFPreferencesCurrentApplication) as? [String] ?? []
}

return languages.compactMap {
Locale.canonicalLanguageIdentifier(from: $0)
}
}

func preferredLocale() -> String? {
guard let preferredLocaleID = CFPreferencesCopyAppValue("AppleLocale" as CFString, kCFPreferencesCurrentApplication) as? String else {
return nil
Expand All @@ -288,29 +298,29 @@ struct LocaleCache : Sendable, ~Copyable {
func preferredLanguages(forCurrentUser: Bool) -> [String] {
[Locale.canonicalLanguageIdentifier(from: "en-001")]
}

func preferredLocale() -> String? {
"en_001"
}
#endif

#if FOUNDATION_FRAMEWORK && !NO_CFPREFERENCES
/// This returns an instance of `Locale` that's set up exactly like it would be if the user changed the current locale to that identifier, set the preferences keys in the overrides dictionary, then called `current`.
func localeAsIfCurrent(name: String?, cfOverrides: CFDictionary? = nil, disableBundleMatching: Bool = false) -> Locale {

var (prefs, _) = preferences()
if let cfOverrides { prefs.apply(cfOverrides) }

let inner = _LocaleICU(name: name, prefs: prefs, disableBundleMatching: disableBundleMatching)
return Locale(inner: inner)
}
#endif

/// This returns an instance of `Locale` that's set up exactly like it would be if the user changed the current locale to that identifier, set the preferences keys in the overrides dictionary, then called `current`.
func localeAsIfCurrent(name: String?, overrides: LocalePreferences? = nil, disableBundleMatching: Bool = false) -> Locale {
var (prefs, _) = preferences()
if let overrides { prefs.apply(overrides) }

let inner = _localeICUClass().init(name: name, prefs: prefs, disableBundleMatching: disableBundleMatching)
return Locale(inner: inner)
}
Expand All @@ -334,7 +344,7 @@ struct LocaleCache : Sendable, ~Copyable {

let preferredLanguages = preferredLanguages(forCurrentUser: false)
guard let preferredLocaleID = preferredLocale() else { return nil }

let canonicalizedLocalizations = availableLocalizations.compactMap { Locale.canonicalLanguageIdentifier(from: $0) }
let identifier = Locale.localeIdentifierForCanonicalizedLocalizations(canonicalizedLocalizations, preferredLanguages: preferredLanguages, preferredLocaleID: preferredLocaleID)
guard let identifier else {
Expand Down