Commit graph

12 commits

Author SHA1 Message Date
f4dbc5cc6c
zebra-spaces: formalize self-listener as FSM — pure spec + observer-driven side effects + 12 unit tests
Fox 2026-06-04 directive: every system should be a state machine
with unit + integration + functional test coverage. Implicit-state
defects keep biting (kicked-listener-UI-still-green, two-kick race,
cohost-toggle-kills-phone, audio-wedge-no-recovery). Starting the
formalization with the most-broken-today system: self-listener mode.

Spec (selfListenerSpec):
  off ──ENABLE / TOGGLE──▶ on
  on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off

Sits next to publishSpec, subscribeSpec, callSpec, remoteTileSpec
in zebra-spaces.html. Composed by wireZebraMachines() into
roomMachines.selfListener.

UNMUTE edge encodes fox's invariant: "unmuting should seamlessly
switch them back to the now of the conversation webrtc mesh" — if
the user clicks unmute while on, they implicitly drop back to off.

Side effects (mic mute, streamMode enrolment, remoteAudio muting)
move out of enableSelfListenerMode/disableSelfListenerMode (deleted)
into runSelfListenerEnable / runSelfListenerDisable, called by an
observer attached to the FSM. Pure spec stays Node-testable; the
runtime drives the actual audio plumbing from observed transitions.

Boolean selfListenerMode flag deleted. window.selfListenerMode is
now a getter against the FSM state — single source of truth, no
drift possible. All callers (toggle-button click, mute-unmute,
peer-joined, role-demote, leave) now dispatch FSM events instead
of calling helpers directly.

Tests in test/self-listener-fsm.test.js:
- starts in off
- TOGGLE / ENABLE / DISABLE transitions
- UNMUTE drops to off (the fox-invariant)
- UNMUTE / CLEAR while off is no-op
- DEMOTED drops to off
- CLEAR drops to off
- unknown event refuses
- observer fires on real transitions with prev/state
- runtime observer skips prev===state edges

Existing test/zebra-fsm.test.js updated to extract+expose
selfListenerSpec alongside the other specs (the wireZebraMachines
extract is the integration test).

Makefile gets test-self-listener target + slot in test-all.

All test suites green:
- self-listener:        12 / 12
- zebra-fsm:            83 / 83
- mod-actions:           6 / 6
- web-protocol:       3348 / 3348
- multi-peer-mesh:       8 / 8
- video-track-removal:  18 / 18
2026-06-04 13:10:29 -04:00
84f591834f
test: pin the mod-action serializer race fix
Six tests covering the kick-race regression fox hit 2026-06-04
("kicked two phones, only one was kicked") and the fix in commit
8d14873:

- single action signs with current epoch and resolves
- second action waits for state-update from the first (the key one
  — fires both actions back-to-back, asserts only the first runs
  before the simulated state-update, then asserts the second's fn
  signs against the FRESH epoch)
- third action queues behind first two and signs cumulatively
- queue does not wedge when state update never arrives (1.5s timeout)
- early state-update releases the gate immediately
- failure inside fn does not poison the queue

Extracts runModSerial / awaitStateUpdate / resolvePendingStateUpdate
from the live web/zebra-spaces.html so the assertions track shipped
code (same pattern as the other web test files). Each test gets a
fresh sandbox so lastModSettled doesn't leak between cases.

Makefile gets a test-mod-actions target plus a slot in test-all.
2026-06-04 10:46:55 -04:00
07fcec7775
zebra-spaces: refactor sub PC ontrack into handleRemoteSfuTrack + multi-peer mesh tests
Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.

test/multi-peer-mesh.test.js pins fox's stated invariant:
  'whatever one device shares all should see, and when unshared none
   should see.'

Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:

