Skip to content

Commit a17939b

Browse files
committed
release: v0.16.1
1 parent 9e52475 commit a17939b

5 files changed

Lines changed: 301 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
44

55
## Unreleased
66

7+
## v0.16.1 - 2026-02-19
8+
9+
### Added
10+
- Websocket compatibility guardrails in docs (`docs/api-notes.md`) covering reconnect semantics, control-message behavior, and polling fallback expectations.
11+
- Troubleshooting guidance for `rr events tail` websocket support/reconnect diagnostics (`docs/troubleshooting.md`).
12+
13+
### Fixed
14+
- Added regression tests for `rr events tail` control-message filtering and reconnect/re-subscribe paths to prevent behavior drift.
15+
- Clarified README caveat that websocket streaming is live-only and reconnects can require follow-up list/search reconciliation.
16+
717
## v0.16.0 - 2026-02-19
818

919
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ rr events tail --all --include-control --stop-after 30s --json
153153

154154
If your desktop build does not expose `/v1/ws`, `rr events tail` returns an explicit unsupported-version error.
155155
For older builds or when you need polling semantics, continue using `rr messages tail`.
156+
This stream is live-only; on reconnect there may be gaps, so re-query state with `rr messages list/search` when exact completeness matters.
156157

157158
## Messages
158159

docs/api-notes.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,17 @@
5151
- In `--json --envelope` mode, cursor-based commands include normalized machine metadata at
5252
`metadata.pagination` with fields: `has_more`, `direction`, `next_cursor`, `oldest_cursor`,
5353
`newest_cursor`, `auto_paged`, `capped`, and `max_items` (when auto-paging is enabled).
54+
55+
## WebSocket (Experimental) guardrails
56+
57+
- `rr events tail` connects to `/v1/ws` and is intentionally treated as best-effort because upstream marks it experimental.
58+
- Unsupported websocket route behavior is explicit and stable: `rr events tail` returns an unsupported-version error if `/v1/ws` is unavailable.
59+
- The stream is live-only (no replay cursor). Treat reconnects as potential event gaps and re-query state with `messages list/search` when exact completeness matters.
60+
- `seq` is monotonic per-connection only. Do not treat it as a global durable checkpoint across reconnects.
61+
- Reconnect behavior is automatic by default:
62+
- on disconnect/read failure, `rr events tail` reconnects and re-sends subscriptions;
63+
- disable with `--reconnect=false` for strict single-connection behavior.
64+
- Control-message handling is explicit:
65+
- default output suppresses `ready`, `subscriptions.updated`, and websocket `error` control events;
66+
- pass `--include-control` to include control events in output streams.
67+
- If websocket semantics are not desired (or unavailable), use `rr messages tail` polling mode as a stable fallback.

docs/troubleshooting.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ rr auth set --stdin
3030
your Beeper Desktop build is older than the required Desktop API routes.
3131
- Update Beeper Desktop and retry `rr doctor`, then re-run the command.
3232

33+
## `events tail` websocket issues
34+
35+
- If you see `websocket events are not supported`, your Beeper Desktop build does not expose `/v1/ws` yet. Use `rr messages tail` or upgrade Desktop.
36+
- If your stream reconnects repeatedly, verify local connectivity and auth first (`rr auth status --check`, `rr doctor`).
37+
- `rr events tail` reconnects by default; disable with `--reconnect=false` if you want failures surfaced immediately.
38+
- For deterministic CI/script runs, bound runtime with `--stop-after` and use `--include-control` when you need subscription/control diagnostics.
39+
3340
## Attachment send validation errors
3441

3542
- `--attachment-upload-id` is required when using attachment metadata override flags.

internal/cmd/events_tail_test.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"net/http/httptest"
77
"strings"
8+
"sync/atomic"
89
"testing"
910
"time"
1011

@@ -91,3 +92,271 @@ func TestEventsTailUnsupportedRouteMessage(t *testing.T) {
9192
t.Fatalf("unexpected error: %v", err)
9293
}
9394
}
95+
96+
func TestEventsTailSkipsControlMessagesByDefault(t *testing.T) {
97+
t.Setenv("BEEPER_TOKEN", "test-token")
98+
t.Setenv("BEEPER_ACCESS_TOKEN", "")
99+
100+
upgrader := websocket.Upgrader{
101+
CheckOrigin: func(r *http.Request) bool { return true },
102+
}
103+
104+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105+
if r.URL.Path != "/v1/ws" {
106+
http.NotFound(w, r)
107+
return
108+
}
109+
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
110+
http.Error(w, "unauthorized", http.StatusUnauthorized)
111+
return
112+
}
113+
114+
conn, err := upgrader.Upgrade(w, r, nil)
115+
if err != nil {
116+
return
117+
}
118+
defer func() { _ = conn.Close() }()
119+
120+
var sub map[string]any
121+
if err := conn.ReadJSON(&sub); err != nil {
122+
return
123+
}
124+
125+
_ = conn.WriteJSON(map[string]any{
126+
"type": "ready",
127+
"version": 1,
128+
"chatIDs": []string{"*"},
129+
})
130+
_ = conn.WriteJSON(map[string]any{
131+
"type": "message.upserted",
132+
"seq": 2,
133+
"ts": 1739320000001,
134+
"chatID": "chat_a",
135+
"ids": []string{"m2"},
136+
})
137+
time.Sleep(100 * time.Millisecond)
138+
}))
139+
defer server.Close()
140+
141+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
142+
cmd := EventsTailCmd{
143+
All: true,
144+
Reconnect: false,
145+
StopAfter: 250 * time.Millisecond,
146+
}
147+
148+
out, _ := captureOutput(t, func() {
149+
if err := cmd.Run(ctx, &RootFlags{BaseURL: server.URL, Timeout: 5}); err != nil {
150+
t.Fatalf("Run() error = %v", err)
151+
}
152+
})
153+
154+
if strings.Contains(out, `"type":"ready"`) {
155+
t.Fatalf("expected ready control message to be filtered, got: %s", out)
156+
}
157+
if !strings.Contains(out, `"type":"message.upserted"`) {
158+
t.Fatalf("expected message.upserted in output, got: %s", out)
159+
}
160+
}
161+
162+
func TestEventsTailIncludesControlMessagesWhenFlagSet(t *testing.T) {
163+
t.Setenv("BEEPER_TOKEN", "test-token")
164+
t.Setenv("BEEPER_ACCESS_TOKEN", "")
165+
166+
upgrader := websocket.Upgrader{
167+
CheckOrigin: func(r *http.Request) bool { return true },
168+
}
169+
170+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171+
if r.URL.Path != "/v1/ws" {
172+
http.NotFound(w, r)
173+
return
174+
}
175+
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
176+
http.Error(w, "unauthorized", http.StatusUnauthorized)
177+
return
178+
}
179+
180+
conn, err := upgrader.Upgrade(w, r, nil)
181+
if err != nil {
182+
return
183+
}
184+
defer func() { _ = conn.Close() }()
185+
186+
var sub map[string]any
187+
if err := conn.ReadJSON(&sub); err != nil {
188+
return
189+
}
190+
191+
_ = conn.WriteJSON(map[string]any{
192+
"type": "ready",
193+
"version": 1,
194+
"chatIDs": []string{"*"},
195+
})
196+
time.Sleep(100 * time.Millisecond)
197+
}))
198+
defer server.Close()
199+
200+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
201+
cmd := EventsTailCmd{
202+
All: true,
203+
IncludeControl: true,
204+
Reconnect: false,
205+
StopAfter: 250 * time.Millisecond,
206+
}
207+
208+
out, _ := captureOutput(t, func() {
209+
if err := cmd.Run(ctx, &RootFlags{BaseURL: server.URL, Timeout: 5}); err != nil {
210+
t.Fatalf("Run() error = %v", err)
211+
}
212+
})
213+
214+
if !strings.Contains(out, `"type":"ready"`) {
215+
t.Fatalf("expected ready control message in output, got: %s", out)
216+
}
217+
}
218+
219+
func TestEventsTailReconnectsAndResubscribesAfterDisconnect(t *testing.T) {
220+
t.Setenv("BEEPER_TOKEN", "test-token")
221+
t.Setenv("BEEPER_ACCESS_TOKEN", "")
222+
223+
upgrader := websocket.Upgrader{
224+
CheckOrigin: func(r *http.Request) bool { return true },
225+
}
226+
227+
var connectCount atomic.Int32
228+
var subscriptionCount atomic.Int32
229+
230+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
231+
if r.URL.Path != "/v1/ws" {
232+
http.NotFound(w, r)
233+
return
234+
}
235+
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
236+
http.Error(w, "unauthorized", http.StatusUnauthorized)
237+
return
238+
}
239+
240+
conn, err := upgrader.Upgrade(w, r, nil)
241+
if err != nil {
242+
return
243+
}
244+
defer func() { _ = conn.Close() }()
245+
246+
attempt := connectCount.Add(1)
247+
248+
var sub map[string]any
249+
if err := conn.ReadJSON(&sub); err != nil {
250+
return
251+
}
252+
subscriptionCount.Add(1)
253+
254+
if attempt == 1 {
255+
// Force reconnect by closing before any domain event is emitted.
256+
return
257+
}
258+
259+
_ = conn.WriteJSON(map[string]any{
260+
"type": "message.upserted",
261+
"seq": 3,
262+
"ts": 1739320000002,
263+
"chatID": "chat_b",
264+
"ids": []string{"m3"},
265+
})
266+
time.Sleep(100 * time.Millisecond)
267+
}))
268+
defer server.Close()
269+
270+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
271+
cmd := EventsTailCmd{
272+
All: true,
273+
Reconnect: true,
274+
ReconnectDelay: 10 * time.Millisecond,
275+
StopAfter: 300 * time.Millisecond,
276+
}
277+
278+
out, _ := captureOutput(t, func() {
279+
if err := cmd.Run(ctx, &RootFlags{BaseURL: server.URL, Timeout: 5}); err != nil {
280+
t.Fatalf("Run() error = %v", err)
281+
}
282+
})
283+
284+
if !strings.Contains(out, `"type":"message.upserted"`) {
285+
t.Fatalf("expected message.upserted in output, got: %s", out)
286+
}
287+
if got := connectCount.Load(); got < 2 {
288+
t.Fatalf("expected reconnect attempts >= 2, got %d", got)
289+
}
290+
if got := subscriptionCount.Load(); got < 2 {
291+
t.Fatalf("expected subscriptions to be re-sent after reconnect, got %d", got)
292+
}
293+
}
294+
295+
func TestEventsTailReconnectsAfterTemporaryHandshakeFailure(t *testing.T) {
296+
t.Setenv("BEEPER_TOKEN", "test-token")
297+
t.Setenv("BEEPER_ACCESS_TOKEN", "")
298+
299+
upgrader := websocket.Upgrader{
300+
CheckOrigin: func(r *http.Request) bool { return true },
301+
}
302+
303+
var requestCount atomic.Int32
304+
305+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306+
if r.URL.Path != "/v1/ws" {
307+
http.NotFound(w, r)
308+
return
309+
}
310+
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
311+
http.Error(w, "unauthorized", http.StatusUnauthorized)
312+
return
313+
}
314+
315+
attempt := requestCount.Add(1)
316+
if attempt == 1 {
317+
http.Error(w, "temporary failure", http.StatusInternalServerError)
318+
return
319+
}
320+
321+
conn, err := upgrader.Upgrade(w, r, nil)
322+
if err != nil {
323+
return
324+
}
325+
defer func() { _ = conn.Close() }()
326+
327+
var sub map[string]any
328+
if err := conn.ReadJSON(&sub); err != nil {
329+
return
330+
}
331+
_ = conn.WriteJSON(map[string]any{
332+
"type": "message.upserted",
333+
"seq": 4,
334+
"ts": 1739320000003,
335+
"chatID": "chat_c",
336+
"ids": []string{"m4"},
337+
})
338+
time.Sleep(100 * time.Millisecond)
339+
}))
340+
defer server.Close()
341+
342+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
343+
cmd := EventsTailCmd{
344+
All: true,
345+
Reconnect: true,
346+
ReconnectDelay: 10 * time.Millisecond,
347+
StopAfter: 350 * time.Millisecond,
348+
}
349+
350+
out, _ := captureOutput(t, func() {
351+
if err := cmd.Run(ctx, &RootFlags{BaseURL: server.URL, Timeout: 5}); err != nil {
352+
t.Fatalf("Run() error = %v", err)
353+
}
354+
})
355+
356+
if !strings.Contains(out, `"type":"message.upserted"`) {
357+
t.Fatalf("expected message.upserted in output, got: %s", out)
358+
}
359+
if got := requestCount.Load(); got < 2 {
360+
t.Fatalf("expected at least two connection attempts, got %d", got)
361+
}
362+
}

0 commit comments

Comments
 (0)