1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-12-21 03:10:06 +01:00

♻️ Add Suspendable to ConfigWatchManager

This commit is contained in:
2025-12-06 11:38:15 +01:00
parent 43728f1192
commit 4b8f05b8df
9 changed files with 180 additions and 91 deletions

View File

@@ -31,6 +31,10 @@
0329A9A42E92A69000A62A12 /* WarningManager+Evaluations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A22E92A68B00A62A12 /* WarningManager+Evaluations.swift */; };
0329A9A52E92A69000A62A12 /* WarningManager+Evaluations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A22E92A68B00A62A12 /* WarningManager+Evaluations.swift */; };
0329A9A62E92A69000A62A12 /* WarningManager+Evaluations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329A9A22E92A68B00A62A12 /* WarningManager+Evaluations.swift */; };
032C7A022EE43B7600758D98 /* Suspendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C7A012EE43B7400758D98 /* Suspendable.swift */; };
032C7A032EE43B7600758D98 /* Suspendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C7A012EE43B7400758D98 /* Suspendable.swift */; };
032C7A042EE43B7600758D98 /* Suspendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C7A012EE43B7400758D98 /* Suspendable.swift */; };
032C7A052EE43B7600758D98 /* Suspendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C7A012EE43B7400758D98 /* Suspendable.swift */; };
032DAC282E8BEB5B0018E01C /* RealWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032DAC272E8BEB590018E01C /* RealWebApi.swift */; };
032DAC292E8BEB5B0018E01C /* RealWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032DAC272E8BEB590018E01C /* RealWebApi.swift */; };
032DAC2A2E8BEB5B0018E01C /* RealWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032DAC272E8BEB590018E01C /* RealWebApi.swift */; };
@@ -1028,6 +1032,7 @@
03263A372E86D5E800BD0415 /* UpdateScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateScheduler.swift; sourceTree = "<group>"; };
0329A9A02E92A2A800A62A12 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = "<group>"; };
0329A9A22E92A68B00A62A12 /* WarningManager+Evaluations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WarningManager+Evaluations.swift"; sourceTree = "<group>"; };
032C7A012EE43B7400758D98 /* Suspendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suspendable.swift; sourceTree = "<group>"; };
032DAC272E8BEB590018E01C /* RealWebApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealWebApi.swift; sourceTree = "<group>"; };
032DAC2C2E8BEB690018E01C /* WebApiProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebApiProtocol.swift; sourceTree = "<group>"; };
0336CAAF2B0D0CDA009A1034 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -1541,6 +1546,7 @@
5489625628312F95004F647A /* Protocols */ = {
isa = PBXGroup;
children = (
032C7A012EE43B7400758D98 /* Suspendable.swift */,
5489625728312FAD004F647A /* CreatedFromFile.swift */,
);
path = Protocols;
@@ -2910,6 +2916,7 @@
C41CA5ED2774F8EE00A2C80E /* DomainListVC+Actions.swift in Sources */,
03B675EA2EBA30D800EE04A9 /* NSImageExtension.swift in Sources */,
C412E5FC25700D5300A1FB67 /* HomebrewDecodable.swift in Sources */,
032C7A042EE43B7600758D98 /* Suspendable.swift in Sources */,
03BFF52E2E313244007F96FA /* StatusMenu+Driver.swift in Sources */,
C4D9ADBF277610E1007277F4 /* PhpSwitcher.swift in Sources */,
C45E76142854A65300B4FE0C /* ServicesManager.swift in Sources */,
@@ -3074,6 +3081,7 @@
C4611E5B2AEAD2E30010BE24 /* ConfigManagerWindowController.swift in Sources */,
C471E85A28F9BB650021E251 /* DomainListTypeCell.swift in Sources */,
C471E85B28F9BB650021E251 /* DomainListKindCell.swift in Sources */,
032C7A052EE43B7600758D98 /* Suspendable.swift in Sources */,
C4611E5E2AEAD2FB0010BE24 /* ConfigManagerView.swift in Sources */,
031F24822EA1071A00CFB8D9 /* Container+Fake.swift in Sources */,
C4BF56AD2949381100379603 /* FakeValetInteractor.swift in Sources */,
@@ -3454,6 +3462,7 @@
C4D3661D291173EA006BD146 /* DictionaryExtension.swift in Sources */,
C471E80D28F9BAE80021E251 /* ArrayExtension.swift in Sources */,
035983A32E97FA9100218DC7 /* Container.swift in Sources */,
032C7A032EE43B7600758D98 /* Suspendable.swift in Sources */,
C471E7CD28F9BA600021E251 /* ShellProtocol.swift in Sources */,
C471E7EC28F9BAC30021E251 /* Events.swift in Sources */,
C471E7CE28F9BA600021E251 /* RealShell.swift in Sources */,
@@ -3658,6 +3667,7 @@
C40F505628ECA64E004AD45B /* TestableConfigurations.swift in Sources */,
C4D9ADC9277611A0007277F4 /* InternalSwitcher.swift in Sources */,
C449B4F227EE7FC400C47E8A /* DomainListPhpCell.swift in Sources */,
032C7A022EE43B7600758D98 /* Suspendable.swift in Sources */,
C42CFB1A27DFE8BD00862737 /* NginxConfigurationTest.swift in Sources */,
C4BF56AC2949381100379603 /* FakeValetInteractor.swift in Sources */,
C471E79428F9B23B0021E251 /* FileSystemProtocol.swift in Sources */,

View File

@@ -83,7 +83,7 @@ class PhpConfigurationFile: CreatedFromFile {
Replaces the value for a specific (existing) key with a new value.
The key must exist for this to work.
*/
public func replace(key: String, value: String) throws {
public func replace(key: String, value: String) async throws {
// Ensure that the key exists
guard let item = getConfig(for: key) else {
throw ReplacementErrors.missingKey
@@ -102,14 +102,11 @@ class PhpConfigurationFile: CreatedFromFile {
self.lines[item.lineIndex] = components.joined(separator: "=")
// Ensure the watchers aren't tripped up by config changes
ConfigWatchManager.ignoresModificationsToConfigValues = true
// Finally, join the string and save the file atomatically again
try self.lines.joined(separator: "\n")
.write(toFile: self.filePath, atomically: true, encoding: .utf8)
// Ensure watcher behaviour is reverted
ConfigWatchManager.ignoresModificationsToConfigValues = false
try await ConfigWatchManager.withSuspended {
// Finally, join the string and save the file atomically again
try self.lines.joined(separator: "\n")
.write(toFile: self.filePath, atomically: true, encoding: .utf8)
}
// Reload the original file
self.reload()

View File

@@ -0,0 +1,58 @@
//
// Suspendable.swift
// PHP Monitor
//
// Created by Nico Verbruggen on 06/12/2025.
// Copyright © 2025 Nico Verbruggen. All rights reserved.
//
import Foundation
/**
A protocol for actors that manage filesystem watchers and can temporarily
suspend their responses to changes.
This is useful when the application itself makes changes to watched files,
preventing duplicate work or unwanted side effects.
*/
protocol Suspendable: Actor {
/**
Suspends responding to filesystem events.
Events are still observed but handlers won't fire.
*/
func suspend() async
/**
Resumes responding to filesystem events.
Handlers will fire normally for observed events.
*/
func resume() async
/**
Executes an action while suspended, ensuring resume happens
even if the action throws.
- Parameter action: The async throwing closure to execute while suspended
- Returns: The result of the action
- Throws: Rethrows any error from the action
*/
func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T
}
extension Suspendable {
/**
Default implementation of withSuspended that ensures proper
suspend/resume lifecycle even when errors occur.
*/
func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T {
await suspend()
do {
let result = try await action()
await resume()
return result
} catch {
await resume()
throw error
}
}
}

View File

@@ -110,14 +110,16 @@ extension MainMenu {
return
}
do {
try file.replace(key: "xdebug.mode", value: "off")
Task {
do {
try await file.replace(key: "xdebug.mode", value: "off")
Log.perf("Refreshing menu...")
MainMenu.shared.rebuild()
restartPhpFpm()
} catch {
Log.err("There was an issue replacing `xdebug.mode` in \(file.filePath)")
Log.perf("Refreshing menu...")
MainMenu.shared.rebuild()
restartPhpFpm()
} catch {
Log.err("There was an issue replacing `xdebug.mode` in \(file.filePath).")
}
}
}
@@ -128,27 +130,29 @@ extension MainMenu {
return Log.info("xdebug.mode could not be found in any .ini file, aborting.")
}
do {
var modes = Xdebug(container).activeModes
Task {
do {
var modes = Xdebug(container).activeModes
if let index = modes.firstIndex(of: sender.mode) {
modes.remove(at: index)
} else {
modes.append(sender.mode)
if let index = modes.firstIndex(of: sender.mode) {
modes.remove(at: index)
} else {
modes.append(sender.mode)
}
var newValue = modes.joined(separator: ",")
if newValue.isEmpty {
newValue = "off"
}
try await file.replace(key: "xdebug.mode", value: newValue)
Log.perf("Refreshing menu...")
MainMenu.shared.rebuild()
restartPhpFpm()
} catch {
Log.err("There was an issue replacing `xdebug.mode` in \(file.filePath).")
}
var newValue = modes.joined(separator: ",")
if newValue.isEmpty {
newValue = "off"
}
try file.replace(key: "xdebug.mode", value: newValue)
Log.perf("Refreshing menu...")
MainMenu.shared.rebuild()
restartPhpFpm()
} catch {
Log.err("There was an issue replacing `xdebug.mode` in \(file.filePath)")
}
}

View File

@@ -90,7 +90,7 @@ struct Preset: Codable, Equatable {
// Apply the configuration changes first
for conf in configuration {
applyConfigurationValue(key: conf.key, value: conf.value ?? "")
await applyConfigurationValue(key: conf.key, value: conf.value ?? "")
}
guard let install = container.phpEnvs.phpInstall else {
@@ -159,7 +159,7 @@ struct Preset: Codable, Equatable {
}
}
private func applyConfigurationValue(key: String, value: String) {
private func applyConfigurationValue(key: String, value: String) async {
guard let file = container.phpEnvs.getConfigFile(forKey: key) else {
return
}
@@ -167,7 +167,7 @@ struct Preset: Codable, Equatable {
do {
if file.has(key: key) {
Log.info("Setting config value \(key) in \(file.filePath)")
try file.replace(key: key, value: value)
try await file.replace(key: key, value: value)
}
} catch {
Log.err("Setting \(key) to \(value) failed.")

View File

@@ -8,18 +8,13 @@
import Foundation
actor ConfigWatchManager {
actor ConfigWatchManager: Suspendable {
enum Behaviour {
case reloadsMenu
case reloadsWatchers
}
// MARK: Global state (applicable to ALL watchers)
// TODO: Rework into suspend mechanism (like `HomebrewWatchManager`) to avoid issues with concurrency
static var ignoresModificationsToConfigValues: Bool = false
// MARK: Static methods
/**
@@ -161,8 +156,7 @@ actor ConfigWatchManager {
guard let self = self else { return }
Task {
if behaviour == .reloadsWatchers
&& !ConfigWatchManager.ignoresModificationsToConfigValues {
if behaviour == .reloadsWatchers {
// Reload all configuration watchers on this manager
await self.reloadWatchers()
return
@@ -184,4 +178,43 @@ actor ConfigWatchManager {
Log.perf("deinit: \(String(describing: self)).\(#function)")
}
// MARK: - Suspendable Protocol
/**
Performs a particular action while suspending the config watcher,
until the task is completed.
This should be used when the application writes to PHP configuration files,
to prevent the watcher from responding to our own changes.
*/
public static func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T {
guard let manager = App.shared.configWatchManager else {
// If there's no manager, run the task as-is
return try await action()
}
// Suspend, execute the action, and resume
return try await manager.withSuspended(action)
}
/**
Suspends the `ConfigWatchManager`.
This prevents any changes to config files from causing events to fire.
*/
func suspend() async {
for watcher in watchers {
await watcher.suspend()
}
await debouncer.cancel()
}
/**
Resumes the `ConfigWatchManager`.
Any changes to config files are picked up again.
*/
func resume() async {
for watcher in watchers {
await watcher.resume()
}
}
}

View File

@@ -8,7 +8,7 @@
import Foundation
actor HomebrewWatchManager {
actor HomebrewWatchManager: Suspendable {
// MARK: Public API
@@ -37,24 +37,6 @@ actor HomebrewWatchManager {
App.shared.homebrewWatchManager = manager
}
/**
Performs a particular action while suspending the Homebrew watcher,
until the task is completed.
Any operations that cause Homebrew to perform tasks (installing,
updating, removing packages) should be wrapped in this helper method,
to prevent the app from doing duplicate work.
*/
public static func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T {
guard let manager = App.shared.homebrewWatchManager else {
// If there's no manager, run the task as-is
return try await action()
}
// Suspend, execute the action, and resume
return try await manager.withSuspended(action)
}
// MARK: - Instance variables
/**
@@ -146,13 +128,32 @@ actor HomebrewWatchManager {
}
}
// MARK: - Suspend and resume
// MARK: - Suspendable Protocol
/**
Performs a particular action while suspending the Homebrew watcher,
until the task is completed.
Any operations that cause Homebrew to perform tasks (installing,
updating, removing packages) should be wrapped in this helper method,
to prevent the app from doing duplicate work.
*/
public static func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T {
guard let manager = App.shared.homebrewWatchManager else {
// If there's no manager, run the task as-is
return try await action()
}
// Suspend, execute the action, and resume
return try await manager.withSuspended(action)
}
/**
Suspends the `HomebrewWatchManager`.
This prevents any changes to `/homebrew/bin` from causing events to fire.
*/
private func suspend() async {
func suspend() async {
await watcher?.suspend()
await debouncer.cancel()
}
@@ -161,23 +162,7 @@ actor HomebrewWatchManager {
Resumes the `HomebrewWatchManager`.
Any changes to `/homebrew/bin` are picked up again.
*/
private func resume() async {
func resume() async {
await watcher?.resume()
}
/**
Executes an `action` callback after suspending the watcher.
*/
private func withSuspended<T>(_ action: () async throws -> T) async rethrows -> T {
await suspend()
do {
let result = try await action()
await resume()
return result
} catch {
await resume()
throw error
}
}
}

View File

@@ -62,11 +62,13 @@ class BytePhpPreference: PhpPreference {
internalValue = "\(value)\(unit.rawValue)"
}
do {
try PhpPreference.persistToIniFile(key: self.key, value: self.internalValue)
Log.info("The preference \(key) was updated to: \(value)")
} catch {
Log.info("The preference \(key) could not be updated")
Task {
do {
try await PhpPreference.persistToIniFile(key: self.key, value: self.internalValue)
Log.info("The preference \(key) was updated to: \(value)")
} catch {
Log.info("The preference \(key) could not be updated")
}
}
}

View File

@@ -20,9 +20,9 @@ class PhpPreference {
self.key = key
}
internal static func persistToIniFile(key: String, value: String) throws {
internal static func persistToIniFile(key: String, value: String) async throws {
if let file = App.shared.container.phpEnvs.getConfigFile(forKey: key) {
return try file.replace(key: key, value: value)
return try await file.replace(key: key, value: value)
}
throw PhpConfigurationFile.ReplacementErrors.missingFile