Skip to content
Closed
62 changes: 61 additions & 1 deletion rust/crates/clj-eval/src/concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ use clj_value::{
use crate::core::{cce_err, is_ifn};
use crate::error::{NativeError, Signal, internal_throwable};
use crate::eval::Evaluator;
use crate::ext::{FastArith, NativeFn};
use crate::ext::{FastArith, NativeFn, as_native};
use crate::lazy::as_pending;
use crate::ns::{NativeFnPtr, check_arity, npe, signal_to_native, str_of};
use crate::vars::check_min_arity;
Expand Down Expand Up @@ -254,6 +254,61 @@ fn try_publish(atom: &Atom, old: &Value, new: &Value) -> Option<Map> {
Some(state.watches.clone())
}

/// A fused `swap!` int op: add/sub/mul by a constant, or negate.
#[derive(Clone, Copy, Debug)]
enum SwapArith {
Add(i64),
Sub(i64),
Mul(i64),
Neg,
}

impl SwapArith {
fn apply(self, old: i64) -> Option<i64> {
match self {
SwapArith::Add(x) => old.checked_add(x),
SwapArith::Sub(x) => old.checked_sub(x),
SwapArith::Mul(x) => old.checked_mul(x),
SwapArith::Neg => old.checked_neg(),
}
}
}

/// Classify `(swap! a f extra...)` for the fast path: known core
/// arithmetic natives over int extras. Anything else (closures,
/// non-int extras, other arities) runs the generic loop.
fn swap_arith_op(f: &Value, extra: &[Value]) -> Option<SwapArith> {
let native = as_native(f)?;
match (native.display.as_ref(), extra) {
("clojure.core/+", [Value::Int(x)]) => Some(SwapArith::Add(*x)),
("clojure.core/+", []) => Some(SwapArith::Add(0)),
("clojure.core/-", [Value::Int(x)]) => Some(SwapArith::Sub(*x)),
("clojure.core/-", []) => Some(SwapArith::Neg),
("clojure.core/*", [Value::Int(x)]) => Some(SwapArith::Mul(*x)),
("clojure.core/inc", []) => Some(SwapArith::Add(1)),
("clojure.core/dec", []) => Some(SwapArith::Sub(1)),
_ => None,
}
}

/// Run a fused int `swap!`: read, compute, and publish under one
/// lock (atomic — no CAS retry needed, and the mutex serializes
/// against generic publishers). `None` bails to the generic loop
/// (validator/watches installed, non-int state, or overflow —
/// the generic path throws `long overflow`, like `+` itself).
fn swap_fast(atom: &Atom, op: SwapArith) -> Option<(Value, Value)> {
let mut state = atom.lock();
if state.validator.is_some() || !state.watches.is_empty() {
return None;
}
let Value::Int(old) = state.value else {
return None;
};
let new = op.apply(old)?;
state.value = Value::Int(new);
Some((Value::Int(old), Value::Int(new)))
}

/// The `swap!` CAS loop: apply `f`, validate, publish iff the state
/// is unchanged (else retry — `f` published meanwhile). Validation
/// vetoes and `f` failures leave the state untouched and fire no
Expand All @@ -264,6 +319,11 @@ fn swap_loop(
f: &Value,
extra: &[Value],
) -> Result<(Value, Value), NativeError> {
if let Some(op) = swap_arith_op(f, extra) {
if let Some(done) = swap_fast(atom, op) {
return Ok(done);
}
}
loop {
let old = atom.lock().value.clone();
let mut f_args = Vec::with_capacity(extra.len() + 1);
Expand Down
22 changes: 22 additions & 0 deletions rust/crates/clj-eval/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,9 @@ fn num_rational(n: &Num) -> num_rational::BigRational {
/// everything else keeps the rational path.
fn compare_exact(a: &Num, b: &Num) -> std::cmp::Ordering {
match (a, b) {
// Direct `i64` compare (the sort hot path — the rational
// fallback below allocates two `BigInt`s per comparison).
(Num::Int(x), Num::Int(y)) => x.cmp(y),
(Num::BigDec(d), Num::BigDec(e)) => d.compare(e),
(Num::BigDec(d), Num::Int(i)) => d.compare(&clj_value::BigDec::from_bigint((*i).into())),
(Num::Int(i), Num::BigDec(e)) => clj_value::BigDec::from_bigint((*i).into()).compare(e),
Expand Down Expand Up @@ -5146,6 +5149,25 @@ mod tests {
let _ = num_bigdec(&Num::Double(0.5));
}

/// `Int` pairs compare directly (no rational round-trip):
/// less, equal (including extremes), greater.
#[test]
fn int_pairs_compare_directly() {
use std::cmp::Ordering;
assert_eq!(
jvm_compare(&Value::Int(-3), &Value::Int(7)).unwrap(),
Ordering::Less
);
assert_eq!(
jvm_compare(&Value::Int(i64::MIN), &Value::Int(i64::MIN)).unwrap(),
Ordering::Equal
);
assert_eq!(
jvm_compare(&Value::Int(i64::MAX), &Value::Int(i64::MIN)).unwrap(),
Ordering::Greater
);
}

/// Instants order by millis (unit-tested: `#inst` reads
/// a `TaggedLiteral`, so no surface form builds a real
/// instant).
Expand Down
61 changes: 46 additions & 15 deletions rust/crates/clj-eval/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1504,23 +1504,40 @@ impl Env {
Arc::ptr_eq(&parent.frame, &outer.frame) && frame.base == outer.len()
});
if flat_over_outer {
let Slots::Shared(slots) = &mut frame.slots else {
return false;
};
let dst = Arc::get_mut(slots).expect("slots ride with their frame");
// Destructuring loops bind fewer values than slots (one
// per pattern), so a mismatch falls back to a fresh
// bind — only exact rebinds go in place. Values move
// (never clone): each assignment drops the old slot
// value, and the drain leaves the buffer for the
// caller's pool (#235: a `clone_from_slice` here
// cloned every slot per `recur`).
if dst.len() == values.len() {
for (slot, new_val) in dst.iter_mut().zip(values.drain(..)) {
*slot = new_val;
}
// Single-slot `Inline` frames (the `extend_one` call
// path) rebind in place exactly like `Shared` ones —
// the frame is unshared, so the slot is unobserved.
// Exactly one value always arrives (the analyzer
// arity-checks every `recur`); popping it drains the
// buffer for the caller's pool, like `Shared` below.
if let Slots::Inline(slot) = &mut frame.slots {
*slot = values.pop().unwrap_or(Value::Nil);
return true;
}
// `Patchable` (`letfn` setup) frames never rebind in
// place (`recur` binds fresh since #171), and neither
// do length mismatches (destructuring loops bind fewer
// values than slots — one per pattern): anything but
// an exact `Shared` hit answers `false`.
let mut rebound = false;
if let Slots::Shared(slots) = &mut frame.slots {
let dst = Arc::get_mut(slots).expect("slots ride with their frame");
// Destructuring loops bind fewer values than slots
// (one per pattern), so a mismatch falls back to a
// fresh bind — only exact rebinds go in place.
// Values move (never clone): each assignment drops
// the old slot value, and the drain leaves the
// buffer for the caller's pool (#235: a
// `clone_from_slice` here cloned every slot per
// `recur`).
if dst.len() == values.len() {
for (slot, new_val) in dst.iter_mut().zip(values.drain(..)) {
*slot = new_val;
}
rebound = true;
}
}
return rebound;
}
false
}
Expand Down Expand Up @@ -1892,4 +1909,18 @@ mod tests {
b.pop_frame();
assert!(b.is_empty());
}

#[test]
fn rebind_declines_patchable_frames() {
// `Patchable` (`letfn` setup) frames never rebind in place
// (`recur` binds fresh since #171): the decline leaves the
// values untouched for the caller's fresh bind. No
// Clojure-level `recur` targets a setup frame, so this pins
// the contract directly.
let outer = Env::new();
let mut frame = outer.extend_patchable(2);
let mut values = vec![Value::Int(1), Value::Int(2)];
assert!(!frame.try_rebind(&outer, &mut values));
assert_eq!(values, vec![Value::Int(1), Value::Int(2)]);
}
}
Loading
Loading