feat(editor): sync gate state machine and bridge protocol codec

This commit is contained in:
Kayshen-X 2026-07-16 23:13:34 +08:00
parent e01542c18c
commit c277cfd012
4 changed files with 610 additions and 0 deletions

View file

@ -0,0 +1,202 @@
//! `postMessage` bridge protocol codec between the VS Code extension host
//! and the wasm-backed web editor. Inbound parsing is serde_json-based (a
//! hand-rolled scanner is unreliable for arbitrary JSON, and foreign
//! postMessage traffic — e.g. react-devtools — must be ignored, never
//! treated as an error); outbound events are built with `serde_json::json!`
//! and serialized once per call.
//!
//! Message `type` values — inbound: `op-bridge/init`, `op-bridge/open-document`,
//! `op-bridge/snapshot`, `op-bridge/save-committed`, `op-bridge/resolve-conflict`;
//! outbound: `op-bridge/ready`, `op-bridge/dirty-changed`, `op-bridge/opened`,
//! `op-bridge/snapshot-result`, `op-bridge/snapshot-conflict`,
//! `op-bridge/sync-conflict`, `op-bridge/conflict-resolved`. Field names are
//! camelCase (`requestId`, `serverVersion`, `docJson`).
use serde_json::Value;
#[derive(Debug, PartialEq)]
pub enum BridgeInbound {
Init {
token: String,
},
OpenDocument {
json: String,
},
Snapshot {
purpose: String,
request_id: String,
},
SaveCommitted {
generation: u64,
revision: u64,
},
ResolveConflict {
mode: ConflictMode,
request_id: String,
},
}
#[derive(Debug, PartialEq)]
pub enum ConflictMode {
UseLocal,
AcceptRemote,
}
impl BridgeInbound {
/// None for non-bridge / malformed messages (foreign postMessage traffic
/// like react-devtools must be ignored, never an error).
pub fn parse(raw: &str) -> Option<Self> {
let value: Value = serde_json::from_str(raw).ok()?;
let ty = value.get("type")?.as_str()?;
match ty {
"op-bridge/init" => {
let token = value.get("token")?.as_str()?.to_string();
Some(BridgeInbound::Init { token })
}
"op-bridge/open-document" => {
let json = value.get("json")?.as_str()?.to_string();
Some(BridgeInbound::OpenDocument { json })
}
"op-bridge/snapshot" => {
let purpose = value.get("purpose")?.as_str()?.to_string();
let request_id = value.get("requestId")?.as_str()?.to_string();
Some(BridgeInbound::Snapshot {
purpose,
request_id,
})
}
"op-bridge/save-committed" => {
let generation = value.get("generation")?.as_u64()?;
let revision = value.get("revision")?.as_u64()?;
Some(BridgeInbound::SaveCommitted {
generation,
revision,
})
}
"op-bridge/resolve-conflict" => {
let mode = match value.get("mode")?.as_str()? {
"use-local" => ConflictMode::UseLocal,
"accept-remote" => ConflictMode::AcceptRemote,
_ => return None,
};
let request_id = value.get("requestId")?.as_str()?.to_string();
Some(BridgeInbound::ResolveConflict { mode, request_id })
}
_ => None,
}
}
}
pub fn event_ready(generation: u64, revision: u64) -> String {
serde_json::json!({
"type": "op-bridge/ready",
"generation": generation,
"revision": revision,
})
.to_string()
}
pub fn event_dirty_changed(generation: u64, revision: u64, dirty: bool) -> String {
serde_json::json!({
"type": "op-bridge/dirty-changed",
"generation": generation,
"revision": revision,
"dirty": dirty,
})
.to_string()
}
pub fn event_opened(generation: u64) -> String {
serde_json::json!({
"type": "op-bridge/opened",
"generation": generation,
})
.to_string()
}
pub fn event_snapshot_result(
request_id: &str,
doc_json: &str,
generation: u64,
revision: u64,
) -> String {
serde_json::json!({
"type": "op-bridge/snapshot-result",
"requestId": request_id,
"docJson": doc_json,
"generation": generation,
"revision": revision,
})
.to_string()
}
pub fn event_snapshot_conflict(request_id: &str, server_version: u64) -> String {
serde_json::json!({
"type": "op-bridge/snapshot-conflict",
"requestId": request_id,
"serverVersion": server_version,
})
.to_string()
}
pub fn event_sync_conflict(generation: u64, revision: u64, server_version: u64) -> String {
serde_json::json!({
"type": "op-bridge/sync-conflict",
"generation": generation,
"revision": revision,
"serverVersion": server_version,
})
.to_string()
}
pub fn event_conflict_resolved(request_id: &str) -> String {
serde_json::json!({
"type": "op-bridge/conflict-resolved",
"requestId": request_id,
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_inbound_messages() {
assert_eq!(
BridgeInbound::parse(r#"{"type":"op-bridge/init","token":"t0k"}"#),
Some(BridgeInbound::Init {
token: "t0k".into()
})
);
assert_eq!(
BridgeInbound::parse(
r#"{"type":"op-bridge/save-committed","generation":3,"revision":41}"#
),
Some(BridgeInbound::SaveCommitted {
generation: 3,
revision: 41
})
);
assert_eq!(
BridgeInbound::parse(
r#"{"type":"op-bridge/resolve-conflict","mode":"use-local","requestId":"r1"}"#
),
Some(BridgeInbound::ResolveConflict {
mode: ConflictMode::UseLocal,
request_id: "r1".into()
})
);
assert_eq!(BridgeInbound::parse(r#"{"type":"react-devtools"}"#), None);
assert_eq!(BridgeInbound::parse("not json"), None);
}
#[test]
fn snapshot_result_json_escapes_doc_payload() {
let ev = event_snapshot_result("r1", r#"{"a":"b \" c"}"#, 2, 17);
let v: serde_json::Value = serde_json::from_str(&ev).unwrap();
assert_eq!(v["type"], "op-bridge/snapshot-result");
assert_eq!(v["docJson"], r#"{"a":"b \" c"}"#);
assert_eq!(v["generation"], 2);
}
}

View file

@ -17,6 +17,7 @@ pub mod agent_settings_button_state;
pub mod agent_settings_connection;
pub mod align;
pub mod align_guides;
pub mod bridge_protocol;
pub mod button_press_state;
pub mod chat;
pub mod chat_activity;
@ -87,6 +88,7 @@ pub mod statusbar_state;
pub mod svg_import;
pub mod svg_path_bounds;
mod svg_path_data;
pub mod sync_gate;
/// Tight source-coordinate bounds for an SVG path-data string.
pub fn svg_path_data_bounds(d: &str) -> Option<(f32, f32, f32, f32)> {

View file

@ -0,0 +1,359 @@
//! Pure sync-concurrency state machine shared by the periodic push tick,
//! the pull tick, and the bridge snapshot path. Baseline semantics: the
//! gate remembers the exact (generation, revision) pair last known to
//! match the daemon (after a pull apply, or after a push confirmation —
//! carrying the pair captured AT SERIALIZATION TIME, never "now"). Any
//! current pair differing from the baseline means unpushed local edits:
//! pulls are gated and a push is due. An oversize skip, failed POST, or
//! version conflict simply never advances the baseline, so the gate
//! cannot reopen and remote state cannot overwrite local edits.
/// Bytes cap for the periodic push channel (see [`SyncGate::periodic_push_allowed`]).
const PERIODIC_PUSH_CAP_BYTES: usize = 2 * 1024 * 1024;
#[derive(Default)]
pub struct SyncGate {
last_synced: Option<(u64, u64)>, // (generation, revision); None = never synced
conflict: Option<u64>, // server version at conflict time
// `opened` not yet emittable; holds the open's TARGET GENERATION —
// open completion is generation-scoped so a pre-existing in-flight
// push (whose confirmation carries a pair serialized BEFORE the open's
// replace_document, i.e. an older generation) can never complete an
// unrelated pending open prematurely.
open_pending: Option<u64>,
open_pull_block: bool, // pulls blocked while the open's probe/push is in flight
// AcceptRemote resolution records the pair observed at resolve time AND
// retains the conflict's server version; the resolving pull is allowed
// ONLY while the pair is unchanged, so edits landing between backup and
// pull-apply cannot be overwritten — and a broken window can re-enter
// the conflict flow with the retained version (no wedged state).
accept_expected: Option<((u64, u64), u64)>, // (expected pair, server version)
// CONSUMABLE edge latches, distinct from the condition fields above: a
// compare-based observer (cached-vs-current) misses edges that rise AND
// fall between two ticks (e.g. a fast open completing, or a conflict
// resolved before the observer ran). These latch the event so it can be
// drained exactly once, independent of whatever the "current" state (
// `open_pending`/`conflict`) happens to read at observation time.
opened_edge: Option<u64>, // generation to announce via `opened`
conflict_edge: Option<u64>, // server version to announce via `sync-conflict`
}
impl SyncGate {
/// After a pull apply (new baseline = post-apply pair) or a push
/// confirmation (baseline = the pair serialized into that push).
/// Clears conflict + accept_expected + any UNDRAINED conflict latch
/// (a resolved conflict must not be announced); clears
/// open_pending/open_pull_block ONLY when `generation >= open target
/// generation` (older confirmations leave the pending open untouched).
pub fn note_synced(&mut self, generation: u64, revision: u64) {
self.last_synced = Some((generation, revision));
self.conflict = None;
self.accept_expected = None;
self.conflict_edge = None;
if let Some(target) = self.open_pending {
if generation >= target {
self.open_pending = None;
self.open_pull_block = false;
// Latch the NOW-LIVE generation, not the open's original
// target — they differ when an AcceptRemote pull-apply
// completes the open with a newer replacement generation.
self.opened_edge = Some(generation);
}
}
}
/// Also clears accept_expected.
pub fn note_conflict(&mut self, server_version: u64) {
self.conflict = Some(server_version);
self.accept_expected = None;
self.conflict_edge = Some(server_version);
}
/// Set SYNCHRONOUSLY in the OpenDocument prologue, right after
/// replace_document minted the new generation and BEFORE the first
/// await (sets open_pending = Some(target_generation) + open_pull_block):
/// during bootstrap the baseline is None so pull_allowed would be true —
/// without the block a 400 ms pull can overwrite the just-replaced local
/// document while the open's probe/push is in flight.
pub fn note_open_pending(&mut self, target_generation: u64) {
self.open_pending = Some(target_generation);
self.open_pull_block = true;
}
pub fn open_pending(&self) -> bool {
self.open_pending.is_some()
}
/// Accept-remote resolution: forget the baseline, clear the conflict
/// AND any undrained conflict latch (retaining the server version
/// inside accept_expected), lift open_pull_block, and record `current`
/// as the expected pair for the resolving pull — but KEEP open_pending:
/// it clears when that pull's apply calls note_synced (bridge's cue to
/// emit `opened`).
pub fn resolve_accept_remote(&mut self, current: (u64, u64)) {
let server_version = self.conflict.take();
self.last_synced = None;
self.conflict_edge = None;
self.open_pull_block = false;
self.accept_expected = server_version.map(|v| (current, v));
}
/// Wiring escape hatch for a broken accept window: returns the retained
/// server version when accept_expected is set and `current` moved past
/// it (an edit landed before the resolving pull applied). The pull tick
/// checks this; Some(v) means "re-enter the conflict flow": call
/// note_conflict(v) (which clears accept_expected) so the user decides
/// again — the gate can never wedge at baseline=None/conflict=None.
pub fn accept_window_broken(&self, current: (u64, u64)) -> Option<u64> {
match self.accept_expected {
Some((expected, server_version)) if current != expected => Some(server_version),
_ => None,
}
}
/// CONSUMABLE edge latches — the bridge's tick observer drains these
/// instead of comparing cached values (a compare-based observer misses
/// edges that rise AND fall between ticks, e.g. a fast open completing
/// before the next tick; a latch cannot lose the event):
/// set by note_synced when it clears a pending open. Holds the
/// GENERATION PASSED TO note_synced — i.e. the now-live document's
/// generation, NOT the open's original target (they differ when an
/// AcceptRemote pull-apply completes the open with a newer replacement
/// generation; `opened` must name the document actually on screen).
pub fn take_opened_edge(&mut self) -> Option<u64> {
self.opened_edge.take()
}
/// set by note_conflict (holds the server version) — drain to emit
/// `sync-conflict`. CLEARED by note_synced and resolve_accept_remote:
/// a conflict resolved before the observer drained the latch must NOT
/// be announced (stale emission would pop a ghost dialog on the host).
/// A back-to-back second conflict simply sets the latch again after
/// the resolution cleared it — still reported.
pub fn take_conflict_edge(&mut self) -> Option<u64> {
self.conflict_edge.take()
}
/// Pulls allowed when open_pull_block is clear, no conflict pending,
/// AND no unpushed local edits. None baseline (mount) allows the
/// initial pull — EXCEPT when accept_expected is set: then the pull is
/// allowed only while `current == accept_expected` (an intervening edit
/// moves the pair; the wiring observes the blocked pull and re-notes
/// the conflict so the user decides again).
pub fn pull_allowed(&self, current: (u64, u64)) -> bool {
if self.open_pull_block || self.conflict.is_some() {
return false;
}
if let Some((expected, _)) = self.accept_expected {
return current == expected;
}
match self.last_synced {
None => true,
Some(baseline) => current == baseline,
}
}
/// Push due when NO conflict is pending (a stored conflict suspends the
/// periodic retry until explicit resolution — spec's "no silent retry"),
/// a baseline exists (daemon-authority: never push before the first
/// apply), and the current pair moved past it.
pub fn needs_push(&self, current: (u64, u64)) -> bool {
if self.conflict.is_some() {
return false;
}
match self.last_synced {
None => false,
Some(baseline) => current != baseline,
}
}
pub fn conflict(&self) -> Option<u64> {
self.conflict
}
/// len <= 2 MiB cap.
pub fn periodic_push_allowed(len: usize) -> bool {
len <= PERIODIC_PUSH_CAP_BYTES
}
/// Uncapped.
pub fn snapshot_push_allowed(_len: usize) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_during_inflight_push_stays_gated() {
let mut g = SyncGate::default();
assert!(g.pull_allowed((1, 0))); // mount: initial pull allowed
g.note_synced(1, 0); // first apply baselines
// edit A -> revision 1: push due, pull gated
assert!(g.needs_push((1, 1)));
assert!(!g.pull_allowed((1, 1)));
// push A in flight; edit B bumps revision to 2; A's confirmation
// carries the pair captured at serialization time (1, 1):
g.note_synced(1, 1);
assert!(!g.pull_allowed((1, 2))); // B still unpushed — gate stays closed
assert!(g.needs_push((1, 2)));
g.note_synced(1, 2); // push B confirmed
assert!(g.pull_allowed((1, 2)));
}
#[test]
fn ui_only_changes_do_not_close_the_gate() {
// revision is content-only (state.rs): an unchanged pair keeps pulls open
let mut g = SyncGate::default();
g.note_synced(1, 5);
assert!(g.pull_allowed((1, 5)));
assert!(!g.needs_push((1, 5))); // no deadlock: nothing to push, gate open
}
#[test]
fn never_pushes_before_first_sync() {
let g = SyncGate::default();
assert!(!g.needs_push((0, 3))); // daemon authority preserved
}
#[test]
fn open_pending_gates_pulls_through_bootstrap() {
let mut g = SyncGate::default();
assert!(g.pull_allowed((1, 0))); // bootstrap: baseline None, pulls open
g.note_open_pending(2); // prologue: replace_document minted gen 2
assert!(!g.pull_allowed((2, 0))); // pull may not overwrite the open
g.note_synced(2, 0); // open's own push confirmed
assert!(!g.open_pending());
assert!(g.pull_allowed((2, 0)));
}
#[test]
fn fast_open_completion_is_latched_not_lost() {
// open rises AND completes between two observer ticks — a compare-based
// observer would see false→false and drop `opened`; the latch cannot:
let mut g = SyncGate::default();
g.note_open_pending(2);
g.note_synced(2, 0);
assert_eq!(g.take_opened_edge(), Some(2)); // observer drains exactly once
assert_eq!(g.take_opened_edge(), None);
}
#[test]
fn back_to_back_conflicts_both_latch() {
let mut g = SyncGate::default();
g.note_synced(1, 0);
g.note_conflict(7);
assert_eq!(g.take_conflict_edge(), Some(7));
g.resolve_accept_remote((1, 1));
g.note_conflict(8); // second conflict lands before any observer tick
assert_eq!(g.take_conflict_edge(), Some(8)); // not lost
assert_eq!(g.take_conflict_edge(), None);
}
#[test]
fn resolved_conflict_latch_is_not_announced() {
// conflict latched, then resolved BEFORE the observer drained it —
// a stale emission would pop a ghost dialog on the host:
let mut g = SyncGate::default();
g.note_synced(1, 0);
g.note_conflict(7);
g.resolve_accept_remote((1, 1)); // resolution clears the pending latch
assert_eq!(g.take_conflict_edge(), None);
g.note_conflict(9);
g.note_synced(1, 2); // UseLocal-style completion also clears it
assert_eq!(g.take_conflict_edge(), None);
}
#[test]
fn older_push_confirmation_cannot_complete_a_pending_open() {
// Interleaving: a periodic push was in flight (serialized at gen 1)
// when OpenDocument minted gen 2 and started waiting on push_busy.
let mut g = SyncGate::default();
g.note_synced(1, 3); // steady state before the open
g.note_open_pending(2); // open targets generation 2
g.note_synced(1, 4); // the OLD push's confirmation lands
assert!(g.open_pending()); // must NOT complete the open (gen 1 < 2)
assert!(!g.pull_allowed((2, 0)));
g.note_synced(2, 0); // the open's own confirmation
assert!(!g.open_pending());
}
#[test]
fn accept_remote_keeps_open_pending_until_the_pull_applies() {
let mut g = SyncGate::default();
g.note_open_pending(2);
g.note_conflict(9); // open's push conflicted twice
g.resolve_accept_remote((2, 0));
assert!(g.open_pending()); // still pending: opened not yet emittable
assert!(g.pull_allowed((2, 0))); // the resolving pull may proceed
g.note_synced(3, 0); // remote applied (gen 3 >= target 2)
assert!(!g.open_pending());
// the latch names the NOW-LIVE generation (3), not the open's target (2):
assert_eq!(g.take_opened_edge(), Some(3));
}
#[test]
fn accept_remote_blocks_the_pull_if_an_edit_intervenes() {
let mut g = SyncGate::default();
g.note_synced(1, 1);
g.note_conflict(9);
g.resolve_accept_remote((1, 2)); // user accepted remote at pair (1,2)
assert!(g.pull_allowed((1, 2))); // unchanged: pull may apply
assert_eq!(g.accept_window_broken((1, 2)), None);
// an edit lands BEFORE the pull applies — those bytes must not be lost:
assert!(!g.pull_allowed((1, 3)));
// the gate itself hands the wiring the retained server version so the
// conflict flow can restart (no manual bookkeeping, no wedged state):
assert_eq!(g.accept_window_broken((1, 3)), Some(9));
g.note_conflict(9); // wiring re-enters the conflict flow
assert_eq!(g.accept_window_broken((1, 3)), None); // window consumed
assert!(!g.pull_allowed((1, 3)));
}
#[test]
fn generation_only_replacement_rebaselines_without_push() {
// open-document with byte-identical content: generation moves, bytes
// match the daemon. The wiring layer (Task 6) detects the byte match via
// should_push and must re-baseline directly instead of pushing —
// note_synced with the new pair reopens the gate:
let mut g = SyncGate::default();
g.note_synced(1, 0);
assert!(g.needs_push((2, 0))); // pair moved (generation bump)
assert!(!g.pull_allowed((2, 0)));
g.note_synced(2, 0); // wiring re-baselines on identical bytes
assert!(!g.needs_push((2, 0)));
assert!(g.pull_allowed((2, 0))); // no deadlock
}
#[test]
fn oversize_or_failed_push_keeps_gate_closed() {
let mut g = SyncGate::default();
g.note_synced(1, 0);
// edit -> (1,1); periodic tick skipped the push (oversize) — no
// confirmation, baseline unchanged, gate must stay closed:
assert!(!SyncGate::periodic_push_allowed(3 * 1024 * 1024));
assert!(!g.pull_allowed((1, 1)));
// snapshot channel is uncapped; its confirmation advances the baseline:
assert!(SyncGate::snapshot_push_allowed(3 * 1024 * 1024));
g.note_synced(1, 1);
assert!(g.pull_allowed((1, 1)));
}
#[test]
fn conflict_holds_gate_until_explicit_resolution() {
let mut g = SyncGate::default();
g.note_synced(1, 0);
g.note_conflict(12); // (1,1) push rejected
assert_eq!(g.conflict(), Some(12));
assert!(!g.pull_allowed((1, 1)));
// A stored conflict SUSPENDS the periodic push — no silent auto-retry
// on the next 2s tick; only explicit resolution may push again:
assert!(!g.needs_push((1, 1)));
g.resolve_accept_remote((1, 1));
assert_eq!(g.conflict(), None);
assert!(g.pull_allowed((1, 1))); // baseline forgotten: next pull re-baselines
}
}

View file

@ -61,6 +61,14 @@ impl WebSyncClient {
self.applied_version
}
/// Alias for [`applied_version`](Self::applied_version) in the bridge's
/// vocabulary — the sync-gate baseline's server-side half — for callers
/// that reason about "the last version this client knows about" rather
/// than the pull-decision path.
pub fn last_version(&self) -> u64 {
self.applied_version
}
/// Parse a `GET /api/mcp/document` response (`{document, version}`, the
/// daemon's shape — see `web_canvas_server::handle_web_canvas_request`) and
/// return the document + its version IFF it is newer than the last APPLIED
@ -139,6 +147,14 @@ impl WebSyncClient {
format!(r#"{{"document":{doc_json}}}"#)
}
/// Same shape as [`wrap_push_body`](Self::wrap_push_body) plus a
/// `baseVersion` field carrying the sync-gate baseline's server version —
/// the daemon's optimistic-concurrency check for a conditional push (see
/// [`parse_push_conflict`](Self::parse_push_conflict) for the rejection shape).
pub fn wrap_push_body_with_base(doc_json: &str, base_version: u64) -> String {
format!(r#"{{"document":{doc_json},"baseVersion":{base_version}}}"#)
}
/// True once the first daemon document has been applied. The push path is
/// gated on this: the host page may reset the daemon document on refresh,
/// but the browser must still pull that authoritative post-reset state
@ -202,6 +218,21 @@ impl WebSyncClient {
}
value.get("version")?.as_u64()
}
/// Parse a rejected `POST /api/mcp/document` push response's
/// `version-conflict` shape (`{"ok":false,"error":"version-conflict","version":N}`)
/// and return the daemon's current server version. `None` for an
/// accepted push or any other rejection reason.
pub fn parse_push_conflict(resp: &str) -> Option<u64> {
let value: serde_json::Value = serde_json::from_str(resp).ok()?;
if value.get("ok")?.as_bool() != Some(false) {
return None;
}
if value.get("error")?.as_str()? != "version-conflict" {
return None;
}
value.get("version")?.as_u64()
}
}
/// Stable change-detection key for the selection-sync push (TS
@ -425,6 +456,22 @@ mod tests {
assert_eq!(WebSyncClient::parse_push_response(""), None);
}
#[test]
fn push_body_with_base_and_conflict_roundtrip() {
let body = WebSyncClient::wrap_push_body_with_base(r#"{"pages":[]}"#, 7);
assert!(body.contains(r#""baseVersion":7"#));
assert_eq!(
WebSyncClient::parse_push_conflict(
r#"{"ok":false,"error":"version-conflict","version":12}"#
),
Some(12)
);
assert_eq!(
WebSyncClient::parse_push_conflict(r#"{"ok":true,"version":9}"#),
None
);
}
#[test]
fn selection_key_and_body_track_ids_and_active_page() {
let mut state = crate::EditorState::new();