Skip to content

Conversation

@chessbyte
Copy link
Contributor

Issue # (if available)

Closes #153

Description of changes

Convert process from plain Object to Class with EventEmitter support, enabling signal handling via process.on('SIGTERM', callback) etc.

Supported signals (Unix only):

  • SIGTERM, SIGINT, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2

Signal handlers are lazily initialized when listeners are first registered, avoiding unnecessary background tasks.

Checklist

  • Created unit tests in tests/unit and/or in Rust for my feature if needed
  • Ran make fix to format JS and apply Clippy auto fixes
  • Made sure my code didn't add any additional warnings: make check
  • Added relevant type info in types/ directory
  • Updated documentation if needed (API.md/README.md/Other)

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@chessbyte chessbyte force-pushed the feature/process-on-signal branch from 1e97328 to f81f2ad Compare December 16, 2025 00:24
Copy link
Collaborator

@richarddavison richarddavison left a comment

Choose a reason for hiding this comment

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

Thanks for the PR and sorry for the delay of review. I like this feature but there are a couple of issues.

  1. This doesn't properly use the event emitter mechanisms that we use in other modules, it just overwrites them. Look how event emitter is used elsewhere.
  2. Do we need to hardcode the signal strings in modules/llrt_process/src/signal_handler.rs are they in libc crate or in std or somewhere else? If not, we should altest use statics, or maybe we can work with SignalKind directly?
  3. The signal handler spawns a task per signal which is fine, however this will not work if there are multiple runtimes. We need to consider this so the atomic bools is tied to the process instances

@chessbyte
Copy link
Contributor Author

chessbyte commented Dec 21, 2025

Thanks for the thorough review, @richarddavison. You raised valid concerns and I appreciate the patience.

Addressing the feedback (in 2nd commit):

  1. Event Emitter Pattern

You are right that I am overriding the event emitter methods rather than properly integrating. I looked at how streams do it - they implement on_event_changed() which sends on a channel, and a task spawned during the stream's operation lifecycle receives and acts.

For Process, I couldn't find a clean way to replicate this because:

  • on_event_changed() only receives &mut self, not Ctx needed to spawn async tasks
  • Process is a global singleton without a natural "start operation" lifecycle like streams have
  • Spawning a coordinator task in init() creates shutdown complexity for the test harness

I am open to suggestions here - if you see a cleaner way to integrate with on_event_changed() I'd be happy to refactor.

  1. Hardcoded Signal Strings

Good catch. I refactored to use a single SUPPORTED_SIGNALS static array that pairs signal names with their libc constants:

static SUPPORTED_SIGNALS: &[(&str, libc::c_int)] = &[
    ("SIGTERM", libc::SIGTERM),
    ("SIGINT", libc::SIGINT),
    // ...
];

Now SignalKind::from_raw() derives from libc, and the hardcoded get_signal_number() helper is eliminated entirely.

  1. Multi-Runtime Support

This was a significant oversight. The global AtomicBool statics meant all runtimes shared state, which is wrong. I replaced them with a per-instance HashSet field on Process, so each runtime correctly tracks its own signal handlers.

Additional fixes:

  • Added signal handler check to addListener, prependListener, and prependOnceListener (they were missing it)
  • Added SIGUSR1/SIGUSR2 to types
  • Added tests for listener method parity

Let me know if there is anything else to address.

@chessbyte chessbyte force-pushed the feature/process-on-signal branch from e5f350a to d51a106 Compare December 21, 2025 14:41
chessbyte added a commit to chessbyte/awslabs-llrt that referenced this pull request Dec 21, 2025
Replace res_bytes.as_slice() with idiomatic &res_bytes[..] to avoid
name collision with new [T]::as_slice() method added in Rust nightly
(rust-lang/rust#145933, merged 2025-12-18).

Issue observed in nightly build checks for PR awslabs#1293.

The WriteBuf trait from zstd was only imported to provide as_slice()
through its impl for [u8], which is unnecessary coupling. Using slice
syntax is more idiomatic and doesn't require external trait imports.
richarddavison pushed a commit that referenced this pull request Dec 22, 2025
…1295)

