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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// @trace STUB-INVENTORY: product real ProcessExit owners (no link_noop)
//! True `ProcessExit` owners for product residual variants.
//!
//! Replaces `link_noop_ProcessExit!` for the closed-set arms that are not
//! already owned by `bun_install` (`LifecycleScript` / `SecurityScan`) or
//! `bun_spawn` (`SyncWindows`). Each owner is a thin, testable state machine:
//! last status / exit count / optional process backref / optional hook.
//! Real domain types (Subprocess, Shell, Cron, Chrome, Host, CLI handles)
//! can later embed or replace these without changing the link_impl registration.
//!
//! ## Dual-def rule
//! Do **not** re-register `LifecycleScript` / `SecurityScan` / `SyncWindows`.
//! Product residual must not also `link_noop` the variants registered here.
//! The old `src/cli` port (never wired, deleted 2026-08-13) had ProcessExit
//! impls that must **consume** these owners (single `link_impl` site).
use core::ptr;
use bun_core::Output;
use bun_spawn::{Process, ProcessExitKind, Rusage, Status};
// ────────────────────────────────────────────────────────────────────────────
// Shared state machine (testable)
// ────────────────────────────────────────────────────────────────────────────
/// Optional exit hook for domain owners (observability / finish accounting).
///
/// `# Safety`
/// `ctx` must remain valid for the duration of the call; the hook must not
/// re-enter `on_process_exit` on the same owner in a way that creates aliasing
/// conflicts with the live `&mut Process`.
pub type ProductExitHook =
unsafe fn(ctx: *mut (), process: &mut Process, status: Status, rusage: &Rusage);
/// Thin exit-handler state shared by all product residual ProcessExit variants.
///
/// `Process::on_exit` already writes `process.status` and calls `detach()`
/// (poll close + clear handler) before dispatching here. This owner still:
/// 1. Mirrors status onto the owner (writeback for callers that only hold us)
/// 2. Clears / optionally unrefs its process backref (safe unref/poll cleanup)
/// 3. Fires an optional hook + debug log (observability)
///
/// Note: not `Debug` — `Status` is not `Debug` (owns `bun_sys::Error` paths).
pub struct ProductProcessExitState {
/// Last status observed by `on_process_exit`.
pub last_status: Option<Status>,
/// How many times `on_process_exit` has run.
pub exit_count: u32,
/// Optional intrusive `*mut Process` the domain owner attached.
pub process: *mut Process,
/// When true, `on_process_exit` will `Process::deref` the matching backref
/// after clearing it (domain took a strong ref via `attach_process`).
pub release_on_exit: bool,
/// Closed-set kind (for logs / debug).
pub kind: ProcessExitKind,
/// Optional user hook context.
pub hook_ctx: *mut (),
/// Optional user hook.
pub hook: Option<ProductExitHook>,
}
impl ProductProcessExitState {
#[inline]
pub fn new(kind: ProcessExitKind) -> Self {
Self {
last_status: None,
exit_count: 0,
process: ptr::null_mut(),
release_on_exit: false,
kind,
hook_ctx: ptr::null_mut(),
hook: None,
}
}
/// Attach a process backref. When `take_ref` is true, the owner holds a
/// strong ref released on exit (caller must have already `ref_`'d).
#[inline]
pub fn attach_process(&mut self, process: *mut Process, take_ref: bool) {
self.process = process;
self.release_on_exit = take_ref && !process.is_null();
}
/// Install an optional observability / finish hook.
#[inline]
pub fn set_hook(&mut self, ctx: *mut (), hook: Option<ProductExitHook>) {
self.hook_ctx = ctx;
self.hook = hook;
}
/// Core exit body — non-empty: status writeback, safe cleanup, log + hook.
///
/// `# Safety`
/// `process` is the live `&mut Process` from `Process::on_exit` /
/// `ProcessExitHandler` dispatch. Must not be freed before this returns.
pub unsafe fn on_process_exit(
&mut self,
process: &mut Process,
status: Status,
rusage: &Rusage,
) {
// 1) Writeback status onto the live Process (parity with MultiRun/FilterRun
// owner mirrors) and onto this owner for callers that only hold us.
process.status = status.clone();
self.last_status = Some(status.clone());
self.exit_count = self.exit_count.saturating_add(1);
// 2) Safe unref / poll cleanup.
// `Process::on_exit` already `detach()`'d when `has_exited()` (closes
// poller + clears exit_handler). We only clear our backref and,
// if we took a strong ref, drop it via `Process::deref`.
let owned = self.process;
let should_release = self.release_on_exit
&& !owned.is_null()
&& core::ptr::eq(owned, process as *mut Process);
self.process = ptr::null_mut();
self.release_on_exit = false;
if should_release {
// SAFETY: `owned` is the same live Process as `process`; detach
// already ran in on_exit. deref drops our strong ref only.
unsafe { Process::deref(owned) };
}
// 3) Observable log (product residual path — verbose debug).
Output::debug(format_args!(
"<d>[ProductProcessExit:{:?}]<r> exit#{} status={}",
self.kind, self.exit_count, status
));
// 4) Optional domain hook (finish accounting / callback).
if let Some(hook) = self.hook {
// SAFETY: caller of `set_hook` established `hook_ctx` validity.
unsafe { hook(self.hook_ctx, process, status, rusage) };
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Per-variant thin owner types + link_impl registration
// ────────────────────────────────────────────────────────────────────────────
/// Generate a named owner wrapping [`ProductProcessExitState`] and register it
/// as the closed-set `ProcessExit` for `$variant`.
macro_rules! product_process_exit {
($variant:ident => $ty:ident) => {
pub struct $ty {
pub state: ProductProcessExitState,
}
impl $ty {
#[inline]
pub fn new() -> Self {
Self {
state: ProductProcessExitState::new(ProcessExitKind::$variant),
}
}
#[inline]
pub fn state(&self) -> &ProductProcessExitState {
&self.state
}
#[inline]
pub fn state_mut(&mut self) -> &mut ProductProcessExitState {
&mut self.state
}
#[inline]
pub fn attach_process(&mut self, process: *mut Process, take_ref: bool) {
self.state.attach_process(process, take_ref);
}
#[inline]
pub fn set_hook(&mut self, ctx: *mut (), hook: Option<ProductExitHook>) {
self.state.set_hook(ctx, hook);
}
/// Install this owner as the process exit handler.
///
/// `# Safety`
/// `self` must remain live for every dispatch through the handler
/// (same contract as `ProcessExit::new`).
#[inline]
pub unsafe fn install_on(&mut self, process: &mut Process) {
process.set_exit_handler(unsafe {
bun_spawn::ProcessExit::new(ProcessExitKind::$variant, self as *mut Self)
});
}
}
impl Default for $ty {
#[inline]
fn default() -> Self {
Self::new()
}
}
bun_spawn::link_impl_ProcessExit! {
$variant for $ty => |this| {
on_process_exit(process, status, rusage) =>
(*this).state.on_process_exit(process, status, rusage),
}
}
// Silence unused-type warning when only the link_impl const unit is live.
const _: fn() = || {
let _ = core::mem::size_of::<$ty>();
};
};
}
// Product spawn / shell / browser residual variants.
product_process_exit!(Subprocess => ProductSubprocessExit);
product_process_exit!(Shell => ProductShellExit);
product_process_exit!(CronRegister => ProductCronRegisterExit);
product_process_exit!(CronRemove => ProductCronRemoveExit);
product_process_exit!(ChromeProcess => ProductChromeProcessExit);
product_process_exit!(HostProcess => ProductHostProcessExit);
// Arms for handles from the deleted `src/cli` port (canonical single
// link_impl site kept — future bao_cli commands must consume, not dual-def).
product_process_exit!(FilterRunHandle => ProductFilterRunHandleExit);
product_process_exit!(MultiRunHandle => ProductMultiRunHandleExit);
product_process_exit!(TestParallelWorker => ProductTestParallelWorkerExit);
/// Ensure product ProcessExit link_impl units stay live.
/// Referenced from `lib.rs` via `force_link_native_c_libs`.
#[inline(never)]
pub fn force_link_product_process_exit() {
let _ = force_link_product_process_exit as *const () as usize;
// Touch type sizes so thin LTO keeps the compilation unit + link_impl symbols.
let _ = core::mem::size_of::<ProductSubprocessExit>();
let _ = core::mem::size_of::<ProductShellExit>();
let _ = core::mem::size_of::<ProductFilterRunHandleExit>();
let _ = core::mem::size_of::<ProductMultiRunHandleExit>();
let _ = core::mem::size_of::<ProductTestParallelWorkerExit>();
let _ = core::mem::size_of::<ProductCronRegisterExit>();
let _ = core::mem::size_of::<ProductCronRemoveExit>();
let _ = core::mem::size_of::<ProductChromeProcessExit>();
let _ = core::mem::size_of::<ProductHostProcessExit>();
let _ = ProcessExitKind::Subprocess;
let _ = ProcessExitKind::HostProcess;
}
// ────────────────────────────────────────────────────────────────────────────
// Unit tests — owner construction / state / hook install (no full Process)
// ────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use bun_spawn::Exited;
#[test]
fn all_product_variants_construct_with_kinds() {
assert_eq!(
ProductSubprocessExit::new().state.kind,
ProcessExitKind::Subprocess
);
assert_eq!(ProductShellExit::new().state.kind, ProcessExitKind::Shell);
assert_eq!(
ProductCronRegisterExit::new().state.kind,
ProcessExitKind::CronRegister
);
assert_eq!(
ProductCronRemoveExit::new().state.kind,
ProcessExitKind::CronRemove
);
assert_eq!(
ProductChromeProcessExit::new().state.kind,
ProcessExitKind::ChromeProcess
);
assert_eq!(
ProductHostProcessExit::new().state.kind,
ProcessExitKind::HostProcess
);
assert_eq!(
ProductFilterRunHandleExit::new().state.kind,
ProcessExitKind::FilterRunHandle
);
assert_eq!(
ProductMultiRunHandleExit::new().state.kind,
ProcessExitKind::MultiRunHandle
);
assert_eq!(
ProductTestParallelWorkerExit::new().state.kind,
ProcessExitKind::TestParallelWorker
);
}
#[test]
fn attach_process_and_hook_fields() {
let mut owner = ProductSubprocessExit::new();
assert!(owner.state.process.is_null());
assert!(!owner.state.release_on_exit);
assert!(owner.state.hook.is_none());
assert_eq!(owner.state.exit_count, 0);
assert!(owner.state.last_status.is_none());
// Sentinel non-null process ptr (not dereferenced when take_ref=false
// until on_process_exit, which we do not call without a live Process).
let sentinel = 0x10 as *mut Process;
owner.attach_process(sentinel, false);
assert_eq!(owner.state.process, sentinel);
assert!(!owner.state.release_on_exit);
owner.attach_process(sentinel, true);
assert!(owner.state.release_on_exit);
static mut HOOK_ARMED: bool = false;
unsafe fn hook(_ctx: *mut (), _process: &mut Process, _status: Status, _rusage: &Rusage) {
unsafe { HOOK_ARMED = true };
}
owner.set_hook(ptr::null_mut(), Some(hook));
assert!(owner.state.hook.is_some());
let _ = unsafe { HOOK_ARMED };
// Mirror writeback without live Process: state fields only.
owner.state.last_status = Some(Status::Exited(Exited { code: 3, signal: 0 }));
owner.state.exit_count = 1;
assert!(matches!(
owner.state.last_status,
Some(Status::Exited(Exited { code: 3, signal: 0 }))
));
assert_eq!(owner.state.exit_count, 1);
}
#[test]
fn force_link_symbol_exists() {
force_link_product_process_exit();
}
}