5959 fi
6060fi
6161
62- # Keep the scratch build dir alongside the install prefix so `--force`
63- # from mix.exs (which rm -rf's the install dir) doesn't orphan it.
62+ # Serialise concurrent script invocations against the same install
63+ # prefix. Mix.Project.with_build_lock can't help here: ElixirLS uses
64+ # its own build path (.elixir_ls/build/...) so an LSP-driven
65+ # `mix compile` and a CLI `mix compile.emily_mlx --force` lock on
66+ # *different* keys and freely race into the same MLX cache dir. Both
67+ # invocations would then rm each other's `${PREFIX}.build/` mid-build,
68+ # surfacing as `clang ... Rename failed: ... No such file or
69+ # directory` during Metal-shader compilation.
70+ #
71+ # `flock(1)` isn't shipped on macOS, so we use atomic `mkdir` as the
72+ # lock primitive. The lock dir is keyed on PREFIX, which both
73+ # contexts share. A PID file inside lets us reclaim a stale lock if
74+ # the previous holder died without cleanup.
6475BUILD_DIR=" ${PREFIX} .build"
76+ STAGING=" ${PREFIX} .staging"
77+ LOCK_DIR=" ${PREFIX} .lock"
78+ LOCK_PID_FILE=" ${LOCK_DIR} /pid"
79+
80+ mkdir -p " $( dirname " $LOCK_DIR " ) "
81+
82+ acquired_lock=0
83+ printed_wait=0
84+
85+ # Define cleanup + install the trap *before* the lock-acquisition loop
86+ # so that a concurrent-winner short-circuit (or any other early exit)
87+ # still releases LOCK_DIR. STAGING is a half-baked install — always
88+ # wipe. BUILD_DIR holds CMakeFiles/ and CMakeError.log on failure;
89+ # keep it on non-zero exit so diagnostics survive.
90+ cleanup () {
91+ local exit_code=$?
92+ rm -rf " $STAGING "
93+ if (( acquired_lock == 1 )) ; then
94+ rm -rf " $LOCK_DIR "
95+ fi
96+ if [[ $exit_code -eq 0 ]]; then
97+ rm -rf " $BUILD_DIR "
98+ else
99+ echo " ==> Build failed (exit ${exit_code} ); preserving ${BUILD_DIR} for diagnostics" >&2
100+ fi
101+ }
102+ trap cleanup EXIT
103+
104+ while : ; do
105+ if mkdir " $LOCK_DIR " 2> /dev/null; then
106+ echo " $$ " > " $LOCK_PID_FILE "
107+ acquired_lock=1
108+ break
109+ fi
65110
66- rm -rf " $BUILD_DIR " " $PREFIX "
67- mkdir -p " $BUILD_DIR " " $PREFIX "
111+ holder=" "
112+ [[ -r " $LOCK_PID_FILE " ]] && holder=$( cat " $LOCK_PID_FILE " 2> /dev/null || true)
113+
114+ if [[ -n " $holder " ]] && ! kill -0 " $holder " 2> /dev/null; then
115+ echo " ==> Reclaiming stale MLX-build lock (dead PID $holder )" >&2
116+ rm -rf " $LOCK_DIR "
117+ continue
118+ fi
119+
120+ if (( printed_wait == 0 )) ; then
121+ echo " ==> Waiting for concurrent MLX build${holder: + (PID $holder )} on ${PREFIX} " >&2
122+ printed_wait=1
123+ fi
124+ sleep 1
125+ done
126+
127+ # A concurrent winner may have completed the install while we waited
128+ # for the lock — re-check and short-circuit if so.
129+ if [[ -f " ${PREFIX} /lib/libmlx.a" && -f " ${PREFIX} /lib/mlx.metallib" ]]; then
130+ echo " ==> MLX already installed at ${PREFIX} (concurrent build won)"
131+ exit 0
132+ fi
133+
134+ rm -rf " $BUILD_DIR " " $STAGING "
135+ mkdir -p " $BUILD_DIR " " $STAGING "
68136
69137echo " ==> Configuring MLX ${VERSION} (${VARIANT} )"
70- cmake \
138+ # Configure triggers `FetchContent_MakeAvailable` for metal_cpp / json /
139+ # fmt, which CMake implements via a recursive `cmake --build` of a tiny
140+ # sub-project per dep. Those sub-builds inherit `CMAKE_BUILD_PARALLEL_LEVEL`
141+ # and race on FetchContent's download → extract → rename → stamp-touch
142+ # pipeline when run in parallel — observed as `getcwd: cannot access
143+ # parent directories` followed by `cd: <dir>/_deps: No such file or
144+ # directory` (FetchContent renames out from under a sub-shell that the
145+ # parallel make spawned). Pin the env to 1 only for this invocation;
146+ # the main MLX build below still runs at NCPU jobs.
147+ CMAKE_BUILD_PARALLEL_LEVEL=1 cmake \
71148 -S " $MLX_SRC_DIR " \
72149 -B " $BUILD_DIR " \
73150 -DCMAKE_BUILD_TYPE=Release \
74- -DCMAKE_INSTALL_PREFIX=" $PREFIX " \
151+ -DCMAKE_INSTALL_PREFIX=" $STAGING " \
75152 -DBUILD_SHARED_LIBS=OFF \
76153 -DMLX_BUILD_TESTS=OFF \
77154 -DMLX_BUILD_EXAMPLES=OFF \
@@ -86,21 +163,31 @@ NCPU="$(sysctl -n hw.ncpu)"
86163echo " ==> Building with ${NCPU} jobs"
87164cmake --build " $BUILD_DIR " --parallel " $NCPU "
88165
89- echo " ==> Installing into ${PREFIX } "
166+ echo " ==> Installing into ${STAGING } "
90167cmake --install " $BUILD_DIR "
91168
92169# MLX's Metal device loader looks for mlx.metallib colocated with the
93170# binary. cmake --install places it under lib/ (via the install rules
94- # in mlx/backend/metal/CMakeLists.txt); sanity-check both artefacts.
171+ # in mlx/backend/metal/CMakeLists.txt); sanity-check both artefacts
172+ # before we rename staging into place.
95173for f in " lib/libmlx.a" " lib/mlx.metallib" ; do
96- if [[ ! -f " ${PREFIX } /${f} " ]]; then
97- echo " error: expected ${PREFIX } /${f} after cmake --install (build is incomplete)" >&2
174+ if [[ ! -f " ${STAGING } /${f} " ]]; then
175+ echo " error: expected ${STAGING } /${f} after cmake --install (build is incomplete)" >&2
98176 exit 1
99177 fi
100178done
101179
102- # Scratch build is large (~1.5 GB on the aot lane). mix.exs doesn't
103- # need it once the install dir is populated.
104- rm -rf " $BUILD_DIR "
180+ # Atomic publish: rename staging to prefix. If a racing build already
181+ # populated PREFIX, `mv` refuses to overwrite the non-empty target and
182+ # exits non-zero — defer to the winner, our trap cleans STAGING up.
183+ echo " ==> Publishing to ${PREFIX} "
184+ if ! mv " $STAGING " " $PREFIX " ; then
185+ if [[ -f " ${PREFIX} /lib/libmlx.a" && -f " ${PREFIX} /lib/mlx.metallib" ]]; then
186+ echo " ==> ${PREFIX} already populated by a concurrent build; keeping it"
187+ else
188+ echo " error: failed to publish staging to ${PREFIX} " >&2
189+ exit 1
190+ fi
191+ fi
105192
106193echo " ==> Done: ${PREFIX} "
0 commit comments