1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// @trace REQ-CLI-001 [entity:BaoRuntime] — SIGINT→cancel bridge wiring the
// SM-EVOLUTION #24 S1 ExecutionControl to the CLI product surface
// (ledger S1 legislation proposal consumed by user ruling 2026-09-10).
//!
//! # SIGINT → `ExecutionControl::cancel` bridge (self-pipe + watcher)
//!
//! ## Why a pipe — async-signal-safety of `cancel()`
//!
//! `ExecutionControl::cancel()` submits an atomic flag plus
//! `JS_RequestInterruptCallback(cx)`. That JSAPI entry is NOT
//! async-signal-safe: the vendored engine implements it as
//! `JSContext::requestInterrupt` (`js/src/vm/Runtime.cpp`), which takes the
//! `FutexThread` lock and calls `wasm::InterruptRunningCode` (more locks).
//! Calling it from inside a signal handler could deadlock against the owner
//! JS thread holding those locks. Therefore the handler body is only:
//!
//! - `write(pipe_wr, 1 byte)` — a POSIX async-signal-safe operation.
//!
//! A dedicated watcher thread (normal thread context) blocks on `read` of the
//! pipe and performs the real `cancel()` on the currently armed control. This
//! mirrors the S1 `ArmedExecutionGuard` deadline-watcher shape
//! (`bao_engine::execution_control.rs`): cross-thread submission is atomic +
//! the documented thread-safe interrupt request, never a JSObject.
//!
//! ## Existing signal surface interaction (checked, per task contract)
//!
//! - `Bun__registerSignalsForForwarding` (`product_native_symbols.rs`)
//! installs its own SIGINT/SIGTERM/SIGHUP/SIGQUIT handlers around
//! synchronous child spawns and restores `SIG_DFL` on unregister. A script
//! that performs a sync spawn inside a `--timeout` run therefore REPLACES
//! this bridge's handler for the rest of the process: SIGINT falls back to
//! exactly the pre-bridge behavior (default disposition / child
//! forwarding). Degradation to status-quo semantics, never corruption —
//! documented, accepted for this slice.
//! - There is no `process.on('SIGINT')` dispatch machinery in bun_runtime
//! today (constants table only), so no listener conflict exists.
//!
//! ## Swallowing guarantee
//!
//! A SIGINT that arrives while no control is armed (entry/exit edges of the
//! controlled run) is NOT silently eaten: the bridge records it, and `Drop`
//! restores the previous disposition and re-raises — reproducing the default
//! Ctrl-C kill the user expected.
//!
//! # Status: internal experimental surface
//!
//! `#[doc(hidden)]` — NOT a stable public API commitment (SM-EVOLUTION #24
//! S1 CLI wiring; one bridge per process, CLI lifecycle only).
use std::os::unix::io::RawFd;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use bao_engine::execution_control::ExecutionControl;
/// Write end of the self-pipe, published for the signal handler.
/// -1 = no bridge installed. Relaxed load in the handler is sufficient: a
/// torn/stale read at worst misses one byte (the pipe is torn down only when
/// no handler can fire anymore).
static PIPE_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
/// One bridge per process (CLI lifecycle). Fail-closed on double install.
static BRIDGE_ACTIVE: AtomicBool = AtomicBool::new(false);
struct Shared {
/// Control currently armed for SIGINT cancellation. `None` = disarmed
/// (SIGINT bytes land as `eaten_unhandled` instead).
control: Mutex<Option<ExecutionControl>>,
/// A SIGINT byte arrived with no armed control — Drop re-raises it.
eaten_unhandled: AtomicBool,
}
/// The entire signal context: one async-signal-safe `write(2)`.
extern "C" fn bridge_sigint_handler(_sig: libc::c_int) {
let fd = PIPE_WRITE_FD.load(Ordering::Relaxed);
if fd >= 0 {
let byte: u8 = 1;
// SAFETY: write(2) on a pipe is async-signal-safe. Errors are
// benign: EAGAIN means the pipe already holds a pending byte (the
// watcher will wake), EINTR cannot occur for pipe writes of 1 byte
// below PIPE_BUF on Linux.
unsafe {
let _ = libc::write(fd, &byte as *const u8 as *const libc::c_void, 1);
}
}
}
/// Watcher thread body: pipe byte → `cancel()` on the armed control, or mark
/// the SIGINT unhandled. Owns `read_fd` and closes it on exit (every path).
fn watcher_loop(read_fd: RawFd, shared: &Shared) {
let mut byte = [0u8; 1];
loop {
// SAFETY: read(2) on our own pipe fd; the write end stays open until
// Drop closes it, so EOF is a well-defined shutdown signal.
let n = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
if n == 1 {
let guard = shared.control.lock().unwrap_or_else(|e| e.into_inner());
if let Some(control) = guard.as_ref() {
// In-flight controlled entry: the signal is consumed by the
// engine-level cancel (uncatchable termination → Cancelled
// terminal state). Normal thread context — cancel() may take
// engine-internal locks safely here.
control.cancel();
} else {
drop(guard);
shared.eaten_unhandled.store(true, Ordering::Release);
}
} else if n == 0 {
break; // write end closed — shutdown
} else {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
break; // EBADF (teardown) or unreadable pipe — exit; fd closed below.
}
}
// SAFETY: read_fd is owned exclusively by this thread from here on.
unsafe {
libc::close(read_fd);
}
}
/// SIGINT interception bridge. `install()` → `arm(&control)` before the
/// controlled entry → `disarm()` after it returns → `Drop` (or scope exit)
/// uninstalls the handler and re-raises any swallowed-but-unhandled SIGINT.
pub struct InterruptBridge {
shared: Arc<Shared>,
watcher: Option<JoinHandle<()>>,
write_fd: RawFd,
prev_action: libc::sigaction,
}
#[doc(hidden)]
impl InterruptBridge {
/// Install the SIGINT handler + self-pipe + watcher thread. At most one
/// bridge per process (panics otherwise — CLI lifecycle is singleton).
pub fn install() -> Self {
assert!(
!BRIDGE_ACTIVE.swap(true, Ordering::AcqRel),
"InterruptBridge: at most one bridge per process"
);
let mut fds = [0 as libc::c_int; 2];
// SAFETY: valid out-pointer for two fds; O_CLOEXEC so exec'd children
// never hold the write end (read-end EOF semantics stay correct).
unsafe {
assert_eq!(
libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC),
0,
"InterruptBridge: pipe2 failed"
);
}
let (read_fd, write_fd) = (fds[0], fds[1]);
PIPE_WRITE_FD.store(write_fd, Ordering::Release);
let shared = Arc::new(Shared {
control: Mutex::new(None),
eaten_unhandled: AtomicBool::new(false),
});
let watcher_shared = Arc::clone(&shared);
let watcher = std::thread::Builder::new()
.name("bao-sigint-bridge".to_string())
.spawn(move || watcher_loop(read_fd, &watcher_shared))
.expect("InterruptBridge: watcher spawn failed");
// Install the handler, preserving the previous disposition for Drop.
// SAFETY: sigaction with a valid oldact out-pointer.
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = bridge_sigint_handler as *const () as usize;
libc::sigemptyset(&mut sa.sa_mask);
// SA_RESTART (libuv-compatible): interrupted host syscalls
// restart; the JS engine observes the cancel on its next loop
// back-edge / JIT stack check. Scripts blocked in non-restartable
// long syscalls terminate when the syscall next returns to JS —
// the same limitation class the S1 deadline watcher documents.
sa.sa_flags = libc::SA_RESTART;
let mut prev: libc::sigaction = std::mem::zeroed();
assert_eq!(
libc::sigaction(libc::SIGINT, &sa, &mut prev),
0,
"InterruptBridge: sigaction failed"
);
InterruptBridge {
shared,
watcher: Some(watcher),
write_fd,
prev_action: prev,
}
}
}
/// Arm: from now on, SIGINT cancels `control` (the in-flight controlled
/// entry consumes the signal; see the module doc).
pub fn arm(&self, control: &ExecutionControl) {
*self.shared.control.lock().unwrap_or_else(|e| e.into_inner()) = Some(control.clone());
}
/// Disarm: SIGINTs beyond this point find no control and are re-raised
/// on Drop (faithful default — Ctrl-C is never silently swallowed).
pub fn disarm(&mut self) {
*self.shared.control.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
impl Drop for InterruptBridge {
fn drop(&mut self) {
// 1. Restore the previous SIGINT disposition FIRST — after this no
// new handler writes can originate from us.
// SAFETY: prev_action was captured by our own sigaction install.
unsafe {
libc::sigaction(libc::SIGINT, &self.prev_action, std::ptr::null_mut());
}
// 2. Detach the static fd and close the write end: the watcher
// drains any pending byte, then sees EOF, closes the read end and
// exits — every request it could make happened inside our frame.
PIPE_WRITE_FD.store(-1, Ordering::Release);
// SAFETY: write_fd is ours and is not touched after this point.
unsafe {
libc::close(self.write_fd);
}
if let Some(handle) = self.watcher.take() {
let _ = handle.join();
}
BRIDGE_ACTIVE.store(false, Ordering::Release);
// 3. Faithful default: re-raise a SIGINT we intercepted but could
// not deliver to an armed control. The handler is already
// uninstalled, so this hits the restored (usually default)
// disposition — the process dies by SIGINT exactly as it would
// have without the bridge.
if self.shared.eaten_unhandled.load(Ordering::Acquire) {
// SAFETY: raise(2) targets the calling process.
unsafe {
libc::raise(libc::SIGINT);
}
}
}
}