- one peer publishes camera -> every other peer ends with that pubHex
  in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
  pointed at the new stream object (this is the MSID-supplant fix from
  d5e9e4e — fresh MediaStream per track means the video element binds
  to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
  NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
  correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
  exactly the other two

Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
2026-06-03 11:24:38 -04:00
9419a415bf
test: state-machine tests for watchVideoTrackForRemoval
15 unit + integration tests that extract the function and the shipped
VIDEO_REMOVE_MUTE_WINDOW_MS constant from web/zebra-spaces.html so the
assertions track exactly what's deployed. Drives synthetic mute/
unmute/ended event sequences against a fake EventTarget track with a
fake clock injected through a Function-constructor harness.

Unit coverage:
- shipped window must be >= 10s (catches accidental shorten)
- initial mute (never flowed) never removes
- flowing + sustained mute past window removes
- mute + unmute within window cancels removal
- ended event removes immediately + cancels pending timer
- removeFn is idempotent (no double-call across mute, ended, or later
  events)
- redundant mute events do not stack timers
- rescue and removal both emit logLine telemetry
- rapid mute/unmute oscillation never removes while unmute lands in time

Integration coverage (realistic lifecycles):
- fresh track -> flow -> publisher unshares -> tile removed
- mobile network handoff (long mute) recovers without removal
- peer leaves abruptly (ended fires) -> tile removed once
- hard refresh of publisher (SFU supplant renegotiation gap) -> tile
  survives — this is the cascade fox flagged where a phone reconnect
  was killing the host's view of its camera
- publisher process crashes (mute holds indefinitely) -> removed at
  window

Wired into Makefile as test-video-removal + added to test-all. Pure
Node, no browser or proxy server needed.
2026-06-03 10:15:16 -04:00
ebda460574
zebra-spaces: lay down FSM framework + publishSpec + unit tests
First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).

Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
  entry/exit hooks and an .on table mapping events → target (string) or
  { target, action }. Observers fire after each transition with
  { state, prev, ev, ctx }. No async in transitions; effects belong in
  observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
    off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
                       │ FAILED                │ FAILED                │ STOP/LOST
                       ▼                       ▼                       ▼
                      off                    stopping ◀──── stopping ──┘
                                                 │ DONE
                                                 ▼
                                                off
  One instance per kind (mic / screen / camera). FAILED in negotiating
  goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
  publishSpec happy path + error/cancel paths + illegal-transition
  no-ops. Function-constructor scope handles const-leak; bare eval()
  doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.

Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
2026-06-02 11:01:33 -04:00
dedb44179d
zebra-spaces: JS↔Go protocol parity + crypto tests
test/zebra-spaces.test.js — pure Node, four tiers:

1. pure protocol parity: extracts sigJoin/sigAction directly from
   web/zebra-spaces.html (so the test tracks the shipped page),
   compares produced bytes against fixtures pinned to the Go-side
   unit tests in proxy.unturf.com/cmd/zebra-spaces-signal/main_test.go.
   If JS drifts from Go by one byte the test fails — exactly the
   silent break that would kill promotions in production.

2. ed25519 sign/verify: WebCrypto Ed25519 round-trip + tamper detection,
   the same crypto stack the page uses for signed role transitions.

3. vault round-trip: PBKDF2 600k + AES-GCM, mirrors vaultExport/Import
   in the page. Verifies wrong-password rejection.

4. live server (optional): if ZEBRA_SPACES_BINARY is set, launches the
   relay, dials over real WebSocket, drives full join -> mic-invite ->
   accept flow using browser APIs end to end.

Makefile: 'test-zebra-spaces' target auto-builds the relay binary
from ../proxy.unturf.com when present so the live tier runs without
manual setup. 'test-all' now includes it.
2026-05-31 11:06:59 -04:00
1e76f62f71
zebra-spaces v0.1: multi-party voice rooms with roles, identity, vault
New page: web/zebra-spaces.html. Extends the rendezvous + WebRTC mesh
model from zebra-audio (1:1) to a room of up to ~8 speakers (host + 2
co-hosts + speakers) with listeners (deferred to v0.2 for SFU fan-out).

