Skip to content

Commit 76b6cc2

Browse files
leifericfclaude
andcommitted
core: set! mutates topmost dynamic binding instead of being a no-op
set! was defined as a no-op macro on the theory that it was just a JVM compiler directive. But Clojure's set! does double duty: on the JVM it mutates fields (irrelevant to mino), and on dynamic vars it mutates the thread-local binding frame inside an enclosing binding. The latter shape is portable Clojure code that mino was silently ignoring -- counters built on (binding [*c* 0] (set! *c* (inc *c*))) stayed at 0. Added prim_set_dyn_binding (set-dyn-binding!) which walks ctx->dyn_ stack, finds the topmost binding for the named symbol, mutates its val, and returns the new value. Throws "Can't change/establish root binding" when no binding frame is active for the name, matching JVM Clojure's runtime contract. set! macro now expands to a set-dyn-binding! call with a quoted target symbol. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bec7776 commit 76b6cc2

5 files changed

Lines changed: 113 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## v0.255.23 — Fix: `set!` mutates dynamic-var bindings (was no-op)
4+
5+
`set!` used to be a no-op macro: `(set! *x* 5)` returned `nil` and
6+
left `*x*` unchanged, even inside a `(binding [*x* 0] ...)` form.
7+
The comment called it "a JVM compiler directive, not applicable to
8+
mino", which is half-true (`set!` does double duty in JVM Clojure:
9+
field mutation on the JVM side, *and* thread-local dynamic-var
10+
binding mutation). The dynamic-var shape is portable code that
11+
mino was silently ignoring.
12+
13+
Now: `(set! *x* expr)` calls the new `set-dyn-binding!` C primitive
14+
which walks `ctx->dyn_stack`, finds the topmost binding for the
15+
symbol, mutates its `val`, and returns the new value. With no
16+
active binding frame for the named var, throws
17+
`Can't change/establish root binding of: *name* with set!` —
18+
matches JVM Clojure's runtime contract.
19+
20+
The JVM-only field-mutation shape `(set! (.-field obj) val)` is not
21+
supported; mino has no JVM fields. Pre-existing Clojure code that
22+
relied on the old no-op behavior to silently swallow
23+
`(set! *warn-on-reflection* true)` now needs to wrap such calls in
24+
`try/catch` (or just delete them, since they had no effect anyway).
25+
Per `[[alpha-no-backwards-compat]]`, this contract change is acceptable.
26+
27+
Regression in `tests/compat_test.clj`
28+
(`set-bang-mutates-dynamic-binding`).
29+
330
## v0.255.22 — Fix: Regex engine now parses `{n}` / `{n,m}` / `{n,}` quantifiers
431

532
The vendored regex engine recognized `*`, `+`, `?` quantifiers but

