1
0
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:
2025-11-23 17:31:59 +01:00
parent 49e69aa61f
commit 93e203be50
4 changed files with 216 additions and 11 deletions

View File

@@ -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 */,

View 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
}
}
}

View File

@@ -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? {

View 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")
}
}