Skip to content

Commit 8cf29d5

Browse files
authored
perf(npm): run lifecycle scripts in parallel (#32666)
1 parent 2353a5a commit 8cf29d5

File tree

2 files changed

+454
-96
lines changed

2 files changed

+454
-96
lines changed

cli/npm.rs

Lines changed: 161 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
// Copyright 2018-2026 the Deno authors. MIT license.
22

33
use std::borrow::Cow;
4+
use std::collections::HashMap;
45
use std::collections::HashSet;
6+
use std::ffi::OsString;
7+
use std::num::NonZeroUsize;
58
use std::path::PathBuf;
69
use std::rc::Rc;
710
use std::sync::Arc;
811

912
use dashmap::DashMap;
1013
use deno_core::error::AnyError;
14+
use deno_core::futures::StreamExt;
1115
use deno_core::serde_json;
1216
use deno_core::url::Url;
1317
use deno_error::JsErrorBox;
@@ -28,6 +32,7 @@ use deno_npm_installer::lifecycle_scripts::LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR;
2832
use deno_npm_installer::lifecycle_scripts::LifecycleScriptsExecutor;
2933
use deno_npm_installer::lifecycle_scripts::LifecycleScriptsExecutorOptions;
3034
use deno_npm_installer::lifecycle_scripts::PackageWithScript;
35+
use deno_npm_installer::lifecycle_scripts::compute_lifecycle_script_layers;
3136
use deno_npm_installer::lifecycle_scripts::is_broken_default_install_script;
3237
use deno_resolver::npm::ByonmNpmResolverCreateOptions;
3338
use deno_resolver::npm::ManagedNpmResolverRc;
@@ -367,6 +372,11 @@ pub struct DenoTaskLifeCycleScriptsExecutor {
367372
system_info: deno_npm::NpmSystemInfo,
368373
}
369374

375+
struct PackageScriptResult<'a> {
376+
package: &'a NpmResolutionPackage,
377+
failed: Option<&'a PackageNv>,
378+
}
379+
370380
#[async_trait::async_trait(?Send)]
371381
impl LifecycleScriptsExecutor for DenoTaskLifeCycleScriptsExecutor {
372382
async fn execute(
@@ -396,105 +406,48 @@ impl LifecycleScriptsExecutor for DenoTaskLifeCycleScriptsExecutor {
396406
// so the subprocess can detect that it is running as part of a lifecycle script,
397407
// and avoid trying to set up node_modules again
398408
env_vars.insert(LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR.into(), "1".into());
399-
// we want to pass the current state of npm resolution down to the deno subprocess
400-
// (that may be running as part of the script). we do this with an inherited temp file
401-
//
402-
// SAFETY: we are sharing a single temp file across all of the scripts. the file position
403-
// will be shared among these, which is okay since we run only one script at a time.
404-
// However, if we concurrently run scripts in the future we will
405-
// have to have multiple temp files.
406-
let temp_file_fd = deno_runtime::deno_process::npm_process_state_tempfile(
407-
options.process_state.as_bytes(),
408-
)
409-
.map_err(DenoTaskLifecycleScriptsError::CreateNpmProcessState)?;
410-
// SAFETY: fd/handle is valid
411-
let _temp_file = unsafe { std::fs::File::from_raw_io_handle(temp_file_fd) }; // make sure the file gets closed
412-
env_vars.insert(
413-
deno_runtime::deno_process::NPM_RESOLUTION_STATE_FD_ENV_VAR_NAME.into(),
414-
(temp_file_fd as usize).to_string().into(),
409+
410+
let concurrency = std::thread::available_parallelism()
411+
.ok()
412+
.and_then(|n| NonZeroUsize::new(n.get().saturating_sub(1)))
413+
.unwrap_or_else(|| NonZeroUsize::new(2).unwrap())
414+
.get();
415+
416+
let layers = compute_lifecycle_script_layers(
417+
options.packages_with_scripts,
418+
options.snapshot,
415419
);
416-
for PackageWithScript {
417-
package,
418-
scripts,
419-
package_folder,
420-
} in options.packages_with_scripts
421-
{
422-
// add custom commands for binaries from the package's dependencies. this will take precedence over the
423-
// baseline commands, so if the package relies on a bin that conflicts with one higher in the dependency tree, the
424-
// correct bin will be used.
425-
let custom_commands = self
426-
.resolve_custom_commands_from_deps(
427-
options.extra_info_provider,
428-
base.clone(),
429-
package,
430-
options.snapshot,
431-
)
432-
.await;
433-
for script_name in ["preinstall", "install", "postinstall"] {
434-
if let Some(script) = scripts.get(script_name) {
435-
if script_name == "install"
436-
&& is_broken_default_install_script(&sys, script, package_folder)
437-
{
438-
continue;
439-
}
440-
let _guard = self.progress_bar.update_with_prompt(
441-
ProgressMessagePrompt::Initialize,
442-
&format!("{}: running '{script_name}' script", package.id.nv),
443-
);
444-
let crate::task_runner::TaskResult {
445-
exit_code,
446-
stderr,
447-
stdout,
448-
} =
449-
crate::task_runner::run_task(crate::task_runner::RunTaskOptions {
450-
task_name: script_name,
451-
script,
452-
cwd: package_folder.clone(),
453-
env_vars: env_vars.clone(),
454-
custom_commands: custom_commands.clone(),
455-
init_cwd: options.init_cwd,
456-
argv: &[],
457-
root_node_modules_dir: Some(options.root_node_modules_dir_path),
458-
stdio: Some(crate::task_runner::TaskIo {
459-
stderr: TaskStdio::piped(),
460-
stdout: TaskStdio::piped(),
461-
}),
462-
kill_signal: kill_signal.clone(),
463-
})
464-
.await
465-
.map_err(DenoTaskLifecycleScriptsError::Task)?;
466-
let stdout = stdout.unwrap();
467-
let stderr = stderr.unwrap();
468-
if exit_code != 0 {
469-
log::warn!(
470-
"error: script '{}' in '{}' failed with exit code {}{}{}",
471-
script_name,
472-
package.id.nv,
473-
exit_code,
474-
if !stdout.trim_ascii().is_empty() {
475-
format!(
476-
"\nstdout:\n{}\n",
477-
String::from_utf8_lossy(&stdout).trim()
478-
)
479-
} else {
480-
String::new()
481-
},
482-
if !stderr.trim_ascii().is_empty() {
483-
format!(
484-
"\nstderr:\n{}\n",
485-
String::from_utf8_lossy(&stderr).trim()
486-
)
487-
} else {
488-
String::new()
489-
},
490-
);
491-
failed_packages.push(&package.id.nv);
492-
// assume if earlier script fails, later ones will fail too
493-
break;
494-
}
420+
421+
for layer in &layers {
422+
log::debug!(
423+
"Running lifecycle scripts layer: {}",
424+
layer
425+
.iter()
426+
.map(|l| l.package.id.as_serialized())
427+
.collect::<Vec<_>>()
428+
.join(", ")
429+
);
430+
431+
let mut results =
432+
deno_core::futures::stream::iter(layer.iter().map(|pkg| {
433+
self.run_single_package_scripts(
434+
pkg,
435+
&env_vars,
436+
&base,
437+
&options,
438+
&kill_signal,
439+
&sys,
440+
)
441+
}))
442+
.buffer_unordered(concurrency);
443+
444+
while let Some(result) = results.next().await {
445+
let result = result?;
446+
if let Some(nv) = result.failed {
447+
failed_packages.push(nv);
495448
}
449+
(options.on_ran_pkg_scripts)(result.package)?;
496450
}
497-
(options.on_ran_pkg_scripts)(package)?;
498451
}
499452

500453
// re-set up bin entries for the packages which we've run scripts for.
@@ -542,6 +495,118 @@ impl DenoTaskLifeCycleScriptsExecutor {
542495
}
543496
}
544497

498+
/// Runs lifecycle scripts for a single package (preinstall, install,
499+
/// postinstall in order). Each package gets its own temp file for
500+
/// npm process state so concurrent execution is safe.
501+
async fn run_single_package_scripts<'a>(
502+
&self,
503+
pkg: &'a PackageWithScript<'a>,
504+
env_vars: &HashMap<OsString, OsString>,
505+
base_custom_commands: &crate::task_runner::TaskCustomCommands,
506+
options: &LifecycleScriptsExecutorOptions<'a>,
507+
kill_signal: &KillSignal,
508+
sys: &CliSys,
509+
) -> Result<PackageScriptResult<'a>, AnyError> {
510+
let PackageWithScript {
511+
package,
512+
scripts,
513+
package_folder,
514+
} = pkg;
515+
516+
// each concurrent package gets its own temp file to avoid fd races
517+
let temp_file_fd = deno_runtime::deno_process::npm_process_state_tempfile(
518+
options.process_state.as_bytes(),
519+
)
520+
.map_err(DenoTaskLifecycleScriptsError::CreateNpmProcessState)?;
521+
// SAFETY: fd/handle is valid
522+
let _temp_file = unsafe { std::fs::File::from_raw_io_handle(temp_file_fd) };
523+
let mut env_vars = env_vars.clone();
524+
env_vars.insert(
525+
deno_runtime::deno_process::NPM_RESOLUTION_STATE_FD_ENV_VAR_NAME.into(),
526+
(temp_file_fd as usize).to_string().into(),
527+
);
528+
529+
// add custom commands for binaries from the package's dependencies.
530+
// this will take precedence over the baseline commands, so if the
531+
// package relies on a bin that conflicts with one higher in the
532+
// dependency tree, the correct bin will be used.
533+
let custom_commands = self
534+
.resolve_custom_commands_from_deps(
535+
options.extra_info_provider,
536+
base_custom_commands.clone(),
537+
package,
538+
options.snapshot,
539+
)
540+
.await;
541+
542+
let mut failed = None;
543+
for script_name in ["preinstall", "install", "postinstall"] {
544+
if let Some(script) = scripts.get(script_name) {
545+
if script_name == "install"
546+
&& is_broken_default_install_script(sys, script, package_folder)
547+
{
548+
continue;
549+
}
550+
let _guard = self.progress_bar.update_with_prompt(
551+
ProgressMessagePrompt::Initialize,
552+
&format!("{}: running '{script_name}' script", package.id.nv),
553+
);
554+
let crate::task_runner::TaskResult {
555+
exit_code,
556+
stderr,
557+
stdout,
558+
} = crate::task_runner::run_task(crate::task_runner::RunTaskOptions {
559+
task_name: script_name,
560+
script,
561+
cwd: package_folder.clone(),
562+
env_vars: env_vars.clone(),
563+
custom_commands: custom_commands.clone(),
564+
init_cwd: options.init_cwd,
565+
argv: &[],
566+
root_node_modules_dir: Some(options.root_node_modules_dir_path),
567+
stdio: Some(crate::task_runner::TaskIo {
568+
stderr: TaskStdio::piped(),
569+
stdout: TaskStdio::piped(),
570+
}),
571+
kill_signal: kill_signal.clone(),
572+
})
573+
.await
574+
.map_err(DenoTaskLifecycleScriptsError::Task)?;
575+
let stdout = stdout.unwrap();
576+
let stderr = stderr.unwrap();
577+
if exit_code != 0 {
578+
log::warn!(
579+
"error: script '{}' in '{}' failed with exit code {}{}{}",
580+
script_name,
581+
package.id.nv,
582+
exit_code,
583+
if !stdout.trim_ascii().is_empty() {
584+
format!(
585+
"\nstdout:\n{}\n",
586+
String::from_utf8_lossy(&stdout).trim()
587+
)
588+
} else {
589+
String::new()
590+
},
591+
if !stderr.trim_ascii().is_empty() {
592+
format!(
593+
"\nstderr:\n{}\n",
594+
String::from_utf8_lossy(&stderr).trim()
595+
)
596+
} else {
597+
String::new()
598+
},
599+
);
600+
failed = Some(&package.id.nv);
601+
// assume if earlier script fails, later ones will fail too
602+
break;
603+
}
604+
}
605+
}
606+
607+
Ok(PackageScriptResult { package, failed })
608+
}
609+
545610
// take in all (non copy) packages from snapshot,
546611
// and resolve the set of available binaries to create
547612
// custom commands available to the task runner

0 commit comments

Comments
 (0)