par-term-scripting 0.2.0

Scripting and observer system for par-term terminal emulator
Documentation
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! Per-kind process supervision for [`super::PluginHost`]: spawning,
//! restart backoff, per-frame command draining, and teardown.
//!
//! Split out of `plugin_manager.rs` (parsight backlog
//! `01a0c581fb837263a23025558d93cd5f`): these are the process-lifecycle and
//! restart-supervision internals the parent module's own doc comment names
//! as one of its responsibilities. [`spawn_kind_leg`](PluginHost::spawn_kind_leg),
//! [`poll_one`](PluginHost::poll_one), and [`teardown`](PluginHost::teardown)
//! are `pub(super)` because [`super::PluginHost::apply_enabled`] and
//! [`super::PluginHost::poll`] drive them; everything else here is called
//! only from within this module.

use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;

use par_term_config::RestartPolicy;

use crate::manager::{ScriptId, ScriptManager};
use crate::observer::ScriptEventForwarder;
use crate::process::ScriptStatus;
use crate::protocol::ScriptCommand;
use crate::restart::{MAX_RESTART_ATTEMPTS, RestartAction, ScriptRestartState};

use super::{
    KindSlot, PLUGIN_RESTART_DELAY_MS, PluginCrashCap, PluginHost, SETTINGS_ARG, action_entry_args,
    overlay_entry_args, panel_entry_args, widget_entry_args,
};

impl PluginHost {
    /// Spawn one declared kind's entry unless it is already running or
    /// backed off. Shared by all three kind legs in
    /// [`Self::apply_enabled`] so their supervision cannot drift apart —
    /// the per-kind differences (which map, which warn gates) hang off
    /// `slot` alone.
    ///
    /// `entry` is `None` in exactly two cases: the kind is not declared
    /// (nothing owed — return), or it is declared but its entry point did
    /// not resolve, which is unreachable for a validated manifest and is
    /// warned, never spawned in its place.
    #[allow(clippy::too_many_arguments)]
    pub(super) fn spawn_kind_leg(
        &mut self,
        id: &str,
        slot: KindSlot,
        entry: Option<(std::path::PathBuf, Vec<String>)>,
        settings_json: &str,
        subscriptions: &[String],
        restart_policy: RestartPolicy,
        now: Instant,
    ) {
        let Some((entry_path, entry_args)) = entry else {
            if self.warned_spawn_failed.should_warn(id) {
                log::warn!(
                    "plugin '{id}' has no {} entry point; not spawned",
                    slot.as_str()
                );
            }
            return;
        };
        if !self.running_map(slot).contains_key(id) && !self.delayed_by_backoff(id, slot, now) {
            match Self::spawn_entry(&mut self.manager, &entry_path, &entry_args, settings_json) {
                Ok(sid) => {
                    self.running_map(slot).insert(id.to_string(), sid);
                    self.settings_json
                        .insert(id.to_string(), settings_json.to_string());
                    self.restart_map(slot)
                        .entry(id.to_string())
                        .or_insert_with(|| {
                            ScriptRestartState::new(restart_policy, PLUGIN_RESTART_DELAY_MS)
                        })
                        .on_started(now);
                    self.ensure_subscription_forwarder(id, subscriptions);
                    // A fresh process has never seen a theme — arm the greet
                    // so the next `sync_theme` delivers the current one.
                    self.theme_greet_due.insert(id.to_string());
                    // A successful spawn resolves the fault episodes.
                    self.warned_not_discovered.clear(id);
                    self.warned_spawn_failed.clear(id);
                    if slot == KindSlot::Action {
                        // A running action process starts a fresh invoke
                        // episode.
                        self.warned_action_not_running.clear(id);
                    }
                    self.warned_event_delivery.clear(id);
                }
                Err(error) => {
                    if self.warned_spawn_failed.should_warn(id) {
                        log::warn!(
                            "failed to spawn {} entry for plugin '{}': {}",
                            slot.as_str(),
                            id,
                            error
                        );
                    }
                    self.arm_backoff(id, slot, restart_policy, now);
                }
            }
        }
    }

