mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2026-03-28 06:50:08 +01:00
🐛 Use NSLock with PhpEnvironments
This commit is contained in:
@@ -63,6 +63,11 @@
|
||||
036C39122E5C8D42008DAEDF /* PackagistError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036C390E2E5C8D3B008DAEDF /* PackagistError.swift */; };
|
||||
036C39142E5CB822008DAEDF /* TestBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036C39132E5CB820008DAEDF /* TestBundle.swift */; };
|
||||
036C3A212E5CBBAA008DAEDF /* ValetConfigurationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AF9F76275447F100D44ED0 /* ValetConfigurationTest.swift */; };
|
||||
0386B0B42ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; };
|
||||
0386B0B52ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; };
|
||||
0386B0B62ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; };
|
||||
0386B0B72ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; };
|
||||
0386B0BC2ED36DF800CA6795 /* LockedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B82ED36DF800CA6795 /* LockedTests.swift */; };
|
||||
0392CDE62EB23B8F009176DA /* CertificateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0392CDE52EB23B8F009176DA /* CertificateValidator.swift */; };
|
||||
0392CDE72EB23B8F009176DA /* CertificateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0392CDE52EB23B8F009176DA /* CertificateValidator.swift */; };
|
||||
0392CDE82EB23B8F009176DA /* CertificateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0392CDE52EB23B8F009176DA /* CertificateValidator.swift */; };
|
||||
@@ -1037,6 +1042,8 @@
|
||||
036C39092E5C8CBD008DAEDF /* PackagistP2Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackagistP2Response.swift; sourceTree = "<group>"; };
|
||||
036C390E2E5C8D3B008DAEDF /* PackagistError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackagistError.swift; sourceTree = "<group>"; };
|
||||
036C39132E5CB820008DAEDF /* TestBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestBundle.swift; sourceTree = "<group>"; };
|
||||
0386B0B32ED36C3D00CA6795 /* Locked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = "<group>"; };
|
||||
0386B0B82ED36DF800CA6795 /* LockedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedTests.swift; sourceTree = "<group>"; };
|
||||
0392CDE52EB23B8F009176DA /* CertificateValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateValidator.swift; sourceTree = "<group>"; };
|
||||
0392CDEA2EB25371009176DA /* SecurePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurePopoverView.swift; sourceTree = "<group>"; };
|
||||
0396160C2E74A61B002DD7F6 /* LoggableEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggableEvent.swift; sourceTree = "<group>"; };
|
||||
@@ -1441,6 +1448,14 @@
|
||||
path = Parsers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0386B0BD2ED36E2500CA6795 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0386B0B82ED36DF800CA6795 /* LockedTests.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0396160B2E74A617002DD7F6 /* Analytics */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1459,13 +1474,6 @@
|
||||
path = Http;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
039C291E2E8AA39B007F5FAB /* Api */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Api;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
03ACC6432ECCAAF70070D4CD /* WebApi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -2099,6 +2107,7 @@
|
||||
C4811D2822D70D9C00B5F6B3 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0386B0B32ED36C3D00CA6795 /* Locked.swift */,
|
||||
C476FF9722B0DD830098105B /* Alert.swift */,
|
||||
54B48B5E275F66AE006D90C5 /* Application.swift */,
|
||||
C474B00524C0E98C00066A22 /* LocalNotification.swift */,
|
||||
@@ -2410,12 +2419,12 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C40C7F1C27720E1400DDDCDC /* Test Files */,
|
||||
039C291E2E8AA39B007F5FAB /* Api */,
|
||||
C4C1019927C65A4D001FACC2 /* Commands */,
|
||||
036C39062E5C8890008DAEDF /* Integration */,
|
||||
036C3A232E5CBC57008DAEDF /* Parsers */,
|
||||
03D53E902E8AE089001B1671 /* Testables */,
|
||||
036575C62EA12E2200BA41BF /* Versions */,
|
||||
0386B0BD2ED36E2500CA6795 /* Helpers */,
|
||||
);
|
||||
path = unit;
|
||||
sourceTree = "<group>";
|
||||
@@ -2932,6 +2941,7 @@
|
||||
C4D3660B29113F20006BD146 /* System.swift in Sources */,
|
||||
C4D36601291132B7006BD146 /* ValetScanners.swift in Sources */,
|
||||
03ACC6472ECCBA130070D4CD /* CaskFile+API.swift in Sources */,
|
||||
0386B0B52ED36C3D00CA6795 /* Locked.swift in Sources */,
|
||||
C4EED88927A48778006D7272 /* InterAppHandler.swift in Sources */,
|
||||
C40C7F1E2772136000DDDCDC /* PhpEnvironments.swift in Sources */,
|
||||
C4B79EB629CA387F00A483EE /* BrewPhpFormulaeHandler.swift in Sources */,
|
||||
@@ -3205,6 +3215,7 @@
|
||||
C40D725C2A018ACC0054A067 /* BusyStatus.swift in Sources */,
|
||||
03DAD3A92EB3B08F003417BD /* DomainListVC+Certs.swift in Sources */,
|
||||
C4821C5C2C2DEDE200357A68 /* AppMenu.swift in Sources */,
|
||||
0386B0B42ED36C3D00CA6795 /* Locked.swift in Sources */,
|
||||
0392CDED2EB25371009176DA /* SecurePopoverView.swift in Sources */,
|
||||
C471E81328F9BAE80021E251 /* XibLoadable.swift in Sources */,
|
||||
C4D3661C291173EA006BD146 /* DictionaryExtension.swift in Sources */,
|
||||
@@ -3233,6 +3244,7 @@
|
||||
C40D725D2A018ACC0054A067 /* BusyStatus.swift in Sources */,
|
||||
C471E89628F9BB8F0021E251 /* PMWindowController.swift in Sources */,
|
||||
C471E89728F9BB8F0021E251 /* VersionExtractor.swift in Sources */,
|
||||
0386B0B72ED36C3D00CA6795 /* Locked.swift in Sources */,
|
||||
C47DF1B2299D5A3B0007055D /* LoginItemManager.swift in Sources */,
|
||||
C4E2E86728FC2F1B003B070C /* XCPMApplication.swift in Sources */,
|
||||
C471E89828F9BB8F0021E251 /* ValetProxy.swift in Sources */,
|
||||
@@ -3479,6 +3491,7 @@
|
||||
C4D9ADC0277610E1007277F4 /* PhpSwitcher.swift in Sources */,
|
||||
C485707528BF454F00539B36 /* StatsView.swift in Sources */,
|
||||
C4F780CC25D80B75000DBC97 /* ActivePhpInstallation.swift in Sources */,
|
||||
0386B0B62ED36C3D00CA6795 /* Locked.swift in Sources */,
|
||||
54D9E0BB27E4F51E003B9AD9 /* ModifierFlagsExtension.swift in Sources */,
|
||||
03D846332EB64E39006EFE3C /* CrashReporter.swift in Sources */,
|
||||
C485707328BF454300539B36 /* OnboardingView.swift in Sources */,
|
||||
@@ -3620,6 +3633,7 @@
|
||||
C485707228BF453800539B36 /* SwiftUIHelper.swift in Sources */,
|
||||
03C29A792EC88E3100FBA25E /* ValetServicesDataManager.swift in Sources */,
|
||||
C4EA3C482BA4F947007B0BA7 /* CustomButtonStyles.swift in Sources */,
|
||||
0386B0BC2ED36DF800CA6795 /* LockedTests.swift in Sources */,
|
||||
C4F2E43B27530F750020E974 /* PhpInstallation.swift in Sources */,
|
||||
C4F780BD25D80B65000DBC97 /* Constants.swift in Sources */,
|
||||
036C39042E5C883B008DAEDF /* Packagist.swift in Sources */,
|
||||
|
||||
56
phpmon/Common/Helpers/Locked.swift
Normal file
56
phpmon/Common/Helpers/Locked.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Locked.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A thread-safe wrapper for a value that can be accessed from multiple threads.
|
||||
|
||||
Uses `NSLock` internally to ensure only one thread can read or write at a time,
|
||||
preventing race conditions where two threads might try to access or modify
|
||||
the value simultaneously.
|
||||
|
||||
## Usage
|
||||
|
||||
```swift
|
||||
private let _counter = Locked<Int>(0)
|
||||
var counter: Int {
|
||||
get { _counter.value }
|
||||
set { _counter.value = newValue }
|
||||
}
|
||||
```
|
||||
|
||||
Without locking, if Thread A reads a value while Thread B is writing to it,
|
||||
Thread A might see a partially-written or inconsistent state, leading to crashes
|
||||
or corrupted data. The lock ensures operations happen one at a time.
|
||||
|
||||
Use with care. Using structured concurrency w/ `actor` or delegating to
|
||||
`MainActor` is generally preferred, but this approach may be necessary in
|
||||
situations where adopting structured concurrency would otherwise be
|
||||
too challenging or a huge refactor.
|
||||
*/
|
||||
final class Locked<T> {
|
||||
private var _value: T
|
||||
private let lock = NSLock()
|
||||
|
||||
init(_ value: T) {
|
||||
self._value = value
|
||||
}
|
||||
|
||||
var value: T {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _value
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,14 +106,28 @@ class PhpEnvironments {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread-Safe PHP Version Storage
|
||||
|
||||
/** All versions of PHP that are currently supported. */
|
||||
var availablePhpVersions: [String] = []
|
||||
private let _availablePhpVersions = Locked<[String]>([])
|
||||
var availablePhpVersions: [String] {
|
||||
get { _availablePhpVersions.value }
|
||||
set { _availablePhpVersions.value = newValue }
|
||||
}
|
||||
|
||||
/** All versions of PHP that are currently installed but not compatible. */
|
||||
var incompatiblePhpVersions: [String] = []
|
||||
private let _incompatiblePhpVersions = Locked<[String]>([])
|
||||
var incompatiblePhpVersions: [String] {
|
||||
get { _incompatiblePhpVersions.value }
|
||||
set { _incompatiblePhpVersions.value = newValue }
|
||||
}
|
||||
|
||||
/** Cached information about the PHP installations. */
|
||||
var cachedPhpInstallations: [String: PhpInstallation] = [:]
|
||||
private let _cachedPhpInstallations = Locked<[String: PhpInstallation]>([:])
|
||||
var cachedPhpInstallations: [String: PhpInstallation] {
|
||||
get { _cachedPhpInstallations.value }
|
||||
set { _cachedPhpInstallations.value = newValue }
|
||||
}
|
||||
|
||||
/** Information about the currently linked PHP installation. */
|
||||
var currentInstall: ActivePhpInstallation? {
|
||||
|
||||
121
tests/unit/Helpers/LockedTests.swift
Normal file
121
tests/unit/Helpers/LockedTests.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// LockedTests.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Copyright © 2023 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
@Suite("Locked Thread Safety")
|
||||
struct LockedTests {
|
||||
|
||||
@Test("Reading and writing from a single thread works correctly")
|
||||
func singleThreadReadWrite() {
|
||||
let locked = Locked<Int>(0)
|
||||
|
||||
locked.value = 42
|
||||
#expect(locked.value == 42)
|
||||
|
||||
locked.value = 100
|
||||
#expect(locked.value == 100)
|
||||
}
|
||||
|
||||
@Test("Concurrent writes do not cause data races")
|
||||
func concurrentWritesAreThreadSafe() async {
|
||||
let locked = Locked<Int>(0)
|
||||
let iterations = 1000
|
||||
|
||||
// Spawn many concurrent tasks that all increment the counter
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<iterations {
|
||||
group.addTask {
|
||||
// Read, increment, write — each step is individually locked
|
||||
// Note: This is NOT atomic, but it shouldn't crash
|
||||
let current = locked.value
|
||||
locked.value = current + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The final value may not be exactly `iterations` because read-then-write
|
||||
// is not atomic, but we should not crash and should have a reasonable value
|
||||
#expect(locked.value > 0, "Value should have been incremented")
|
||||
#expect(locked.value <= iterations, "Value should not exceed iterations")
|
||||
}
|
||||
|
||||
@Test("Concurrent reads and writes do not crash")
|
||||
func concurrentReadsAndWritesDoNotCrash() async {
|
||||
let locked = Locked<[String]>([])
|
||||
let iterations = 500
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
// Writers
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
locked.value = ["item-\(i)"]
|
||||
}
|
||||
}
|
||||
|
||||
// Readers
|
||||
for _ in 0..<iterations {
|
||||
group.addTask {
|
||||
_ = locked.value.first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here without crashing, the test passes
|
||||
#expect(locked.value.count <= 1, "Array should have 0 or 1 elements")
|
||||
}
|
||||
|
||||
@Test("Dictionary access is thread-safe")
|
||||
func dictionaryAccessIsThreadSafe() async {
|
||||
let locked = Locked<[String: Int]>([:])
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
// Multiple tasks replacing the entire dictionary
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
locked.value = ["key": i]
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple tasks reading from the dictionary
|
||||
for _ in 0..<iterations {
|
||||
group.addTask {
|
||||
_ = locked.value["key"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have exactly one key if any writes completed
|
||||
#expect(locked.value.keys.count <= 1)
|
||||
}
|
||||
|
||||
@Test("Stress test with high concurrency")
|
||||
func stressTestHighConcurrency() async {
|
||||
let locked = Locked<Int>(0)
|
||||
let taskCount = 10
|
||||
let incrementsPerTask = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<taskCount {
|
||||
group.addTask {
|
||||
for _ in 0..<incrementsPerTask {
|
||||
// Full replacement (not increment) to avoid needing atomic read-modify-write
|
||||
let newValue = Int.random(in: 0..<1000)
|
||||
locked.value = newValue
|
||||
_ = locked.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without EXC_BAD_ACCESS or data corruption, we're good
|
||||
let finalValue = locked.value
|
||||
#expect(finalValue >= 0 && finalValue < 1000, "Value should be within expected range")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user