Skip to content

fear(allocator): introduce CapacityLimit for FixedSizedAllocator#17014

Closed
camc314 wants to merge 1 commit intomainfrom
c/12-17-refactor_allocator_extract_capacitylimit_struct_for_pool_capacity_limiting
Closed

fear(allocator): introduce CapacityLimit for FixedSizedAllocator#17014
camc314 wants to merge 1 commit intomainfrom
c/12-17-refactor_allocator_extract_capacitylimit_struct_for_pool_capacity_limiting

Conversation

@camc314
Copy link
Contributor

@camc314 camc314 commented Dec 17, 2025

For some windows machines, we need to limit the total number of fixed sized allocators to less than thread count, however it is OK to use all threads.

this PE introduces a limiter for this, and introduces atomic waits that wait until a new fixed sized allocator is available.

…imiting

Extract Condvar and max_count logic into a separate CapacityLimit struct.
This ensures the Condvar is only allocated when max_count is Some, and
makes the capacity limiting logic reusable and generic.
@github-actions github-actions bot added the A-linter Area - Linter label Dec 17, 2025
Copy link
Contributor Author

camc314 commented Dec 17, 2025


How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions github-actions bot added the C-cleanup Category - technical debt or refactoring. Solution not expected to change behavior label Dec 17, 2025
@codspeed-hq
Copy link

codspeed-hq bot commented Dec 17, 2025

CodSpeed Performance Report

Merging #17014 will not alter performance

Comparing c/12-17-refactor_allocator_extract_capacitylimit_struct_for_pool_capacity_limiting (c58b1c4) with main (f8abd56)

Summary

✅ 42 untouched
⏩ 3 skipped1

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@camc314 camc314 changed the title refactor(allocator): extract CapacityLimit struct for pool capacity limiting fear(allocator): introduce CapacityLimit for FixedSizedAllocator Dec 17, 2025
@camc314 camc314 marked this pull request as ready for review December 17, 2025 17:51
Copilot AI review requested due to automatic review settings December 17, 2025 17:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces capacity limiting for FixedSizeAllocatorPool to address Windows-specific limitations where the number of fixed-sized allocators needs to be constrained below the thread count. The implementation adds a CapacityLimit struct with blocking/waiting behavior using condition variables.

Key changes:

  • Adds CapacityLimit struct with condition variable-based blocking when capacity is reached
  • Updates FixedSizeAllocatorPool::new to accept max_count: Option<u32> parameter
  • Implements waiting/notification mechanism for allocator availability

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
crates/oxc_linter/src/service/runtime.rs Updates call site to pass None for max_count, maintaining existing behavior
crates/oxc_allocator/src/pool/mod.rs Updates public API to accept max_count parameter, adds documentation
crates/oxc_allocator/src/pool/fixed_size.rs Implements core capacity limiting logic with CapacityLimit struct and blocking behavior

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +114 to +129
// Check if we're at maximum capacity
if let Some(capacity_limit) = &self.capacity_limit {
let current_count = self.next_id.load(Ordering::Relaxed);
if capacity_limit.is_at_capacity(current_count) {
// At maximum capacity - wait for an item to be returned
return capacity_limit.wait_for_item(allocators);
}
}

// Create a new allocator.
// Each allocator needs to have a unique ID, but the order those IDs are assigned in
// doesn't matter, so `Ordering::Relaxed` is fine.
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
// Protect against IDs wrapping around.
assert!(id < u32::MAX, "Created too many allocators");
FixedSizeAllocator::new(id)
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a race condition between checking capacity and incrementing next_id. Multiple threads could simultaneously:

  1. Check that allocators.pop() returns None (line 110)
  2. Load current_count and see it's below max_count (line 116)
  3. All pass the capacity check (line 117)
  4. All increment next_id with fetch_add (line 126)

This allows the pool to create more allocators than max_count allows. The capacity check needs to happen atomically with the ID increment, or use a different synchronization approach.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +153
}

