feat: party clean removes the caller's own crash leftovers

Turns the manual rm -rf recovery from host's EEXIST message into a
verb, behind the same gates cmd_close uses: own-basename glob, no
symlinks, ownership check, and a dead socket. Live parties are skipped
with a pointer to party close.
This commit is contained in:
veg 2026-07-11 12:32:39 +00:00
parent 6799a57405
commit a011e4b03c
6 changed files with 133 additions and 9 deletions

View file

@ -119,6 +119,7 @@ Three honest caveats, with the full detail in `man party`:
| `party who [--short]` | Show invited and attached users for the current party. |
| `party status` | Show the caller's own state: hosting, attached, or idle. |
| `party close` | Tear down the party server and its roster entry. Host-only. |
| `party clean` | Remove the caller's own dead party dirs: crash leftovers whose tmux server is gone. |
| `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). Read-only invitees always join passive. |
| `party leave` | Detach and clean up the per-guest session. |
| `party knock <name>` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). |

View file

@ -16,9 +16,6 @@ Direction, not promises. Two rules govern everything below (README
per-party dir (group-readable so every attendee can grab a copy);
`party log --stop` ends it. "What did we do last night?" for
collectives, and the audit trail human+AI co-work needs.
- **`party clean`:** remove the caller's own crash leftovers (dirs
they own whose socket is dead), turning the manual `rm -rf` recovery
in host's error message into a verb.
## Later

47
party
View file

@ -49,6 +49,7 @@ Hosting:
Names: letters, digits, '_', '-' (max 63).
close [name] Tear down the party you host (name it when
you host several).
clean Remove your own dead party dirs (crash leftovers).
Joining:
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
@ -764,14 +765,14 @@ EOF
# races with a concurrent in-flight setup: two processes both seeing a
# stale dir would both rm -rf it, and the loser's late rm -rf would
# wipe the winner's freshly-created replacement.
# The recovery path for genuine crash leftovers is a one-shot manual
# `rm -rf $party_dir` followed by retry; the error message says so.
# The recovery path for genuine crash leftovers is `party clean`
# followed by retry; the error message says so.
if ! mkdir -m 0700 "$party_dir" 2>/dev/null; then
cat >&2 <<EOF
party: $party_dir already exists. Either another host is starting '$name'
right now, or a prior crashed attempt left this directory behind. If no
other host is in flight, remove the directory and retry:
rm -rf $party_dir
other host is in flight, clean up and retry:
party clean
EOF
exit 1
fi
@ -946,6 +947,43 @@ cmd_close() {
rm -rf "$expected_dir"
echo "Party '$name' closed."
}
cmd_clean() {
case "${1:-}" in
-h|--help)
cat <<'EOF'
Usage: party clean
Remove your own dead party directories (crash leftovers whose tmux
server is gone). Live parties and other users' directories are never
touched; end a live party with `party close`.
EOF
exit 0 ;;
'') ;;
*) echo "party clean: unexpected arg '$1'" >&2; exit 2 ;;
esac
# Same mistake-proofing gates as cmd_close: the glob only matches
# the caller's own basename prefix, symlinks are never followed,
# [ -O ] pins ownership, and a socket answering the tmux protocol
# means the party is live and gets skipped, `party close` is the
# verb for those. An unmatched glob stays a literal string and
# fails [ -d ].
removed=0
for d in "$PARTY_SOCKET_DIR"/party-"$USER":*.d; do
[ -d "$d" ] || continue
[ -L "$d" ] && continue
[ -O "$d" ] || continue
if [ -e "$d/sock" ] \
&& [ "$(party_conn_state "$d/sock")" != dead ]; then
echo "party clean: skipping $d (server is live; use party close)." >&2
continue
fi
rm -rf "$d"
echo "removed $d"
removed=$((removed+1))
done
[ "$removed" -gt 0 ] || echo "nothing to clean."
}
cmd_join() {
passive=0
name=
@ -1547,6 +1585,7 @@ dispatch() {
--version) echo "party $PARTY_VERSION"; exit 0 ;;
host) cmd_host "$@" ;;
close) cmd_close "$@" ;;
clean) cmd_clean "$@" ;;
join) cmd_join "$@" ;;
leave) cmd_leave "$@" ;;
knock) cmd_knock "$@" ;;

View file

@ -14,6 +14,8 @@
.Cm close
.Op Ar name
.Nm
.Cm clean
.Nm
.Cm join
.Op Ar name
.Op Fl -passive
@ -136,6 +138,10 @@ With no
.Ar name ,
closes the only party the caller hosts.
Host-only.
.It Cm clean
Remove the caller's own dead party directories: crash leftovers whose
tmux server no longer answers.
Never touches live parties, other users' directories, or symlinks.
.It Cm join Op Ar name Op Fl -passive
Join a party.
With no

81
tests/85-clean.bats Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bats
#
# party clean: remove the caller's own dead party dirs. Mistake-proofing
# gates mirror cmd_close: own-basename glob, no symlinks, [ -O ], and a
# dead socket. Liveness is faked through PARTY_TMUX stubs so no real
# server or group membership is needed.
load 'helpers'
setup() {
setup_party_sandbox
load_party_lib
}
teardown() { teardown_party_sandbox; }
@test "clean removes a dead leftover dir" {
ensure_party_dir "$USER" ghost
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[[ "$output" == *"removed"*"ghost"* ]]
[ ! -d "$PARTY_SOCKET_DIR/party-$USER:ghost.d" ]
}
@test "clean removes a crash-before-bind dir (no sock at all)" {
mkdir "$PARTY_SOCKET_DIR/party-$USER:stillborn.d"
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[ ! -d "$PARTY_SOCKET_DIR/party-$USER:stillborn.d" ]
}
@test "clean skips a live party and says to use close" {
cat > "$PARTY_TMP/tmux-ok" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$PARTY_TMP/tmux-ok"
export PARTY_TMUX="$PARTY_TMP/tmux-ok"
ensure_party_dir "$USER" running
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[[ "$output" == *"skipping"*"running"* ]]
[[ "$output" == *"party close"* ]]
[ -d "$PARTY_SOCKET_DIR/party-$USER:running.d" ]
}
@test "clean never follows a planted symlink" {
mkdir "$PARTY_TMP/target"
: > "$PARTY_TMP/target/precious"
ln -s "$PARTY_TMP/target" "$PARTY_SOCKET_DIR/party-$USER:lnk.d"
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[ -e "$PARTY_TMP/target/precious" ]
[ -L "$PARTY_SOCKET_DIR/party-$USER:lnk.d" ]
}
@test "clean ignores other users' dirs" {
ensure_party_dir nobody theirparty
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[ -d "$PARTY_SOCKET_DIR/party-nobody:theirparty.d" ]
}
@test "clean with nothing to do says so" {
run "$PARTY_BIN" clean
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to clean"* ]]
}
@test "clean rejects arguments" {
run "$PARTY_BIN" clean extra
[ "$status" -eq 2 ]
}
@test "host's EEXIST message recommends party clean" {
ensure_party_dir "$USER" blocked
export TMUX_PARTY_GROUP="$(id -gn)"
run "$PARTY_BIN" host blocked
[ "$status" -eq 1 ]
[[ "$output" == *"party clean"* ]]
}

View file

@ -564,7 +564,7 @@ STUB
# deliberately do NOT auto-clean: the same code path triggers on a
# concurrent in-flight host attempt for the same name, and racing
# rm -rf calls would wipe each other's fresh dirs.
# Recovery is a manual `rm -rf` per the error message.
# Recovery is `party clean` per the error message.
party_dir="$PARTY_SOCKET_DIR/party-$USER:resurrect.d"
mkdir -p "$party_dir"
: > "$party_dir/sock"
@ -572,7 +572,7 @@ STUB
run "$PARTY_BIN" host resurrect
[ "$status" -ne 0 ]
[[ "$output" == *"already exists"* ]]
[[ "$output" == *"rm -rf $party_dir"* ]]
[[ "$output" == *"party clean"* ]]
# The leftover dir is untouched — proves we did not race-clean.
[ -d "$party_dir" ]