@@ -7,6 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77
88<!-- %% CHANGELOG_ENTRIES %% -->
99
10+ ## 0.6.0 - 2026-05-31
11+
12+ This release is a security-hardening pass over the native (NIF) boundary
13+ and the build/release pipeline: direct ` Emily.Native ` calls now validate
14+ their arguments instead of trusting Elixir-side normalization,
15+ precompiled-NIF downloads verify against a checksum pinned in the hex
16+ package (a trust root independent of the GitHub release), and the
17+ per-stream worker is bounded and tears down without blocking a BEAM
18+ scheduler. It is backward compatible, but two behaviour changes matter
19+ for high-concurrency callers: the per-worker async queue is now bounded
20+ (` worker_queue_limit ` , default 8192) and rejects when full, and a stopped
21+ or dropped worker replies ` {:error, :stopped} ` to queued callers instead
22+ of running their work.
23+
24+ ### Added
25+
26+ - ` Emily.Stream.close/1 ` stops a stream's worker thread deterministically
27+ instead of waiting for garbage collection: queued operations are
28+ cancelled (their callers get a ` RuntimeError ` ), the in-flight op
29+ finishes, and the OS thread is joined off the BEAM schedulers.
30+ - ` config :emily, worker_queue_limit: N ` (default ` 8192 ` ) bounds the
31+ per-worker async queue, and ` config :emily, await_timeout: ms ` (default
32+ ` :infinity ` ) sets an optional timeout for awaiting native results.
33+
34+ ### Security
35+
36+ - Worker-thread teardown no longer blocks a BEAM scheduler. The resource
37+ destructor previously drained the worker's entire queue and joined the
38+ OS thread inline, so collecting a busy stream during GC could stall a
39+ scheduler. Workers are now joined off-scheduler by a dedicated reaper
40+ (itself joined at NIF unload), and on stop the worker cancels its
41+ queued tasks — replying ` {:error, :stopped} ` — instead of running them.
42+
43+ - The async NIF worker queue is now bounded (` worker_queue_limit ` , reject
44+ when full) so a flood of operations can't grow it without limit and pin
45+ host/GPU memory, and a stopped or dropped worker now replies
46+ ` {:error, :stopped} ` to every queued caller instead of leaving it
47+ blocked forever. ` Emily.Native.worker_queue_depth/1 ` exposes the depth
48+ for observability.
49+
50+ - The dev/CI source-build path now refuses to trust an MLX install
51+ directory it doesn't own and keeps the build cache ` 0700 ` , so a shared
52+ or attacker-controlled ` EMILY_CACHE ` can't plant a ` libmlx.a ` that is
53+ then statically linked into the NIF. Fixed system tools (` getconf ` ,
54+ ` id ` , ` sw_vers ` , plus ` xcrun ` /` sysctl ` /` ps ` in ` build-mlx.sh ` ) resolve
55+ from absolute/system paths rather than ` $PATH ` , and the MLX-build lock
56+ records the holder's process start time so a recycled PID can't be
57+ mistaken for the original holder. Build-time only; no runtime change.
58+
59+ - Precompiled NIF downloads are now verified against checksums pinned
60+ inside the hex package (` native_checksums.txt ` ) rather than a ` .sha256 `
61+ sidecar fetched from the same GitHub release as the tarball. Because
62+ the package contents are covered by Hex's package hash in the
63+ consumer's ` mix.lock ` , the trust root no longer lives in the mutable
64+ release. The tarball is also extracted with ` :erl_tar ` against a strict
65+ entry allowlist (` libemily.{so,dylib} ` + ` mlx.metallib ` ), rejecting
66+ symlinks, hardlinks, ` .. ` traversal, absolute paths, and unexpected
67+ entries — closing a path-traversal/arbitrary-write vector in the old
68+ ` tar -xzf ` extraction. New ` mix emily.checksums ` task regenerates the
69+ pinned file per release.
70+
71+ - Integer arguments crossing the NIF boundary are now range-checked
72+ before being narrowed from Elixir's ` int64 ` to C++ ` int ` . Previously an
73+ out-of-range axis, count, or shape entry wrapped silently (e.g. an axis
74+ of ` 2^32 + 3 ` became ` 3 ` ), dispatching the wrong MLX operation; and
75+ unbounded sample counts in ` random_split ` /` random_categorical ` could
76+ drive huge allocations. Out-of-range values, and negative counts, now
77+ raise ` ArgumentError ` . Centralized as ` checked_int ` / ` require_count `
78+ helpers applied across the reduce, shape, sort, random, index, linalg,
79+ conv, and fast NIFs.
80+
81+ - Native indexing and window NIFs now validate their vector arguments
82+ against the tensor rank before indexing, and reject non-positive
83+ strides, dilations, and window dimensions. Previously a direct
84+ ` Emily.Native ` call with a malformed ` slice_update ` start, a short
85+ pad/window vector, or a zero window stride could read a C++ vector out
86+ of bounds or trigger an integer divide-by-zero (SIGFPE) — both of which
87+ crash the whole BEAM VM rather than raising in the caller. They now
88+ raise ` ArgumentError ` .
89+
90+ - ` Emily.Native.from_binary/3 ` now validates tensor shapes at the NIF
91+ boundary. Dimensions above ` INT32_MAX ` are rejected (previously they
92+ silently truncated through MLX's ` int32 ` ` ShapeElem ` ), and the element
93+ and byte counts are computed with overflow checking. Without this an
94+ attacker-chosen shape whose element product wrapped (e.g.
95+ ` [2^21, 2^21, 2^22] ` → ` 0 ` ) could pass the binary-size check against an
96+ undersized — even empty — binary and build an array whose shape outran
97+ its allocation, an out-of-bounds read on the next ` eval ` /` to_binary ` .
98+
99+ - ` Emily.Native.conv_general/8 ` now rejects a non-positive ` groups `
100+ argument with ` ArgumentError ` instead of crashing the BEAM VM. MLX's
101+ convolution checks compute ` in_channels % groups ` , so ` groups <= 0 `
102+ (or a large value that narrows to zero through the ` int64 → int `
103+ conversion) was an integer modulo-by-zero — a SIGFPE that bypassed the
104+ NIF's exception path and terminated the entire node. The guard
105+ validates the un-narrowed value at the NIF boundary.
106+
10107## 0.5.1 - 2026-05-23
11108
12109### Fixed
0 commit comments