Summary
Since #410 (058eda6, shipped in 0.31.4), MLXArray/Device/Stream.description acquire the global evalLock and hold it for the full duration of the internal mlx::core::eval + array::wait():
public var description: String {
var s = mlx_string_new()
defer { mlx_string_free(s) }
_ = evalLock.withLock {
mlx_array_tostring(&s, ctx) // internally evals + blocks in array::wait()
}
return String(cString: mlx_string_data(s), encoding: .utf8)!
}
Because the public eval() also holds evalLock across its blocking array::wait(), any description / string-interpolation of an MLX value on one thread while an eval() is in flight on another thread now blocks on a thread that is itself parked in a kernel wait. The result is a deadlock or an EINVAL fatal on the first forward pass of a real generation.
This is a regression: 0.31.3 is fine, 0.31.4 reproduces it.
Environment
- mlx-swift 0.31.4 (regressed) vs 0.31.3 (last good)
- macOS 26 / Apple Silicon (also reproduces on iOS 26)
- Workload: FLUX.2 Klein 4B image generation (flux-2-swift-mlx)
Two observed failure modes (same root area, mlx_eval)
Hang. The first Flux2Transformer2DModel.callAsFunction forward pass deadlocks: all MLX worker threads parked in __psynch_cvwait, ~0% CPU, RSS flat (~230 MB, never grows). Swift Task cancellation can't free it (the thread is blocked in synchronous C++ inside a kernel wait), so the UI Cancel button does nothing and the app must be force-quit.
Crash. Same forward pass instead raises:
MLX/ErrorHandler.swift: Fatal error: mutex lock failed: Invalid argument
raised inside mlx_eval. mlx-swift's default error handler turns any MLX error into fatalError/SIGTRAP, so the app vanishes with no dialog.
Application-side stack (crash path):
GenerationActor.generate
→ VinetasImageGenerator.generateSequence
→ Flux2Engine.generate
→ Flux2Pipeline.generateWithResult
→ eval → mlx_eval ← Fatal error here
The inverse direction is exactly the trace in #410's own linked issue (mlx-swift-lm#291):
description → MLXArray.description.getter → mlx_array_tostring
→ operator<< → array::eval → mlx::core::eval → array::wait
→ MTL::SharedEvent::waitUntilSignaledValue ← parks holding evalLock
Bisect
With every other dependency held constant and only the mlx-swift pin changed:
.exact("0.31.3") → full generation completes, valid output, exit 0.
.exact("0.31.4") → hangs or crashes identically on the first eval.
A logging/string-interpolation line that touched an MLXArray/Device on the generation path was harmless on 0.31.3 and is the trigger on 0.31.4.
Root cause
#410 fixed a genuine data race — concurrent tostring/eval is not thread-safe — but it does so by holding the global evalLock across a blocking array::wait(). Note evalLock is already an NSRecursiveLock (since #217), so this is not same-thread reentrancy. It's cross-thread lock ordering: description is now gated behind a possibly-long, possibly-parked eval(). #410 effectively traded a data race for a lock-held-across-blocking-wait hang. The most common trigger is the most innocuous code imaginable — a log line that stringifies an array while generation is running on another thread.
Suggested directions
- Don't hold
evalLock across the blocking array::wait() inside tostring. Serialize only the non-blocking graph access, or snapshot/copy the array before stringifying so the wait happens outside the lock.
- Make the
description path use a tryLock / timeout and fall back to a non-evaluating summary (shape/dtype/device) so logging can never wedge generation.
- At minimum, document that stringifying an MLX value concurrently with an in-flight
eval() can deadlock in 0.31.4.
Workaround
Pin mlx-swift to .exact("0.31.3"), or remove all MLXArray/Device/Stream string-interpolation from the eval/sampling path.
Summary
Since #410 (
058eda6, shipped in 0.31.4),MLXArray/Device/Stream.descriptionacquire the globalevalLockand hold it for the full duration of the internalmlx::core::eval+array::wait():Because the public
eval()also holdsevalLockacross its blockingarray::wait(), anydescription/ string-interpolation of an MLX value on one thread while aneval()is in flight on another thread now blocks on a thread that is itself parked in a kernel wait. The result is a deadlock or an EINVAL fatal on the first forward pass of a real generation.This is a regression: 0.31.3 is fine, 0.31.4 reproduces it.
Environment
Two observed failure modes (same root area,
mlx_eval)Hang. The first
Flux2Transformer2DModel.callAsFunctionforward pass deadlocks: all MLX worker threads parked in__psynch_cvwait, ~0% CPU, RSS flat (~230 MB, never grows). SwiftTaskcancellation can't free it (the thread is blocked in synchronous C++ inside a kernel wait), so the UI Cancel button does nothing and the app must be force-quit.Crash. Same forward pass instead raises:
raised inside
mlx_eval. mlx-swift's default error handler turns any MLX error intofatalError/SIGTRAP, so the app vanishes with no dialog.Application-side stack (crash path):
The inverse direction is exactly the trace in #410's own linked issue (mlx-swift-lm#291):
Bisect
With every other dependency held constant and only the mlx-swift pin changed:
.exact("0.31.3")→ full generation completes, valid output, exit 0..exact("0.31.4")→ hangs or crashes identically on the first eval.A logging/string-interpolation line that touched an
MLXArray/Deviceon the generation path was harmless on 0.31.3 and is the trigger on 0.31.4.Root cause
#410 fixed a genuine data race — concurrent
tostring/evalis not thread-safe — but it does so by holding the globalevalLockacross a blockingarray::wait(). NoteevalLockis already anNSRecursiveLock(since #217), so this is not same-thread reentrancy. It's cross-thread lock ordering:descriptionis now gated behind a possibly-long, possibly-parkedeval(). #410 effectively traded a data race for a lock-held-across-blocking-wait hang. The most common trigger is the most innocuous code imaginable — a log line that stringifies an array while generation is running on another thread.Suggested directions
evalLockacross the blockingarray::wait()insidetostring. Serialize only the non-blocking graph access, or snapshot/copy the array before stringifying so the wait happens outside the lock.descriptionpath use atryLock/ timeout and fall back to a non-evaluating summary (shape/dtype/device) so logging can never wedge generation.eval()can deadlock in 0.31.4.Workaround
Pin
mlx-swiftto.exact("0.31.3"), or remove allMLXArray/Device/Streamstring-interpolation from the eval/sampling path.