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
//! Scripting, coprocess, and trigger state for a terminal tab.
//!
//! Groups all fields related to script execution, coprocess management,
//! and trigger handling.
use std::time::Instant;
use par_term_config::ScriptConfig;
use par_term_scripting::manager::ScriptId;
use par_term_scripting::{RestartAction, ScriptRestartState};
use par_term_terminal::TerminalManager;
use tokio::sync::RwLock;
/// Scripting, coprocess, and trigger state for a terminal tab.
pub(crate) struct TabScriptingState {
/// Script manager for this tab
pub(crate) script_manager: par_term_scripting::manager::ScriptManager,
/// Maps config index to ScriptId for running scripts
pub(crate) script_ids: Vec<Option<par_term_scripting::manager::ScriptId>>,
/// Observer IDs registered with the terminal for script event forwarding
pub(crate) script_observer_ids: Vec<Option<par_term_emu_core_rust::observer::ObserverId>>,
/// Event forwarders (shared with observer registration)
pub(crate) script_forwarders:
Vec<Option<std::sync::Arc<par_term_scripting::observer::ScriptEventForwarder>>>,
/// Pane-terminal observer registrations for the forwarders above,
/// keyed by (config index, pane id). In a mux tab the daemon's output
/// flows through the pane terminals, not `tab.terminal`, so each live
/// forwarder is also attached to every mirror pane; reconciled per
/// sweep against the live pane set.
pub(crate) script_pane_observer_ids: std::collections::HashMap<
(usize, crate::pane::PaneId),
par_term_emu_core_rust::observer::ObserverId,
>,
/// Per-config-index restart supervisor. Lazily populated; preserved across
/// automatic restarts so the attempt cap accumulates, and reset by the grace
/// window once a run survives long enough.
pub(crate) restart_state: Vec<Option<ScriptRestartState>>,
/// Mapping from config index to coprocess ID (for UI tracking)
pub(crate) coprocess_ids: Vec<Option<par_term_emu_core_rust::coprocess::CoprocessId>>,
/// Trigger-generated scrollbar marks (from MarkLine actions)
pub(crate) trigger_marks: Vec<crate::config::ScrollbackMark>,
/// Security metadata: maps trigger_id -> prompt_before_run flag.
/// When true, dangerous actions show a confirmation dialog instead of executing automatically.
pub(crate) trigger_prompt_before_run: std::collections::HashMap<u64, bool>,
/// Rate limiter for output-triggered dangerous actions.
pub(crate) trigger_rate_limiter: par_term_config::TriggerRateLimiter,
}
impl TabScriptingState {
/// Spawn the script at `config_index` and register its event forwarder as a
/// terminal observer, recording all per-index tracking state on success.
///
/// Shared by the tab-creation auto-start loop and the Settings UI start
/// button so both paths populate `script_ids` / `script_observer_ids` /
/// `script_forwarders` identically. Callers are responsible for checking
/// [`ScriptConfig::enabled`] — this method starts whatever it is given.
///
/// On spawn failure the observer is unregistered again so a failed start
/// leaves no dangling observer on the terminal.
///
/// Any previous script at `config_index` is torn down first. A script that
/// exits on its own leaves its observer registered (only an explicit stop
/// removes it), so restarting after a crash would otherwise orphan the old
/// forwarder — still attached to the terminal, still being pushed events
/// into an unbounded buffer that nobody drains.
///
/// # Errors
/// Returns the underlying spawn error string if the process cannot start.
pub(crate) fn start_script_at(
&mut self,
terminal: &TerminalManager,
config_index: usize,
script_config: &ScriptConfig,
) -> Result<ScriptId, String> {
self.clear_script_at(terminal, config_index);
let subscription_filter = if script_config.subscriptions.is_empty() {
None
} else {
Some(
script_config
.subscriptions
.iter()
.cloned()
.collect::<std::collections::HashSet<String>>(),
)
};
let forwarder = std::sync::Arc::new(
par_term_scripting::observer::ScriptEventForwarder::new(subscription_filter),
);
let observer_id = terminal.add_observer(forwarder.clone());
match self.script_manager.start_script(script_config) {
Ok(script_id) => {
let slots_needed = config_index + 1;
if self.script_ids.len() < slots_needed {
self.script_ids.resize(slots_needed, None);
}
if self.script_observer_ids.len() < slots_needed {
self.script_observer_ids.resize(slots_needed, None);
}
if self.script_forwarders.len() < slots_needed {
self.script_forwarders.resize(slots_needed, None);
}
self.script_ids[config_index] = Some(script_id);
self.script_observer_ids[config_index] = Some(observer_id);
self.script_forwarders[config_index] = Some(forwarder);
// Begin a fresh grace window for the (re)started process.
self.restart_state_for(config_index, script_config)
.on_started(Instant::now());
Ok(script_id)
}
Err(e) => {
terminal.remove_observer(observer_id);
Err(e)
}
}
}
/// Stop the script at `config_index` (if any), unregister its observer, and
/// clear its tracking slots.
///
/// Safe to call for an index that was never started or has already exited.
pub(crate) fn clear_script_at(&mut self, terminal: &TerminalManager, config_index: usize) {
if let Some(slot) = self.script_ids.get_mut(config_index)
&& let Some(script_id) = slot.take()
{
self.script_manager.stop_script(script_id);
log::info!(
"Stopped script at index {} (id={})",
config_index,
script_id
);
}
if let Some(slot) = self.script_observer_ids.get_mut(config_index)
&& let Some(observer_id) = slot.take()
{
terminal.remove_observer(observer_id);
}
if let Some(slot) = self.script_forwarders.get_mut(config_index) {
*slot = None;
}
}
/// Drive restart supervision for one slot after its process exited.
///
/// Call once per frame per slot whose [`ScriptStatus`] is `Exited`. The
/// supervisor decides whether to stop, wait out the delay, or re-spawn;
/// this method performs the side effects (clearing the slot or calling
/// [`start_script_at`], which re-registers the observer/forwarder).
///
/// `now` is taken as a parameter so every slot in a sweep shares one frame
/// clock and the supervisor stays deterministic.
pub(crate) fn handle_script_exit(
&mut self,
terminal: &TerminalManager,
config_index: usize,
script_config: &ScriptConfig,
success: bool,
now: Instant,
) {
let action = {
let rs = self.restart_state_for(config_index, script_config);
if rs.pending() {
rs.poll(now)
} else {
rs.on_exit(now, success)
}
};
match action {
RestartAction::Idle | RestartAction::Wait => {}
RestartAction::Stop => {
log::info!(
"Script at index {} giving up after {} consecutive restart failures",
config_index,
self.restart_state_for(config_index, script_config)
.consecutive_failures()
);
self.clear_script_at(terminal, config_index);
}
RestartAction::Restart => {
match self.start_script_at(terminal, config_index, script_config) {
Ok(_) => log::info!("Restarted script at index {}", config_index),
Err(e) => {
log::warn!(
"Restart of script at index {} failed: {}; will retry after delay",
config_index,
e
);
self.restart_state_for(config_index, script_config)
.reschedule(now);
}
}
}
}
}
/// Reconcile pane-terminal observer registrations for this tab's
/// script forwarders against `panes`, the tab's live pane list:
/// register every live forwarder on every pane terminal it is not yet
/// attached to, and unregister registrations whose pane or forwarder
/// is gone.
///
/// In a mux tab the panes are mirrors fed through
/// `process_mux_output`, which fires observer delivery — without a
/// pane attachment a subscribed script never sees daemon-fed events.
/// An empty `panes` unregisters everything (a non-mux tab passes
/// exactly that: its pane 0 shares `tab.terminal`, where the forwarder
/// is already attached, and a second attachment there would duplicate
/// every event).
pub(crate) fn reconcile_pane_observers(
&mut self,
panes: &[(crate::pane::PaneId, std::sync::Arc<RwLock<TerminalManager>>)],
) {
// The desired registration set: every live forwarder on every pane.
// An empty `panes` yields an empty set, which unregisters any
// leftovers (the non-mux case, or a tab whose panes all closed).
let mut desired: std::collections::HashSet<(usize, crate::pane::PaneId)> =
std::collections::HashSet::new();
for (index, slot) in self.script_forwarders.iter().enumerate() {
if slot.is_some() {
for (pane_id, _) in panes {
desired.insert((index, *pane_id));
}
}
}
// Register anything desired but missing. A miss on a contended
// pane-terminal lock retries on the next sweep.
for key in &desired {
if self.script_pane_observer_ids.contains_key(key) {
continue;
}
let Some(forwarder) = self
.script_forwarders
.get(key.0)
.and_then(|slot| slot.as_ref())
else {
continue;
};
let Some((_, terminal)) = panes.iter().find(|(pane_id, _)| *pane_id == key.1) else {
continue;
};
let Ok(term) = terminal.try_read() else {
continue;
};
let observer_id = term.add_observer(forwarder.clone());
self.script_pane_observer_ids.insert(*key, observer_id);
}
// Unregister anything registered but no longer desired. A pane
// that is already gone took its terminal (and the observer
// registry inside it) with it — dropping the map entry is the
// whole cleanup there.
let stale: Vec<(usize, crate::pane::PaneId)> = self
.script_pane_observer_ids
.keys()
.filter(|key| !desired.contains(*key))
.copied()
.collect();
for key in stale {
if let Some(observer_id) = self.script_pane_observer_ids.remove(&key)
&& let Some((_, terminal)) = panes.iter().find(|(pane_id, _)| *pane_id == key.1)
&& let Ok(term) = terminal.try_read()
{
term.remove_observer(observer_id);
}
}
}
/// Get or create the restart supervisor for `config_index`, syncing it to
/// the current config so edits to policy/delay take effect.
fn restart_state_for(
&mut self,
config_index: usize,
script_config: &ScriptConfig,
) -> &mut ScriptRestartState {
if self.restart_state.len() <= config_index {
self.restart_state.resize(config_index + 1, None);
}
let slot = self.restart_state[config_index].get_or_insert_with(|| {
ScriptRestartState::new(script_config.restart_policy, script_config.restart_delay_ms)
});
slot.reconfigure(script_config.restart_policy, script_config.restart_delay_ms);
slot
}
}
impl Default for TabScriptingState {
fn default() -> Self {
Self {
script_manager: par_term_scripting::manager::ScriptManager::new(),
script_ids: Vec::new(),
script_observer_ids: Vec::new(),
script_forwarders: Vec::new(),
script_pane_observer_ids: std::collections::HashMap::new(),
restart_state: Vec::new(),
coprocess_ids: Vec::new(),
trigger_marks: Vec::new(),
trigger_prompt_before_run: std::collections::HashMap::new(),
trigger_rate_limiter: par_term_config::TriggerRateLimiter::default(),
}
}
}