    /// Whether a kind's supervisor currently owns the spawn slot with a
    /// pending backoff: `true` means do not attempt a spawn this frame (the
    /// restart delay has not elapsed). A never-started plugin has no running
    /// process for [`Self::poll`] to drive, so its backoff deadline is
    /// polled here — without this gate, a persistently-failing entry
    /// re-executes once per render frame.
    fn delayed_by_backoff(&mut self, id: &str, slot: KindSlot, now: Instant) -> bool {
        let state = self.restart_map(slot).get_mut(id);
        match state {
            Some(state) if state.pending() => state.poll(now) != RestartAction::Restart,
            _ => false,
        }
    }

    /// Arm the supervisor's capped backoff after a failed spawn (same policy
    /// as a failed restart). When the attempt cap is reached the state is
    /// dropped so the next attempt starts a fresh capped round — the reset
    /// the crash-loop path gets via [`Self::stop_kind`].
    fn arm_backoff(&mut self, id: &str, slot: KindSlot, policy: RestartPolicy, now: Instant) {
        let gave_up = {
            let state = self
                .restart_map(slot)
                .entry(id.to_string())
                .or_insert_with(|| ScriptRestartState::new(policy, PLUGIN_RESTART_DELAY_MS));
            state.reschedule(now) == RestartAction::Stop
        };
        if gave_up {
            self.restart_map(slot).remove(id);
            self.push_crash_cap(id, slot, policy, Vec::new());
        }
    }

    /// Queue a supervisor give-up event for the frontend to surface as a
    /// crash-triage offer. `stderr_tail` is whatever the dying process left.
    fn push_crash_cap(
        &mut self,
        id: &str,
        slot: KindSlot,
        policy: RestartPolicy,
        stderr_tail: Vec<String>,
    ) {
        self.crash_caps.push(PluginCrashCap {
            plugin_id: id.to_string(),
            kind: slot.as_str(),
            entry: self.entry_display(id, slot),
            restart_mode: format!("{policy:?}"),
            stderr_tail,
        });
    }

    /// The display path of a plugin's entry executable for one kind slot.
    fn entry_display(&self, id: &str, slot: KindSlot) -> String {
        let entry = self
            .discovered
            .iter()
            .find(|d| d.manifest.id == id)
            .and_then(|d| match slot {
                KindSlot::Widget => Some(d.entry_path.clone()),
                KindSlot::Action => d.action_entry_path.clone(),
                KindSlot::Panel => d.panel_entry_path.clone(),
                KindSlot::Overlay => d.overlay_entry_path.clone(),
            });
        entry
            .map(|path| path.display().to_string())
            .unwrap_or_else(|| "<entry not found>".to_string())
    }

    /// Queue a crash-cap when a `Stop` decision is a crash-loop give-up
    /// (attempts exhausted inside the grace window) rather than a policy
    /// stop or a clean exit — those are not crashes.
    fn note_exit_crash_cap(&mut self, id: &str, slot: KindSlot, sid: ScriptId, success: bool) {
        if success {
            return; // clean exit: the policy said stop, nothing crashed
        }
        let exhausted = self
            .restart_map(slot)
            .get(id)
            .is_some_and(|state| state.consecutive_failures() >= MAX_RESTART_ATTEMPTS);
        if !exhausted {
            return;
        }
        let stderr_tail = self.manager.read_errors(sid);
        let policy = self
            .restart_map(slot)
            .get(id)
            .map(|state| state.policy())
            .unwrap_or(RestartPolicy::Never);
        self.push_crash_cap(id, slot, policy, stderr_tail);
    }

