Skip to content

Commit e8cbaae

Browse files
committed
perf(npm): run lifecycle scripts in parallel
1 parent f0dfb89 commit e8cbaae

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;
@@ -27,6 +31,7 @@ use deno_npm_installer::lifecycle_scripts::LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR;
2731
use deno_npm_installer::lifecycle_scripts::LifecycleScriptsExecutor;
2832
use deno_npm_installer::lifecycle_scripts::LifecycleScriptsExecutorOptions;
2933
use deno_npm_installer::lifecycle_scripts::PackageWithScript;
34+
use deno_npm_installer::lifecycle_scripts::compute_lifecycle_script_layers;
3035
use deno_npm_installer::lifecycle_scripts::is_broken_default_install_script;
3136
use deno_resolver::npm::ByonmNpmResolverCreateOptions;
3237
use deno_resolver::npm::ManagedNpmResolverRc;
@@ -349,6 +354,11 @@ pub struct DenoTaskLifeCycleScriptsExecutor {
349354
system_info: deno_npm::NpmSystemInfo,
350355
}
351356

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

482435
// re-set up bin entries for the packages which we've run scripts for.
@@ -524,6 +477,118 @@ impl DenoTaskLifeCycleScriptsExecutor {
524477
}
525478
}
526479

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

0 commit comments

Comments
 (0)