Skip to content

Commit adff9fa

Browse files
fix(droid-control): kill full true-input process tree on close (CLI-1171)
launch_true_input saved meta with CAGE_PID="" before starting cage, and for --record launches start_true_input_recording's load_meta clobbered the in-memory CAGE_PID back to empty, so cmd_close skipped the compositor kill and leaked the whole cage/ghostty/script/CLI tree (9 stale compositors on one host). Persist CAGE_PID right after the wayland socket wait, before the recording branch can reload the meta. Launch cage under setsid so it leads its own process group, and kill the compositor before die on socket timeout. Teardown now enumerates descendants and group-kills with TERM->poll->KILL escalation: each half covers what the other misses (script(1) children escape the group into new sessions; init-reparented members escape enumeration). The recorder gets INT->poll->KILL so recordings finalize. A stray sweep on the session's run-*.sh argv closes pre-fix sessions whose metas have empty CAGE_PID, guarded by pid_is_self_or_ancestor so it never kills the caller. wf-recorder is required up front when recording so a missing binary fails before cage starts.
1 parent 0c10b39 commit adff9fa

1 file changed

Lines changed: 127 additions & 15 deletions

File tree

  • plugins/droid-control/bin

plugins/droid-control/bin/tctl

Lines changed: 127 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,115 @@ wait_for_wayland_socket() {
8484
return 1
8585
}
8686