    /// Advance one plugin's supervision for one kind: drain commands,
    /// observe an exit, and act on the supervisor's decision. Shared by all
    /// kinds so their supervision is identical; only the accepted command
    /// set differs per kind (v1 plugins are display-only, design D2).
    pub(super) fn poll_one(&mut self, id: &str, slot: KindSlot, now: Instant) {
        let Some(&sid) = self.running_map(slot).get(id) else {
            return;
        };

        for cmd in self.manager.read_commands(sid) {
            match cmd {
                ScriptCommand::SetWidget { text }
                    if slot == KindSlot::Widget || slot == KindSlot::Action =>
                {
                    self.widget_texts.insert(id.to_string(), text);
                }
                ScriptCommand::SetPanel { title, content } if slot == KindSlot::Panel => {
                    self.panel_contents.insert(id.to_string(), (title, content));
                }
                ScriptCommand::ClearPanel {} if slot == KindSlot::Panel => {
                    self.panel_contents.remove(id);
                }
                ScriptCommand::SetOverlay {
                    id: overlay_id,
                    position,
                    size,
                    opacity,
                    interactive,
                    content,
                } if slot == KindSlot::Overlay => {
                    // Update-rate clamp (design: ~30/sec with a warning —
                    // a runaway plugin must not spin the renderer). The
                    // window slides on `now`; over-budget upserts are
                    // dropped after the first (warned once per episode).
                    if !self.overlay_update_permitted(id, now) {
                        continue;
                    }
                    // The manifest capability gates interactivity (design,
                    // Permissions): `interactive: true` survives only for a
                    // plugin whose manifest carries `overlay.interactive`.
                    // Any other plugin is forced display-only here.
                    let interactive = interactive
                        && self
                            .discovered
                            .iter()
                            .find(|d| d.manifest.id == id)
                            .is_some_and(|d| {
                                d.manifest
                                    .overlay
                                    .as_ref()
                                    .is_some_and(|cap| cap.interactive)
                            });
                    self.overlays.insert(
                        id.to_string(),
                        crate::protocol::PluginOverlay {
                            id: overlay_id,
                            position,
                            size,
                            opacity: opacity.clamp(0.0, 1.0),
                            interactive,
                            content,
                        },
                    );
                    // A replaced overlay that no longer qualifies drops any
                    // focus it held — a display-only overlay never keeps it.
                    if !interactive {
                        self.clear_focus_if(id);
                    }
                }
                ScriptCommand::ClearOverlay { id: overlay_id } if slot == KindSlot::Overlay => {
                    let plugin_id = id;
                    if self
                        .overlays
                        .get(plugin_id)
                        .is_some_and(|live| live.id == overlay_id)
                    {
                        self.overlays.remove(plugin_id);
                        // A cleared overlay cannot keep focus.
                        self.clear_focus_if(plugin_id);
                    }
                }
                other => {
                    // v1 plugins are display-only (design D2); anything
                    // outside the kind's accepted set is refused with an
                    // error-style line.
                    let accepted = match slot {
                        KindSlot::Panel => {
                            "only SetPanel/ClearPanel are accepted from a panel plugin in v1"
                        }
                        KindSlot::Overlay => {
                            "only SetOverlay/ClearOverlay are accepted from an overlay plugin in v1"
                        }
                        _ => "only SetWidget is accepted from plugins in v1",
                    };
                    let line = format!(
                        "[error] plugin '{}' sent {}; {accepted} — ignored",
                        id,
                        other.command_name()
                    );
                    log::warn!("{}", line);
                    self.ignored_lines.push(line);
                }
            }
        }

        if let ScriptStatus::Exited { success } = self.manager.poll_status(sid) {
            let decision = match self.restart_map(slot).get_mut(id) {
                Some(state) if !state.pending() => state.on_exit(now, success),
                Some(state) => state.poll(now),
                None => RestartAction::Stop,
            };
            match decision {
                RestartAction::Stop => {
                    self.note_exit_crash_cap(id, slot, sid, success);
                    self.stop_kind(id, slot);
                }
                RestartAction::Restart => {
                    if let Err(error) = self.respawn(id, slot, now) {
                        log::warn!(
                            "plugin '{}' failed to restart its {} entry: {}",
                            id,
                            slot.as_str(),
                            error
                        );
                        // Honor the cap: reschedule() returns Stop once the
                        // attempt budget is spent. Dropping it (the old bug)
                        // left the sticky-exit poll to re-decide, whose
                        // grace-window reset keeps zeroing the counter — an
                        // unspawnable entry then retried forever. Give up the
                        // same way the exit crash-loop does, surfacing the
                        // spawn error on the crash-cap.
                        let mut gave_up_policy = None;
                        if let Some(state) = self.restart_map(slot).get_mut(id)
                            && state.reschedule(now) == RestartAction::Stop
                        {
                            gave_up_policy = Some(state.policy());
                        }
                        if let Some(policy) = gave_up_policy {
                            self.push_crash_cap(id, slot, policy, vec![error]);
                            self.stop_kind(id, slot);
                        }
                    }
                }
                RestartAction::Wait | RestartAction::Idle => {}
            }
        }
    }

    /// Stop one plugin entirely: every kind slot plus all shared state.
    pub(super) fn teardown(&mut self, id: &str) {
        self.stop_kind(id, KindSlot::Widget);
        self.stop_kind(id, KindSlot::Action);
        self.stop_kind(id, KindSlot::Panel);
        self.stop_kind(id, KindSlot::Overlay);
        self.subscription_forwarders.remove(id);
        self.warned_event_delivery.clear(id);
        self.theme_names.remove(id);
        self.theme_greet_due.remove(id);
    }

    /// The running-process map for one kind. Every per-kind code path
    /// selects its pair through this so the three maps cannot drift apart.
    fn running_map(&mut self, slot: KindSlot) -> &mut HashMap<String, ScriptId> {
        match slot {
            KindSlot::Widget => &mut self.running,
            KindSlot::Action => &mut self.action_running,
            KindSlot::Panel => &mut self.panel_running,
            KindSlot::Overlay => &mut self.overlay_running,
        }
    }

    /// The restart-supervisor map for one kind — see [`Self::running_map`].
    fn restart_map(&mut self, slot: KindSlot) -> &mut HashMap<String, ScriptRestartState> {
        match slot {
            KindSlot::Widget => &mut self.restart,
            KindSlot::Action => &mut self.action_restart,
            KindSlot::Panel => &mut self.panel_restart,
            KindSlot::Overlay => &mut self.overlay_restart,
        }
    }

    /// Stop one kind's process and drop its supervision. Shared per-plugin
    /// state (settings argv, warn gates) survives while another kind still
    /// runs, so a crash-looping action entry never tears down a healthy
    /// widget entry of the same plugin (design D5). Stopping the widget or
    /// panel kind also drops its display state (widget text / panel
    /// content) so a disabled plugin leaves no orphaned surface.
    fn stop_kind(&mut self, id: &str, slot: KindSlot) {
        let stopped = self.running_map(slot).remove(id);
        if let Some(sid) = stopped {
            self.manager.stop_script(sid);
        }
        self.restart_map(slot).remove(id);
        match slot {
            KindSlot::Widget => {
                self.widget_texts.remove(id);
            }
            KindSlot::Panel => {
                self.panel_contents.remove(id);
            }
            KindSlot::Overlay => {
                self.overlays.remove(id);
                self.overlay_update_times.remove(id);
                self.clear_focus_if(id);
            }
            KindSlot::Action => {}
        }
        let other_running = [
            KindSlot::Widget,
            KindSlot::Action,
            KindSlot::Panel,
            KindSlot::Overlay,
        ]
        .iter()
        .any(|s| self.running_map(*s).contains_key(id));
        if !other_running {
            self.settings_json.remove(id);
            // Disarming the fault gates here makes a disable/enable cycle
            // warn again — a fresh user action deserves a fresh diagnostic.
            self.warned_not_discovered.clear(id);
            self.warned_spawn_failed.clear(id);
            self.warned_action_not_running.clear(id);
            // A fully stopped plugin keeps no theme memory: re-enabling it
            // re-greets with the current theme even if it never changed.
            self.theme_names.remove(id);
            self.theme_greet_due.remove(id);
        }
    }

