Repository navigation
Expand file tree
/
Copy pathclients.rs
More file actions
2887 lines (2722 loc) · 128 KB
/
Copy pathclients.rs
File metadata and controls
2887 lines (2722 loc) · 128 KB
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
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Client processes and the app-launching model, behavior read from
//! omarchy's source (local/agent_state/wm/omarchy-launch-model.md):
//!
//! - the terminal is ALWAYS a fresh instance, opened in the cwd of the
//! focused terminal (omarchy-launch-terminal + omarchy-cmd-terminal-cwd;
//! our children report pwd over OSC 7 -> Layer B custom message),
//! - other apps use launch-or-focus: `\b<pattern>\b` case-insensitive
//! against window class OR title focuses an existing window, else spawns
//! (bin/omarchy-launch-or-focus).
//!
//! Children are Makepad apps launched with `--stdin-loop` and
//! `STUDIO_HOST`/`STUDIO_BUILD` pointing at the in-process hub, exactly
//! like studio launches run targets.
//!
//! **They are built before they start** (`cargo build --release`, see
//! [`launch_plan`]) whenever the shell runs out of a checkout, so a stale
//! or missing binary is rebuilt on launch instead of failing or showing
//! yesterday's app; a native app from the OctoSense workspace, held to its
//! `Cargo.lock`. The build runs outside any sandbox, in its checkout, and
//! its "Compiling …" output lands in the client's log; then the built
//! binary itself starts under the app's sandbox ([`ClientSlot::continue_launch`]).
//! Cargo never starts the app (no `cargo run`): what the app can write never
//! decides what the next build runs. An installed shell with no checkout
//! around it starts the sibling binary.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::Sender;
use crate::host;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(unix)]
use makepad_widgets::makepad_platform::thread::CancellationToken;
use makepad_widgets::makepad_platform::thread::{Lane, SignalToUI, TaskPool, ThreadSpawner, ThreadOptions};
#[cfg(any(unix, test))]
use makepad_widgets::Cx;
use crate::hub::ClientId;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LaunchPolicy {
/// Every invocation spawns a new instance (the terminal, viewers).
AlwaysNew,
/// Focus a running instance of this app if one exists, else spawn.
OrFocus,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AppDef {
/// Registry id, also the launch-or-focus window pattern.
pub id: String,
/// The name a human reads in the menu.
pub label: String,
/// Binary name, for the installed (no checkout) fallback.
pub bin: String,
/// Cargo package name — what `cargo run -p` gets.
pub package: String,
/// Package directory relative to the checkout root.
pub dir: String,
/// A crate outside the root workspace (its own workspace root) needs
/// its manifest named explicitly; relative to the checkout root.
pub manifest: Option<String>,
pub args: Vec<String>,
pub policy: LaunchPolicy,
/// Where `cargo` should build this app. Set for rows that resolve into
/// a checkout this project does not own, so the build lands in our tree
/// instead of someone else's cache.
pub target_dir: Option<String>,
}
impl AppDef {
pub(crate) fn app(
id: &str,
label: &str,
package: &str,
dir: &str,
bin: &str,
policy: LaunchPolicy,
) -> Self {
Self {
id: id.to_string(),
label: label.to_string(),
bin: bin.to_string(),
package: package.to_string(),
dir: dir.to_string(),
manifest: None,
args: Vec::new(),
policy,
target_dir: None,
}
}
/// True when this app can actually be started right now — the honest
/// filter behind the menu (no row that cannot run).
pub fn is_available(&self) -> bool {
if let Some(manifest) = &self.manifest {
return Path::new(manifest).is_file();
}
resolve_bin(&self.bin).is_some()
}
}
/// One `name = "..."` value out of a Cargo.toml `[package]` table.
#[cfg(test)]
fn manifest_value(manifest: &str, key: &str) -> Option<String> {
let mut in_package = false;
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if !in_package {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
return Some(v.trim().trim_matches('"').to_string());
}
None
}
/// The app registry: the applications this WM is built around, in menu
/// order. Curated on purpose — every row is one we run and verify, not a
/// scan of whatever the workspace happens to contain: the catalog's rows,
/// the linked modules, then the apps App Hub's Card runner hosts (system
/// apps and installed apps, read fresh so an install needs no restart).
pub fn registry() -> Vec<AppDef> {
let base = crate::octosense::catalog::loaded().as_ref().cloned().unwrap_or_default();
let mut installed = crate::apps::installed_card_apps();
installed.extend(crate::apps::studio_apps());
merge_catalog(
base,
crate::apps::bundled_modules_catalog(),
crate::apps::system_card_apps(),
installed,
)
}
/// One row per id. A system app replaces a catalog row of the same id in
/// place (it keeps that row's menu position): the system Mail, not
/// Makepad's example Mail, even in a personal catalog. Otherwise the first
/// definition wins — catalog rows, then linked modules, so native
/// definitions take precedence. Installed ids live under `hub:`, so a
/// manifest named after a built-in becomes its own row beside it, never a
/// replacement. What `apps::catalog_visible` hides (the internal `card`
/// host, the retired `appstore`) is no row.
fn merge_catalog(base: Vec<AppDef>, bundled: Vec<AppDef>, system: Vec<AppDef>, installed: Vec<AppDef>) -> Vec<AppDef> {
let mut system: Vec<Option<AppDef>> = system.into_iter().map(Some).collect();
let mut take_system = |id: &str| system.iter_mut().find(|a| a.as_ref().is_some_and(|a| a.id == id)).and_then(Option::take);
let mut rows: Vec<AppDef> = Vec::new();
for app in base.into_iter().chain(bundled) {
rows.push(take_system(&app.id).unwrap_or(app));
}
rows.extend(system.into_iter().flatten());
rows.extend(installed);
let mut ids = std::collections::HashSet::new();
rows.retain(|app| crate::apps::catalog_visible(&app.id) && ids.insert(app.id.clone()));
rows
}
/// Catalog rows, bundled modules and the Card runner's apps (system and
/// installed through App Hub), read fresh on each call.
pub fn available_apps() -> Vec<AppDef> {
registry()
}
/// Registered ids take precedence over binary aliases. A linked module
/// without a catalog row (a module-only app such as `appcard`, which has no
/// process form) is still an app: its bundled definition answers, and the
/// hosting rules decide whether it may open (`--module <id>` on a desktop).
pub fn find_app(id: &str) -> Option<AppDef> {
find_app_in(®istry(), id)
}
/// Every Card runner row has the binary `card`, so that name is no alias.
fn find_app_in(apps: &[AppDef], id: &str) -> Option<AppDef> {
apps.iter().find(|a| a.id == id)
.or_else(|| apps.iter().find(|a| a.bin == id && a.bin != "card")).cloned()
}
/// `bin/omarchy-launch-or-focus`'s window test, verbatim:
/// `test("\\b" + pattern + "\\b"; "i")` — a case-insensitive WHOLE-WORD
/// match, where a word boundary is any non-alphanumeric/underscore.
pub fn word_match(haystack: &str, pattern: &str) -> bool {
if pattern.is_empty() {
return false;
}
let hay = haystack.to_lowercase();
let pat = pattern.to_lowercase();
let word = |c: char| c.is_alphanumeric() || c == '_';
let bytes: Vec<char> = hay.chars().collect();
let needle: Vec<char> = pat.chars().collect();
if needle.len() > bytes.len() {
return false;
}
for start in 0..=bytes.len() - needle.len() {
if bytes[start..start + needle.len()] != needle[..] {
continue;
}
let before_ok = start == 0 || !word(bytes[start - 1]);
let end = start + needle.len();
let after_ok = end == bytes.len() || !word(bytes[end]);
if before_ok && after_ok {
return true;
}
}
false
}
/// The checkout root: `MAKEPAD_WM_ROOT`, else the checkout above the
/// running exe (`target/<profile>/wm`), else the checkout at or above the
/// current directory — a wm started from the repo root with its target
/// dir elsewhere (CARGO_TARGET_DIR) is still running out of a checkout,
/// and every app is then one `cargo run` away.
pub fn repo_root() -> Option<PathBuf> {
crate::octosense::paths::project_root()
}
/// Resolve a sibling binary of the running wm executable (`.exe` on
/// Windows, where a bare name never exists).
pub fn resolve_bin(bin: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let mut path = dir.join(bin);
if cfg!(windows) {
path.set_extension("exe");
}
if !path.is_file() { return None; }
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { return None; }
}
Some(path)
}
/// The cargo to launch with: whatever is on PATH, else the rustup default.
fn cargo_bin() -> PathBuf {
if let Ok(cargo) = std::env::var("CARGO") {
return PathBuf::from(cargo);
}
if let Some(home) = std::env::var_os("HOME") {
let rustup = PathBuf::from(home).join(".cargo/bin/cargo");
if rustup.exists() {
return rustup;
}
}
PathBuf::from("cargo")
}
// ======================================================================
// The warm-instance pool
// ======================================================================
/// How many DORMANT instances of an app the pool keeps standing by, so a
/// new window is a swap instead of a launch. The user's sizing: terminals
/// get two (people burst-open them), the rest one each. An app that is not
/// in this table is never pre-spawned.
///
/// This is the whole registry of warmable apps — `is_warm_app` and the
/// startup top-up both read it, so adding an app here is the only edit an
/// app needs to join the pool.
pub const WARM_CAPACITY: &[(&str, usize)] = &[
("terminal", 2),
("browser", 1),
("files", 1),
("task", 1),
];
/// The env a warm instance is spawned with. `makepad_wm_api::warm_start()` reads
/// exactly this: the app boots its window and draws once, then IDLES — no
/// samplers, no refresh timers, no polling — until `WmEvent::Adopted`
/// arrives. Without it a cached task manager would sit there sampling
/// every process on the machine for nothing.
pub const WARM_ENV: (&str, &str) = ("MAKEPAD_WM_WARM_START", "1");
/// Crash budget: this many UNEXPECTED warm deaths per app inside
/// `WARM_CRASH_WINDOW`, after which the pool gives that app up quietly and
/// every launch takes the cold path (which always works). Adoption
/// replacements are NOT crashes and are never capped — capping those would
/// switch the pool off for anyone who opens four terminals in a minute,
/// which is exactly who it exists for.
pub const WARM_CRASH_LIMIT: usize = 3;
pub const WARM_CRASH_WINDOW: f64 = 60.0;
/// What the WM knows about one pooled instance right now, handed to
/// `WarmPool::adopt` so the pool itself stays free of WM state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WarmStatus {
pub client: ClientId,
/// Still in the client table: the process has not been reaped.
pub alive: bool,
/// Connected to the hub AND past `CreateWindow` — it has a framebuffer
/// and a drawn frame, so a tile can show it this instant. A warm
/// instance that is still building (or still starting) is not one.
pub connected: bool,
}
/// The pool: per app, the ids of the instances standing by.
///
/// Deliberately a plain state machine over ids — no processes, no cx, no
/// layout — so the rules that matter (adopt clears and tops back up, a
/// dead instance falls back to a cold spawn, a cwd override skips the pool,
/// MAKEPAD_WM_NO_WARM turns it off, crash loops give up) are unit-testable
/// without a running window manager.
#[derive(Debug)]
pub struct WarmPool {
enabled: bool,
/// Appearance in which browser pages were warmed. A loaded page may
/// choose its theme only once, so a media-query update is not sufficient.
browser_dark: Option<bool>,
/// app id -> the warm clients of that app, oldest first.
ready: HashMap<String, Vec<ClientId>>,
/// app id -> when (platform seconds) its warm instances died unexpectedly, newest last.
crashes: HashMap<String, Vec<f64>>,
}
impl Default for WarmPool {
fn default() -> Self {
Self::from_env()
}
}
/// MAKEPAD_WM_NO_WARM disables the pool entirely. An empty or `0` value is not a
/// request — `MAKEPAD_WM_NO_WARM=` in a stale profile should not silently cost
/// everyone the feature.
pub fn warm_enabled(no_warm: Option<&str>) -> bool {
match no_warm {
None => true,
Some(v) => matches!(v.trim(), "" | "0"),
}
}
impl WarmPool {
pub fn from_env() -> Self {
// A build without processes has nothing to keep warm.
Self::new(host::processes_available() && crate::octosense::policy::requested("--prewarm") && warm_enabled(std::env::var("MAKEPAD_WM_NO_WARM").ok().as_deref()))
}
pub fn new(enabled: bool) -> Self {
Self {
enabled,
browser_dark: None,
ready: HashMap::new(),
crashes: HashMap::new(),
}
}
pub fn enabled(&self) -> bool {
self.enabled
}
/// How many instances of this app the pool wants standing by; 0 for an
/// app that is not pooled at all.
pub fn capacity(app: &str) -> usize {
WARM_CAPACITY
.iter()
.find(|(id, _)| *id == app)
.map(|(_, n)| *n)
.unwrap_or(0)
}
pub fn is_warm_app(app: &str) -> bool {
Self::capacity(app) > 0
}
/// How many instances of this app are currently held.
pub fn held(&self, app: &str) -> usize {
self.ready.get(app).map(|v| v.len()).unwrap_or(0)
}
/// Retire only unused browsers on a light/dark change. Removing them
/// from the adoption pool is immediate; the host closes their processes
/// and refills after they exit. Deliberate retirement is not a crash.
pub fn set_browser_appearance(&mut self, dark: bool) -> Vec<ClientId> {
let previous = self.browser_dark.replace(dark);
if previous.is_some_and(|previous| previous != dark) {
self.ready.remove("browser").unwrap_or_default()
} else {
Vec::new()
}
}
/// Every warm client, whatever the app — the shutdown / close-all
/// paths walk this so no pooled process is ever left behind.
pub fn clients(&self) -> Vec<ClientId> {
let mut all: Vec<ClientId> = self.ready.values().flatten().copied().collect();
all.sort_unstable();
all
}
pub fn holds(&self, client: ClientId) -> bool {
self.ready.values().any(|v| v.contains(&client))
}
/// True while this app is under capacity and inside its crash budget:
/// the WM may spawn one more standby instance now.
pub fn wants(&self, app: &str, now: f64) -> bool {
self.enabled
&& self.held(app) < Self::capacity(app)
&& self.recent_crashes(app, now) < WARM_CRASH_LIMIT
}
/// The next app that is short an instance, in table order — the tick
/// tops the pool up ONE spawn at a time so a cold start never forks
/// five cargo builds into the same target-dir lock at once.
pub fn next_missing(&self, now: f64) -> Option<String> {
WARM_CAPACITY
.iter()
.map(|(app, _)| *app)
.find(|app| self.wants(app, now))
.map(str::to_string)
}
/// A standby instance was spawned for `app`.
pub fn note_spawned(&mut self, app: &str, client: ClientId) {
self.ready.entry(app.to_string()).or_default().push(client);
}
/// A warm instance died on its own. Counted against the crash budget;
/// a DELIBERATE close (WM shutdown, close-all) calls `forget` instead.
pub fn note_crash(&mut self, app: &str, now: f64) {
self.crashes.entry(app.to_string()).or_default().push(now);
}
fn recent_crashes(&self, app: &str, now: f64) -> usize {
self.crashes
.get(app)
.map(|v| {
v.iter()
.filter(|t| now - **t < WARM_CRASH_WINDOW)
.count()
})
.unwrap_or(0)
}
/// Drop a client from the pool however it left (died, was closed with
/// everything else). Returns the app it was standing by for, which is
/// the app the caller then tops back up.
pub fn forget(&mut self, client: ClientId) -> Option<String> {
let mut which = None;
for (app, ids) in self.ready.iter_mut() {
if let Some(pos) = ids.iter().position(|c| *c == client) {
ids.remove(pos);
which = Some(app.clone());
break;
}
}
which
}
/// THE decision, for one launch of `app`.
///
/// `Some(client)` = adopt that instance into a real tile (it leaves the
/// pool; the caller tops the app back up immediately). `None` = spawn
/// cold exactly as before. Dead entries are pruned on the way past, so
/// a crashed instance both falls back cleanly AND frees its slot for
/// the next respawn.
///
/// THE CWD CARVE-OUT (omarchy's rule, `omarchy-cmd-terminal-cwd`): a
/// new terminal opens in the FOCUSED terminal's directory. A warm
/// terminal's shell started long ago, in the default directory — it
/// cannot be moved after the fact without lying about where it is — so
/// when a cwd is being inherited the pool stands aside and the launch
/// goes cold. Correct beats instant; the instant path is what you get
/// from the desktop, the bar and any non-terminal focus.
pub fn adopt(
&mut self,
app: &str,
cwd_override: bool,
status: &[WarmStatus],
) -> Option<ClientId> {
if !self.enabled {
return None;
}
let ids = self.ready.get_mut(app)?;
ids.retain(|id| {
status
.iter()
.any(|s| s.client == *id && s.alive)
});
if cwd_override {
return None;
}
let pos = ids.iter().position(|id| {
status
.iter()
.any(|s| s.client == *id && s.connected)
})?;
Some(ids.remove(pos))
}
}
pub struct ClientSlot {
#[allow(dead_code)]
pub id: ClientId,
/// Registry id of the app this client runs.
pub app: String,
/// The manifest id of the script app this client runs, recorded when it
/// opened (`apps::card_manifest_id`): a system app's `os.<name>` or an
/// installed app's own id. What host services check as the app in front
/// ([`ClientSlot::foreground_app`]); `None` for every other client.
pub card_app: Option<String>,
pub title: String,
/// The process behind the slot: the build (`cargo build`) while
/// `pending` holds the app, then the app itself.
pub child: Option<ProcessGroup>,
/// The app a launch starts once its build succeeded
/// ([`ClientSlot::continue_launch`]).
pending: Option<PendingApp>,
/// Why the app could not start after its build (shown on failure).
pub launch_error: Option<String>,
task_pool: Option<TaskPool>,
pub sender: Option<Sender<Vec<u8>>>,
pub socket: Option<u64>,
/// The child's main window id in the studio protocol (0 until
/// CreateWindow says otherwise).
pub window_id: usize,
/// CreateWindow arrived: the child is ready for a swapchain.
pub ready: bool,
/// Working directory reported by the child (terminals, via OSC 7).
pub pwd: Option<PathBuf>,
/// Opened as a Quick-Look preview: a centered float that Escape or
/// Space dismisses.
pub is_preview: bool,
/// A DORMANT warm-pool instance (see `WarmPool`): the process is up,
/// connected and drawing into its own off-desk framebuffer, but it has
/// NO tile. Everything the desk enumerates works off the LAYOUT, which
/// a warm client is never in, so this flag is only needed where the WM
/// walks the client table itself — launch-or-focus matching, and the
/// tile plumbing that must stay away until adoption.
pub warm: bool,
/// When this client was opened as a real window (launched cold, or
/// adopted out of the pool) and whether that open was the warm path —
/// the pair behind the "first frame in Nms" log line that measures the
/// pool honestly.
pub open_at: Option<f64>,
pub opened_warm: bool,
/// FOCUS RULE: a Quick-Look preview never takes key focus — keys keep
/// flowing to the requesting tile (files). `focus_client` refuses to
/// focus a client with this false; every normal client defaults true.
pub takes_focus: bool,
/// Launched through cargo, so the tile can say "building…" until the
/// child actually connects.
#[allow(dead_code)]
pub via_cargo: bool,
/// The newest line the child (or cargo) wrote, shown on the tile
/// under "starting…" until the first frame arrives.
pub status: String,
/// Last output before presentation filtering, retained for failure feedback.
pub diagnostic: String,
pub log_path: Option<PathBuf>,
/// cargo has finished linking and handed over: the child's first exec
/// is the one macOS scans.
pub linked: bool,
pub linked_at: Option<f64>,
/// A polite close was sent at this instant (omarchy's
/// `hl.dsp.window.close()`); the hard kill is only the fallback.
pub closing: Option<f64>,
/// The aichat child seated in the AI pane: not in the layout, no tile.
pub pane: bool,
/// On the phone, this client owns the left and right edges of its
/// viewport (a map that pans from the edge): the shell's back gesture
/// is not recognised over it. Off by default; an app opts in.
pub owns_edges: bool,
/// Its process died unexpectedly: the tile stays, closed, with a
/// Restart (ADR 0004 §2); nothing runs behind it any more.
pub stopped: bool,
}
impl ClientSlot {
/// The slot of an IN-PROCESS module instance (aicontrol §3): a window
/// in the layout like any other — the bar, alt-tab and the `os` service
/// see it — with no process behind it: no child, no socket, no build.
pub fn module(id: ClientId, app: &str, title: &str) -> ClientSlot {
ClientSlot {
id,
app: app.to_string(),
card_app: None,
title: title.to_string(),
child: None,
pending: None,
launch_error: None,
task_pool: None,
sender: None,
socket: None,
window_id: 0,
ready: true,
pwd: None,
is_preview: false,
warm: false,
open_at: Some(host::now()),
opened_warm: false,
takes_focus: true,
owns_edges: false,
via_cargo: false,
status: String::new(),
diagnostic: String::new(),
log_path: None,
linked: false,
linked_at: None,
closing: None,
pane: false,
stopped: false,
}
}
/// The app host services treat as in front while this client has the
/// focus (`files_service::set_foreground_app`, the native audio
/// session): its script app's manifest id ([`ClientSlot::card_app`]),
/// a system app's as much as an installed one's. A hosted Hub preview
/// has no identity of its own: it must open the installed app first.
pub fn foreground_app(&self) -> Option<String> {
self.card_app.clone().or_else(|| self.app.strip_prefix("hub:").map(str::to_owned))
}
}
/// How long a client gets to honor a close request before it is killed.
pub const CLOSE_GRACE: std::time::Duration = std::time::Duration::from_millis(1500);
/// SIGTERM-to-SIGKILL escalation gap inside `kill_child_group`, once a
/// caller has already decided to hard-kill (past `CLOSE_GRACE`, or the
/// client never got that far — still building when it was closed).
pub const GROUP_KILL_GRACE: std::time::Duration = std::time::Duration::from_millis(300);
/// Put `cmd`'s child at the head of a brand-new process group (unix only):
/// `process_group(0)` is `setpgid(0, 0)` before exec, so the pgid becomes
/// the child's own pid. Every process it forks (rustc under a build, a
/// helper the app starts) inherits that same pgid, so the whole tree can be
/// reached by one negative-pid signal later.
#[cfg(unix)]
fn own_process_group(cmd: &mut Command) {
cmd.process_group(0);
}
/// `kill(2)` by hand — this crate has no `libc` dependency, and a
/// two-liner beats pulling one in for a single syscall pair.
#[cfg(unix)]
mod signal {
extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
pub const SIGTERM: i32 = 15;
pub const SIGKILL: i32 = 9;
/// Signal the whole process group led by `pid` — the POSIX convention
/// of a negative pid.
pub fn kill_group(pid: i32, sig: i32) {
unsafe { kill(-pid, sig) };
}
/// `kill(pid, 0)` sends nothing. A negative pid checks the entire
/// process group, including descendants whose leader has been reaped.
pub fn alive(pid: i32) -> bool {
unsafe { kill(pid, 0) == 0 }
}
}
/// What a [`ProcessGroup`] shares with the escalations it starts: its
/// leader's pid and whether that leader has been reaped.
#[derive(Debug)]
struct GroupState {
pid: i32,
/// Held while the leader is reaped and while the group is signalled, so
/// no signal ever goes out after the reap.
reaped: std::sync::Mutex<bool>,
/// Group signals sent, for the tests.
sent: std::sync::atomic::AtomicUsize,
}
impl GroupState {
/// Signal the group, unless its leader was reaped: from then on its pid,
/// and with it the group id, may name someone else's process group.
#[cfg_attr(not(unix), allow(unused_variables))]
fn signal(&self, sig: i32, only_if_alive: bool) -> bool {
let reaped = self.reaped.lock().unwrap_or_else(|e| e.into_inner());
if *reaped {
return false;
}
#[cfg(unix)]
{
if only_if_alive && !signal::alive(-self.pid) {
return false;
}
signal::kill_group(self.pid, sig);
self.sent.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
true
}
#[cfg(not(unix))]
false
}
}
/// A child at the head of its own process group (unix), and the rule for
/// signalling that group: **only until its leader is reaped**. An unreaped
/// leader (running, or a zombie) holds its pid, so the group id is still
/// this group's. The moment [`ProcessGroup::try_wait`] or
/// [`ProcessGroup::wait`] reaps it, whatever the group still holds is killed
/// at once (the leader's leftovers: nothing of an ended app outlives it),
/// and after that nothing, not a close, not a slot's drop, not an
/// escalation already under way, signals that group id again.
#[derive(Debug)]
pub struct ProcessGroup {
child: Child,
state: std::sync::Arc<GroupState>,
}
impl ProcessGroup {
pub fn new(child: Child) -> ProcessGroup {
let pid = child.id() as i32;
ProcessGroup { child, state: std::sync::Arc::new(GroupState { pid, reaped: std::sync::Mutex::new(false), sent: Default::default() }) }
}
pub fn id(&self) -> u32 {
self.child.id()
}
/// The leader's exit status, reaping it if it has exited (then the
/// group's leftovers are killed, and the group is never signalled again).
pub fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
let mut reaped = self.state.reaped.lock().unwrap_or_else(|e| e.into_inner());
let status = self.child.try_wait();
if !*reaped && matches!(status, Ok(Some(_))) {
Self::sweep(&self.state);
*reaped = true;
}
status
}
/// Block until the leader exits, then as [`ProcessGroup::try_wait`].
pub fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
let mut reaped = self.state.reaped.lock().unwrap_or_else(|e| e.into_inner());
let status = self.child.wait();
if !*reaped && status.is_ok() {
Self::sweep(&self.state);
*reaped = true;
}
status
}
/// Right after the reap: whatever is left in the group. The group id is
/// still ours while anything is left in it (a group id is not reused
/// while its group exists).
#[cfg_attr(not(unix), allow(unused_variables))]
fn sweep(state: &GroupState) {
#[cfg(unix)]
if signal::alive(-state.pid) {
signal::kill_group(state.pid, signal::SIGKILL);
state.sent.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
/// SIGKILL to the group now (the child alone on Windows); nothing once
/// the leader has been reaped.
pub fn kill_now(&mut self) {
#[cfg(unix)]
self.state.signal(signal::SIGKILL, false);
#[cfg(not(unix))]
if !self.reaped() {
let _ = self.child.kill();
}
}
/// Whether the leader has been reaped.
pub fn reaped(&self) -> bool {
*self.state.reaped.lock().unwrap_or_else(|e| e.into_inner())
}
/// How many group signals were sent (tests).
#[cfg(test)]
fn signals_sent(&self) -> usize {
self.state.sent.load(std::sync::atomic::Ordering::Relaxed)
}
}
/// Kill the whole process group a `spawn_client` child heads — the fix for
/// the leak `Child::kill()` had through a wrapper (then `cargo run`, which
/// left the app and any still-building rustc running as orphans). SIGTERM
/// now, SIGKILL after `grace` for whatever is still alive; the escalation
/// runs off-thread so a UI-thread caller never blocks on it, and it never
/// fires once the leader has been reaped ([`ProcessGroup`]). Windows keeps
/// the plain `Child::kill()` this replaced.
#[cfg(unix)]
pub fn kill_child_group(child: &mut ProcessGroup, grace: std::time::Duration, pool: &TaskPool) {
if !child.state.signal(signal::SIGTERM, false) {
return;
}
let state = child.state.clone();
let wait = CancellationToken::new();
let submitted = pool.submit(Lane::Heavy, move || {
let _ = wait.wait_until(Cx::monotonic_now() + grace.as_secs_f64());
state.signal(signal::SIGKILL, true);
});
match submitted {
Ok(task) => task.detach(),
Err(_) => {
child.state.signal(signal::SIGKILL, true);
}
}
}
#[cfg(not(unix))]
pub fn kill_child_group(child: &mut ProcessGroup, _grace: std::time::Duration, _pool: &TaskPool) {
if !child.reaped() {
let _ = child.child.kill();
}
}
/// Final slot teardown owns the child from here on. Signal and reap it wholly
/// on a heavy pool worker; dropping a slot on the UI thread never waits for a
/// process or decoder wrapper to exit. A leader reaped already (the tick saw
/// it exit) is left alone: its group id may be someone else's by now.
fn reap_child_group(mut child: ProcessGroup, _grace: std::time::Duration, pool: &TaskPool) {
if child.reaped() {
return;
}
#[cfg(unix)]
child.state.signal(signal::SIGTERM, false);
match pool.reserve(Lane::Heavy) {
Ok(slot) => slot
.submit(move || {
#[cfg(unix)]
{
let wait = CancellationToken::new();
let _ = wait.wait_until(Cx::monotonic_now() + _grace.as_secs_f64());
child.state.signal(signal::SIGKILL, true);
}
#[cfg(not(unix))]
let _ = child.child.kill();
let _ = child.wait();
})
.detach(),
Err(_) => {
#[cfg(unix)]
child.state.signal(signal::SIGKILL, true);
#[cfg(not(unix))]
let _ = child.child.kill();
}
}
}
/// Final application shutdown cannot depend on UI ticks, destructors, or
/// detached workers. Stop every group together and reap the direct children
/// within one shared deadline, including builds that never connected.
pub fn shutdown_clients(clients: &mut HashMap<ClientId, ClientSlot>) {
let mut children: Vec<ProcessGroup> = Vec::new();
let mut groups: Vec<i32> = Vec::new();
for slot in clients.values_mut() {
if let Some(sender) = slot.sender.take() {
crate::hub::send_to_app(&sender, vec![makepad_studio_protocol::StudioToApp::Kill]);
}
slot.pending = None;
if let Some(child) = slot.child.take() {
#[cfg(not(unix))]
let mut child = child;
#[cfg(unix)]
child.state.signal(signal::SIGTERM, false);
#[cfg(not(unix))]
if !child.reaped() {
let _ = child.child.kill();
}
groups.push(child.id() as i32);
children.push(child);
}
slot.task_pool = None;
}
// No remaining slot owns a child or sender, so Drop cannot queue cleanup.
clients.clear();
for (phase, grace) in [GROUP_KILL_GRACE, std::time::Duration::from_secs(2)].into_iter().enumerate() {
#[cfg(unix)]
if phase == 1 {
for child in &children {
child.state.signal(signal::SIGKILL, true);
}
}
#[cfg(not(unix))]
let _ = phase;
let deadline = std::time::Instant::now() + grace;
loop {
// Reaping sweeps each group's leftovers (ProcessGroup::try_wait).
children.retain_mut(|child| match child.try_wait() {
Ok(Some(_)) => false,
Ok(None) => true,
Err(error) => {
makepad_widgets::log!("octosense: could not reap child {}: {error}", child.id());
false
}
});
// Only looked at, never signalled: the swept leftovers going.
#[cfg(unix)]
let group_alive = groups.iter().any(|pid| signal::alive(-*pid));
#[cfg(not(unix))]
let group_alive = { let _ = &groups; false };
if children.is_empty() && !group_alive { return; }
if std::time::Instant::now() >= deadline { break; }
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
makepad_widgets::log!("octosense: child cleanup reached its shutdown deadline after termination");
}
impl ClientSlot {
pub fn exit_failure(&self, status: std::process::ExitStatus, label: &str) -> Option<String> {
if self.closing.is_some() || self.warm || (self.ready && status.success()) {
return None;
}
let mut message = match &self.launch_error {
Some(why) => format!("{label} did not start: {why}."),
None => {
let reason = if self.ready { "exited unexpectedly" } else { "exited before opening a window" };
format!("{label} {reason} ({status}).")
}
};
if !self.diagnostic.is_empty() {
message.push('\n');
message.extend(self.diagnostic.chars().take(400));
}
if let Some(path) = &self.log_path {
message.push_str(&format!("\nLog: {}", path.display()));
}
Some(message)
}
/// Whether an exit leaves this client's tile in place, closed with a
/// Restart: an unexpected death (`failure`) of a window the person
/// has. A warm instance, the AI pane, a Quick-Look preview and a
/// client being closed go away as before.
pub fn stops_in_place(&self, failure: bool) -> bool {
failure && self.ready && !self.warm && !self.pane && !self.is_preview && self.closing.is_none() && self.child.is_some()
}
pub fn display_title(&self) -> &str {
if self.title.is_empty() {
&self.app
} else {
&self.title
}
}
}
/// One line of a child's output, on its way to the tile.
#[derive(Clone, Debug)]
pub struct ClientLine {
pub client: ClientId,
pub text: String,
}
/// Cargo (and rustc) paint their progress; a pipe usually turns that off,
/// but a stray CSI sequence must never reach a Label.
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
if c != '\r' {
out.push(c);
}
continue;
}
// ESC [ … <final byte 0x40..0x7e>, or ESC ] … BEL (OSC).
match chars.next() {
Some('[') => {
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') => {
for c in chars.by_ref() {
if c == '\u{7}' {
break;
}
}
}
_ => {}
}
}
out
}
/// Read a child stream line by line into the log file and the UI channel.
fn pump<R: std::io::Read + Send + 'static>(
spawner: &ThreadSpawner,
client: ClientId,
stream: R,
mut log: Option<std::fs::File>,
lines: Sender<ClientLine>,
) {
// Each pipe lives for the child's entire lifetime. A blocking reader
// must not occupy a finite pool worker: enough open apps would starve
// new compile logs and even process cleanup.