aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src/command/create_project_command.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-11 12:19:31 +0900
committernsfisis <nsfisis@gmail.com>2026-08-11 12:19:31 +0900
commit9539fea16e0d8d07faf7edf50d480e0a80c4ccfc (patch)
treec989aae95701e24aace29fec25c9d33c4c065e6b /crates/shirabe/src/command/create_project_command.rs
parent144d059b725e2d178d7f4a6403cde9474fe65dcc (diff)
downloadphp-shirabe-9539fea16e0d8d07faf7edf50d480e0a80c4ccfc.tar.gz
php-shirabe-9539fea16e0d8d07faf7edf50d480e0a80c4ccfc.tar.zst
php-shirabe-9539fea16e0d8d07faf7edf50d480e0a80c4ccfc.zip
feat(signal): abort on SIGINT, SIGTERM and SIGHUP at checkpoints
The SignalHandler port was a no-op stub, so all four of Composer's abort paths were dead code: nothing removed a half-created project, reverted composer.json, or cleaned up half-installed packages. Composer runs those handlers from pcntl callbacks, which a Rust signal handler cannot do -- it may touch nothing beyond atomics. SignalSubscription records the signal instead, and the abort runs from checkpoints on the normal call stack, where the clean-up can borrow the state it needs. That also resolves the closure-capture TODO(phase-c)s in RequireCommand and InstallationManager, and replaces exit_with_last_signal's exit(0) with the restore-and-re-raise Seld\Signal does. A subscription is live only inside the four abort regions, so elsewhere the signals keep their default disposition and kill the process at once. It is installed without SA_RESTART so a signal interrupts an interactive prompt rather than resuming the read. A signal reaches only the innermost subscription, reproducing SignalHandler's single-stack dispatch. Drop SignalRegistry, SignalableCommandInterface and the Application wiring for them: nothing in Composer reaches that path, and SignalHandler discards whatever they register. Signal handling from plugins and scripts is undefined behavior; see docs/dev/signals.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src/command/create_project_command.rs')
-rw-r--r--crates/shirabe/src/command/create_project_command.rs61
1 files changed, 33 insertions, 28 deletions
diff --git a/crates/shirabe/src/command/create_project_command.rs b/crates/shirabe/src/command/create_project_command.rs
index 787e31d7..c7768f11 100644
--- a/crates/shirabe/src/command/create_project_command.rs
+++ b/crates/shirabe/src/command/create_project_command.rs
@@ -32,6 +32,7 @@ use crate::repository::PlatformRepository;
use crate::repository::RepositoryFactory;
use crate::repository::RepositorySet;
use crate::script::ScriptEvents;
+use crate::signal::SignalSubscription;
use crate::util::Filesystem;
use crate::util::Platform;
use crate::util::ProcessExecutor;
@@ -43,7 +44,6 @@ use shirabe_php_shim::{
chdir, explode_with_limit, file_exists, getcwd, impl_php_class, implode, is_dir, is_file,
mkdir, realpath, rtrim, strtolower, unlink,
};
-use shirabe_seld_signal::SignalHandler;
use shirabe_symfony_console::command::Command;
use shirabe_symfony_console::input::InputInterface;
use shirabe_symfony_console::output::OutputInterface;
@@ -731,28 +731,23 @@ impl CreateProjectCommand {
// handler Ctrl+C aborts gracefully
let _ = mkdir(&directory, 0o777, true);
- let mut signal_handler: Option<SignalHandler> = None;
- if let Some(real_dir) = realpath(&directory) {
- let real_dir_clone = real_dir;
- let io_for_signal = io.clone();
- signal_handler = Some(SignalHandler::create(
- vec![
- SignalHandler::SIGINT.to_string(),
- SignalHandler::SIGTERM.to_string(),
- SignalHandler::SIGHUP.to_string(),
- ],
- Box::new(move |signal: String, handler: &SignalHandler| {
- io_for_signal.write_error3(
- &format!("Received {}, aborting", signal),
- true,
- crate::io::DEBUG,
- );
- let mut fs = Filesystem::new(None);
- fs.remove_directory(&real_dir_clone).ok();
- handler.exit_with_last_signal();
- }),
- ));
- }
+ let real_dir = realpath(&directory);
+ let signals = real_dir.as_ref().map(|_| SignalSubscription::new());
+ let abort_on_signal = |signals: &SignalSubscription| {
+ io.write_error3(
+ &format!("Received {}, aborting", signals.last_signal().as_str()),
+ true,
+ crate::io::DEBUG,
+ );
+ let mut fs = Filesystem::new(None);
+ fs.remove_directory(
+ real_dir
+ .as_ref()
+ .expect("subscribed only when realpath succeeded"),
+ )
+ .ok();
+ signals.exit_with_last_signal();
+ };
// avoid displaying 9999999-dev as version if default-branch was selected
if let Some(alias) = package.as_alias()
@@ -791,13 +786,19 @@ impl CreateProjectCommand {
);
// A shared borrow: plugin registration inside execute re-enters this manager handle
// through the Composer graph.
- installation_manager.borrow().execute(
+ let executed = installation_manager.borrow().execute(
&installed_repo,
vec![InstallOperation::new(package.clone()).into()],
true,
true,
false,
- )?;
+ );
+ if let Some(signals) = &signals
+ && signals.is_triggered()
+ {
+ abort_on_signal(signals);
+ }
+ executed?;
installation_manager
.borrow_mut()
.notify_installs(io.clone());
@@ -826,11 +827,15 @@ impl CreateProjectCommand {
Platform::put_env("COMPOSER_ROOT_VERSION", &package.get_pretty_version());
- // once the root project is fully initialized, we do not need to wipe everything on user abort anymore even if it happens during deps install
- if let Some(handler) = signal_handler {
- handler.unregister();
+ if let Some(signals) = &signals
+ && signals.is_triggered()
+ {
+ abort_on_signal(signals);
}
+ // once the root project is fully initialized, we do not need to wipe everything on user abort anymore even if it happens during deps install
+ drop(signals);
+
Ok(installed_from_vcs)
}
}