Skip to content

Commit 12591ef

Browse files
committed
Merge PR warpdotdev#37: bound /dev/tty write so hooks can't hang when Warp UI is unresponsive
Resolution: hybrid of warpdotdev#44's tty walker and warpdotdev#37's timeout watchdog. Each candidate tty is now tried with a TIMEOUT_SEC watchdog (default 2s) so a hung Warp UI on one tty falls through to the next ancestor instead of blocking forever.
2 parents dbea8a0 + d29cf60 commit 12591ef

3 files changed

Lines changed: 182 additions & 10 deletions

File tree

plugins/warp/scripts/legacy/warp-notify.sh

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
#!/bin/bash
22
# Warp notification utility using OSC escape sequences
33
# Usage: warp-notify.sh <title> <body>
4+
#
5+
# The write to /dev/tty is bounded by WARP_NOTIFY_TIMEOUT_SEC (default 2)
6+
# so an unresponsive Warp UI cannot block the caller indefinitely.
47

58
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
69
source "$SCRIPT_DIR/../find-controlling-tty.sh"
710

811
TITLE="${1:-Notification}"
912
BODY="${2:-}"
13+
TARGET="${WARP_NOTIFY_TARGET:-/dev/tty}"
14+
TIMEOUT_SEC="${WARP_NOTIFY_TIMEOUT_SEC:-2}"
1015

1116
# OSC 777 format: \033]777;notify;<title>;<body>\007
17+
SEQ=$(printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY")
18+
1219
emit() {
13-
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
20+
local target="$1"
21+
{
22+
printf '%s' "$SEQ" > "$target" 2>/dev/null
23+
} &
24+
local writer_pid=$!
25+
{
26+
sleep "$TIMEOUT_SEC" 2>/dev/null
27+
kill -KILL "$writer_pid" 2>/dev/null
28+
} &
29+
local watchdog_pid=$!
30+
wait "$writer_pid" 2>/dev/null
31+
local rc=$?
32+
kill -KILL "$watchdog_pid" 2>/dev/null
33+
wait "$watchdog_pid" 2>/dev/null
34+
return $rc
1435
}
1536

16-
# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
17-
# that detached the subprocess from its controlling terminal), fall back to
18-
# trying each ancestor process's tty in turn until one accepts the write.
19-
emit /dev/tty && exit 0
37+
emit "$TARGET" && exit 0
2038

2139
while read -r tty; do
2240
emit "$tty" && exit 0

plugins/warp/scripts/warp-notify.sh

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
#
55
# For structured Warp notifications, title should be "warp://cli-agent"
66
# and body should be a JSON string matching the cli-agent notification schema.
7+
#
8+
# The write to /dev/tty is bounded by WARP_NOTIFY_TIMEOUT_SEC (default 2).
9+
# Without this bound, an unresponsive Warp UI — which leaves the controlling
10+
# TTY's output buffer undrained — would block the calling Claude Code session
11+
# indefinitely. Tests can redirect output via WARP_NOTIFY_TARGET.
712

813
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
914
source "$SCRIPT_DIR/should-use-structured.sh"
@@ -16,19 +21,42 @@ fi
1621

1722
TITLE="${1:-Notification}"
1823
BODY="${2:-}"
24+
TARGET="${WARP_NOTIFY_TARGET:-/dev/tty}"
25+
TIMEOUT_SEC="${WARP_NOTIFY_TIMEOUT_SEC:-2}"
1926

2027
# OSC 777 format: \033]777;notify;<title>;<body>\007
28+
SEQ=$(printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY")
29+
30+
# Try a single target with a timeout watchdog. Returns 0 if the write
31+
# completed before TIMEOUT_SEC, non-zero on failure or timeout. Bounding
32+
# the write protects against an unresponsive Warp UI leaving the pty's
33+
# output buffer undrained, which would otherwise block indefinitely.
2134
emit() {
22-
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
35+
local target="$1"
36+
{
37+
printf '%s' "$SEQ" > "$target" 2>/dev/null
38+
} &
39+
local writer_pid=$!
40+
{
41+
sleep "$TIMEOUT_SEC" 2>/dev/null
42+
kill -KILL "$writer_pid" 2>/dev/null
43+
} &
44+
local watchdog_pid=$!
45+
wait "$writer_pid" 2>/dev/null
46+
local rc=$?
47+
kill -KILL "$watchdog_pid" 2>/dev/null
48+
wait "$watchdog_pid" 2>/dev/null
49+
return $rc
2350
}
2451

25-
# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
26-
# that detached the subprocess from its controlling terminal), fall back to
27-
# trying each ancestor process's tty in turn until one accepts the write.
28-
emit /dev/tty && exit 0
52+
# Prefer TARGET (defaults to /dev/tty). If unopenable (e.g. hook is in a
53+
# sandbox detached from its controlling terminal) or the write times out,
54+
# fall back to each ancestor process's tty in turn.
55+
emit "$TARGET" && exit 0
2956

3057
while read -r tty; do
3158
emit "$tty" && exit 0
3259
done < <(find_candidate_ttys)
3360

61+
# Notifications are best-effort; never propagate failure to the caller.
3462
exit 0
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/bin/bash
2+
# Tests for warp-notify.sh hang protection.
3+
#
4+
# Verifies that warp-notify.sh:
5+
# 1. Completes immediately when the target is writable (the happy path).
6+
# 2. Exits cleanly within the configured timeout when the target's output
7+
# buffer is full and never drained (the bug scenario: Warp UI hung).
8+
# 3. Defaults to a sane upper bound (2s) without explicit configuration.
9+
# 4. Same guarantees apply to the legacy variant.
10+
#
11+
# Implementation notes:
12+
# - We simulate "Warp UI hung" by pointing WARP_NOTIFY_TARGET at a FIFO
13+
# with no reader. The kernel blocks open()/write() on such a FIFO the
14+
# same way it blocks writes to a slave PTY whose master isn't reading,
15+
# which is the exact failure mode we observed in production.
16+
# - We export WARP_CLI_AGENT_PROTOCOL_VERSION and WARP_CLIENT_VERSION so
17+
# should_use_structured returns true and we exercise the write path.
18+
19+
set -uo pipefail
20+
21+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)"
22+
23+
export WARP_CLI_AGENT_PROTOCOL_VERSION=1
24+
export WARP_CLIENT_VERSION="v0.2026.04.01.08.00.stable_00"
25+
26+
PASSED=0
27+
FAILED=0
28+
29+
assert_eq() {
30+
local test_name="$1"
31+
local expected="$2"
32+
local actual="$3"
33+
if [ "$expected" = "$actual" ]; then
34+
echo "$test_name"
35+
PASSED=$((PASSED + 1))
36+
else
37+
echo "$test_name"
38+
echo " expected: $expected"
39+
echo " actual: $actual"
40+
FAILED=$((FAILED + 1))
41+
fi
42+
}
43+
44+
assert_lt() {
45+
local test_name="$1"
46+
local actual="$2"
47+
local upper="$3"
48+
if [ "$actual" -lt "$upper" ] 2>/dev/null; then
49+
echo "$test_name ($actual < $upper)"
50+
PASSED=$((PASSED + 1))
51+
else
52+
echo "$test_name (got $actual, expected < $upper)"
53+
FAILED=$((FAILED + 1))
54+
fi
55+
}
56+
57+
cleanup() {
58+
[ -n "${FIFO:-}" ] && rm -f "$FIFO"
59+
}
60+
trap cleanup EXIT
61+
62+
run_notify() {
63+
local script="$1"
64+
shift
65+
local start end
66+
start=$(date +%s)
67+
bash "$script" "warp://cli-agent" '{"v":1,"agent":"claude","event":"test"}' "$@"
68+
LAST_RC=$?
69+
end=$(date +%s)
70+
LAST_ELAPSED=$((end - start))
71+
}
72+
73+
echo "=== warp-notify.sh hang protection ==="
74+
75+
echo ""
76+
echo "--- Fast path: writable target completes immediately ---"
77+
WARP_NOTIFY_TARGET=/dev/null run_notify "$SCRIPT_DIR/warp-notify.sh"
78+
assert_eq "writable target exits 0" "0" "$LAST_RC"
79+
assert_lt "writable target completes under 2s" "$LAST_ELAPSED" "2"
80+
81+
echo ""
82+
echo "--- Hang protection: blocked target times out at configured limit ---"
83+
FIFO=$(mktemp -u)
84+
mkfifo "$FIFO"
85+
WARP_NOTIFY_TARGET="$FIFO" WARP_NOTIFY_TIMEOUT_SEC=1 \
86+
run_notify "$SCRIPT_DIR/warp-notify.sh"
87+
assert_eq "blocked target still exits 0 (best-effort)" "0" "$LAST_RC"
88+
# Timeout=1s plus watchdog/teardown overhead — generous bound to avoid CI flake.
89+
assert_lt "blocked target exits within 4s" "$LAST_ELAPSED" "4"
90+
rm -f "$FIFO"
91+
92+
echo ""
93+
echo "--- Default timeout caps unbounded waits ---"
94+
FIFO=$(mktemp -u)
95+
mkfifo "$FIFO"
96+
WARP_NOTIFY_TARGET="$FIFO" run_notify "$SCRIPT_DIR/warp-notify.sh"
97+
assert_eq "default timeout still exits 0" "0" "$LAST_RC"
98+
# Default is 2s; allow 5s for CI scheduling jitter.
99+
assert_lt "default timeout exits within 5s" "$LAST_ELAPSED" "5"
100+
rm -f "$FIFO"
101+
102+
echo ""
103+
echo "=== legacy/warp-notify.sh hang protection ==="
104+
105+
echo ""
106+
echo "--- Fast path: writable target completes immediately ---"
107+
WARP_NOTIFY_TARGET=/dev/null run_notify "$SCRIPT_DIR/legacy/warp-notify.sh"
108+
assert_eq "legacy writable target exits 0" "0" "$LAST_RC"
109+
assert_lt "legacy writable target completes under 2s" "$LAST_ELAPSED" "2"
110+
111+
echo ""
112+
echo "--- Hang protection: blocked target times out ---"
113+
FIFO=$(mktemp -u)
114+
mkfifo "$FIFO"
115+
WARP_NOTIFY_TARGET="$FIFO" WARP_NOTIFY_TIMEOUT_SEC=1 \
116+
run_notify "$SCRIPT_DIR/legacy/warp-notify.sh"
117+
assert_eq "legacy blocked target still exits 0" "0" "$LAST_RC"
118+
assert_lt "legacy blocked target exits within 4s" "$LAST_ELAPSED" "4"
119+
rm -f "$FIFO"
120+
121+
echo ""
122+
echo "=== Results: $PASSED passed, $FAILED failed ==="
123+
124+
if [ "$FAILED" -gt 0 ]; then
125+
exit 1
126+
fi

0 commit comments

Comments
 (0)