src/core.clj

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,8 +1004,18 @@
10041004
`(let [~@pairs] ~@body)))
10051005

10061006
(defmacro set!
1007-
"No-op. JVM compiler directive, not applicable to mino."
1008-
[& _] nil)
1007+
"Mutates a thread-local dynamic-var binding to the given value.
1008+
The target must be a dynamic var with an enclosing (binding ...)
1009+
form on the call stack; without one, throws \"Can't
1010+
change/establish root binding\". Matches Clojure JVM's contract
1011+
for set! on Vars. Returns the new value.
1012+
1013+
The JVM-only field-mutation shape (set! (.-field obj) val) is not
1014+
supported -- mino has no JVM fields."
1015+
[target value]
1016+
(when-not (symbol? target)
1017+
(throw "set!: first argument must be a symbol naming a dynamic var"))
1018+
(list 'set-dyn-binding! (list 'quote target) value))
10091019

10101020
(defmacro comment "Ignores body, returns nil." [& body] nil)
10111021

src/mino.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
*/
2929
#define MINO_VERSION_MAJOR 0
3030
#define MINO_VERSION_MINOR 255
31-
#define MINO_VERSION_PATCH 22
31+
#define MINO_VERSION_PATCH 23
3232

3333
/*
3434
* Human-readable version string of the *linked* runtime, e.g. "0.48.0".

src/prim/stateful.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,50 @@ mino_val_t *prim_get_thread_bindings(mino_state_t *S, mino_val_t *args,
625625
return mino_snapshot_thread_bindings(S);
626626
}
627627

628+
/* (set-dyn-binding! 'name value) -- mutate the topmost active dynamic
629+
* binding for `name` to `value`. Returns the new value. Throws when
630+
* there is no active binding frame for the name (matches Clojure's
631+
* contract: set! on a dynamic var without an enclosing binding form
632+
* raises "Can't change/establish root binding"). Used by the
633+
* (set! *var* expr) macro to back the JVM-Clojure dynamic-var mutation
634+
* shape. */
635+
mino_val_t *prim_set_dyn_binding(mino_state_t *S, mino_val_t *args,
636+
mino_env_t *env)
637+
{
638+
mino_val_t *name_sym;
639+
mino_val_t *new_val;
640+
const char *name;
641+
dyn_frame_t *f;
642+
dyn_binding_t *b;
643+
(void)env;
644+
if (!mino_is_cons(args) || !mino_is_cons(args->as.cons.cdr)
645+
|| mino_is_cons(args->as.cons.cdr->as.cons.cdr)) {
646+
return prim_throw_classified(S, "eval/arity", "MAR001",
647+
"set-dyn-binding! requires two arguments: name value");
648+
}
649+
name_sym = args->as.cons.car;
650+
new_val = args->as.cons.cdr->as.cons.car;
651+
if (name_sym == NULL || mino_type_of(name_sym) != MINO_SYMBOL) {
652+
return prim_throw_classified(S, "eval/type", "MTY001",
653+
"set-dyn-binding!: first argument must be a symbol");
654+
}
655+
name = name_sym->as.s.data;
656+
for (f = mino_current_ctx(S)->dyn_stack; f != NULL; f = f->prev) {
657+
for (b = f->bindings; b != NULL; b = b->next) {
658+
if (strcmp(b->name, name) == 0) {
659+
b->val = new_val;
660+
return new_val;
661+
}
662+
}
663+
}
664+
{
665+
char msg[256];
666+
snprintf(msg, sizeof(msg),
667+
"Can't change/establish root binding of: %s with set!", name);
668+
return prim_throw_classified(S, "eval/contract", "MCT001", msg);
669+
}
670+
}
671+
628672
/* (with-bindings* bindings-map fn) -- pushes a fresh dynamic-binding
629673
* frame from the map's entries (symbol-or-string keys), invokes fn
630674
* with no arguments, pops the frame, and returns the result. Used by
@@ -994,6 +1038,8 @@ const mino_prim_def k_prims_stateful[] = {
9941038
"Returns a map of symbol->value for the active dynamic bindings, or nil if no binding frames are active."},
9951039
{"with-bindings*", prim_with_bindings_star,
9961040
"(with-bindings* bindings-map fn) — pushes the bindings as a dynamic frame and invokes fn with no args."},
1041+
{"set-dyn-binding!", prim_set_dyn_binding,
1042+
"(set-dyn-binding! 'name value) — mutate the topmost active dynamic binding for `name`. Returns the value. Throws when no binding frame is active for `name`. Backs (set! *var* expr)."},
9971043
{"set-fail-alloc-at!", prim_set_fail_alloc_at,
9981044
"Make the n-th GC allocation fail (simulated OOM). Pass 0 to disable."},
9991045
{"mino-thread-limit", prim_mino_thread_limit,

tests/compat_test.clj

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,33 @@
266266
(testing "second defonce does not overwrite"
267267
(is (= 42 compat-defonce-val))))
268268

269-
;; --- set! no-op ---
270-
271-
(deftest set-bang-noop
272-
(testing "set! is a no-op that returns nil"
273-
(is (nil? (set! *warn-on-reflection* true)))))
269+
;; --- set! on dynamic vars ---
270+
;;
271+
;; mino used to make set! a no-op so JVM-Clojure code that pokes
272+
;; *warn-on-reflection* etc. would load without erroring. That hid
273+
;; the legitimate set!-on-bound-dynamic-var contract: real Clojure
274+
;; mutates the thread-local binding frame, and code that needs to
275+
;; bump a counter or stash a value inside binding relies on it.
276+
;; Now set! mutates the topmost dyn frame when one exists, and
277+
;; throws "Can't change/establish root binding" when no binding
278+
;; frame is active -- matching JVM Clojure's runtime contract.
279+
280+
(deftest set-bang-mutates-dynamic-binding
281+
(testing "set! mutates the topmost binding frame"
282+
(def ^:dynamic *set-bang-test* 0)
283+
(binding [*set-bang-test* 0]
284+
(is (= 1 (set! *set-bang-test* 1)))
285+
(is (= 1 *set-bang-test*))
286+
(set! *set-bang-test* 99)
287+
(is (= 99 *set-bang-test*))))
288+
(testing "set! on dynamic var without binding form throws"
289+
(def ^:dynamic *set-bang-unbound* 0)
290+
(is (thrown? (set! *set-bang-unbound* 5))))
291+
(testing "set!-style increment inside binding"
292+
(def ^:dynamic *counter* 0)
293+
(binding [*counter* 0]
294+
(dotimes [_ 5] (set! *counter* (inc *counter*)))
295+
(is (= 5 *counter*)))))
274296

275297
;; --- random-uuid ---
276298

0 commit comments

Comments
 (0)