Skip to content

Huddle auto-archive never republishes kind:39000, so the ephemeral channel stays live in every client forever #4879

Description

@TolgaCinisli

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> huddle in 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

  1. Start a huddle in a channel. The relay creates the ephemeral backing channel <parent> huddle (ttl_seconds = 3600).
  2. 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.
  3. Inspect relay state:
    • SELECT archived_at FROM channels WHERE id = '<huddle channel>' → set.
    • The newest kind 39000 for that d tag → no archived tag.
      Compare with a huddle the owner ended normally: that one has a kind 9002 and a follow-up 39000 with ["archived", "true"].
  4. The channel stays in the sidebar forever. Right-click → Archive channel400 invalid: channel is archived. Delete channelDeleting 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 (commit 8342dfcc5890b81a269a8ec3db73a8a56f76ce79), 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:
    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:
    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 9002 path archives and republishes discovery at the end of handle_edit_metadata:
    "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?;
    }
    and
    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:
    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 archivedAt purely from the archived tag on kind 39000, which is why the stale event is decisive for the client:
    // 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions