zebra-report/Makefile
Russell Ballestrini 5e8cdadb29
zebra-spaces: send 'bye' on pagehide for listeners even on bfcache (kill closed-firefox roster zombies)
Fox 2026-06-06: "closed firefox on both phones [they] are both still
in the list" — listener-roster entries persisting indefinitely after
mobile Firefox close.

Two facts collided:

1. SERVER (proxy.unturf.com main.go aliveJanitorTick ~line 1872):
   listeners are fully exempt from the heartbeat-stall reaper.
   Justified fox 2026-06-04 because the page's {type:"alive"}
   timer throttles hard on backgrounded mobile tabs (1Hz on
   Android, paused on iOS power-save) — without the exemption,
   mobile listeners lost their seat every time they tab-switched.

2. CLIENT (this file, sendByeIfRealClose): pagehide with
   event.persisted=true means the page is going into bfcache
   (mobile app-switch / tab-close-to-bfcache / lock screen),
   so we SKIPPED the 'bye' message to keep PCs warm for resume.
   Justified fox 2026-06-03 because "the phone leaving and
   coming back cannot hear the music" — bye-driven SFU eviction
   killed the speaker's publish + subscribe PCs.

Net: a mobile Firefox close fires pagehide(persisted=true) →
no 'bye' → server has only readTimeout (120s) + hiccup grace
(8s) to detect the dead socket → ~128s of phantom listener
seat in every other client's roster.

The bfcache justification on the CLIENT side was always
specific to speakers (they have PCs to protect). LISTENERS:

  - have no publisher PC
  - finalizeLeave at main.go:1089 explicitly exempts them from
    evictFromSFU (their subscriber PC stays alive through the
    bye)
  - re-handshake fresh on pageshow via POST /subscribe (same
    path as a cold join)

So for listeners, sending 'bye' on persisted=true is
roster-only cleanup: peer-left broadcast, members.delete(uuid)
on every other client, brief disappearance from the room. On
pageshow they re-handshake and reappear — same UX as a cold
rejoin, which already works.

Fix: split the bfcache rule by role. Send 'bye' on pagehide
even when persisted=true if myRole === 'listener'. Speakers
keep the original bfcache skip exactly.

Server-side backstop (60s listener-specific TTL replacing the
full exemption) ships as a separate commit in proxy.unturf.com
so 'bye' losses (carrier NAT eating the TCP shutdown, abrupt
process kill, custom Firefox close paths) still get reaped
within the minute.

Pinned by test/sendbye-fsm.test.js — extracts
sendByeIfRealClose from this page and drives 9 scenarios
covering each (role, persisted) combination plus defensive
edges (no event, ws not open, post-demote listener state).
Wired into test-all via test-sendbye target.
2026-06-06 14:45:39 -04:00

160 lines
6.3 KiB
Makefile

CC = gcc
CFLAGS = -Wall -Wextra -O2 -Iinclude $(shell pkg-config --cflags libpulse)
LDFLAGS = $(shell pkg-config --libs libpulse) -lrt -lpthread
PULSE = src/pulse.c
.PHONY: all clean serve blog test test-all test-web test-zebra-spaces stamp zebrad
# stamp each web page with today's date + its own md5/sha256 (run before deploy)
stamp:
@node web/stamp.js web/chat.html web/zebra-audio.html web/zebra-spaces.html web/how-it-works.html web/host-your-own.html
all: tx rx chat bt carrier zebrad
test: test/unit
@./test/unit
# web modem/protocol tests — pure Node, no browser or second device needed
test-web:
@node test/web-protocol.test.js
# zebra-spaces state-machine tests — pure unit tests for the FSM framework
# + each machine spec (publish / subscribe / call / remote-tile). Extracts
# the live code from web/zebra-spaces.html so the tests track the page.
test-fsm:
@node test/zebra-fsm.test.js
# Receive-side stream lifecycle (watchVideoTrackForRemoval) — fake-clock
# state-machine tests covering 'when is it safe to remove a tile'. Catches
# the regressions where transient mutes (NACK gaps, network blips, mobile
# handoffs, hard-refresh renegotiation churn) would otherwise kill live
# tiles. Extracts the function from the page and the shipped mute window
# so the assertions track what's live.
test-video-removal:
@node test/video-track-removal.test.js
# Mesh state-sync invariant tests — pins the contract fox stated as
# "whatever one device shares all should see, and when unshared none
# should see." Runs the shipped handleRemoteSfuTrack + renderVideoTile +
# removeVideoTile + watchVideoTrackForRemoval against multi-peer
# scenarios with synthetic ontrack/mute/unmute/ended events. Catches
# regressions where one peer's publish/unpublish leaves another peer
# out of sync.
test-mesh:
@node test/multi-peer-mesh.test.js
# Mod-action serializer — pins the kick race fix fox hit 2026-06-04
# ("kicked two phones, only one was kicked"). Extracts runModSerial +
# awaitStateUpdate + resolvePendingStateUpdate from the live page so
# the assertions track the shipped code, then drives synthetic mod
# actions through them with a stubbed roomEpoch counter to prove the
# second action signs against the FRESH epoch.
test-mod-actions:
@node test/mod-action-serializer.test.js
# SelfListenerFSM — pins the state-machine contract for the speaker/
# cohost/host "switch myself to the buffered HTTP listener stream"
# toggle. Extracts createFSM + selfListenerSpec from the live page so
# the spec can't drift from shipped transitions (off↔on with
# TOGGLE/ENABLE/DISABLE/UNMUTE/DEMOTED/CLEAR edges).
test-self-listener:
@node test/self-listener-fsm.test.js
# Listener audio attach pipeline — pins src→(jbuf)→gain→destination
# graph reachability through attachAudioStreamViaWorklet,
# setWorkletStream, installJitterBuffer, attachSfuTrack,
# flushSfuStreams. Extracts each function from the live page and
# replays the cascade scenarios (fresh attach, same-stream no-op,
# different-stream in-place swap, dedup by pubkey, publisher rejoin,
# zero-live-track reject). Catches silent-listener regressions where
# a chain builds but never reaches audioCtx.destination.
test-listener-audio:
@node test/listener-audio-attach.test.js
# sendByeIfRealClose role-aware bfcache split — pins that listeners
# send bye even on pagehide(persisted=true) so closed-Firefox phones
# don't sit as 128s roster zombies, while speakers keep the bfcache
# skip so their PCs survive a tab-switch nap.
test-sendbye:
@node test/sendbye-fsm.test.js
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
# alongside this checkout AND has a Go toolchain — we build the relay binary
# transparently and point the test at it. Otherwise that tier is skipped and
# only the pure-protocol/crypto tiers run.
test-zebra-spaces:
@bin=""; \
if [ -d ../proxy.unturf.com/cmd/zebra-spaces-signal ]; then \
go=$$(command -v go || echo /home/fox/.local/go/bin/go); \
if [ -x "$$go" ]; then \
echo "Building zebra-spaces-signal for live-server test..."; \
(cd ../proxy.unturf.com && "$$go" build -o /tmp/zspc-signal-test ./cmd/zebra-spaces-signal/) && bin=/tmp/zspc-signal-test; \
fi; \
fi; \
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-listener-audio test-sendbye test-zebra-spaces
@echo "--- unit ---"
@./test/unit
@echo "--- integration ---"
@./test/integration
@echo "--- functional ---"
@./test/functional
@echo "--- web protocol ---"
@node test/web-protocol.test.js
@echo "--- zebra-fsm ---"
@node test/zebra-fsm.test.js
@echo "--- video-track-removal ---"
@node test/video-track-removal.test.js
@echo "--- multi-peer-mesh ---"
@node test/multi-peer-mesh.test.js
@echo "--- mod-action serializer ---"
@node test/mod-action-serializer.test.js
@echo "--- self-listener FSM ---"
@node test/self-listener-fsm.test.js
@echo "--- listener audio attach ---"
@node test/listener-audio-attach.test.js
@echo "--- sendBye FSM ---"
@node test/sendbye-fsm.test.js
@echo "--- zebra-spaces ---"
@$(MAKE) -s test-zebra-spaces
test/unit: test/unit.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/unit.c
test/integration: test/integration.c src/pulse.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/integration.c src/pulse.c $(LDFLAGS)
test/functional: test/functional.c src/pulse.c include/zebra.h include/modem.h test/test.h
$(CC) $(CFLAGS) -o $@ test/functional.c src/pulse.c $(LDFLAGS)
tx: src/tx.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
rx: src/rx.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
chat: src/chat.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
bt: src/bt.c $(PULSE)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
carrier: src/carrier.c
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
zebrad: src/zebrad.c include/zebra.h
$(CC) $(CFLAGS) -o $@ src/zebrad.c $(LDFLAGS) -lm
blog:
python3 blog/build.py
serve: blog
cd web && python3 -m http.server 8765
clean:
rm -f tx rx chat bt carrier zebrad test/unit test/integration test/functional
rm -rf web/blog/001-volume-modem web/blog/002-sse-chatroom web/blog/index.html