Skip to content
Merged
Show file tree
Hide file tree
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
6 changes: 3 additions & 3 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
137 changes: 119 additions & 18 deletions Sources/MLXControl/MLXControlApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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()
Expand All @@ -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() {
Expand All @@ -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] {
Expand All @@ -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 ──
Expand Down
21 changes: 21 additions & 0 deletions Tests/MLXControlTests/HelpersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "→ 아이콘 생성…"
Expand Down
Loading