diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 74a9ede3..bf5b6ea4 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -23,6 +23,7 @@ on: permissions: contents: read checks: read + actions: read pull-requests: write jobs: @@ -71,15 +72,25 @@ jobs: exit 1 } + # The self-hosted runner has curl and jq but not the gh CLI + # (issue #460), so promotion queries the GitHub REST API directly. + api() { + curl -fsS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + # A green source branch is a prerequisite, not a substitute for the # target PR checks. Select the newest completed run for each check so # an old green run cannot satisfy this gate. Documentation-only # commits intentionally have no image build; a release tag builds # the deployable artifact later. for check in ci runner-policy; do - conclusion="$(gh api \ - "repos/$GITHUB_REPOSITORY/commits/$source_sha/check-runs" \ - --jq "[.check_runs[] | select(.name == \"$check\" and .status == \"completed\")] | sort_by(.completed_at) | last | .conclusion")" + conclusion="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/commits/$source_sha/check-runs?per_page=100" \ + | jq -r "[.check_runs[] | select(.name == \"$check\" and .status == \"completed\")] | sort_by(.completed_at) | last | .conclusion")" [ "$conclusion" = success ] || { echo "::error::$SOURCE@$source_sha does not have a successful $check check (got ${conclusion:-missing})." exit 1 @@ -88,13 +99,16 @@ jobs: if [ "$SOURCE" = dev ]; then receipt_name="vogt-dev-deployment-receipt-${source_sha}" - receipt_run="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow deploy-dev.yml --json databaseId,headSha,status,conclusion --limit 50 \ - --jq ".[] | select(.headSha == \"$source_sha\" and .status == \"completed\" and .conclusion == \"success\") | .databaseId" | head -n 1)" + receipt_run="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/deploy-dev.yml/runs?per_page=50" \ + | jq -r ".workflow_runs[] | select(.head_sha == \"$source_sha\" and .status == \"completed\" and .conclusion == \"success\") | .id" | head -n 1)" [ -n "$receipt_run" ] || { echo "::error::dev@$source_sha has no successful verified dev deployment receipt ($receipt_name)." exit 1 } - artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$receipt_run/artifacts" --jq ".artifacts[] | select(.name == \"$receipt_name\") | .id" | head -n 1)" + artifact_id="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$receipt_run/artifacts" \ + | jq -r ".artifacts[] | select(.name == \"$receipt_name\") | .id" | head -n 1)" [ -n "$artifact_id" ] || { echo "::error::successful dev deployment has no matching receipt artifact." exit 1 @@ -122,9 +136,22 @@ jobs: run: | set -euo pipefail : "${GH_TOKEN:?Configure VOGT_PROMOTION_TOKEN with pull-request write access}" - existing="$(gh pr list --repo "$GITHUB_REPOSITORY" \ - --head "$SOURCE" --base "$TARGET" --state open \ - --json number,url --jq '.[0] // empty')" + + # Talk to the REST API directly; the gh CLI is not on the runner + # (issue #460). This step authenticates as VOGT_PROMOTION_TOKEN so the + # created PR receives its normal checks. + api() { + curl -fsS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + owner="${GITHUB_REPOSITORY%%/*}" + existing="$(api \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?head=$owner:$SOURCE&base=$TARGET&state=open" \ + | jq -r '.[0].html_url // empty')" if [ -n "$existing" ]; then echo "An open promotion PR already exists: $existing" exit 0 @@ -148,6 +175,11 @@ jobs: in this repository pushes a protected branch or deploys production as a side effect of this PR. EOF - gh pr create --repo "$GITHUB_REPOSITORY" --head "$SOURCE" --base "$TARGET" \ - --title "Promote $SOURCE → $TARGET (${SOURCE_SHA:0:7})" \ - --body-file "$body" + jq -n \ + --arg title "Promote $SOURCE → $TARGET (${SOURCE_SHA:0:7})" \ + --arg head "$SOURCE" \ + --arg base "$TARGET" \ + --rawfile body "$body" \ + '{title: $title, head: $head, base: $base, body: $body}' \ + | api -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls" --data @- \ + | jq -r '"Opened promotion PR: " + .html_url' diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e0b74ec2..10a7e0cc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -229,6 +229,15 @@ overrides. The image records the resolved versions and refuses to start when a persisted home volume would shadow an image-managed CLI (set `VOGT_AGENT_SHADOW_POLICY=warn` only for a deliberate user-local override). +This build-time flag is the only way the CLIs enter a deployment, and it +defaults to `false`. No published image carries them: the release `vogt` core +image runs the register alone, and the signed release digests promoted to +production (§7) are CLI-free by design. So a deployment that runs agent +sessions is one whose engine was built with `VOGT_INSTALL_AI_CLIENTS=true` — +pinning a plain release image instead leaves the `Claude Code (protected)` and +`Codex (protected)` session templates registered but unable to start, because +the `claude` and `codex` binaries are simply not in the image. + Be aware before you run it: the engine image is a **development pod**, not a hardened service image — it carries a writable home, `sudo`, optional agent CLIs, and an entrypoint that supports integrations this repository's @@ -367,6 +376,18 @@ a version tag creates signed, immutable artifacts; deployment selects the digest a production instance runs. A successful build or published image does not change production by itself. +The desired state a production instance runs — which digests, which overlays, +which host specifics — is owned by the operator's own deployment repository, +not this one (#204); this tree ships only the estate-neutral base and overlays, +never a turnkey production estate. Two consequences are worth stating plainly. +The signed release digests are CLI-free (§3.2), so a production engine that +runs agent sessions is one the operator built with +`VOGT_INSTALL_AI_CLIENTS=true` and published to its own registry — the release +`vogt`/`vogt-stack` digests are not that image. And the maintainer's own +production is one such private deployment, layering a private overlay on the +public base; it is not a supported drop-in scenario reproducible from this +repository alone. + ### 7.1 Promote `dev` to production Promotion is two explicit, fast-forward-only pull requests. First deploy the diff --git a/engine/server/src/ws.rs b/engine/server/src/ws.rs index 0bbba2b7..3d3b1fa0 100644 --- a/engine/server/src/ws.rs +++ b/engine/server/src/ws.rs @@ -9,11 +9,14 @@ use axum::{ }; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; -use tokio::sync::{broadcast::error::RecvError, mpsc}; +use tokio::sync::{ + broadcast::error::{RecvError, TryRecvError}, + mpsc, +}; use uuid::Uuid; use vogt_engine_contract::{ClientControl, ServerControl}; -use crate::{app::AppState, auth}; +use crate::{app::AppState, auth, pty::OutputChunk, pty::Session}; #[derive(Debug, Deserialize)] pub struct AttachQuery { @@ -33,6 +36,21 @@ const SNAPSHOT_CHUNK: usize = 64 * 1024; /// dump from becoming a multi-megabyte editable command line inside the PTY. const MAX_INPUT_BYTES: usize = 64 * 1024; +/// Upper bound on the bytes coalesced into a single outbound frame. Bursty +/// output (agent token streams, TUI redraws, a build) arrives as many small +/// broadcast chunks; batching the ones already queued into one WebSocket frame +/// means a chatty session wakes the client's single message-draining thread far +/// fewer times, which is what starves the other panes' sockets under load +/// (#466). Bounded so one burst cannot build an unbounded frame. +const OUTBOUND_COALESCE_CAP: usize = 256 * 1024; + +/// How many in-band lag resyncs to attempt, with no normal live send in +/// between, before giving up and asking the client for a clean reattach. A +/// client that keeps falling behind every resync would otherwise drive +/// unbounded resnapshotting; the counter resets whenever live output flows +/// again (#466). +const MAX_CONSECUTIVE_RESYNCS: u32 = 5; + /// How long a freshly-upgraded socket has to send `{"type":"auth",...}` before /// we drop it. Keeps unauth clients from hanging on to a socket indefinitely. const AUTH_DEADLINE: Duration = Duration::from_secs(5); @@ -61,6 +79,100 @@ fn live_skip(chunk_pos: u64, chunk_len: usize, snap_pos: u64) -> Option { } } +/// Coalesce a run of consecutive broadcast chunks into the bytes to forward. +/// +/// `snap_pos` is the current dedup boundary: any part of a chunk at or before +/// it was already delivered (in the initial snapshot or a resync) and is +/// dropped via [`live_skip`]. Returns the concatenated not-yet-seen bytes and +/// the absolute position after the last chunk that contributed output — the +/// caller's new "sent up to here" cursor. When nothing new is forwarded the +/// returned position is `snap_pos`, so the caller can keep its existing cursor. +fn coalesce(snap_pos: u64, chunks: &[OutputChunk]) -> (Vec, u64) { + let mut out = Vec::new(); + let mut end = snap_pos; + for chunk in chunks { + if let Some(skip) = live_skip(chunk.pos, chunk.data.len(), snap_pos) { + out.extend_from_slice(&chunk.data[skip..]); + end = chunk.pos + chunk.data.len() as u64; + } + } + (out, end) +} + +/// Re-synchronise a lagging client in-band, on the same socket, instead of +/// dropping it and forcing a reconnect + replay (the cascade in #466). +/// +/// Snapshots from `from_pos` (the client's last delivered position) and streams +/// it as the same `SnapshotStart` → payload → `SnapshotDone` sequence a fresh +/// attach uses. When the cursor is still inside the scrollback window the server +/// sends a `reset: false` delta (the client appends it); when it has aged out it +/// sends a `reset: true` full snapshot (the client clears and reloads). Either +/// way the socket stays open. Returns the new absolute position the client is +/// synchronised to, or `Err(())` if the socket died mid-send. +async fn send_resync(sink: &mut S, session: &Session, from_pos: u64) -> Result +where + S: SinkExt + Unpin, +{ + let (payload, pos, reset) = session.snapshot_for_attach(Some(from_pos)); + let meta = ServerControl::SnapshotStart { + session_id: Some(session.id), + scrollback_bytes: payload.len() as u64, + scrollback_pos: pos, + reset, + }; + sink.send(Message::Text(serde_json::to_string(&meta).unwrap().into())) + .await + .map_err(|_| ())?; + for chunk in payload.chunks(SNAPSHOT_CHUNK) { + sink.send(Message::Binary(chunk.to_vec().into())) + .await + .map_err(|_| ())?; + } + sink.send(Message::Text( + serde_json::to_string(&ServerControl::SnapshotDone) + .unwrap() + .into(), + )) + .await + .map_err(|_| ())?; + Ok(pos) +} + +/// Outcome of an in-band lag recovery attempt. +enum Recovery { + /// Client re-synchronised to this absolute position on the same socket. + Resynced(u64), + /// Too many resyncs without progress — fall back to a clean reattach. + GiveUp, +} + +/// Attempt an in-band resync, tripping the circuit breaker after too many in a +/// row. Increments `resyncs`; the caller resets it whenever live output flows. +async fn recover_from_lag( + sink: &mut S, + session: &Session, + from_pos: u64, + resyncs: &mut u32, +) -> Recovery +where + S: SinkExt + Unpin, +{ + *resyncs += 1; + if *resyncs > MAX_CONSECUTIVE_RESYNCS { + let lag = ServerControl::Lag { + note: "client too slow; reattach".into(), + }; + let _ = sink + .send(Message::Text(serde_json::to_string(&lag).unwrap().into())) + .await; + return Recovery::GiveUp; + } + match send_resync(sink, session, from_pos).await { + Ok(pos) => Recovery::Resynced(pos), + Err(()) => Recovery::GiveUp, + } +} + pub async fn attach( ws: WebSocketUpgrade, State(state): State>, @@ -242,8 +354,21 @@ async fn handle_socket( } }); - // Outbound: broadcast chunks → client, skipping anything already in the snapshot. + // Outbound: broadcast chunks → client. Bursts are coalesced into fewer + // frames, and a lagging client is recovered in-band rather than dropped + // (#466). + let outbound_session = Arc::clone(&session); let outbound = tokio::spawn(async move { + // `snap_pos` is the dedup boundary handed to `live_skip`; `sent_pos` is + // the absolute offset the client has been streamed up to. Both jump + // forward after an in-band resync. + let mut snap_pos = snap_pos; + let mut sent_pos = snap_pos; + // Consecutive resyncs with no normal live send in between. Reset on any + // live output; a client that trips the ceiling is handed back to a + // clean reattach instead of driving unbounded resnapshotting. + let mut resyncs: u32 = 0; + loop { tokio::select! { Some(control) = control_rx.recv() => { @@ -255,38 +380,87 @@ async fn handle_socket( break; } } - result = rx.recv() => match result { - Ok(chunk) => { - // Skip anything already delivered in the replayed snapshot; - // for a chunk straddling the snapshot boundary, send only - // the not-yet-seen tail. - let Some(skip) = live_skip(chunk.pos, chunk.data.len(), snap_pos) else { - continue; - }; - let send_buf = if skip == 0 { - chunk.data - } else { - chunk.data.slice(skip..) + result = rx.recv() => { + let first = match result { + Ok(chunk) => chunk, + // Lagged straight from the blocking recv: recover in-band. + Err(RecvError::Lagged(_)) => { + match recover_from_lag( + &mut sink, + &outbound_session, + sent_pos, + &mut resyncs, + ) + .await + { + Recovery::Resynced(pos) => { + snap_pos = pos; + sent_pos = pos; + continue; + } + Recovery::GiveUp => break, + } + } + Err(RecvError::Closed) => break, }; - if sink - .send(Message::Binary(send_buf.to_vec().into())) - .await - .is_err() - { + + // Drain everything already queued so a burst becomes one + // frame, not dozens — each frame is a client main-thread + // wakeup. Bounded so one session can't build a huge frame. + let mut drained = vec![first]; + let mut queued = drained[0].data.len(); + let mut lagged = false; + let mut closed = false; + while queued < OUTBOUND_COALESCE_CAP { + match rx.try_recv() { + Ok(next) => { + queued += next.data.len(); + drained.push(next); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Lagged(_)) => { + lagged = true; + break; + } + Err(TryRecvError::Closed) => { + closed = true; + break; + } + } + } + + let (frame, end) = coalesce(snap_pos, &drained); + if end > sent_pos { + sent_pos = end; + } + if !frame.is_empty() { + if sink.send(Message::Binary(frame.into())).await.is_err() { + break; + } + // Live output flowed: the client is keeping up again. + resyncs = 0; + } + + if closed { break; } + if lagged { + match recover_from_lag( + &mut sink, + &outbound_session, + sent_pos, + &mut resyncs, + ) + .await + { + Recovery::Resynced(pos) => { + snap_pos = pos; + sent_pos = pos; + } + Recovery::GiveUp => break, + } + } } - Err(RecvError::Lagged(_n)) => { - let lag = ServerControl::Lag { - note: "client too slow; reattach".into(), - }; - let _ = sink - .send(Message::Text(serde_json::to_string(&lag).unwrap().into())) - .await; - break; - } - Err(RecvError::Closed) => break, - }, } } }); @@ -300,7 +474,55 @@ async fn handle_socket( #[cfg(test)] mod tests { - use super::live_skip; + use super::{coalesce, live_skip}; + use crate::pty::OutputChunk; + use bytes::Bytes; + + fn chunk(pos: u64, data: &'static [u8]) -> OutputChunk { + OutputChunk { + pos, + data: Bytes::from_static(data), + } + } + + #[test] + fn coalesce_concatenates_consecutive_new_chunks() { + // A burst of small chunks all past the boundary becomes one buffer, and + // the returned cursor is the end of the last chunk. + let chunks = [chunk(10, b"abc"), chunk(13, b"de"), chunk(15, b"f")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"abcdef"); + assert_eq!(end, 16); + } + + #[test] + fn coalesce_drops_chunks_already_below_the_boundary() { + // The first chunk is fully within an earlier snapshot (pos+len <= 10); + // only the not-yet-seen chunks survive, and the cursor tracks them. + let chunks = [chunk(4, b"OLD"), chunk(10, b"new")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"new"); + assert_eq!(end, 13); + } + + #[test] + fn coalesce_trims_a_chunk_straddling_the_boundary() { + // Chunk [8,13) with boundary 10: only bytes [10,13) are new. + let chunks = [chunk(8, b"XXabc")]; + let (out, end) = coalesce(10, &chunks); + assert_eq!(&out, b"abc"); + assert_eq!(end, 13); + } + + #[test] + fn coalesce_forwards_nothing_and_keeps_the_cursor_when_all_seen() { + // Everything is at or before the boundary: no bytes, cursor unchanged so + // the caller keeps its existing sent position. + let chunks = [chunk(0, b"seen"), chunk(4, b"more")]; + let (out, end) = coalesce(8, &chunks); + assert!(out.is_empty()); + assert_eq!(end, 8); + } #[test] fn chunk_fully_in_snapshot_is_dropped() { diff --git a/tests/test_deploy.py b/tests/test_deploy.py index d3590e12..954c265e 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1508,7 +1508,15 @@ def test_promotion_is_fast_forward_only_and_never_pushes_a_branch() -> None: ) assert environment in workflow assert "git merge-base --is-ancestor" in workflow - assert "gh pr create" in workflow + # #460: the self-hosted runner has no gh CLI, so the promotion PR is opened + # through the GitHub REST API (POST .../pulls, authenticated with the + # promotion token), never with `gh` and never by pushing a branch. + assert "gh pr create" not in workflow + assert "gh api" not in workflow + assert "gh run list" not in workflow + assert "/repos/$GITHUB_REPOSITORY/pulls" in workflow + assert "-X POST" in workflow + assert "actions: read" in workflow assert "VOGT_PROMOTION_TOKEN" in workflow assert "GITHUB_TOKEN" in workflow assert "contents: write" not in workflow diff --git a/voice/Cargo.lock b/voice/Cargo.lock index 0799d90b..9ac5d195 100644 --- a/voice/Cargo.lock +++ b/voice/Cargo.lock @@ -413,6 +413,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "either" version = "1.18.0" @@ -724,14 +735,108 @@ dependencies = [ "tower-service", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" -version = "0.4.0" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "icu_normalizer", + "icu_properties", ] [[package]] @@ -795,6 +900,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -1056,6 +1167,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1202,11 +1322,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", + "once_cell", "ring", "rustls-pki-types", "rustls-webpki", @@ -1225,9 +1346,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1412,6 +1533,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1561,6 +1688,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tar" version = "0.4.46" @@ -1615,20 +1753,15 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -1752,27 +1885,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -1781,9 +1899,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.9.7" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", "flate2", @@ -1791,7 +1909,6 @@ dependencies = [ "once_cell", "rustls", "rustls-pki-types", - "rustls-webpki", "socks", "url", "webpki-roots 0.26.11", @@ -1799,15 +1916,22 @@ dependencies = [ [[package]] name = "url" -version = "2.4.1" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2079,6 +2203,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -2089,12 +2219,89 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/voice/tts/Cargo.toml b/voice/tts/Cargo.toml index 1213913e..f01b353f 100644 --- a/voice/tts/Cargo.toml +++ b/voice/tts/Cargo.toml @@ -23,8 +23,13 @@ home = "=0.5.9" half = "=2.4.1" rustls-pki-types = "=1.12.0" zeroize = "=1.8.1" -ureq = "=2.9.7" -url = "=2.4.1" +# Version floors that steer the shared voice/Cargo.lock past the vulnerable +# transitive crates (issue #459): ureq >=2.12 pulls rustls-webpki >=0.103.13 +# (clears GHSA-82j2-j2ch-gfr8 et al.) and url >=2.5 pulls idna >=1.0 +# (clears GHSA-h97m-ww89-6jmq). Kept on the ureq 2.x line so no source uses +# the ureq 3.x API. Neither crate is referenced directly from tts/src. +ureq = "2.12" +url = "2.5" tempfile = "=3.23.0" tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "time"] } thiserror = "2" diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index 2c44c2d5..5cbef986 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -860,8 +860,26 @@ const TerminalView: Component = (props) => { clearCountdown(); setReconnectView(null); setStatusText("Loading terminal..."); - ws = openAttach(props.sessionId, outputPosition); - ws.addEventListener("open", () => { + // Never leave a second socket attached to this terminal. Several paths can + // reach connect() while a socket is still open or connecting (pane resume, + // wake, watchdog recycle, a delayed close that reschedules). A leftover + // socket keeps delivering the same PTY output, so its bytes get written on + // top of the live socket's and every line doubles or triples (#466). Drop + // any existing socket first; the `socket`-identity guards on the handlers + // below turn its late events into no-ops. + if (ws) { + const stale = ws; + ws = null; + try { + stale.close(); + } catch { + /* already closing */ + } + } + const socket = openAttach(props.sessionId, outputPosition); + ws = socket; + socket.addEventListener("open", () => { + if (ws !== socket) return; reconnect.recover(); watchdog.reset(); startWatchdog(); @@ -869,7 +887,8 @@ const TerminalView: Component = (props) => { flushPendingInput(); checkWatchdog(true); }); - ws.addEventListener("message", (ev) => { + socket.addEventListener("message", (ev) => { + if (ws !== socket) return; if (typeof ev.data === "string") { try { const ctrl = JSON.parse(ev.data) as @@ -952,7 +971,8 @@ const TerminalView: Component = (props) => { term?.write(buf); } }); - ws.addEventListener("close", () => { + socket.addEventListener("close", () => { + if (ws !== socket) return; stopWatchdog(); if (socketParked || isParked()) return; // Write the [disconnected] marker once at the start of the outage, not on @@ -966,7 +986,8 @@ const TerminalView: Component = (props) => { } scheduleReconnect(); }); - ws.addEventListener("error", () => { + socket.addEventListener("error", () => { + if (ws !== socket) return; // Browser fires both error + close; close handler is enough. }); }