Repository navigation
Description
Describe the bug
When an audio room empties, the relay auto-archives the huddle's ephemeral backing channel by writing channels.archived_at directly and emitting only kind 48103. Unlike the two other archive paths, it never calls emit_group_discovery_events, so no kind 39000 carrying ["archived", "true"] is ever published for that channel.
Clients build their channel list from kind 39000, so the database and the event projection diverge permanently:
- The relay knows the channel is archived.
- Every client — including a fresh install on a new machine — still sees a live channel and keeps
<parent> huddlein the sidebar indefinitely. - Archiving it from the UI is rejected by the archived-channel guard (
invalid: channel is archived), and deleting it hits Deleting an auto-archived huddle channel returns 400 "channel is archived" #2954. The channel cannot be removed by any client action.
This does not self-heal. The ephemeral reaper is the one path that would publish the missing discovery update, but its UPDATE filters ch.archived_at IS NULL, so a channel already archived by the audio path is never picked up. The stale 39000 is a replaceable event, so the wrong state is what every future client syncs.
I believe this is the root cause of the "Desktop can keep the channel row visible" behaviour described in #2954. That issue proposes letting kind 9008 through for archived channels; this one is about clients never learning the archive happened in the first place. Fixing this removes the ghost row entirely, and the two fixes are complementary.
Steps to reproduce
- Start a huddle in a channel. The relay creates the ephemeral backing channel
<parent> huddle(ttl_seconds = 3600). - End the huddle by having every audio peer drop — force-quit the client or drop the socket — rather than the owner ending the huddle. Relay logs
audio room empty — auto-ending huddle. - Inspect relay state:
SELECT archived_at FROM channels WHERE id = '<huddle channel>'→ set.- The newest kind
39000for thatdtag → noarchivedtag.
Compare with a huddle the owner ended normally: that one has a kind9002and a follow-up39000with["archived", "true"].
- The channel stays in the sidebar forever. Right-click → Archive channel →
400 invalid: channel is archived. Delete channel → Deleting an auto-archived huddle channel returns 400 "channel is archived" #2954.
Observed on a self-hosted single-relay deployment: of 16 huddle channels, the one that ended via the audio auto-end path was the only ghost; the 15 ended by the owner all converged correctly.
Expected behavior
The audio auto-end path should converge the event projection with the database exactly like the other two archive paths do — i.e. after a successful archive_channel, emit the group discovery events (and, mirroring the reaper, likely also the channel_auto_archived system message and evict_all_channel_subscriptions).
More generally: archive_channel is currently called from three places, and only two of them publish the discovery update. Coupling the republish to the archive itself would make this class of divergence unrepresentable.
Version and platform
- Relay:
ghcr.io/block/buzz:sha-8342dfc(commit8342dfcc5890b81a269a8ec3db73a8a56f76ce79), self-hosted, single relay - Buzz Desktop 0.5.5, macOS 26.5.2
Logs / additional context
Code paths at 8342dfcc5890b81a269a8ec3db73a8a56f76ce79:
- Audio auto-end archives the channel and emits only kind
48103— no discovery republish:buzz/crates/buzz-relay/src/audio/handler.rs
Lines 833 to 860 in 8342dfc
if should_auto_end { info!(channel_id = %channel_id, "audio room empty — auto-ending huddle"); match state .db .archive_channel(tenant.community(), channel_id) .await { Err(e) => { warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}"); room.clear_ended(); room_emptied = false; } Ok(()) => { room_emptied = state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); emit_participant_event( &state, &tenant, Kind::Custom(48103), channel_id, parent_id_for_event, &pubkey_hex, ) .await; } - The reaper does it correctly — system message,
emit_group_discovery_events, subscription eviction:buzz/crates/buzz-relay/src/main.rs
Lines 662 to 706 in 8342dfc
for channel in &expired { // Per-row tenant: the reaper crosses communities, so each // archived channel carries its own server-resolved // `(community, host)` from the DB RETURNING. Build the // `TenantContext` from that row — never a default tenant. let tenant = buzz_core::tenant::TenantContext::resolved( channel.community_id, channel.host.clone(), ); let channel_id = channel.channel_id; // Emit a system message so members see why the channel was archived. if let Err(e) = buzz_relay::handlers::side_effects::emit_system_message( &tenant, &reaper_state, channel_id, serde_json::json!({ "type": "channel_auto_archived" }), ) .await { error!(channel = %channel_id, "reaper system message failed: {e}"); } // Update NIP-29 discovery events so clients see the archived state. if let Err(e) = buzz_relay::handlers::side_effects::emit_group_discovery_events( &tenant, &reaper_state, channel_id, ) .await { error!(channel = %channel_id, "reaper discovery update failed: {e}"); } // Close live subscriptions so connected clients drop the // archived channel immediately (CLOSED is in the client's // drop-set → no reconnect storm). Offline clients are caught // by the archived=true skip in discover_channels on reconnect. buzz_relay::handlers::side_effects::evict_all_channel_subscriptions( &tenant, &reaper_state, channel_id, ) .await; } } - The owner-driven kind
9002path archives and republishes discovery at the end ofhandle_edit_metadata:andbuzz/crates/buzz-relay/src/handlers/side_effects.rs
Lines 1573 to 1589 in 8342dfc
"archived" => { match val { "true" => { state .db .archive_channel(tenant.community(), channel_id) .await?; emit_system_message( tenant, state, channel_id, serde_json::json!({ "type": "channel_archived", "actor": actor_hex }), ) .await?; } if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { - The reaper skips already-archived rows, so it cannot repair the divergence:
buzz/crates/buzz-db/src/channel.rs
Lines 1495 to 1506 in 8342dfc
pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result<Vec<ReapedEphemeralChannel>> { let rows = sqlx::query( "UPDATE channels AS ch SET archived_at = NOW() \ FROM communities AS c \ WHERE ch.community_id = c.id \ AND ch.ttl_seconds IS NOT NULL \ AND ch.ttl_deadline < NOW() \ AND ch.archived_at IS NULL \ AND ch.deleted_at IS NULL \ AND c.archived_at IS NULL \ RETURNING ch.community_id, c.host, ch.id", ) - Desktop derives
archivedAtpurely from thearchivedtag on kind39000, which is why the stale event is decisive for the client:buzz/desktop/src-tauri/src/nostr_convert.rs
Lines 148 to 155 in 8342dfc
// If the relay emits ["archived", "true"], surface it as a timestamp placeholder // so the frontend knows the channel is archived. The exact timestamp isn't available // from the tag alone, so we use the event's created_at as a proxy. let archived_at = if first_tag_value(event, "archived") == Some("true") { Some(timestamp_to_iso(event.created_at.as_secs())) } else { None };
Workaround for an already-stuck channel — force the relay through the path that does publish, as the channel owner (unarchive is the one mutation permitted on an archived channel):
buzz channels unarchive --channel <uuid>
buzz channels archive --channel <uuid>The second call publishes kind 39000 with ["archived", "true"] and every client converges. Note that buzz channels get does not surface archived state, so verification has to read the channel's latest kind 39000 tags.
A regression test could assert that after the last audio peer leaves, the newest kind 39000 for the huddle channel carries ["archived", "true"] — not just that channels.archived_at is set.
Activity
Pratikkale26 commentedon Aug 5, 2026
Reproduced this locally and opened #4918 with a fix.
Your analysis matched the code exactly. Capturing the divergence in case it's useful for the review — after the last audio peer drops on an unfixed relay:
No
["archived","true"]. The relay knows; no client ever will.The fix mirrors the reaper after a successful
archive_channel:emit_group_discovery_events(the actual fix — it stamps["archived","true"]from
channel.archived_at) plusevict_all_channel_subscriptionsso connectedclients drop the channel immediately instead of waiting for a reconnect. Both are
best-effort and logged, since huddle teardown has already completed at that point.
Two judgment calls worth flagging, both easy to reverse if maintainers disagree:
I did not mirror the reaper's
channel_auto_archivedsystem message. Thispath already emits kind:48103 (huddle ended), which is the precise signal for a
huddle, so a second "auto archived" notice seemed redundant in the timeline.
One-line change if strict parity is preferred.
I did not do the structural refactor you suggested at the end. I agree
coupling the republish to the archive is the better end state, but
db.archive_channellives inbuzz-db, which has no access to relay state andcannot emit events — so it needs a relay-level wrapper adopted by all three
paths. That's a refactor of two currently-working paths, which felt out of scope
for a bug fix. Happy to follow up separately if wanted.
The e2e test does what you suggested — drives the real audio path (kind:48100 link,
NIP-42 handshake on
/huddle/{id}/audio, wait forjoined, drop the socket) andasserts the newest kind:39000 carries
["archived","true"]rather than justchecking
channels.archived_at. It fails against the unfixed relay withleft: Some(false).One note for anyone else reproducing this: the audio door returns a generic
"not a member"to the client for every admission failure — the real reason(
ephemeral channel is not linked to claimed parent, when the creator-signedkind:48100 is missing) only appears in the relay log.
dingjun606 commentedon Oct 27, 2026
Reproduced this projection divergence with official Buzz Desktop
0.0.0-preview.185.1on macOS, against a self-hosted relay built from016310d3cc44688779a7d517a13d9cc8024ddfa8(with unrelated voice-media validation compatibility patches).Observed two private ephemeral Huddle backing channels:
channel_type=stream,ttl_seconds=3600,archived_atset,deleted_at=NULL.39000metadata still had noarchived=truetag.9002archive and kind9008delete requests with HTTP 400:invalid: channel is archived(the delete gate is also tracked in Deleting an auto-archived huddle channel returns 400 "channel is archived" #2954).The deployed audio auto-end path still calls
archive_channeland emits kind48103, without callingemit_group_discovery_events; the TTL reaper handles only channels not already archived. This matches the root cause reported here. I also found the existing fix PR #4918, which is currently open; this report does not claim the behavior is unchanged on every later relay revision.There is an additional older-record compatibility detail for current Desktop: one of the two stored kind
39000records lacked the immutable creatorPtag, even though its creator-signed kind9007carried the correctBuzz Huddle (buzz.huddles/v1)marker and parent. Current Desktop's Huddle classification requires matching creation proof and creator identity, so that legacy record cannot be classified as a Huddle from its name/description alone. This is separate from the archive projection bug and should not be addressed by hiding arbitrary channels named “Huddle”.Verified data-only recovery, without changing deployed relay/client/plugin code or restarting services:
39000records, signed by the existing relay identity, preserving other metadata and fillingP, originalcreated_at, andarchived=truefrom authoritative channel rows. Use the existing officialDb::replace_addressable_eventwriter.The affected user chose data repair plus this report rather than deploying a custom relay patch. A documented repair path for existing divergent metadata would be useful alongside the auto-end fix, since fixing future teardown alone does not repair already-stuck records.