From a011e4b03ce6ec1ad883293e83b65a46bad19e68 Mon Sep 17 00:00:00 2001 From: veg Date: Sat, 11 Jul 2026 12:32:39 +0000 Subject: [PATCH] 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. --- README.md | 1 + ROADMAP.md | 3 -- party | 47 +++++++++++++++++++-- party.1 | 6 +++ tests/85-clean.bats | 81 ++++++++++++++++++++++++++++++++++++ tests/95-stub-roundtrip.bats | 4 +- 6 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tests/85-clean.bats diff --git a/README.md b/README.md index 13e067c..c512486 100644 --- a/README.md +++ b/README.md @@ -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 ` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). | diff --git a/ROADMAP.md b/ROADMAP.md index ae11b01..5088ca6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/party b/party index 326453e..cf80530 100755 --- a/party +++ b/party @@ -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 <&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 "$@" ;; diff --git a/party.1 b/party.1 index da119df..e0d6c61 100644 --- a/party.1 +++ b/party.1 @@ -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 diff --git a/tests/85-clean.bats b/tests/85-clean.bats new file mode 100644 index 0000000..a4fe68f --- /dev/null +++ b/tests/85-clean.bats @@ -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"* ]] +} diff --git a/tests/95-stub-roundtrip.bats b/tests/95-stub-roundtrip.bats index 60e1fb6..7c6ecec 100644 --- a/tests/95-stub-roundtrip.bats +++ b/tests/95-stub-roundtrip.bats @@ -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" ]