87+
collect_tree_pids() {
88+
local root="$1"
89+
local pid ppid
90+
local -A children=()
91+
while read -r pid ppid; do
92+
children["$ppid"]+=" $pid"
93+
done < <(ps -eo pid=,ppid= 2>/dev/null || true)
94+
95+
local -a result=("$root") queue=("$root")
96+
local current
97+
while (( ${#queue[@]} )); do
98+
current="${queue[0]}"
99+
queue=("${queue[@]:1}")
100+
for pid in ${children[$current]:-}; do
101+
result+=("$pid")
102+
queue+=("$pid")
103+
done
104+
done
105+
printf '%s\n' "${result[@]}"
106+
}
107+
108+
# Tear down a true-input compositor and everything under it. Descendant
109+
# enumeration alone misses processes that script(1) moved to a new session;
110+
# a process-group kill alone misses those same processes AND anything
111+
# reparented to init after the leader died. Do both, then escalate.
112+
terminate_true_input_stack() {
113+
local root="$1"
114+
local grace_ms="${2:-2000}"
115+
[[ -n "$root" ]] || return 0
116+
117+
local -a targets=()
118+
mapfile -t targets < <(collect_tree_pids "$root")
119+
120+
local pid
121+
kill -TERM -- "-$root" >/dev/null 2>&1 || true
122+
for pid in "${targets[@]}"; do
123+
kill -TERM "$pid" >/dev/null 2>&1 || true
124+
done
125+
126+
local deadline=$(( $(date +%s%3N) + grace_ms ))
127+
local alive=1
128+
while (( $(date +%s%3N) <= deadline )); do
129+
alive=0
130+
kill -0 -- "-$root" >/dev/null 2>&1 && alive=1
131+
if (( ! alive )); then
132+
for pid in "${targets[@]}"; do
133+
if kill -0 "$pid" >/dev/null 2>&1; then
134+
alive=1
135+
break
136+
fi
137+
done
138+
fi
139+
(( alive )) || break
140+
sleep 0.05
141+
done
142+
143+
if (( alive )); then
144+
kill -KILL -- "-$root" >/dev/null 2>&1 || true
145+
for pid in "${targets[@]}"; do
146+
kill -KILL "$pid" >/dev/null 2>&1 || true
147+
done
148+
fi
149+
}
150+
151+
# SIGINT first so wf-recorder finalizes the container; escalate if it hangs.
152+
terminate_recorder_pid() {
153+
local pid="$1"
154+
local grace_ms="${2:-3000}"
155+
[[ -n "$pid" ]] || return 0
156+
kill -INT "$pid" >/dev/null 2>&1 || true
157+
local deadline=$(( $(date +%s%3N) + grace_ms ))
158+
while kill -0 "$pid" >/dev/null 2>&1 && (( $(date +%s%3N) <= deadline )); do
159+
sleep 0.05
160+
done
161+
if kill -0 "$pid" >/dev/null 2>&1; then
162+
kill -KILL "$pid" >/dev/null 2>&1 || true
163+
fi
164+
}
165+
166+
pid_is_self_or_ancestor() {
167+
local candidate="$1"
168+
local cur=$$ ppid
169+
while [[ -n "$cur" && "$cur" != "0" && "$cur" != "1" ]]; do
170+
[[ "$cur" == "$candidate" ]] && return 0
171+
ppid="$(ps -o ppid= -p "$cur" 2>/dev/null | tr -d '[:space:]')" || return 1
172+
[[ "$ppid" != "$cur" ]] || return 1
173+
cur="$ppid"
174+
done
175+
return 1
176+
}
177+
178+
# Belt-and-suspenders for sessions whose meta lost the compositor PID (a
179+
# pre-fix launch bug left CAGE_PID empty for every recorded session). Match
180+
# only the session's runner scripts ($dir/run-*.sh) -- matching the bare dir
181+
# path would also hit unrelated processes that merely mention it in argv
182+
# (an inspecting shell, an editor) -- and never kill ourselves or a caller.
183+
terminate_session_strays() {
184+
local session="$1"
185+
local dir
186+
dir="$(session_dir "$session")"
187+
[[ -n "$dir" ]] || return 0
188+
local stray
189+
while read -r stray; do
190+
[[ -n "$stray" ]] || continue
191+
pid_is_self_or_ancestor "$stray" && continue
192+
terminate_true_input_stack "$stray" 1000
193+
done < <(pgrep -f -- "$dir/run-" 2>/dev/null || true)
194+
}
195+
87196
quote_sh() {
88197
printf '%q' "$1"
89198
}
@@ -481,7 +590,11 @@ launch_true_input() {
481590
require_cmd cage
482591
require_cmd wtype
483592
require_cmd script
593+
require_cmd setsid
484594
require_cmd "$TERMINAL"
595+
# Fail before the compositor starts, not after: a die inside the
596+
# recording path would strand a live cage.
597+
[[ -z "$record_path" ]] || require_cmd wf-recorder
485598

486599
local dir log_file terminal_cmd runtime_dir socket_path
487600
dir="$(session_dir "$session")"
@@ -519,18 +632,23 @@ launch_true_input() {
519632
WARMED_UP="0"
520633
save_session_state "$session"
521634

635+
# setsid: cage leads its own process group, so teardown can group-kill
636+
# the whole stack even after members reparent to init.
522637
XDG_RUNTIME_DIR="$runtime_dir" \
523638
WLR_BACKENDS="${WLR_BACKENDS:-headless}" \
524639
WLR_LIBINPUT_NO_DEVICES="${WLR_LIBINPUT_NO_DEVICES:-1}" \
525-
cage -- "${terminal_cmd[@]}" >/dev/null 2>&1 &
640+
setsid cage -- "${terminal_cmd[@]}" >/dev/null 2>&1 &
526641
CAGE_PID="$!"
527-
wait_for_wayland_socket "$CAGE_PID" "$socket_path" 5000 \
528-
|| die "true-input compositor did not create $socket_path"
642+
if ! wait_for_wayland_socket "$CAGE_PID" "$socket_path" 5000; then
643+
terminate_true_input_stack "$CAGE_PID" 1000
644+
die "true-input compositor did not create $socket_path"
645+
fi
646+
# Persist CAGE_PID before anything below calls load_meta: the recording
647+
# path reloads the meta file and would clobber it back to empty.
648+
save_session_state "$session"
529649

530650
if [[ -n "$record_path" ]]; then
531651
start_true_input_recording "$session" "$record_path"
532-
else
533-
save_session_state "$session"
534652
fi
535653
}
536654

@@ -741,8 +859,7 @@ stop_true_input_recording() {
741859
load_meta "$session"
742860
[[ "$BACKEND" == "true-input" ]] || die "tuistory recordings stop when the session exits; use close to finalize the cast"
743861
[[ -n "$RECORDER_PID" ]] || die "no active recorder for session: $session"
744-
kill -INT "$RECORDER_PID" >/dev/null 2>&1 || true
745-
wait "$RECORDER_PID" 2>/dev/null || true
862+
terminate_recorder_pid "$RECORDER_PID"
746863
RECORDER_PID=""
747864
save_session_state "$session"
748865
}
@@ -1049,14 +1166,9 @@ cmd_close() {
10491166
fi
10501167
(( close_status == 0 )) || die "failed to close tuistory session: $session"
10511168
else
1052-
if [[ -n "$RECORDER_PID" ]]; then
1053-
kill -INT "$RECORDER_PID" >/dev/null 2>&1 || true
1054-
wait "$RECORDER_PID" 2>/dev/null || true
1055-
fi
1056-
if [[ -n "$CAGE_PID" ]]; then
1057-
kill "$CAGE_PID" >/dev/null 2>&1 || true
1058-
wait "$CAGE_PID" 2>/dev/null || true
1059-
fi
1169+
terminate_recorder_pid "$RECORDER_PID"
1170+
terminate_true_input_stack "$CAGE_PID"
1171+
terminate_session_strays "$session"
10601172
if [[ -n "$RUNTIME_DIR" ]]; then
10611173
rm -rf "$RUNTIME_DIR"
10621174
fi

0 commit comments

Comments
 (0)