// Notify one waiting thread that an allocator is available (if capacity is limited)
if let Some(capacity_limit) = &self.capacity_limit {
capacity_limit.notify_available();
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutex guard is dropped before notifying waiting threads. This creates a window where a thread waiting in wait_for_item could be notified but another thread could acquire the lock first and pop the allocator, causing the notified thread to wait again unnecessarily. Consider moving the notify_available call inside the mutex-protected block, or document why this ordering is intentional.

Suggested change
}
// Notify one waiting thread that an allocator is available (if capacity is limited)
if let Some(capacity_limit) = &self.capacity_limit {
capacity_limit.notify_available();
// Notify one waiting thread that an allocator is available (if capacity is limited)
if let Some(capacity_limit) = &self.capacity_limit {
capacity_limit.notify_available();
}

Copilot uses AI. Check for mistakes.
/// This method blocks until an item is returned to the pool.
fn wait_for_item<T>(&self, mut items: std::sync::MutexGuard<'_, Vec<T>>) -> T {
loop {
items = self.available.wait(items).unwrap();
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unwrap() in wait_for_item could panic if the mutex is poisoned, but this is not documented. The panic behavior should either be documented in the method's docstring or the error should be propagated to the caller.

Suggested change
items = self.available.wait(items).unwrap();
items = match self.available.wait(items) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +130
fn get_impl(&self) -> FixedSizeAllocator {
let mut allocators = self.allocators.lock().unwrap();

// Try to get an allocator from the pool
if let Some(alloc) = allocators.pop() {
return alloc;
}

// Check if we're at maximum capacity
if let Some(capacity_limit) = &self.capacity_limit {
let current_count = self.next_id.load(Ordering::Relaxed);
if capacity_limit.is_at_capacity(current_count) {
// At maximum capacity - wait for an item to be returned
return capacity_limit.wait_for_item(allocators);
}
}

// Create a new allocator.
// Each allocator needs to have a unique ID, but the order those IDs are assigned in
// doesn't matter, so `Ordering::Relaxed` is fine.
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
// Protect against IDs wrapping around.
assert!(id < u32::MAX, "Created too many allocators");
FixedSizeAllocator::new(id)
}
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new capacity limiting behavior lacks test coverage. Consider adding tests to verify: (1) blocking behavior when capacity is reached, (2) correct unblocking when allocators are returned, (3) race condition handling when multiple threads try to acquire allocators simultaneously, and (4) correct behavior when max_count is None.

Copilot uses AI. Check for mistakes.
FixedSizeAllocatorPool {
allocators: Mutex::new(allocators),
next_id: AtomicU32::new(0),
capacity_limit: max_count.map(CapacityLimit::new),
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no validation that max_count is greater than zero. If max_count is Some(0), the pool will always block in wait_for_item and never be able to create any allocators, resulting in a deadlock. Consider adding validation to ensure max_count is at least 1 when it's Some.

Suggested change
capacity_limit: max_count.map(CapacityLimit::new),
capacity_limit: max_count.filter(|&count| count > 0).map(CapacityLimit::new),

Copilot uses AI. Check for mistakes.
@overlookmotel
Copy link
Member

overlookmotel commented Dec 17, 2025

I've never used Condvar before. Had to look it up!

Hmm. Do we need this? What I vaguely had in mind after you suggested this approach is:

Windows

We want to create as many FixedSizeAllocators as the system can bear (it depends on amount of RAM and max size of swap file, which depends on how full hard drive is).

So I imagined that on Windows:

  1. Make FixedSizeAllocator::new return an Option<Self>, and return None if allocation fails (instead of panic).
  2. FixedSizeAllocatorPool::new would create as many allocators as it can - until FixedSizeAllocator::new returns None, or it's created thread_count allocators.
  3. After that, no more allocators can be added to the pool. FixedSizeAllocatorPool::get would block if self.allocators is empty, and wait until one becomes available.

Actually, maybe need a Condvar for step (3).

Or maybe instead of blocking, could do what resolve_modules does and call rayon::yield_now() to allow other work to continue on the thread (e.g. linting another file in Rust) while it's waiting for an allocator to become available?

// Windows only
let fixed_size_allocator = loop {
  // Try to get an allocator
  let allocator = {
    let mut allocators = self.allocators.lock().unwrap();
    allocators.pop()
  };
  if let Some(allocator) = allocator {
    break allocator;
  }

  // Do other work while waiting for an allocator to become available
  rayon::yield_now();
};

// Got an allocator. Continue.

Mac OS + Linux

No need for any limit. As long as no more than [thread count] FixedSizeAllocators are in play at any time, can't get OOM.

I believe you plan to only get a fixed-size allocator from the pool in run_external_rules, and then drop it (return it to the pool) at end of that function. That would naturally enforce the limit, without any other synchronization mechanisms.

@overlookmotel
Copy link
Member

overlookmotel commented Dec 17, 2025

Actually, on Windows, maybe want to create 1 less FixedSizeAllocator than system can bear, to leave spare memory for normal allocators. I guess that'd mean creating as many FixedSizeAllocators as possible until allocating fails, and then throw away the last one you got.

@camc314
Copy link
Contributor Author

camc314 commented Dec 17, 2025

Windows

We want to create as many FixedSizeAllocators as the system can bear (it depends on amount of RAM and max size of swap file, which depends on how full hard drive is).

This is a problem to implement, if we consider the LSP case, we could be allocating a very large chunk of memory that won't be freed for the lifecycle of the user's editing session, which feels unacceptable. In a different PR, i am going to take the approach of create 1x allocator (bear min) in new, then we lazily try and create more

So I imagined that on Windows:

  1. Make FixedSizeAllocator::new return an Option<Self>, and return None if allocation fails (instead of panic).

Since it's an error case, I called it try_new and returns Result

  1. FixedSizeAllocatorPool::new would create as many allocators as it can - until FixedSizeAllocator::new returns None, or it's created thread_count allocators.
  2. After that, no more allocators can be added to the pool. FixedSizeAllocatorPool::get would block if self.allocators is empty, and wait until one becomes available.

See above for why I don't think we should do this

Actually, maybe need a Condvar for step (3).

Or maybe instead of blocking, could do what resolve_modules does and call rayon::yield_now() to allow other work to continue on the thread (e.g. linting another file in Rust) while it's waiting for an allocator to become available?

If we wanted to move the blocking logic into the linter, I think this would be OK, but importing rayon (where currently oxc_allocator does nothing related to rayon) feels wrong to me. We can discuss though

// Windows only
let fixed_size_allocator = loop {
  // Try to get an allocator
  let allocator = {
    let mut allocators = self.allocators.lock().unwrap();
    allocators.pop()
  };
  if let Some(allocator) = allocator {
    break allocator;
  }

  // Do other work while waiting for an allocator to become available
  rayon::yield_now();
};

// Got an allocator. Continue.

Mac OS + Linux

No need for any limit. As long as no more than [thread count] FixedSizeAllocators are in play at any time, can't get OOM.

I believe you plan to only get a fixed-size allocator from the pool in run_external_rules, and then drop it (return it to the pool) at end of that function. That would naturally enforce the limit, without any other synchronization mechanisms.

👍

I will change the win/non-win logic in a diff PR

@camc314
Copy link
Contributor Author

camc314 commented Dec 17, 2025

closing in favour of #17023 #17022

@camc314 camc314 closed this Dec 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter C-cleanup Category - technical debt or refactoring. Solution not expected to change behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants