Skip to content

Make broadcast paste immune to a wedged Tailscale daemon - #7

Merged
andyhtran merged 1 commit into
mainfrom
fix-broadcast-paste-stall
Jul 23, 2026
Merged

Make broadcast paste immune to a wedged Tailscale daemon#7
andyhtran merged 1 commit into
mainfrom
fix-broadcast-paste-stall

Conversation

@andyhtran

Copy link
Copy Markdown
Owner

Problem

⌘⌥V broadcast pastes would lag, then flush several pastes at once a minute or two later. Root cause: on a UDP-blocked/VPN network, tailscaled gets wedged (netcheck failures, health.Change is slow events up to ~99s), and each broadcast ran tailscale status --json synchronously. Every queued paste blocked on that call and then completed together the instant the daemon recovered — producing the burst.

Latency data confirmed the scope: every paste ≥10s was a Broadcast; local ⌘V pastes stayed at ~0–1s.

Fix

Three layers, so no single one is load-bearing:

  • Broadcast.handle types before peer discovery. The typed path never depended on which hosts are reachable, and uploads already raced it on the remote side — so the reorder exposes no window that wasn't already open. The paste no longer waits on the network at all.
  • runShell gains a watchdog timeout (3s for status). A hung child gets SIGTERM, the pipe reads unblock on EOF, and the result reports timedOut — failing open to "attempt every host, let SSH's ConnectTimeout filter."
  • TailscaleStatusCache (30s TTL, single-flight). A paste burst and every menu render ride one status subprocess instead of each spawning its own. Settings' "Refresh peers" bypasses the TTL so the button stays honest.

Scope / trade-offs

This makes CopyCat immune to a wedged daemon — it does not fix the daemon itself. When Tailscale is genuinely down, broadcasts fail open (attempt all hosts, SSH times out), so a paste made while offline may not reach peers — but it always types instantly and never bursts.

Tests

  • TailscaleStatusCacheTests — TTL, single-flight burst sharing, empty-result caching, forced refetch.
  • ShellTests — timeout kills a hung process; fast processes and the no-timeout path are unaffected.
  • Full suite: 85 tests, 0 failures. swift build clean.

🤖 Generated with Claude Code

Typing the path now happens the moment the compressed file exists,
before peer discovery. The typed string never depended on which hosts
are reachable, yet it waited on tailscale status --json — which hangs
for tens of seconds when tailscaled is starved (UDP-blocked networks),
so a burst of pastes flushed all at once when the daemon recovered.

Three layers, so no single one is load-bearing:

- Broadcast.handle types before discovery. Uploads already raced the
  typed path, so the ordering exposes no window that wasn't open before.
- runShell gains a watchdog timeout (3s for status): a hung child gets
  SIGTERM and the result reports timedOut, failing open to "attempt all
  hosts, let SSH's ConnectTimeout filter".
- TailscaleStatusCache (30s TTL, single-flight): a paste burst and every
  menu render ride one status subprocess instead of spawning their own.
  Settings' "Refresh peers" bypasses the TTL so the button stays honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andyhtran
andyhtran merged commit 58ece0d into main Jul 23, 2026
1 check passed
@andyhtran
andyhtran deleted the fix-broadcast-paste-stall branch July 23, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant