diff --git a/.env.example b/.env.example index 3dc54856e7..1307d2e6e8 100644 --- a/.env.example +++ b/.env.example @@ -211,6 +211,13 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Set to true to process the agent's own messages (default: ignore self). # BUZZ_ACP_NO_IGNORE_SELF=false +# Seconds to wait after an Ok mention turn before posting a "couldn't publish" +# notice, so a late ws echo (including post-reconnect replay) can clear it. +# Default 8 covers ≈6s relay resubscribe bursts; set higher for many-channel +# harnesses. Values ≤0 or unparsable fall back to 8 (grace cannot be disabled). +# (#2459) +# BUZZ_SILENT_REPLY_GRACE_SECS=8 + # ── Context ────────────────────────────────────────────────────────────────── # Max context messages fetched for thread replies and DMs (0–100). 0 = disabled. # BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..e836c34577 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -10,15 +10,38 @@ mod pool_lifecycle; mod queue; mod relay; mod setup_mode; +mod silent_reply; mod usage; +/// Default grace before posting a silent-reply notice so a late ws echo can +/// land (#2459). Relay resubscribe bursts can spread ≈6s under REQ pacing, so +/// the default sits above that; override with `BUZZ_SILENT_REPLY_GRACE_SECS`. +const DEFAULT_SILENT_REPLY_GRACE_SECS: u64 = 8; + +fn silent_reply_grace() -> Duration { + std::env::var("BUZZ_SILENT_REPLY_GRACE_SECS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .filter(|&secs| secs > 0) + .map(Duration::from_secs) + .unwrap_or(Duration::from_secs(DEFAULT_SILENT_REPLY_GRACE_SECS)) +} + +/// Deferred silent-reply check payload: batch plus the watch generation armed +/// when the Ok turn completed. +#[derive(Clone)] +struct SilentReplyCheck { + batch: FlushBatch, + generation: u64, +} + pub use usage::TurnUsage; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; -use acp::{AcpClient, EnvVar, McpServer}; +use acp::{AcpClient, EnvVar, McpServer, StopReason}; use anyhow::Result; use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, @@ -1626,6 +1649,9 @@ async fn tokio_main() -> Result<()> { // withheld event in `EventQueue::withheld_native_steer` until // `IN_FLIGHT_DEADLINE_SECS` expires. let (steer_ack_tx, mut steer_ack_rx) = mpsc::unbounded_channel::(); + // Deferred silent-reply checks: Ok mention turns wait a grace window so a + // late ws echo can clear the "couldn't publish" notice (#2459). + let (silent_reply_tx, mut silent_reply_rx) = mpsc::unbounded_channel::(); // ── Step 7: Shutdown signal ─────────────────────────────────────────────── let (shutdown_tx, mut shutdown_rx) = watch::channel(()); @@ -1700,6 +1726,7 @@ async fn tokio_main() -> Result<()> { Result(Box), Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), + SilentReplyDue(SilentReplyCheck), Wake(u32, Result), } @@ -1843,6 +1870,9 @@ async fn tokio_main() -> Result<()> { Some(ack_event) = steer_ack_rx.recv() => { Some(PoolEvent::SteerAck(ack_event)) } + Some(check) = silent_reply_rx.recv() => { + Some(PoolEvent::SilentReplyDue(check)) + } Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { Some(PoolEvent::Wake(attempt, result)) } @@ -2023,6 +2053,14 @@ async fn tokio_main() -> Result<()> { continue; } + // Count self-authored stream replies for silent-reply + // detection regardless of ignore_self (#2459): under + // --no-ignore-self the counter must still increment. + if buzz_event.event.pubkey.to_hex() == pubkey_hex + && silent_reply::is_agent_reply_kind(kind_u32) + { + queue.note_self_publish(buzz_event.channel_id); + } if config.ignore_self && buzz_event.event.pubkey.to_hex() == pubkey_hex { tracing::debug!(channel_id = %buzz_event.channel_id, "dropping self-authored event"); continue; @@ -2366,6 +2404,7 @@ async fn tokio_main() -> Result<()> { &mut respawn_tasks, observer.clone(), Some(&ctx.rest_client), + Some(&silent_reply_tx), ) == LoopAction::Exit { break; @@ -2554,6 +2593,26 @@ async fn tokio_main() -> Result<()> { typing_channels.insert(channel_id, thread_tags); } } + Some(PoolEvent::SilentReplyDue(check)) => { + let Some(publishes) = + queue.take_silent_reply_watch(check.batch.channel_id, check.generation) + else { + // Already consumed or superseded by a later Ok on the same + // channel; nothing to do. + continue; + }; + if let Some(content) = silent_reply::silent_reply_loss_notice( + &PromptOutcome::Ok(StopReason::EndTurn), + Some(&check.batch), + publishes, + ) { + tracing::warn!( + channel_id = %check.batch.channel_id, + "mention turn completed Ok with no agent channel publish — posting failure notice" + ); + spawn_failure_notice(Some(&ctx.rest_client), &check.batch, content); + } + } Some(PoolEvent::Wake(attempt, result)) => { let completion = result.as_ref().map(|_| ()).map_err(|error| error.clone()); if let Err(error) = @@ -3064,6 +3123,7 @@ fn handle_prompt_result( respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, rest_client: Option<&relay::RestClient>, + silent_reply_tx: Option<&mpsc::UnboundedSender>, ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; @@ -3089,7 +3149,47 @@ fn handle_prompt_result( // Don't requeue batches for channels the agent was removed from — // those events are stale and should be silently dropped. if !removed_channels.contains(&batch.channel_id) { - if matches!( + if matches!(result.outcome, PromptOutcome::Ok(_)) { + // Ok turns keep the batch only for silent-reply detection — + // never requeue a successful turn (#2459). + if silent_reply::batch_expects_channel_reply(&batch) { + // Defer the notice: the reply echo arrives on the ws path + // and is not ordered vs pool completion. Arm a watch so + // counts survive mark_complete, then re-check after a + // short grace window. + let generation = queue.arm_silent_reply_watch(batch.channel_id); + if let Some(tx) = silent_reply_tx { + let tx = tx.clone(); + let check = SilentReplyCheck { + batch: batch.clone(), + generation, + }; + let grace = silent_reply_grace(); + tokio::spawn(async move { + tokio::time::sleep(grace).await; + let _ = tx.send(check); + }); + } else { + // Tests / callers without a deferred channel: decide now. + let publishes = queue + .take_silent_reply_watch(batch.channel_id, generation) + .unwrap_or(0); + if let Some(content) = silent_reply::silent_reply_loss_notice( + &result.outcome, + Some(&batch), + publishes, + ) { + tracing::warn!( + channel_id = %batch.channel_id, + "mention turn completed Ok with no agent channel publish — posting failure notice" + ); + spawn_failure_notice(rest_client, &batch, content); + } + } + } else { + let _ = queue.take_self_publishes(batch.channel_id); + } + } else if matches!( result.outcome, PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) ) { @@ -5336,6 +5436,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); let turn_errors: Vec<_> = observer @@ -5501,6 +5602,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); let events = observer.snapshot(); let turn_error = events.iter().find(|e| e.kind == "turn_error").unwrap(); @@ -5591,6 +5693,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + None, ); ( queue.pending_channels(), @@ -5696,6 +5799,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + None, ); ( queue.pending_channels(), @@ -5787,6 +5891,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); let events = observer.snapshot(); @@ -5880,6 +5985,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); let events = observer.snapshot(); @@ -5995,6 +6101,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); // Batch preserved as a cancelled merge, not dead-lettered — same @@ -6127,6 +6234,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + None, ); // No batch to merge — the queue has nothing pending for any channel. @@ -6309,6 +6417,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + None, ); // The batch must not be requeued: pending_channels returns 0. @@ -6394,6 +6503,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + None, ); // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index b1fd68d044..56cdc73994 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2055,7 +2055,8 @@ pub async fn run_prompt_task( agent, source, PromptOutcome::Ok(StopReason::EndTurn), - None, // turn succeeded — batch was processed, no requeue + // Keep batch for silent-reply detection; Ok must not requeue. + batch, ); return; } @@ -2118,7 +2119,8 @@ pub async fn run_prompt_task( agent, source, PromptOutcome::Ok(stop_reason), - None, + // Keep batch for silent-reply detection; Ok must not requeue. + batch, ); } Err(AcpError::AgentExited) => { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..5933dde8f2 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -141,6 +141,18 @@ pub struct EventQueue { in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). in_flight_batch_sizes: HashMap, + /// Agent-authored stream messages observed on the wire while a channel + /// turn is in flight (or during a post-Ok silent-reply grace watch). + /// Used to detect silent reply loss when ACP reports Ok but + /// `buzz messages send` never landed (#2459). + in_flight_self_publishes: HashMap, + /// Channels whose Ok turn is waiting on a deferred silent-reply check, + /// keyed by a per-channel generation. Counts in + /// [`in_flight_self_publishes`] survive [`mark_complete`] while a watch is + /// armed so a late ws echo can still clear the notice. Generation IDs stop + /// a stale grace timer from consuming a newer turn's watch on the same + /// channel (#2459). + silent_reply_watches: HashMap, retry_after: HashMap, /// Per-channel retry attempt counter for exponential backoff / dead-lettering. retry_counts: HashMap, @@ -182,6 +194,8 @@ impl EventQueue { in_flight_channels: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), + in_flight_self_publishes: HashMap::new(), + silent_reply_watches: HashMap::new(), retry_after: HashMap::new(), retry_counts: HashMap::new(), dedup_mode, @@ -278,6 +292,7 @@ impl EventQueue { ); self.in_flight_channels.remove(&id); self.in_flight_deadlines.remove(&id); + self.in_flight_self_publishes.remove(&id); // Recover any withheld goose-native steer events for the expired // channel back to the queue front so normal dispatch delivers // them. Unlike the in-flight batch above (already delivered to a @@ -393,6 +408,11 @@ impl EventQueue { self.in_flight_channels.remove(&channel_id); self.in_flight_deadlines.remove(&channel_id); self.in_flight_batch_sizes.remove(&channel_id); + // Keep publish counts while a silent-reply grace watch is armed so a + // late echo can still arrive after turn completion (#2459). + if !self.silent_reply_watches.contains_key(&channel_id) { + self.in_flight_self_publishes.remove(&channel_id); + } let now = Instant::now(); match self.retry_after.get(&channel_id) { // Active throttle → channel was requeued; keep retry_counts intact. @@ -574,6 +594,7 @@ impl EventQueue { ); self.in_flight_channels.remove(&id); self.in_flight_deadlines.remove(&id); + self.in_flight_self_publishes.remove(&id); // Symmetric with the flush_next expiry block: recover withheld // goose-native steer events for the expired channel so they are // not permanently orphaned in the side table. @@ -646,6 +667,54 @@ impl EventQueue { self.in_flight_channels.contains(&channel_id) } + /// Record a self-authored stream message seen on the wire for an in-flight + /// channel turn, or during a deferred silent-reply grace watch. + pub fn note_self_publish(&mut self, channel_id: Uuid) { + if self.in_flight_channels.contains(&channel_id) + || self.silent_reply_watches.contains_key(&channel_id) + { + *self.in_flight_self_publishes.entry(channel_id).or_insert(0) += 1; + } + } + + /// Take and clear the self-publish count for `channel_id` (0 if none). + pub fn take_self_publishes(&mut self, channel_id: Uuid) -> u64 { + self.in_flight_self_publishes + .remove(&channel_id) + .unwrap_or(0) + } + + /// Arm a post-Ok silent-reply watch so publish counts survive + /// [`mark_complete`] until [`take_silent_reply_watch`]. + /// + /// Returns the watch generation. Callers must pass that generation back to + /// [`take_silent_reply_watch`] so a stale grace timer cannot consume a + /// newer turn's watch on the same channel. + pub fn arm_silent_reply_watch(&mut self, channel_id: Uuid) -> u64 { + let next = self + .silent_reply_watches + .get(&channel_id) + .copied() + .unwrap_or(0) + .wrapping_add(1); + self.silent_reply_watches.insert(channel_id, next); + next + } + + /// End a silent-reply watch and take the accumulated publish count. + /// + /// Returns `None` if no matching generation is armed (already consumed, + /// never armed, or superseded by a later Ok on the same channel). + pub fn take_silent_reply_watch(&mut self, channel_id: Uuid, generation: u64) -> Option { + match self.silent_reply_watches.get(&channel_id).copied() { + Some(current) if current == generation => { + self.silent_reply_watches.remove(&channel_id); + Some(self.take_self_publishes(channel_id)) + } + _ => None, + } + } + // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight @@ -4756,4 +4825,60 @@ mod tests { "second extend must not move deadline backward (monotonic)" ); } + + #[test] + fn silent_reply_watch_survives_mark_complete_and_counts_late_echo() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.in_flight_channels.insert(ch); + q.note_self_publish(ch); + assert_eq!(q.take_self_publishes(ch), 1); + + // Re-note during in-flight, arm watch, then complete — count must survive. + q.in_flight_channels.insert(ch); + q.note_self_publish(ch); + let gen = q.arm_silent_reply_watch(ch); + q.mark_complete(ch); + assert!(!q.is_channel_in_flight(ch)); + // Late echo after completion still counts. + q.note_self_publish(ch); + assert_eq!(q.take_silent_reply_watch(ch, gen), Some(2)); + // Second take is a no-op. + assert_eq!(q.take_silent_reply_watch(ch, gen), None); + // Without a watch, post-complete echoes are ignored. + q.note_self_publish(ch); + assert_eq!(q.take_self_publishes(ch), 0); + } + + #[test] + fn silent_reply_watch_generation_ignores_stale_timer() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.in_flight_channels.insert(ch); + q.note_self_publish(ch); + let first = q.arm_silent_reply_watch(ch); + q.mark_complete(ch); + + // A second Ok on the same channel re-arms with a new generation. + q.in_flight_channels.insert(ch); + let second = q.arm_silent_reply_watch(ch); + assert_ne!(first, second); + q.mark_complete(ch); + q.note_self_publish(ch); + + // Stale first timer must not consume the newer watch (count still + // includes the earlier turn's publish — generations only gate take). + assert_eq!(q.take_silent_reply_watch(ch, first), None); + assert_eq!(q.take_silent_reply_watch(ch, second), Some(2)); + } + + #[test] + fn mark_complete_clears_publishes_without_silent_reply_watch() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.in_flight_channels.insert(ch); + q.note_self_publish(ch); + q.mark_complete(ch); + assert_eq!(q.take_self_publishes(ch), 0); + } } diff --git a/crates/buzz-acp/src/silent_reply.rs b/crates/buzz-acp/src/silent_reply.rs new file mode 100644 index 0000000000..111fff460d --- /dev/null +++ b/crates/buzz-acp/src/silent_reply.rs @@ -0,0 +1,135 @@ +//! Detect turns that finished `Ok` without publishing a channel reply (#2459). +//! +//! Agents reply out-of-band via `buzz messages send`. When that write fails, +//! ACP still reports `end_turn` / `Ok` and the human sees silence. The harness +//! counts agent-authored stream messages observed on the wire during the turn +//! (self-events that `ignore_self` would otherwise drop) and posts a failure +//! notice when a mention-triggered turn ends with zero publishes. + +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2}; + +use crate::pool::PromptOutcome; +use crate::queue::FlushBatch; + +/// True when this batch was admitted as an `@mention` of a stream message — +/// the path that is expected to produce a visible channel reply. +/// +/// `SubscribeMode::All` batches use `prompt_tag: "all"` and intentionally get +/// no protection here: every event fires a turn in that mode, so a zero-publish +/// outcome is not anomalous the way a missed mention reply is. +pub(crate) fn batch_expects_channel_reply(batch: &FlushBatch) -> bool { + batch.events.iter().any(|be| { + be.prompt_tag == "@mention" + && matches!( + be.event.kind.as_u16() as u32, + KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2 + ) + }) +} + +/// Kind filter used when counting self-authored publishes during a turn. +pub(crate) fn is_agent_reply_kind(kind: u32) -> bool { + matches!(kind, KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2) +} + +/// Return a user-visible notice when an Ok turn looks like a silently lost reply. +pub(crate) fn silent_reply_loss_notice( + outcome: &PromptOutcome, + batch: Option<&FlushBatch>, + agent_message_publishes: u64, +) -> Option { + if !matches!(outcome, PromptOutcome::Ok(_)) { + return None; + } + let batch = batch?; + if !batch_expects_channel_reply(batch) { + return None; + } + if agent_message_publishes > 0 { + return None; + } + Some( + "⚠️ I finished the turn but couldn't publish a reply to the channel. Please re-send if it's still needed." + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::{AcpError, StopReason}; + use crate::queue::BatchEvent; + use nostr::{EventBuilder, Keys, Kind}; + use std::time::Instant; + use uuid::Uuid; + + fn mention_batch() -> FlushBatch { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi @agent") + .tag(nostr::Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()) + .sign_with_keys(&keys) + .unwrap(); + FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![BatchEvent { + event, + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn all_mode_batch() -> FlushBatch { + let mut batch = mention_batch(); + batch.events[0].prompt_tag = "all".into(); + batch + } + + #[test] + fn notice_fires_for_mention_ok_with_zero_publishes() { + let batch = mention_batch(); + let notice = + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 0); + assert!(notice.unwrap().contains("couldn't publish")); + } + + #[test] + fn notice_skips_when_agent_published() { + let batch = mention_batch(); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 1,) + .is_none() + ); + } + + #[test] + fn notice_skips_non_mention_batches() { + let batch = all_mode_batch(); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 0,) + .is_none() + ); + } + + #[test] + fn notice_skips_errors_and_missing_batch() { + assert!(silent_reply_loss_notice( + &PromptOutcome::Error(AcpError::Protocol("x".into())), + Some(&mention_batch()), + 0, + ) + .is_none()); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), None, 0,).is_none() + ); + } + + #[test] + fn reply_kind_filter_excludes_reactions() { + assert!(is_agent_reply_kind(KIND_STREAM_MESSAGE)); + assert!(is_agent_reply_kind(KIND_STREAM_MESSAGE_V2)); + assert!(!is_agent_reply_kind(7)); + } +} diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 9309074895..b70e7c6f67 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -8,7 +8,9 @@ description: | (subcharts on) and HA production (external services, existingSecret). type: application version: 0.1.7 -appVersion: "0.1.0" +# Post-auto-migrate image (see #988). Empty values.image.tag falls back here — +# keep this on a tag that understands BUZZ_AUTO_MIGRATE (compose uses `:main`). +appVersion: "main" home: https://github.com/block/buzz sources: - https://github.com/block/buzz @@ -25,6 +27,8 @@ annotations: artifacthub.io/changes: | - kind: added description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. + - kind: fixed + description: Default appVersion tracks ghcr.io/block/buzz:main so BUZZ_AUTO_MIGRATE works out of the box (#2473). artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 3e044f5d7c..7fbe23d36e 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -36,6 +36,11 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml + # Empty image.tag falls back to Chart.appVersion — must be post-auto-migrate (#2473). + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz:main + template: templates/deployment.yaml # Security default: media GET/HEAD reads must be auth-gated out of the # box. A private attachment must never be publicly readable by URL/hash # in an unmodified render. If this assertion fails, someone flipped the diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8ac5086e27..9bdd4436dd 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -24,7 +24,9 @@ quickstart: false # ── Image ──────────────────────────────────────────────────────────────────── image: repository: ghcr.io/block/buzz - tag: "" # empty → .Chart.AppVersion + # Empty → Chart.appVersion (must be a post-auto-migrate image; see #2473). + # Pin to sha-<7> or a release tag for production. + tag: "" pullPolicy: IfNotPresent pullSecrets: [] diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 3cd6411996..d09d0b4c6d 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -164,6 +164,38 @@ exec -a "buzz-desktop" "$here/buzz-desktop.bin" "$@" SHIM chmod +x "$APP_BIN" +# WebKitGTK's dmabuf renderer cores on Intel Mesa + rootless XWayland +# (KDE Plasma Wayland, etc.). Setting this is a no-op where dmabuf already +# works and is required for a usable window on the failing path (#2338). +echo "==> Disabling WebKitGTK dmabuf renderer for AppImage launches" +HOOK_DIR="$WORKDIR/squashfs-root/apprun-hooks" +mkdir -p "$HOOK_DIR" +cat > "$HOOK_DIR/99-buzz-webkit-dmabuf.sh" <<'EOF' +# Allow operators to override (e.g. WEBKIT_DISABLE_DMABUF_RENDERER=0) if needed. +export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}" +EOF +# linuxdeploy AppRun sources apprun-hooks/*.sh; if this layout ever skips that, +# inject the export near the top of AppRun so the env still applies. +if [[ -f "$WORKDIR/squashfs-root/AppRun" ]] \ + && ! grep -q 'WEBKIT_DISABLE_DMABUF_RENDERER' "$WORKDIR/squashfs-root/AppRun"; then + if ! grep -q 'apprun-hooks' "$WORKDIR/squashfs-root/AppRun"; then + # Insert after the shebang (or at line 1 if shebang-less). + tmp_apprun="$(mktemp)" + { + if head -n1 "$WORKDIR/squashfs-root/AppRun" | grep -q '^#!'; then + head -n1 "$WORKDIR/squashfs-root/AppRun" + echo 'export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"' + tail -n +2 "$WORKDIR/squashfs-root/AppRun" + else + echo 'export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"' + cat "$WORKDIR/squashfs-root/AppRun" + fi + } > "$tmp_apprun" + mv "$tmp_apprun" "$WORKDIR/squashfs-root/AppRun" + chmod +x "$WORKDIR/squashfs-root/AppRun" + fi +fi + echo "==> Repacking AppImage" # Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); # without it appimagetool downloads the runtime from its mutable `continuous`