Replace res_bytes.as_slice() with idiomatic &res_bytes[..] to avoid
name collision with new [T]::as_slice() method added in Rust nightly
(rust-lang/rust#145933, merged 2025-12-18).

Issue observed in nightly build checks for PR #1293.

The WriteBuf trait from zstd was only imported to provide as_slice()
through its impl for [u8], which is unnecessary coupling. Using slice
syntax is more idiomatic and doesn't require external trait imports.
@richarddavison
Copy link
Collaborator

Fantastic! Thanks for the quick updates.

For Process, I couldn't find a clean way to replicate this because:

  • on_event_changed() only receives &mut self, not Ctx needed to spawn async tasks
  • Process is a global singleton without a natural "start operation" lifecycle like streams have
  • Spawning a coordinator task in init() creates shutdown complexity for the test harness

I am open to suggestions here - if you see a cleaner way to integrate with on_event_changed() I'd be happy to refactor.

Then maybe the best way here is to modify on_event changed to pass a &ctx reference as well. Would clean this up I think.

I was also thinking that listening for "each" signal in a separate task could lead to races and is ineffective. However this is kind of complex as in linux/tokio+signals you kind of "hook-in" to signal managed or you don't. It's not designed for on/off use cases. In other words if you remove signal handling for SIGINT, ctrl+c would not work anymore.

If we can find a good way to fallback to platform defaults when disabling a single AND use a single thread with a FuturesUnordered.next() for all signals this would be better.

I think we have to use something like signal_hook crate for this so we can dynamically register signals on and off

@chessbyte chessbyte force-pushed the feature/process-on-signal branch from d51a106 to dbe9231 Compare December 22, 2025 22:01
Convert process from plain Object to Class with EventEmitter support,
enabling signal handling via process.on('SIGTERM', callback) etc.

Supported signals (Unix only):
- SIGTERM, SIGINT, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2

Signal handlers are lazily initialized when listeners are first
registered, avoiding unnecessary background tasks.

Closes awslabs#153
Move Unix-specific methods (getuid, getgid, geteuid, getegid, setuid,
setgid, seteuid, setegid) out of the #[rquickjs::methods] impl block
and add them directly to the process object in init().

The #[cfg(unix)] attribute on individual methods inside
#[rquickjs::methods] doesn't work correctly with the proc macro -
it generates code referencing those methods even when they're
conditionally compiled out, causing build failures on Windows.
- Refactor signal handler to use per-instance HashSet tracking instead
  of global AtomicBool statics, supporting multiple runtimes correctly
- Use SUPPORTED_SIGNALS static array with libc constants instead of
  hardcoded signal strings and numbers
- Add signal handler check to all listener methods (addListener,
  prependListener, prependOnceListener) for consistency
- Add SIGUSR1/SIGUSR2 support to signals.rs and types
- Add tests for listener method parity and event emitter functionality
- Document on_event_changed() constraint in module docs
@chessbyte chessbyte force-pushed the feature/process-on-signal branch from c02a67a to 850c7db Compare December 22, 2025 23:21
Addresses review feedback on signal handling architecture:

1. Extends Emitter trait's on_event_changed to receive &Ctx and Class<Self>,
   enabling signal handler setup without manual hooks in listener methods.

2. Replaces per-signal spawned tasks with a single coordinator task that
   multiplexes all signals via tokio::select!, eliminating race conditions.

3. Implements lazy signal registration - signals are only registered with
   the OS when the first listener is added for that specific signal. This
   ensures we don't interfere with default OS behavior for unrequested signals.

4. Tracks listener counts per signal via MPSC channel commands, enabling
   proper handling when listeners are added/removed.

Note: Once a signal is registered, default OS behavior cannot be restored
(tokio::signal limitation). Lazy registration mitigates this by only
affecting signals the user explicitly requested.
@chessbyte chessbyte force-pushed the feature/process-on-signal branch from 850c7db to 8d0f3ad Compare December 22, 2025 23:34
@chessbyte
Copy link
Contributor Author

@richarddavison Tried to address your feedback in latest commit:

  1. on_event_changed with &Ctx
    Extended the Emitter trait's on_event_changed to receive &Ctx and Class. This eliminated the manual maybe_start_signal_handler calls in each listener method (on, once, addListener, etc.).

  2. Single coordinator task
    Replaced per-signal spawned tasks with a single coordinator using tokio::select!. Commands (AddListener/RemoveListener) are sent via MPSC channel, and listener counts are tracked in a HashMap<String, usize>.

  3. Lazy signal registration
    Signals are now registered with the OS only when the first listener is added for that specific signal. Unregistered signals use std::future::pending() in the select branches, effectively making them invisible to the coordinator until needed. This ensures we don't interfere with default OS behavior for signals the user never requested.

Options not pursued:

  • signal_hook crate: Investigated this and found it has the same limitation as tokio::signal - once a signal is registered, the default OS handler cannot be restored. Adding a dependency wouldn't solve the core issue.
  • FuturesUnordered: Used tokio::select! with a recv_if_registered() helper instead. This matches existing patterns in the codebase and achieves dynamic signal handling without introducing a different async pattern.
  • Restoring platform defaults on removal: This would require bypassing tokio and using libc::sigaction directly (as libuv does for Node.js). The complexity, unsafe code, and potential conflicts with tokio's signal infrastructure outweigh the benefit for LLRT's Lambda use case where signal handlers are typically set once at startup and rarely removed.

Please let me know if this is a path forward for this PR or if I need to further research/investigate alternatives.

@richarddavison
Copy link
Collaborator

@richarddavison Tried to address your feedback in latest commit:

  1. on_event_changed with &Ctx
    Extended the Emitter trait's on_event_changed to receive &Ctx and Class. This eliminated the manual maybe_start_signal_handler calls in each listener method (on, once, addListener, etc.).
  2. Single coordinator task
    Replaced per-signal spawned tasks with a single coordinator using tokio::select!. Commands (AddListener/RemoveListener) are sent via MPSC channel, and listener counts are tracked in a HashMap<String, usize>.
  3. Lazy signal registration
    Signals are now registered with the OS only when the first listener is added for that specific signal. Unregistered signals use std::future::pending() in the select branches, effectively making them invisible to the coordinator until needed. This ensures we don't interfere with default OS behavior for signals the user never requested.

Options not pursued:

  • signal_hook crate: Investigated this and found it has the same limitation as tokio::signal - once a signal is registered, the default OS handler cannot be restored. Adding a dependency wouldn't solve the core issue.
  • FuturesUnordered: Used tokio::select! with a recv_if_registered() helper instead. This matches existing patterns in the codebase and achieves dynamic signal handling without introducing a different async pattern.
  • Restoring platform defaults on removal: This would require bypassing tokio and using libc::sigaction directly (as libuv does for Node.js). The complexity, unsafe code, and potential conflicts with tokio's signal infrastructure outweigh the benefit for LLRT's Lambda use case where signal handlers are typically set once at startup and rarely removed.

Please let me know if this is a path forward for this PR or if I need to further research/investigate alternatives.

Thanks for this!

However, I'm still a bit concerned that deregistering a signal is problematic. For example, say I register a SIGINT and then deregister it. On ctrl+c we no longer quit the program. My best bet here is to look at node.js (or libuvs) internal signal handling to see how they do it. Maybe tokio abstractions is not an option here and we have to rely on libc crate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

process.on is not implemented

2 participants