Skip to content

Commit 5afe380

Browse files
Merge pull request #7169 from aaronb-stacks/feat/defensive-memcheck
Feat: add defensive memory allocation for miners/signers
2 parents 922d54c + 7a6c8da commit 5afe380

20 files changed

Lines changed: 559 additions & 15 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add defensive memory allocation checks for miners and signers. This adds two new config options: `max_assembly_mem_bytes` and `block_proposal_max_tx_mem_bytes`

clarity/src/vm/analysis/errors.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,11 @@ pub enum RuntimeCheckErrorKind {
561561
/// Unexpected condition or failure in the type-checker, indicating a catastrophic bug or invalid state.
562562
Unreachable(String),
563563

564+
/// Execution was deliberately aborted by the per-`eval` abort callback.
565+
/// (e.g., by the memory limit enforcement in block proposal validation or
566+
/// miner block assembly)
567+
AbortedByExecutionHook(String),
568+
564569
// List typing errors
565570
/// List elements have mismatched types, violating type consistency.
566571
ListTypesMustMatch,
@@ -662,10 +667,12 @@ pub struct StaticCheckError {
662667

663668
impl RuntimeCheckErrorKind {
664669
/// This check indicates that the transaction should be rejected.
665-
/// Currently identical to `is_unreachable()` since `Unreachable` is the only
666-
/// rejectable variant, but they answer different questions and may diverge.
667670
pub fn rejectable(&self) -> bool {
668-
matches!(self, RuntimeCheckErrorKind::Unreachable(_))
671+
matches!(
672+
self,
673+
RuntimeCheckErrorKind::Unreachable(_)
674+
| RuntimeCheckErrorKind::AbortedByExecutionHook(_)
675+
)
669676
}
670677

671678
/// Returns true if this error is an unreachable error, indicating a potential bug.

clarity/src/vm/contexts.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use std::time::{Duration, Instant};
2222
use clarity_types::representations::ClarityName;
2323
use serde::Serialize;
2424
use serde_json::json;
25+
use stacks_common::alloc_tracker::{AllocationCounter, thread_allocated};
2526
use stacks_common::types::StacksEpochId;
2627
use stacks_common::types::chainstate::StacksBlockId;
2728

@@ -275,6 +276,58 @@ pub enum ExecutionTimeTracker {
275276
},
276277
}
277278

279+
/// Per-`eval` abort check. This operates alongside the execution time
280+
/// tracker.
281+
///
282+
/// The `None` variant is a no-op (the common case during
283+
/// block append/replay); other variants encode specific abort
284+
/// conditions and are dispatched statically via match.
285+
#[derive(Clone)]
286+
pub enum AbortCallback {
287+
/// No abort check.
288+
None,
289+
/// Abort when net heap allocation since `baseline` exceeds
290+
/// `limit_bytes` (per-thread).
291+
///
292+
/// Used by miner block assembly and proposal validation.
293+
MemAbort {
294+
baseline: AllocationCounter,
295+
limit_bytes: u64,
296+
},
297+
/// Test fixture: always aborts with the given reason.
298+
#[cfg(test)]
299+
AlwaysAbort(String),
300+
}
301+
302+
impl AbortCallback {
303+
/// Run the abort check.
304+
///
305+
/// Returns:
306+
/// * `Ok(())` to continue
307+
/// * `Err(reason)` to abort execution with
308+
/// `RuntimeCheckErrorKind::AbortedByExecutionHook`.
309+
pub fn check(&self) -> Result<(), String> {
310+
match self {
311+
Self::None => Ok(()),
312+
Self::MemAbort {
313+
baseline,
314+
limit_bytes,
315+
} => {
316+
let net_alloc = thread_allocated().net_allocated(baseline);
317+
if net_alloc > *limit_bytes {
318+
Err(format!(
319+
"Transaction heap usage ({net_alloc} bytes) exceeded limit ({limit_bytes} bytes)"
320+
))
321+
} else {
322+
Ok(())
323+
}
324+
}
325+
#[cfg(test)]
326+
Self::AlwaysAbort(reason) => Err(reason.clone()),
327+
}
328+
}
329+
}
330+
278331
/** GlobalContext represents the outermost context for a single transaction's
279332
execution. It tracks an asset changes that occurred during the
280333
processing of the transaction, whether or not the current context is read_only,
@@ -294,6 +347,11 @@ pub struct GlobalContext<'a, 'hooks> {
294347
pub chain_id: u32,
295348
pub eval_hooks: Option<Vec<&'hooks mut dyn EvalHook>>,
296349
pub execution_time_tracker: ExecutionTimeTracker,
350+
/// Callback checked at every `eval` call. When `check()` returns
351+
/// `Err(reason)`, execution is aborted with
352+
/// `VmExecutionError::RuntimeCheck(AbortedByExecutionHook)`. The
353+
/// default `AbortCallback::None` is a no-op.
354+
pub abort_callback: AbortCallback,
297355
}
298356

299357
#[derive(Serialize, Deserialize, Clone)]
@@ -739,6 +797,11 @@ impl<'a, 'hooks> OwnedEnvironment<'a, 'hooks> {
739797
}
740798
}
741799

800+
/// Set an abort callback that will be checked at every `eval` call.
801+
pub fn set_abort_callback(&mut self, callback: AbortCallback) {
802+
self.context.abort_callback = callback;
803+
}
804+
742805
pub fn get_exec_environment<'b>(
743806
&'b mut self,
744807
sender: Option<PrincipalData>,
@@ -1703,6 +1766,7 @@ impl<'a, 'hooks> GlobalContext<'a, 'hooks> {
17031766
chain_id,
17041767
eval_hooks: None,
17051768
execution_time_tracker: ExecutionTimeTracker::NoTracking,
1769+
abort_callback: AbortCallback::None,
17061770
}
17071771
}
17081772

clarity/src/vm/costs/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,7 @@ impl LimitedCostTracker {
853853
Self::Free
854854
}
855855

856+
/// Return the default cost contract name for the provided epoch.
856857
pub fn default_cost_contract_for_epoch(epoch_id: StacksEpochId) -> Result<String, CostErrors> {
857858
let result = match epoch_id {
858859
StacksEpochId::Epoch10 => {

clarity/src/vm/mod.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -483,22 +483,31 @@ pub fn apply_evaluated(
483483
)
484484
}
485485

486-
fn check_max_execution_time_expired(
486+
/// Check for interpreter-level abort conditions.
487+
///
488+
/// Currently, this is either the AbortCallback or the execution
489+
/// time limit.
490+
fn check_interpreter_abort_condition(
487491
global_context: &GlobalContext,
488492
) -> Result<(), VmExecutionError> {
489493
match global_context.execution_time_tracker {
490-
ExecutionTimeTracker::NoTracking => Ok(()),
494+
ExecutionTimeTracker::NoTracking => {}
491495
ExecutionTimeTracker::MaxTime {
492496
start_time,
493497
max_duration,
494498
} => {
495499
if start_time.elapsed() >= max_duration {
496-
Err(CostErrors::ExecutionTimeExpired.into())
497-
} else {
498-
Ok(())
500+
return Err(CostErrors::ExecutionTimeExpired.into());
499501
}
500502
}
501503
}
504+
if let Err(reason) = global_context.abort_callback.check() {
505+
return Err(VmExecutionError::RuntimeCheck(
506+
RuntimeCheckErrorKind::AbortedByExecutionHook(reason),
507+
));
508+
}
509+
510+
Ok(())
502511
}
503512

504513
pub fn eval<'a>(
@@ -511,7 +520,7 @@ pub fn eval<'a>(
511520
Atom, AtomValue, Field, List, LiteralValue, TraitReference,
512521
};
513522

514-
check_max_execution_time_expired(exec_state.global_context)?;
523+
check_interpreter_abort_condition(exec_state.global_context)?;
515524

516525
if let Some(mut eval_hooks) = exec_state.global_context.eval_hooks.take() {
517526
for hook in eval_hooks.iter_mut() {

clarity/src/vm/tests/simple_apply_eval.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,3 +1869,33 @@ fn test_execution_time_expiration() {
18691869
ClarityEvalError::Vm(CostErrors::ExecutionTimeExpired.into())
18701870
);
18711871
}
1872+
1873+
#[test]
1874+
fn test_abort_callback_stops_execution() {
1875+
use crate::vm::contexts::AbortCallback;
1876+
use crate::vm::execute_with_parameters_and_call_in_global_context;
1877+
let abort_msg = "abort callback fired";
1878+
1879+
// An abort callback that always fires
1880+
let result = execute_with_parameters_and_call_in_global_context(
1881+
"(+ 1 1)",
1882+
ClarityVersion::Clarity1,
1883+
StacksEpochId::Epoch20,
1884+
false,
1885+
clarity_types::types::StandardPrincipalData::transient(),
1886+
|g| {
1887+
g.abort_callback = AbortCallback::AlwaysAbort(abort_msg.into());
1888+
Ok(())
1889+
},
1890+
|_| Ok(()),
1891+
);
1892+
match result {
1893+
Err(ClarityEvalError::Vm(e)) => {
1894+
let expected = VmExecutionError::RuntimeCheck(
1895+
RuntimeCheckErrorKind::AbortedByExecutionHook(abort_msg.into()),
1896+
);
1897+
assert_eq!(e, expected);
1898+
}
1899+
other => panic!("Expected aborted-by-execution-hook error, got: {other:?}"),
1900+
}
1901+
}

contrib/stacks-inspect/src/main.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use clarity::consts::CHAIN_ID_MAINNET;
2121
use clarity::types::StacksEpochId;
2222
use clarity::types::chainstate::StacksPrivateKey;
2323
use clarity_cli::{DEFAULT_CLI_EPOCH, read_file_or_stdin, read_file_or_stdin_bytes, vm_execute};
24+
use stacks_common::alloc_tracker::TrackingAllocator;
2425
use stacks_inspect::cli::{Cli, Command};
2526
use stacks_inspect::{
2627
CommonOpts, command_contract_hash, command_replay_mock_mining, command_try_mine,
@@ -41,7 +42,13 @@ use tikv_jemallocator::Jemalloc;
4142

4243
#[cfg(not(any(target_os = "macos", target_os = "windows", target_arch = "arm")))]
4344
#[global_allocator]
44-
static GLOBAL: Jemalloc = Jemalloc;
45+
static GLOBAL: TrackingAllocator<Jemalloc> = TrackingAllocator { inner: Jemalloc };
46+
47+
#[cfg(any(target_os = "macos", target_os = "windows", target_arch = "arm"))]
48+
#[global_allocator]
49+
static GLOBAL: TrackingAllocator<std::alloc::System> = TrackingAllocator {
50+
inner: std::alloc::System,
51+
};
4552

4653
use std::collections::{BTreeMap, HashMap, HashSet};
4754
use std::fs::File;

stacks-common/src/alloc_tracker.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (C) 2026 Stacks Open Internet Foundation
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
use core::cell::Cell;
16+
use std::alloc::{GlobalAlloc, Layout};
17+
use std::sync::OnceLock;
18+
19+
thread_local! {
20+
static THREAD_ALLOCATIONS: Cell<AllocationCounter> = const { Cell::new(AllocationCounter::ZERO) };
21+
}
22+
23+
/// A static bool for checking if the tracking allocator is installed
24+
/// as the global allocator
25+
static TRACKING_ALLOCATOR_INSTALLED: OnceLock<bool> = OnceLock::new();
26+
27+
/// Counter for net allocated bytes
28+
#[derive(Clone, Copy)]
29+
pub struct AllocationCounter {
30+
net_allocated: u64,
31+
}
32+
33+
impl AllocationCounter {
34+
pub const ZERO: Self = Self { net_allocated: 0 };
35+
36+
/// Net allocation (allocated - deallocated) over a `baseline`
37+
pub fn net_allocated(&self, baseline: &AllocationCounter) -> u64 {
38+
self.net_allocated.saturating_sub(baseline.net_allocated)
39+
}
40+
41+
/// Return `self` with net allocated incremented by `increment`
42+
fn increment(mut self, increment: u64) -> Self {
43+
self.net_allocated = self.net_allocated.saturating_add(increment);
44+
self
45+
}
46+
47+
/// Return `self` with net allocated decremented by `decrement`
48+
fn decrement(mut self, decrement: u64) -> Self {
49+
self.net_allocated = self.net_allocated.saturating_sub(decrement);
50+
self
51+
}
52+
}
53+
54+
/// Check if the tracking allocator is installed
55+
///
56+
/// If the check has already been performed in this process,
57+
/// it returns the prior value. Otherwise, it forces an allocation
58+
/// and checks if the tracker picked it up.
59+
pub fn tracking_allocator_installed() -> bool {
60+
*TRACKING_ALLOCATOR_INSTALLED.get_or_init(|| {
61+
let before = thread_allocated();
62+
let probe: Vec<u8> = Vec::with_capacity(1024);
63+
// Prevent the optimizer from eliding the allocation.
64+
std::hint::black_box(&probe);
65+
let installed = thread_allocated().net_allocated(&before) > 0;
66+
if !installed {
67+
error!(
68+
"TrackingAllocator is not installed as the global allocator; any configured memory limits will never trigger"
69+
);
70+
}
71+
drop(probe);
72+
installed
73+
})
74+
}
75+
76+
/// Read the allocation counter for the current thread.
77+
///
78+
/// Returns AllocationCounter::ZERO if the tracking allocator is not installed or if TLS is
79+
/// being torn down (thread shutdown).
80+
pub fn thread_allocated() -> AllocationCounter {
81+
THREAD_ALLOCATIONS
82+
.try_with(Cell::get)
83+
.unwrap_or(AllocationCounter::ZERO)
84+
}
85+
86+
/// A `GlobalAlloc` wrapper that counts per-thread allocations and
87+
/// deallocations. Delegates all actual allocation work to the inner
88+
/// allocator `A`.
89+
pub struct TrackingAllocator<A: GlobalAlloc> {
90+
/// The underlying allocator that performs the real work.
91+
pub inner: A,
92+
}
93+
94+
unsafe impl<A: GlobalAlloc> GlobalAlloc for TrackingAllocator<A> {
95+
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
96+
let ptr = unsafe { self.inner.alloc(layout) };
97+
if !ptr.is_null() {
98+
let _ = THREAD_ALLOCATIONS.try_with(|c| {
99+
let next = c.get().increment(layout.size() as u64);
100+
c.set(next);
101+
});
102+
}
103+
ptr
104+
}
105+
106+
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
107+
unsafe { self.inner.dealloc(ptr, layout) };
108+
let _ = THREAD_ALLOCATIONS.try_with(|c| {
109+
let next = c.get().decrement(layout.size() as u64);
110+
c.set(next);
111+
});
112+
}
113+
114+
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
115+
let ptr = unsafe { self.inner.alloc_zeroed(layout) };
116+
if !ptr.is_null() {
117+
let _ = THREAD_ALLOCATIONS.try_with(|c| {
118+
let next = c.get().increment(layout.size() as u64);
119+
c.set(next);
120+
});
121+
}
122+
ptr
123+
}
124+
125+
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
126+
let new_ptr = unsafe { self.inner.realloc(ptr, layout, new_size) };
127+
// Note: if `new_ptr` is null, no deallocation or allocation
128+
// happened, `ptr` remains valid.
129+
if !new_ptr.is_null() {
130+
let _ = THREAD_ALLOCATIONS.try_with(|c| {
131+
let next = c
132+
.get()
133+
.decrement(layout.size() as u64)
134+
.increment(new_size as u64);
135+
c.set(next);
136+
});
137+
}
138+
new_ptr
139+
}
140+
}

stacks-common/src/libcommon.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ pub mod deps_common {
4545
pub mod ctrlc;
4646
}
4747

48+
pub mod alloc_tracker;
49+
4850
pub mod bitvec;
4951

5052
pub mod consts {

0 commit comments

Comments
 (0)