diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 7dd97a9..75427fa 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -34,7 +34,7 @@ use pmacs_protocol::{ FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message, - write_message, + requested_protocol_version, write_message, }; use winit::event_loop::EventLoopProxy; @@ -557,8 +557,16 @@ fn connect_stream_with_sink( // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). + // Bottom-panel Stage 2B-3: the `Hello` version is a compatibility + // BASELINE, and this counter-offer is what activates anything above + // it. The handshake is server-first, so the daemon cannot advertise a + // version a shipped frontend might reject; the frontend is the only + // party that can safely name a higher one, because by this point it + // has already accepted the baseline. `requested_protocol_version` + // echoes anything older than the current baseline verbatim. + let session_protocol_version = requested_protocol_version(hello.protocol_version); let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: session_protocol_version, frontend_capabilities: FrontendCapabilities { synchronized_output: false, unicode_smp: true, @@ -678,7 +686,8 @@ fn connect_stream_with_sink( outbox, shutdown_handle, frontend_id: hello.assigned_frontend_id, - server_protocol_version: hello.protocol_version, + session_protocol_version, + baseline_protocol_version: hello.protocol_version, initial_message, }) } @@ -859,10 +868,25 @@ pub struct AttachClient { /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. frontend_id: FrontendId, - /// The daemon's `Hello.protocol_version`. Wire variants newer - /// than the daemon (e.g. `Pointer`, v5) must be gated on this — - /// an older daemon hard-errors decoding an unknown variant. - server_protocol_version: u32, + /// The version this session actually speaks: the frontend's + /// `AttachRequest` counter-offer, which the daemon adopts. Wire + /// variants newer than the session (e.g. `Pointer`, v5) must be gated + /// on this — a daemon below the variant's floor hard-errors decoding + /// an unknown discriminant. + /// + /// Bottom-panel Stage 2B-3: this is deliberately NOT + /// `Hello.protocol_version` any more. The server-first `Hello` carries + /// a compatibility *baseline* that no shipped frontend may be forced + /// to reject, so it under-reports what the pair can speak; the offer + /// this frontend made is the session's real ceiling. + session_protocol_version: u32, + /// The baseline the daemon advertised in its server-first `Hello`. + /// + /// Kept beside the negotiated version because they answer different + /// questions, and because "the daemon still advertises 20 while this + /// session runs 21" is precisely the compatibility property Stage + /// 2B-3 has to be able to demonstrate. + baseline_protocol_version: u32, /// Target snapshot retained across the pre-window readiness barrier. initial_message: Option, } @@ -914,7 +938,7 @@ impl AttachClient { /// Send a `FrontendEvent::Pointer` (session M-2): a locally /// hit-tested gesture in source bytes. Callers gate on - /// [`Self::server_protocol_version`] `>= 5`. + /// [`Self::session_protocol_version`] `>= 5`. pub fn send_pointer( &self, buffer_id: BufferId, @@ -944,7 +968,7 @@ impl AttachClient { /// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the /// terminal-cell geometry this frontend has on screen. Callers gate - /// on [`Self::server_protocol_version`] `>= 19`. + /// on [`Self::session_protocol_version`] `>= 19`. /// /// Cells, never pixels — the frontend divides its own drawable /// rectangle by its own metrics, keeping the no-pixels contract the @@ -963,7 +987,7 @@ impl AttachClient { /// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a /// gesture hit-tested locally to a terminal cell. Callers gate on - /// [`Self::server_protocol_version`] `>= 19`. + /// [`Self::session_protocol_version`] `>= 19`. pub fn send_terminal_pointer( &self, buffer_id: BufferId, @@ -996,9 +1020,22 @@ impl AttachClient { }) } - /// The daemon's negotiated wire version from `Hello`. - pub fn server_protocol_version(&self) -> u32 { - self.server_protocol_version + /// The version this session negotiated — the frontend's + /// `AttachRequest` offer, which the daemon adopts. + /// + /// Every "is this variant on the wire?" gate keys on this, never on + /// [`Self::baseline_protocol_version`]. + pub fn session_protocol_version(&self) -> u32 { + self.session_protocol_version + } + + /// The compatibility baseline the daemon advertised in `Hello`. + /// + /// Only the handshake itself needs this. It is *lower* than + /// [`Self::session_protocol_version`] whenever an additive family has + /// been activated by counter-offer, which is the normal case. + pub fn baseline_protocol_version(&self) -> u32 { + self.baseline_protocol_version } /// Send a locally-authored CRDT operation to the daemon. The GPU @@ -1385,7 +1422,8 @@ mod tests { outbox: Arc::new((Mutex::new(outbox), Condvar::new())), shutdown_handle: b, frontend_id: FrontendId::LOCAL, - server_protocol_version: PROTOCOL_VERSION, + session_protocol_version: PROTOCOL_VERSION, + baseline_protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, initial_message: None, }; // A send against the closed outbox fails *and* shuts the socket @@ -1546,7 +1584,16 @@ mod tests { .expect("transient sequence must attach"); assert_eq!(attempts, 4); assert!(managed.daemon.spawned_daemon()); - assert_eq!(managed.client.server_protocol_version(), PROTOCOL_VERSION); + assert_eq!( + managed.client.session_protocol_version(), + PROTOCOL_VERSION, + "a managed attach negotiates this binary's wire, not the Hello baseline" + ); + assert_eq!( + managed.client.baseline_protocol_version(), + pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, + "while the daemon's server-first Hello still advertises the baseline" + ); server.join().expect("handshake server"); } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3a8bb55..9dab1bf 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -751,7 +751,8 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { state.set_frontend_id(client.frontend_id()); let mut facts = ProbeFacts { - server_protocol_version: client.server_protocol_version(), + session_protocol_version: client.session_protocol_version(), + baseline_protocol_version: client.baseline_protocol_version(), ..ProbeFacts::default() }; @@ -889,8 +890,13 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let mut out = String::new(); let _ = writeln!( out, - "server_protocol_version={}", - facts.server_protocol_version + "session_protocol_version={}", + facts.session_protocol_version + ); + let _ = writeln!( + out, + "baseline_protocol_version={}", + facts.baseline_protocol_version ); let _ = writeln!(out, "declarations={}", facts.declarations); let _ = writeln!(out, "frames={}", facts.frames); @@ -974,7 +980,8 @@ fn run_headless_managed_probe( return 7; } let daemon = managed.daemon; - let protocol = client.server_protocol_version(); + let protocol = client.session_protocol_version(); + let baseline = client.baseline_protocol_version(); let (stdin_tx, stdin_rx) = mpsc::channel(); std::thread::Builder::new() @@ -998,6 +1005,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1028,6 +1036,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1061,6 +1070,7 @@ fn run_headless_managed_probe( report, "ready", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1081,6 +1091,7 @@ fn run_headless_managed_probe( report, "complete", protocol, + baseline, &daemon, &buffer_facts, &disconnect, @@ -1138,6 +1149,7 @@ fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, + baseline: u32, daemon: &attach::ManagedDaemonFacts, buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, @@ -1146,7 +1158,8 @@ fn write_managed_probe_report( let mut out = String::new(); let _ = writeln!(out, "phase={phase}"); - let _ = writeln!(out, "server_protocol_version={protocol}"); + let _ = writeln!(out, "session_protocol_version={protocol}"); + let _ = writeln!(out, "baseline_protocol_version={baseline}"); let _ = writeln!(out, "buffer_snapshot=true"); let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots); let _ = writeln!( @@ -1181,7 +1194,16 @@ fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> { /// Named observations the headless probe reports back to the acceptance. #[derive(Default)] struct ProbeFacts { - server_protocol_version: u32, + /// The version the SESSION negotiated (this frontend's counter-offer). + session_protocol_version: u32, + /// The compatibility baseline the daemon advertised in `Hello`. + /// + /// Reported beside the negotiated version rather than instead of it: + /// Stage 2B-3's whole activation claim is that these two DIFFER — the + /// daemon still advertises a version every shipped frontend accepts + /// while this session speaks the newer wire — and a report carrying + /// only one of them cannot express that. + baseline_protocol_version: u32, declarations: u32, frames: u32, rendered_nonuniform_frames: u32, @@ -1993,14 +2015,14 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 5 { + if client.session_protocol_version() < 5 { return; } // TripleDown is a v7 variant; a pre-v7 instance would // hard-error decoding it. Downgrade to a plain Down — the // exact behavior the third click had before v7 (the chain // restarting). - let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 { + let kind = if kind == PointerKind::TripleDown && client.session_protocol_version() < 7 { PointerKind::Down } else { kind @@ -2008,7 +2030,7 @@ impl App { // Context (right-click, Q#CM1) is a v11 variant; a pre-v11 // instance can't open a menu, so drop the gesture rather than // sending an undecodable variant. - if kind == PointerKind::Context && client.server_protocol_version() < 11 { + if kind == PointerKind::Context && client.session_protocol_version() < 11 { return; } if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) { @@ -2030,7 +2052,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 19 { + if client.session_protocol_version() < 19 { return; } if let Err(e) = client.send_terminal_pointer(buffer_id, coord, kind, mods) { @@ -2047,7 +2069,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 19 { + if client.session_protocol_version() < 19 { return; } let Some(state) = self.state.as_mut() else { @@ -2089,7 +2111,7 @@ impl App { let Some(client) = self.attach_client.as_ref() else { return; }; - if client.server_protocol_version() < 11 { + if client.session_protocol_version() < 11 { return; } if let Err(e) = client.send_menu_pointer(index, invoke) { diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index e7e36e3..3d51c95 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -68,7 +68,7 @@ pub use message::{ PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, - negotiate_capabilities, + negotiate_capabilities, negotiated_session_version, requested_protocol_version, }; pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 0e4e815..b9a7428 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1667,14 +1667,87 @@ pub const PROTOCOL_VERSION: u32 = 21; /// Protocol version placed in the daemon's server-first [`Hello`]. /// -/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but -/// production attachment remains on v20 until the Stage 2B-3 capability -/// activation can preserve compatibility with existing v20 frontends. -/// Those frontends reject an unknown server-first version before they can -/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would -/// make the otherwise-dark protocol slice user-visible. +/// **This is a compatibility *baseline*, not a ceiling, and Stage 2B-3 +/// makes that permanent.** The handshake is server-first: the daemon +/// writes [`Hello`] before the frontend has said anything at all, and a +/// frontend rejects an unrecognized `protocol_version` *before* it can +/// send [`AttachRequest`]. Advertising [`PROTOCOL_VERSION`] here would +/// therefore lock out every already-shipped frontend whose supported +/// range ends lower — an incompatible act on its own, independent of +/// whether a single new message is ever exchanged. +/// +/// So the baseline stays at the highest version every shipped frontend +/// is known to accept, and the session's actual version is settled by +/// the frontend's [`AttachRequest`] instead: +/// +/// 1. the daemon advertises this baseline; +/// 2. the frontend answers with [`requested_protocol_version`] — its own +/// [`PROTOCOL_VERSION`] when the baseline is this constant, and a +/// verbatim echo of anything older; +/// 3. the daemon negotiates [`negotiated_session_version`] of that offer. +/// +/// A shipped baseline-version frontend echoes the baseline and gets a +/// baseline session, exactly as before. A current frontend offers up and +/// gets the current wire. Nothing about the `Hello` encoding or its +/// value changes, which is why the old frontend never sees a version it +/// must reject. +/// +/// Moving this constant is therefore a **deliberately incompatible** +/// act, reserved for a wire change that cannot be expressed additively. +/// An additive family — like the bottom panel's v21 shapes — never needs +/// it. pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20; +/// The version a frontend puts in its [`AttachRequest`], given the +/// server-first [`Hello`] baseline it just read. +/// +/// The counter-offer is confined to the *current* baseline on purpose. A +/// daemon advertising anything other than [`ADVERTISED_PROTOCOL_VERSION`] +/// is genuinely older than this ladder rung, so its baseline is echoed +/// verbatim and that attachment takes byte-for-byte the pre-Stage-2B-3 +/// path. Only the current baseline — the one every daemon built from +/// this ladder sends — is answered with this binary's own +/// [`PROTOCOL_VERSION`]. +/// +/// The offer is never *lower* than the baseline: a frontend that +/// supported less than the daemon advertised would already have rejected +/// the `Hello` via [`is_supported_protocol_version`]. +/// +/// # The one-way window this leaves open +/// +/// A daemon whose own `PROTOCOL_VERSION` *equals* the baseline also +/// advertises the baseline, and rejects an offer above its supported +/// range with [`GoodbyeReason::VersionMismatch`]. That is the price of a +/// server-first handshake with no client-first hint: compatibility can +/// be preserved for old *frontends* (the direction that matters, since +/// the daemon is what a user leaves running) or for old *daemons*, but a +/// single `AttachRequest` cannot mean both "I want 21" and "≤ 20" at +/// once. The window closes as soon as the running daemon is restarted on +/// a binary from this ladder rung or later, and it is one-connection +/// visible — [`GoodbyeReason::VersionMismatch`] names both versions. +#[must_use] +pub fn requested_protocol_version(server_baseline: u32) -> u32 { + if server_baseline == ADVERTISED_PROTOCOL_VERSION { + PROTOCOL_VERSION + } else { + server_baseline + } +} + +/// The version a daemon records for a session, given the frontend's +/// [`AttachRequest`] offer. +/// +/// The offer has already passed [`is_supported_protocol_version`], so +/// this clamp cannot bind today; it is here because "the session speaks +/// the lower of the two ceilings" is the *rule*, and leaving it implicit +/// in a membership test is how a future ladder widening (accepting a +/// version this binary cannot itself produce) would silently ship an +/// over-negotiated session. +#[must_use] +pub fn negotiated_session_version(frontend_offer: u32) -> u32 { + frontend_offer.min(PROTOCOL_VERSION) +} + /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept /// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat diff --git a/src/attach.rs b/src/attach.rs index 31245d9..e7f58e2 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -450,8 +450,15 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> { // its AttachRequest (the v0.1 daemon's strict-equality check will // accept). The frontend's runtime behavior on the wire is the // intersection of features both sides support. + // + // Bottom-panel Stage 2B-3: that echo is now a *floor*, not the whole + // rule. `Hello` is server-first, so the daemon must advertise a + // baseline every shipped frontend accepts; the session's real version + // is settled here, by this frontend counter-offering its own + // `PROTOCOL_VERSION` when the baseline is the current one. Anything + // older is still echoed verbatim. let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version), frontend_capabilities: build_capabilities(), initial_size, }; @@ -1914,9 +1921,12 @@ fn run_one_session( // T M10.5: match the server's protocol version so v1.0 frontends // attaching to v0.1 daemons advertise protocol_version=1. Same - // pattern as the local-socket path above. + // pattern as the local-socket path above, including Stage 2B-3's + // counter-offer: this path *spawns* the daemon from the running + // executable, so the peer is always this same ladder rung and the + // counter-offer is always accepted. let req = AttachRequest { - protocol_version: hello.protocol_version, + protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version), frontend_capabilities: build_capabilities(), initial_size, }; diff --git a/src/daemon.rs b/src/daemon.rs index c97e032..b6f20d0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -735,6 +735,15 @@ fn per_attach_thread( daemon_debug(format!("received AttachRequest from {frontend_id:?}")); // T M10.5 version check. + // + // Bottom-panel Stage 2B-3: this is where a session's version is + // actually settled. `Hello` above carried only the compatibility + // BASELINE (`ADVERTISED_PROTOCOL_VERSION`) — it has to, because a + // server-first handshake reaches a shipped frontend before that + // frontend can say anything, and a version it does not recognize is + // rejected outright. The frontend's counter-offer is therefore the + // upper half of the negotiation, and this membership test is what + // bounds it. if !crate::protocol::is_supported_protocol_version(req.protocol_version) { let _ = write_message( &mut stream, @@ -812,8 +821,16 @@ fn per_attach_thread( u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0) }; - let session_state = - crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot); + // The session speaks the lower of the two ceilings. The membership + // test above already bounds the offer, so this clamp cannot bind + // today; it is applied through the shared rule anyway so a future + // ladder widening cannot silently record a version this binary is + // unable to produce. + let session_state = crate::presence::SessionState::new( + crate::protocol::negotiated_session_version(req.protocol_version), + negotiated_caps, + color_slot, + ); // Hand the write-half to the dispatcher; keep a read-half for // this thread's reader loop. **Reader loop starts immediately @@ -899,23 +916,30 @@ fn peer_declared_terminal_support( /// /// Grid sessions paint the whole cell grid the daemon composes, so a side /// window is just another leaf for them. A semantic session needs the GPU -/// band, which does not exist yet — so this still answers `false` for -/// every semantic peer, whatever it declares. No client-asserted +/// band, which Stage 2B-3 lands — so it is panel-capable exactly when it +/// negotiated a wire that can carry the band. No client-asserted /// standalone boolean is trusted: the answer is derived from the daemon's /// own negotiated state. /// -/// **Stage 2B-2 deliberately does not turn the version arm on.** The -/// daemon-side projection and epoch machine below are complete and -/// exercised through a test-only panel-capable view, but the production -/// flip (`semantic_render && negotiated_protocol_version >= -/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the -/// compatibility-preserving activation the server-first `Hello` requires: +/// **Stage 2B-3 turns the version arm on** (framing §3.5): `panel_capable` +/// is true for an authenticated semantic session that negotiated +/// [`PANEL_MIN_VERSION`] or later, and false for every earlier one. The +/// gate is on *placement*, not only on transport — denying the events +/// while still putting a pre-panel peer's window in a side panel it cannot +/// render would leave that window invisible, so a v6–v20 semantic session +/// keeps the Stage 1 fallback with every side-specific parameter +/// discarded (Q#BP2c). +/// +/// The version reaching this predicate is the *negotiated* one, which is +/// the frontend's `AttachRequest` counter-offer rather than the /// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION) -/// is still 20, so no session can negotiate 21 yet, and denying only the -/// events while still *placing* such a peer in a side window would leave -/// its window invisible. +/// baseline the daemon put in `Hello`. That distinction is the whole +/// activation mechanism: the baseline stays where every shipped frontend +/// can accept it, and only a frontend that named the newer wire itself +/// becomes panel-capable. fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render + || session_state.negotiated_protocol_version >= PANEL_MIN_VERSION } /// The same belt-and-braces write-loop gate for the additive @@ -1959,10 +1983,11 @@ fn handle_session_established( // collapses folds, a semantic one keeps raw-line reckoning until // Stage 3. // Bottom-panel arc (Q#BP13): panel capability comes from the SAME - // negotiated bit in this same transaction. Stage 1 ships the TUI - // side windows only, so a semantic session is not panel-capable and - // a `side` request falls back to its document target with every - // side-specific parameter discarded. + // negotiated state in this same transaction. Stage 2B-3 made the + // semantic arm live: a semantic session that negotiated + // `PANEL_MIN_VERSION` or later can render the GPU band and is + // panel-capable, while a v6-v20 semantic session still falls back to + // its document target with every side-specific parameter discarded. let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, @@ -5570,7 +5595,7 @@ mod tests { fn viewport_aligns_the_document_without_taking_focus_from_the_panel() { let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); let other = { - let mut core = editor.core.borrow_mut(); + let core = editor.core.borrow_mut(); core.registry.borrow_mut().create("*other*") }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 2f56e8f..153ed6f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -579,9 +579,19 @@ mod crdt { ); let facts = parse_report(&report); assert_eq!(facts.get("phase").map(String::as_str), Some("complete")); + // Stage 2B-3: the negotiated session version and the advertised + // baseline are different facts, and this managed spawn pins both. + // The daemon it spawned advertises the baseline; the client it + // handed back counter-offered this binary's own wire. assert_eq!( facts - .get("server_protocol_version") + .get("session_protocol_version") + .and_then(|value| value.parse::().ok()), + Some(PROTOCOL_VERSION) + ); + assert_eq!( + facts + .get("baseline_protocol_version") .and_then(|value| value.parse::().ok()), Some(ADVERTISED_PROTOCOL_VERSION) ); diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index f6b1f7f..e4eb3c1 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -717,10 +717,25 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .filter_map(|line| line.split_once('=')) .collect(); + // Bottom-panel Stage 2B-3 activated v21 by frontend counter-offer, so + // the two halves of the handshake now report DIFFERENT numbers and both + // are load-bearing: the daemon still advertises the v20 compatibility + // baseline in its server-first `Hello` (so a shipped v20 frontend is + // never handed a version it must reject), while this real client + // negotiated v21 and is therefore panel-capable. Asserting only the + // session version would pass if the baseline had been bumped too — + // which is exactly the incompatible change this mechanism exists to + // avoid — and asserting only the baseline would pass with the whole + // activation missing. assert_eq!( - facts.get("server_protocol_version").copied(), + facts.get("session_protocol_version").copied(), + Some("21"), + "the real client must negotiate the v21 panel wire: {text}" + ); + assert_eq!( + facts.get("baseline_protocol_version").copied(), Some("20"), - "the dark v21 wire slice must keep the real client on v20: {text}" + "while the daemon's server-first Hello still advertises v20: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(),