Identity: per-browser persistent Ed25519 keypair in localStorage,
plus a per-session UUID for in-room "whose who". Password-vault
backup/restore (PBKDF2 600k + AES-GCM, matches zebra-audio's crypto)
emits a self-contained 'zspc-id-v1|...' blob.

Roles: everyone joins as listener; mods (host + co-hosts) extend
mic-invites that listeners accept/decline; host promotes to co-host;
mods demote and boot. Co-hosts cannot remove the host. Every role
transition is signed by the acting mod's Ed25519 over a canonical
input bound to room_id + epoch, so a compromised relay cannot forge
promotions, only refuse to relay them.

Pairs with cmd/zebra-spaces-signal in proxy.unturf.com.

Nav: zebra-audio and host-your-own cross-link to spaces.
Makefile: stamp target now covers zebra-spaces.html (web/chat.html
and web/how-it-works.html stamps refreshed today as a side effect).
2026-05-31 10:54:36 -04:00
5e1c4402b2
web: host-your-own page + nav links; deploy-to-both-repos reminder
Add web/host-your-own.html and link it from the chat and how-it-works headers
and footers. make stamp now stamps it alongside the other pages. CLAUDE.md:
turn the deploy flow into an explicit push-BOTH-repos reminder (zebra-report
source + www.unturf.com served) and list host-your-own.html as a deployed page.
2026-05-29 15:17:22 -04:00
ae3e4316fb
web: stamp each zebra-report page with build date + md5/sha256
Add an integrity footer to chat.html, zebra-audio.html, and how-it-works.html
showing the build date (2026-05-28) and the page's own MD5 + SHA-256. A file
can't hold its own hash, so web/stamp.js (make stamp) computes the hashes with
the two hash fields zeroed, then writes the real values back — self-consistent
and idempotent. To verify a served page: blank the two fields and re-hash;
confirmed it reproduces the stamped value with plain sha256sum.
2026-05-28 17:50:12 -04:00
7c39f3537a
test: node web protocol suite (no devices) + exponential retransmit backoff
Add test/web-protocol.test.js — runs the real protocol code from chat.html in
Node (unit: crc/frame/ACK/HELLO codec, Hamming, Gray; integration: multi-level
modem roundtrip + FEC recovery of off-by-one symbol errors; functional: full
frame -> modem -> FEC -> assembler -> parse + ACK roundtrip). 3348 assertions.
Wired as `make test-web` (also in test-all). Lets us QA the modem without two
devices. Also: retransmit now uses exponential backoff so a lost ACK spaces out
retries instead of hammering the channel.
2026-05-28 12:44:20 -04:00
c8a755513f add zebrad: pulseaudio→websocket introspector for chat.html
single C file (~620 LOC), no third-party deps beyond libpulse.
closes a receive loop for web/chat.html: a browser tab cannot read
another tab's PulseAudio state, so each peer runs zebrad locally
to forward decoded frames to ws://127.0.0.1:7777.

components inline:
  * SHA-1 (RFC 3174) + base64 encode — for the WebSocket handshake;
    verified against the canonical RFC 6455 example
    (key dGhlIHNhbXBsZSBub25jZQ== → accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)
  * CRC-32 (IEEE 802.3) — verifies DATA + HELLO frame payloads
  * UART symbol decoder with adaptive threshold:
    running peak with 2-second half-life decay, threshold = 50% of peak,
    falling-edge start detect, sample at 1.5..8.5 × sps from edge
  * frame assembler: magic-sync ZB, type dispatch OFFER/READY/DATA/HELLO,
    overflow + corrupt-length guards, resync on bad checksum
  * embedded WebSocket server: TCP listener on 127.0.0.1:7777,
    handshake responder, binary frames only (opcode 0x82), no masking
    (server→client), any inbound data closes the client (browser
    auto-reconnects); max 16 concurrent clients
  * pulseaudio: pa_mainloop integration, record stream on @DEFAULT_MONITOR@
    by default, PA_SAMPLE_FLOAT32LE mono @ 8 kHz, 10 ms fragsize, peak
    energy extracted from |abs(sample)|

adaptive baud: starts at ZEBRA_BAUD_HANDSHAKE (50). On valid READY frame
locks in negotiated baud from payload. Matches the chat.html send path.

binds 127.0.0.1 only — never reachable from the LAN, even though the
chat.html page itself may be served over HTTPS from a public domain.

smoke tested: PA connect + stream ready + WS handshake (RFC example
verified) + clean shutdown. -Wall -Wextra clean.

Makefile: zebrad now part of `make all`; clean target updated.
README: new section documenting it.
2026-05-27 15:57:22 -04:00
b77da42bbe phase 1: unfirehose reconstruction from session JSONL ingest
Source: ~/.unfirehose/unfirehose.db (project_id=81, 4 sessions covering
2026-03-29 through 2026-04-05). Reconstructed via chronological replay
of Write/Edit tool_input on file_paths under /home/fox/zebra-report/.

stats:
  files reconstructed:    20
  writes baselined:       all (zero missing)
  edits applied:          68
  edits unapplied:        8 (1 SKIP pre-baseline, 6 FAIL old_string drift, 1 AMBIGUOUS)

unapplied edits represent small drift in 6 files; baseline content for
each is intact. quality verification deferred to phase 2.

recovered tree:
  CLAUDE.md, Makefile
  src/{tx,rx,pulse,carrier,chat,bt}.c
  include/{modem,zebra}.h
  test/{functional,integration,unit}.c, test/test.h
  web/{index,kernel}.html, web/blog/style.css
  blog/build.py, blog/posts/{001-volume-modem,002-sse-chatroom}.md

report: /tmp/zebra_recover_report.txt
script: /tmp/zebra_recover.py
2026-05-27 13:51:14 -04:00