Skip to content

Commit fb9e193

Browse files
fix(linter): OOM problems with custom plugins (#17082)
This PR fixes OOM (Out of Memory) problems when using JS plugins together with the import plugin for cross-module analysis. Previously, a stopgap solution simply refused to run when >10,000 files were linted with both features enabled. This PR implements a real fix using a deferred cloning strategy: standard allocators are used for parsing/linting (efficient, dynamic sizing), and the AST is only copied into a fixed-size allocator briefly when calling JS plugins. This dramatically reduces memory usage from n_files × 6 GiB to n_files × dynamic + thread_count × 6 GiB. --------- Co-authored-by: Cameron Clark <[email protected]>
1 parent c619f64 commit fb9e193

File tree

5 files changed

+217
-81
lines changed

5 files changed

+217
-81
lines changed

apps/oxlint/src/lint.rs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -352,21 +352,6 @@ impl CliRunner {
352352
.with_report_unused_directives(report_unused_directives);
353353

354354
let number_of_files = files_to_lint.len();
355-
356-
// Due to the architecture of the import plugin and JS plugins,
357-
// linting a large number of files with both enabled can cause resource exhaustion.
358-
// See: https://github.com/oxc-project/oxc/issues/15863
359-
if number_of_files > 10_000 && use_cross_module && has_external_linter {
360-
print_and_flush_stdout(
361-
stdout,
362-
&format!(
363-
"Failed to run oxlint.\n{}\n",
364-
render_report(&handler, &OxcDiagnostic::error(format!("Linting {number_of_files} files with both import plugin and JS plugins enabled can cause resource exhaustion.")).with_help("See https://github.com/oxc-project/oxc/issues/15863 for more details."))
365-
),
366-
);
367-
return CliRunResult::TooManyFilesWithImportAndJsPlugins;
368-
}
369-
370355
let tsconfig = basic_options.tsconfig;
371356
if let Some(path) = tsconfig.as_ref() {
372357
if path.is_file() {
@@ -404,10 +389,16 @@ impl CliRunner {
404389
}
405390
};
406391

407-
// Configure the file system for external linter if needed
392+
// Configure the file system for external linter if needed.
393+
// When using the copy-to-fixed-allocator approach (cross-module + JS plugins),
394+
// we use `OsFileSystem` instead of `RawTransferFileSystem`, because we use standard allocators for parsing.
408395
let file_system = if has_external_linter {
409396
#[cfg(all(feature = "napi", target_pointer_width = "64", target_endian = "little"))]
410-
{
397+
if use_cross_module {
398+
// Use standard file system - source text will be copied to fixed-size allocator later
399+
None
400+
} else {
401+
// Use raw transfer file system - source text goes directly to fixed-size allocator
411402
Some(
412403
&crate::js_plugins::RawTransferFileSystem
413404
as &(dyn oxc_linter::RuntimeFileSystem + Sync + Send),

apps/oxlint/src/result.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ pub enum CliRunResult {
1717
ConfigFileInitFailed,
1818
ConfigFileInitSucceeded,
1919
TsGoLintError,
20-
TooManyFilesWithImportAndJsPlugins,
2120
}
2221

2322
impl Termination for CliRunResult {
@@ -38,8 +37,7 @@ impl Termination for CliRunResult {
3837
| Self::InvalidOptionSeverityWithoutFilter
3938
| Self::InvalidOptionSeverityWithoutPluginName
4039
| Self::InvalidOptionSeverityWithoutRuleName
41-
| Self::TsGoLintError
42-
| Self::TooManyFilesWithImportAndJsPlugins => ExitCode::FAILURE,
40+
| Self::TsGoLintError => ExitCode::FAILURE,
4341
}
4442
}
4543
}

crates/oxc_allocator/src/pool/fixed_size.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,6 @@ impl FixedSizeAllocatorPool {
134134
return None;
135135
}
136136

137-
// Protect against IDs wrapping around.
138-
// TODO: Does this work? Do we need it anyway?
139-
assert!(id < u32::MAX, "Created too many allocators");
140-
141137
// Try to claim this ID. If another thread got there first, retry with new ID.
142138
if self
143139
.next_id

crates/oxc_linter/src/lib.rs

Lines changed: 155 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::{
77
rc::Rc,
88
};
99

10-
use oxc_allocator::Allocator;
10+
use oxc_allocator::{Allocator, AllocatorPool, CloneIn};
1111
use oxc_ast::{ast::Program, ast_kind::AST_TYPE_MAX};
1212
use oxc_ast_macros::ast;
1313
use oxc_ast_visit::utf8_to_utf16::Utf8ToUtf16;
@@ -148,18 +148,24 @@ impl Linter {
148148
context_sub_hosts: Vec<ContextSubHost<'a>>,
149149
allocator: &'a Allocator,
150150
) -> Vec<Message> {
151-
self.run_with_disable_directives(path, context_sub_hosts, allocator).0
151+
self.run_with_disable_directives(path, context_sub_hosts, allocator, None).0
152152
}
153153

154154
/// Same as `run` but also returns the disable directives for the file
155155
///
156+
/// # Parameters
157+
/// - `js_allocator_pool`: Optional pool of fixed-size allocators for copying AST before JS transfer.
158+
/// When `Some`, the AST will be copied into a fixed-size allocator before passing to JS plugins,
159+
/// allowing the main allocator to be a standard (non-fixed-size) allocator.
160+
///
156161
/// # Panics
157162
/// Panics in debug mode if running with and without optimizations produces different diagnostic counts.
158163
pub fn run_with_disable_directives<'a>(
159164
&self,
160165
path: &Path,
161166
context_sub_hosts: Vec<ContextSubHost<'a>>,
162167
allocator: &'a Allocator,
168+
js_allocator_pool: Option<&AllocatorPool>,
163169
) -> (Vec<Message>, Option<DisableDirectives>) {
164170
let ResolvedLinterState { rules, config, external_rules } = self.config.resolve(path);
165171

@@ -350,7 +356,13 @@ impl Linter {
350356
// can mutably access `ctx_host` via `Rc::get_mut` without panicking due to multiple references.
351357
drop(rules);
352358

353-
self.run_external_rules(&external_rules, path, &mut ctx_host, allocator);
359+
self.run_external_rules(
360+
&external_rules,
361+
path,
362+
&mut ctx_host,
363+
allocator,
364+
js_allocator_pool,
365+
);
354366

355367
// Report unused directives is now handled differently with type-aware linting
356368

@@ -382,65 +394,157 @@ impl Linter {
382394
(diagnostics, disable_directives)
383395
}
384396

397+
#[cfg(all(target_pointer_width = "64", target_endian = "little"))]
385398
fn run_external_rules<'a>(
386399
&self,
387400
external_rules: &[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)],
388401
path: &Path,
389402
ctx_host: &mut Rc<ContextHost<'a>>,
390403
allocator: &'a Allocator,
404+
js_allocator_pool: Option<&AllocatorPool>,
391405
) {
392406
if external_rules.is_empty() {
393407
return;
394408
}
395409

396-
// `external_linter` always exists when `external_rules` is not empty
397-
let external_linter = self.external_linter.as_ref().unwrap();
410+
// If `js_allocator_pool` is provided, use clone-into-fixed-allocator approach
411+
if let Some(js_pool) = js_allocator_pool {
412+
self.clone_into_fixed_size_allocator_and_run_external_rules(
413+
external_rules,
414+
path,
415+
ctx_host,
416+
js_pool,
417+
);
418+
return;
419+
}
420+
421+
// Extract `Semantic` from `ContextHost`, and get a mutable reference to `Program`.
422+
//
423+
// It's not possible to obtain a `&mut Program` while `Semantic` exists, because `Semantic`
424+
// contains `AstNodes`, which contains `AstKind`s for every AST nodes, each of which contains
425+
// an immutable `&` ref to an AST node.
426+
// Obtaining a `&mut Program` while `Semantic` exists would be illegal aliasing.
427+
//
428+
// So instead we get a pointer to `Program`.
429+
// The pointer is obtained initially from `&Program` in `Semantic`, but that pointer
430+
// has no provenance for mutation, so can't be converted to `&mut Program`.
431+
// So create a new pointer to `Program` which inherits `data_end_ptr`'s provenance,
432+
// which does allow mutation.
433+
//
434+
// We then drop `Semantic`, after which no references to any AST nodes remain.
435+
// We can then safety convert the pointer to `&mut Program`.
436+
//
437+
// `Program` was created in `allocator`, and that allocator is a `FixedSizeAllocator`,
438+
// so only has 1 chunk. So `data_end_ptr` and `Program` are within the same allocation.
439+
// All callers of `Linter::run` obtain `allocator` and `Semantic` from `ModuleContent`,
440+
// which ensure they are in same allocation.
441+
// However, we have no static guarantee of this, so strictly speaking it's unsound.
442+
// TODO: It would be better to avoid the need for a `&mut Program` here, and so avoid this
443+
// sketchy behavior.
444+
let ctx_host = Rc::get_mut(ctx_host).unwrap();
445+
let semantic = mem::take(ctx_host.semantic_mut());
446+
let program_addr = NonNull::from(semantic.nodes().program()).addr();
447+
let mut program_ptr =
448+
allocator.data_end_ptr().cast::<Program<'a>>().with_addr(program_addr);
449+
drop(semantic);
450+
// SAFETY: Now that we've dropped `Semantic`, no references to any AST nodes remain,
451+
// so can get a mutable reference to `Program` without aliasing violations.
452+
let program = unsafe { program_ptr.as_mut() };
453+
454+
self.convert_and_call_external_linter(external_rules, path, ctx_host, allocator, program);
455+
}
456+
457+
#[cfg(not(all(target_pointer_width = "64", target_endian = "little")))]
458+
fn run_external_rules<'a>(
459+
&self,
460+
_external_rules: &[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)],
461+
_path: &Path,
462+
_ctx_host: &mut Rc<ContextHost<'a>>,
463+
_allocator: &'a Allocator,
464+
_js_allocator_pool: Option<&AllocatorPool>,
465+
) {
466+
// External rules (JS plugins) are not supported on non-64-bit or big-endian platforms
467+
}
398468

399-
let (program_offset, source_text, span_converter) = {
400-
// Extract `Semantic` from `ContextHost`, and get a mutable reference to `Program`.
401-
//
402-
// It's not possible to obtain a `&mut Program` while `Semantic` exists, because `Semantic`
403-
// contains `AstNodes`, which contains `AstKind`s for every AST nodes, each of which contains
404-
// an immutable `&` ref to an AST node.
405-
// Obtaining a `&mut Program` while `Semantic` exists would be illegal aliasing.
406-
//
407-
// So instead we get a pointer to `Program`.
408-
// The pointer is obtained initially from `&Program` in `Semantic`, but that pointer
409-
// has no provenance for mutation, so can't be converted to `&mut Program`.
410-
// So create a new pointer to `Program` which inherits `data_end_ptr`'s provenance,
411-
// which does allow mutation.
412-
//
413-
// We then drop `Semantic`, after which no references to any AST nodes remain.
414-
// We can then safety convert the pointer to `&mut Program`.
415-
//
416-
// `Program` was created in `allocator`, and that allocator is a `FixedSizeAllocator`,
417-
// so only has 1 chunk. So `data_end_ptr` and `Program` are within the same allocation.
418-
// All callers of `Linter::run` obtain `allocator` and `Semantic` from `ModuleContent`,
419-
// which ensure they are in same allocation.
420-
// However, we have no static guarantee of this, so strictly speaking it's unsound.
421-
// TODO: It would be better to avoid the need for a `&mut Program` here, and so avoid this
422-
// sketchy behavior.
423-
let ctx_host = Rc::get_mut(ctx_host).unwrap();
424-
let semantic = mem::take(ctx_host.semantic_mut());
425-
let program_addr = NonNull::from(semantic.nodes().program()).addr();
426-
let mut program_ptr =
427-
allocator.data_end_ptr().cast::<Program<'a>>().with_addr(program_addr);
428-
drop(semantic);
429-
// SAFETY: Now that we've dropped `Semantic`, no references to any AST nodes remain,
430-
// so can get a mutable reference to `Program` without aliasing violations.
431-
let program = unsafe { program_ptr.as_mut() };
432-
433-
// Convert spans to UTF-16
434-
let span_converter = Utf8ToUtf16::new(program.source_text);
435-
span_converter.convert_program(program);
436-
span_converter.convert_comments(&mut program.comments);
437-
438-
// Get offset of `Program` within buffer (bottom 32 bits of pointer)
439-
let program_offset = ptr::from_ref(program) as u32;
440-
441-
(program_offset, program.source_text, span_converter)
469+
/// Clone AST into a fixed-size allocator and run external rules.
470+
///
471+
/// This copies the AST and source text from the standard allocator into a fixed-size
472+
/// allocator before passing to JS plugins. This allows using standard allocators for
473+
/// parsing/linting while still supporting JS plugin raw transfer.
474+
#[cfg(all(target_pointer_width = "64", target_endian = "little"))]
475+
fn clone_into_fixed_size_allocator_and_run_external_rules(
476+
&self,
477+
external_rules: &[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)],
478+
path: &Path,
479+
ctx_host: &mut Rc<ContextHost<'_>>,
480+
js_allocator_pool: &AllocatorPool,
481+
) {
482+
let js_allocator = js_allocator_pool.get();
483+
484+
// Get the original source text and program from semantic
485+
let ctx_host_ref = Rc::get_mut(ctx_host).unwrap();
486+
let semantic = mem::take(ctx_host_ref.semantic_mut());
487+
let original_program = semantic.nodes().program();
488+
let original_source_text = original_program.source_text;
489+
490+
// Copy source text to the START of the fixed-size allocator.
491+
// This is critical - the JS deserializer expects source text at offset 0.
492+
// SAFETY: `js_allocator` is from a fixed-size allocator pool, which wraps the allocator
493+
// in a custom `Drop` that doesn't actually drop it (it returns it to the pool), so the
494+
// memory remains valid. This matches the safety requirements of `alloc_bytes_start`.
495+
let new_source_text: &str = unsafe {
496+
let bytes = original_source_text.as_bytes();
497+
let ptr = js_allocator.alloc_bytes_start(bytes.len());
498+
ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.as_ptr(), bytes.len());
499+
std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr.as_ptr(), bytes.len()))
442500
};
443501

502+
// Clone `Program` into fixed-size allocator.
503+
// We need to allocate the `Program` struct ITSELF in the allocator, not just its contents.
504+
// `clone_in` returns a value on the stack, but we need it in the allocator for raw transfer.
505+
let program: &mut Program<'_> = {
506+
let mut program = original_program.clone_in(&js_allocator);
507+
program.source_text = new_source_text;
508+
js_allocator.alloc(program)
509+
};
510+
511+
// Drop semantic now that we've copied what we need
512+
drop(semantic);
513+
514+
self.convert_and_call_external_linter(
515+
external_rules,
516+
path,
517+
ctx_host_ref,
518+
&js_allocator,
519+
program,
520+
);
521+
522+
// The `AllocatorGuard` (`js_allocator`) is dropped here, returning the allocator to the pool.
523+
// This ensures that we never have too many allocators in play at once, avoiding OOM.
524+
}
525+
526+
/// Convert spans to UTF-16, write metadata, call external linter, and process diagnostics.
527+
///
528+
/// This is the common code path shared by both `run_external_rules` and
529+
/// `clone_into_fixed_size_allocator_and_run_external_rules`.
530+
fn convert_and_call_external_linter(
531+
&self,
532+
external_rules: &[(ExternalRuleId, ExternalOptionsId, AllowWarnDeny)],
533+
path: &Path,
534+
ctx_host: &ContextHost<'_>,
535+
allocator: &Allocator,
536+
program: &mut Program<'_>,
537+
) {
538+
let source_text = program.source_text;
539+
540+
// Convert spans to UTF-16
541+
let span_converter = Utf8ToUtf16::new(source_text);
542+
span_converter.convert_program(program);
543+
span_converter.convert_comments(&mut program.comments);
544+
545+
// Get offset of `Program` within buffer (bottom 32 bits of pointer)
546+
let program_offset = ptr::from_ref(program) as u32;
547+
444548
// Write offset of `Program` in metadata at end of buffer
445549
let metadata = RawTransferMetadata::new(program_offset);
446550
let metadata_ptr = allocator.end_ptr().cast::<RawTransferMetadata>();
@@ -470,6 +574,9 @@ impl Linter {
470574
"{}".to_string()
471575
});
472576

577+
// `external_linter` always exists when `external_rules` is not empty
578+
let external_linter = self.external_linter.as_ref().unwrap();
579+
473580
// Pass AST and rule IDs + options IDs to JS
474581
let result = (external_linter.lint_file)(
475582
path.to_owned(),

0 commit comments

Comments
 (0)