diff --git a/.swiftlint.yml b/.swiftlint.yml index aa5e824..d1d0c25 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -17,10 +17,10 @@ line_length: identifier_name: min_length: 1 type_body_length: - warning: 600 + warning: 700 file_length: - warning: 1400 - error: 2000 + warning: 1500 + error: 2200 cyclomatic_complexity: warning: 15 error: 25 diff --git a/CHANGELOG.md b/CHANGELOG.md index c8228e4..ed72f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/), versioning: [SemVer](ht ## [Unreleased] +## [0.1.2] - 2026-06-04 +### Fixed +- Ensure only one `mlx_lm.server` instance runs while switching models. +- Wait for the previous server process and port to stop before launching the selected model. +- Queue a follow-up restart when the model is changed again during an active transition. + ## [1.3.0] - 2026-06-04 ### Added - Initial public release. @@ -24,5 +30,6 @@ Format: [Keep a Changelog](https://keepachangelog.com/), versioning: [SemVer](ht ### Performance - Polling I/O runs off the main thread (gather/apply split); idle CPU stays near zero. -[Unreleased]: https://github.com/wonsss/MLXControl/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/wonsss/MLXControl/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/wonsss/MLXControl/compare/v0.1.1...v0.1.2 [1.3.0]: https://github.com/wonsss/MLXControl/releases/tag/v1.3.0 diff --git a/Sources/MLXControl/MLXControlApp.swift b/Sources/MLXControl/MLXControlApp.swift index c77508e..3eb2f9c 100644 --- a/Sources/MLXControl/MLXControlApp.swift +++ b/Sources/MLXControl/MLXControlApp.swift @@ -133,6 +133,21 @@ func allMatches(_ pattern: String, _ text: String) -> [String] { } } +@MainActor +func waitUntilFalse( + timeout: TimeInterval, + pollInterval: TimeInterval, + condition: @escaping () -> Bool +) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while condition() { + if Date() >= deadline { return false } + let sleepNs = UInt64(max(0.005, pollInterval) * 1_000_000_000) + try? await Task.sleep(nanoseconds: sleepNs) + } + return true +} + // AppleScript notification — all args fully escaped to block injection func notify(_ title: String, _ subtitle: String, _ message: String) { func esc(_ s: String) -> String { @@ -252,6 +267,7 @@ final class ServerController { @ObservationIgnored private var lastRAMAlert = Date.distantPast @ObservationIgnored private var lastFreeAlert = Date.distantPast @ObservationIgnored private var lastActivity = Date.distantPast + @ObservationIgnored private var restartAfterBusy = false var isRunning: Bool { status != .stopped } @@ -503,15 +519,66 @@ final class ServerController { } } - private func launchServer() { + private static func mlxServerPIDs() -> [Int] { + sh("/usr/bin/pgrep", ["-f", "mlx_lm.server"]) + .split(separator: "\n") + .compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + + private static func isMlxServerRunning() -> Bool { + !mlxServerPIDs().isEmpty + } + + private func stopExistingServersAndWait() async -> Bool { + if let serverProcess, serverProcess.isRunning { + serverProcess.terminate() + } + _ = sh("/usr/bin/pkill", ["-TERM", "-f", "mlx_lm.server"]) + + let terminated = await waitUntilFalse(timeout: 4, pollInterval: 0.1) { + Self.isMlxServerRunning() + } + if terminated { + serverProcess = nil + return true + } + + _ = sh("/usr/bin/pkill", ["-KILL", "-f", "mlx_lm.server"]) + let killed = await waitUntilFalse(timeout: 2, pollInterval: 0.1) { + Self.isMlxServerRunning() + } + if killed { serverProcess = nil } + return killed + } + + private func waitForPortRelease() async -> Bool { + await waitUntilFalse(timeout: 3, pollInterval: 0.1) { + Self.isPortInUse(Config.port) + } + } + + private func finishServerTransition() { + let hasServer = Self.isMlxServerRunning() + busy = false + refresh() + guard restartAfterBusy, hasServer else { + restartAfterBusy = false + return + } + restartAfterBusy = false + restart() + } + + @discardableResult + private func launchServer() -> Bool { guard let bin = Config.serverBin else { notify("⚡ MLX Control", "Start failed", "mlx_lm.server not found. pip install mlx-lm") - return + return false } // Append to log file via FileHandle kept alive in serverProcess termination handler. let logPath = Config.logPath FileManager.default.createFile(atPath: logPath, contents: nil) - guard let logHandle = FileHandle(forWritingAtPath: logPath) else { return } + guard let logHandle = FileHandle(forWritingAtPath: logPath) else { return false } logHandle.seekToEndOfFile() let p = Process() @@ -522,8 +589,15 @@ final class ServerController { p.qualityOfService = .userInitiated // Close logHandle only after the process exits — prevents early interpreter shutdown. p.terminationHandler = { _ in try? logHandle.close() } - serverProcess = p - try? p.run() + do { + try p.run() + serverProcess = p + return true + } catch { + try? logHandle.close() + notify("⚡ MLX Control", "Start failed", error.localizedDescription) + return false + } } func start() { @@ -532,11 +606,6 @@ final class ServerController { notify("⚡ MLX Control", "Start failed", "mlx_lm.server not found. pip install mlx-lm") return } - if Self.isPortInUse(Config.port) { - notify("⚡ MLX Control", "Port \(Config.port) in use", - "Another app (e.g. oMLX) is already on this port. Stop it first.") - return - } // Memory guard: compare model disk size against available system RAM. let freeGB = sysTotalGB - sysUsedGB if let modelBytes = modelSizes[selectedModel] { @@ -555,36 +624,68 @@ final class ServerController { } } busy = true; warmedUp = false; lastActivity = Date() - launchServer() Task { @MainActor in - try? await Task.sleep(for: .seconds(2)); self.busy = false; self.refresh() + guard await self.stopExistingServersAndWait() else { + self.finishServerTransition() + self.notifyStartBlockedByExistingServer() + return + } + guard await self.waitForPortRelease() else { + self.finishServerTransition() + notify("⚡ MLX Control", "Port \(Config.port) in use", + "Stop the app using this port, then try again.") + return + } + self.launchServer() + try? await Task.sleep(for: .seconds(2)) + self.finishServerTransition() } } func stop() { guard !busy else { return } busy = true - _ = sh("/usr/bin/pkill", ["-f", "mlx_lm.server"]) Task { @MainActor in - try? await Task.sleep(for: .seconds(1.5)); self.busy = false; self.refresh() + _ = await self.stopExistingServersAndWait() + try? await Task.sleep(for: .seconds(1.5)) + self.finishServerTransition() } } func restart() { guard !busy else { return } busy = true; warmedUp = false - _ = sh("/usr/bin/pkill", ["-f", "mlx_lm.server"]) Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) + guard await self.stopExistingServersAndWait() else { + self.finishServerTransition() + self.notifyStartBlockedByExistingServer() + return + } + guard await self.waitForPortRelease() else { + self.finishServerTransition() + notify("⚡ MLX Control", "Port \(Config.port) in use", + "Stop the app using this port, then try again.") + return + } self.launchServer() try? await Task.sleep(for: .seconds(2)) - self.busy = false; self.refresh() + self.finishServerTransition() } } + private func notifyStartBlockedByExistingServer() { + notify("⚡ MLX Control", "Server still running", + "Could not stop the existing mlx_lm.server process. Stop it manually, then try again.") + } + func switchModel(_ m: String) { selectedModel = m - if isRunning { restart() } + guard isRunning else { return } + if busy { + restartAfterBusy = true + } else { + restart() + } } // ── Warm-up ── diff --git a/Tests/MLXControlTests/HelpersTests.swift b/Tests/MLXControlTests/HelpersTests.swift index 52fe37c..532d855 100644 --- a/Tests/MLXControlTests/HelpersTests.swift +++ b/Tests/MLXControlTests/HelpersTests.swift @@ -33,4 +33,25 @@ final class HelpersTests: XCTestCase { XCTAssertFalse(valid("a/b/c")) // two slashes XCTAssertFalse(valid("name with space")) } + + @MainActor + func testWaitUntilFalseReturnsTrueAfterConditionClears() async { + var checks = 0 + let cleared = await waitUntilFalse(timeout: 0.2, pollInterval: 0.01) { + checks += 1 + return checks < 3 + } + + XCTAssertTrue(cleared) + XCTAssertGreaterThanOrEqual(checks, 3) + } + + @MainActor + func testWaitUntilFalseReturnsFalseWhenConditionStaysTrue() async { + let cleared = await waitUntilFalse(timeout: 0.03, pollInterval: 0.01) { + true + } + + XCTAssertFalse(cleared) + } } diff --git a/build.sh b/build.sh index 9bc014a..b83f656 100755 --- a/build.sh +++ b/build.sh @@ -3,7 +3,7 @@ set -e cd "$(dirname "$0")" BUNDLE_ID="io.github.wonsss.mlxcontrol" -VERSION="1.3" +VERSION="0.1.2" APP="MLXControl.app" echo "→ 아이콘 생성…"