    /// Re-spawn a plugin's entry of one kind after a supervised restart,
    /// reusing its settings.
    ///
    /// The exited process slot stays mapped until the new spawn succeeds —
    /// removing it first would orphan the supervisor (a pending restart
    /// nothing polls).
    fn respawn(&mut self, id: &str, slot: KindSlot, now: Instant) -> Result<ScriptId, String> {
        let settings = self
            .settings_json
            .get(id)
            .cloned()
            .unwrap_or_else(|| "{}".to_string());
        let Some(found) = self.discovered.iter().find(|d| d.manifest.id == id) else {
            return Err(format!("plugin '{}' is no longer discovered", id));
        };
        let (entry_path, entry_args) = match slot {
            KindSlot::Action => {
                let Some(path) = found.action_entry_path.clone() else {
                    return Err(format!(
                        "plugin '{}' no longer resolves an action entry point",
                        id
                    ));
                };
                (path, action_entry_args(found).to_vec())
            }
            KindSlot::Panel => {
                let Some(path) = found.panel_entry_path.clone() else {
                    return Err(format!(
                        "plugin '{}' no longer resolves a panel entry point",
                        id
                    ));
                };
                (path, panel_entry_args(found).to_vec())
            }
            KindSlot::Overlay => {
                let Some(path) = found.overlay_entry_path.clone() else {
                    return Err(format!(
                        "plugin '{}' no longer resolves an overlay entry point",
                        id
                    ));
                };
                (path, overlay_entry_args(found).to_vec())
            }
            KindSlot::Widget => (found.entry_path.clone(), widget_entry_args(found).to_vec()),
        };
        let new_sid = Self::spawn_entry(&mut self.manager, &entry_path, &entry_args, &settings)?;
        let old = self.running_map(slot).insert(id.to_string(), new_sid);
        if let Some(old_sid) = old {
            self.manager.stop_script(old_sid);
        }
        if let Some(state) = self.restart_map(slot).get_mut(id) {
            state.on_started(now);
        }
        // The respawned process has never seen a theme; re-arm the greet so
        // `sync_theme` redelivers even when the theme name is unchanged.
        self.theme_greet_due.insert(id.to_string());
        Ok(new_sid)
    }

    /// Spawn one plugin entry point: the manifest's entry args, then the
    /// settings marker and the settings JSON as a single argv string (design
    /// D2 — stdin stays pure NDJSON). Empty env: the event stream reaches
    /// plugin stdin through the per-plugin forwarder
    /// ([`Self::subscription_forwarders`]), not the environment.
    fn spawn_entry(
        manager: &mut ScriptManager,
        entry_path: &Path,
        entry_args: &[String],
        settings_json: &str,
    ) -> Result<ScriptId, String> {
        let mut argv = entry_args.to_vec();
        argv.push(SETTINGS_ARG.to_string());
        argv.push(settings_json.to_string());
        manager.spawn_command(&entry_path.to_string_lossy(), &argv, &HashMap::new())
    }

    /// Create the plugin's event forwarder on its first successful spawn
    /// when its manifest declares subscriptions. Idempotent per plugin id:
    /// the second kind's spawn finds the first kind's forwarder already in
    /// place, and a supervised respawn reuses it (registration on terminals
    /// is by `Arc`, so the process id changing underneath changes nothing).
    fn ensure_subscription_forwarder(&mut self, id: &str, subscriptions: &[String]) {
        if subscriptions.is_empty() {
            return;
        }
        self.subscription_forwarders
            .entry(id.to_string())
            .or_insert_with(|| {
                let filter = subscriptions.iter().cloned().collect();
                Arc::new(ScriptEventForwarder::new(Some(filter)))
            });
    }
}