Skip to content

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

Open

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 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 (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.

Activity

  1. Pratikkale26 commented on Aug 5, 2026

    @Pratikkale26

    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:

    # relay log
    INFO audio room empty — auto-ending huddle channel_id=c773e368-…
    
    # database — archived
    select archived_at is not null from channels where id = 'c773e368-…';
     t
    
    # newest kind:39000 for that channel — NOT archived
    [["d","c773e368-…"],["name","huddle-c773e368-…"],["public"],["closed"],
     ["t","stream"],["ttl","3600"],["ttl_deadline","2026-08-05T17:25:13Z"]]

    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) plus evict_all_channel_subscriptions so connected
    clients 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:

    1. I did not mirror the reaper's channel_auto_archived system message. This
      path 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.

    2. 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_channel lives in buzz-db, which has no access to relay state and
      cannot 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 for joined, drop the socket) and
    asserts the newest kind:39000 carries ["archived","true"] rather than just
    checking channels.archived_at. It fails against the unfixed relay with
    left: 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-signed
    kind:48100 is missing) only appears in the relay log.

  2. dingjun606 commented on Oct 27, 2026

    @dingjun606

    Reproduced this projection divergence with official Buzz Desktop 0.0.0-preview.185.1 on macOS, against a self-hosted relay built from 016310d3cc44688779a7d517a13d9cc8024ddfa8 (with unrelated voice-media validation compatibility patches).

    Observed two private ephemeral Huddle backing channels:

    • channel_type=stream, ttl_seconds=3600, archived_at set, deleted_at=NULL.
    • Their latest relay-signed kind 39000 metadata still had no archived=true tag.
    • Desktop listed a room as an ordinary channel and offered Archive channel and Delete channel.
    • The relay rejected both the kind 9002 archive and kind 9008 delete 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_channel and emits kind 48103, without calling emit_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 39000 records lacked the immutable creator P tag, even though its creator-signed kind 9007 carried the correct Buzz 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:

    1. Retain the channel rows in their existing archived state and back up their original signed metadata.
    2. Republish only the two affected kind 39000 records, signed by the existing relay identity, preserving other metadata and filling P, original created_at, and archived=true from authoritative channel rows. Use the existing official Db::replace_addressable_event writer.
    3. Notify the existing community-scoped channel pub/sub topics with the new signed records.
    4. Fresh database reads confirm current metadata is archived and signatures verify. The existing Desktop sidebar drops the ghost Huddle row immediately, without an app restart. Channel rows, memberships, and all non-metadata events remain unchanged.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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