Skip to content

[BUG] Regression in 0.31.4: holding evalLock across tostring (#410) deadlocks/crashes when an MLXArray is stringified during an in-flight eval() #428

Description

@stovak

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

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions