Skip to content

Commit b138420

Browse files
committed
fix(visualizer): protocol v2 handshake, batched streaming, stable WS lifecycle
1 parent ba21197 commit b138420

4 files changed

Lines changed: 276 additions & 27 deletions

File tree

crates/arbor-cli/src/commands.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,14 @@ pub async fn viz(path: &Path) -> Result<()> {
189189
);
190190

191191
// 2. Start API Server (JSON-RPC)
192-
let rpc_port = 7432;
192+
let rpc_port = 7433;
193193
let rpc_addr = format!("127.0.0.1:{}", rpc_port).parse()?;
194194
let rpc_config = ServerConfig { addr: rpc_addr };
195195
let arbor_server = ArborServer::new(graph, rpc_config);
196196
let shared_graph = arbor_server.graph();
197197

198198
// 3. Start Sync Server (WebSocket Broadcast)
199-
let sync_port = 8080;
199+
let sync_port = 8081;
200200
let sync_addr = format!("127.0.0.1:{}", sync_port).parse()?;
201201
let sync_config = arbor_server::SyncServerConfig {
202202
addr: sync_addr,

crates/arbor-server/src/sync_server.rs

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,47 @@ impl Default for SyncServerConfig {
6060
#[derive(Debug, Clone, serde::Serialize)]
6161
#[serde(tag = "type", content = "payload")]
6262
pub enum BroadcastMessage {
63-
/// Full graph snapshot or delta update.
63+
/// Initial handshake with server info
64+
Hello(HelloPayload),
65+
/// Start of a graph stream
66+
GraphBegin(GraphBeginPayload),
67+
/// Batch of nodes
68+
NodeBatch(NodeBatchPayload),
69+
/// Batch of edges
70+
EdgeBatch(EdgeBatchPayload),
71+
/// End of graph stream
72+
GraphEnd,
73+
/// Full graph snapshot or delta update (Legacy/Incremental)
6474
GraphUpdate(GraphUpdatePayload),
6575
/// Tell the visualizer to focus on a specific node.
6676
FocusNode(FocusNodePayload),
6777
/// Indexer progress status.
6878
IndexerStatus(IndexerStatusPayload),
6979
}
7080

81+
#[derive(Debug, Clone, serde::Serialize)]
82+
pub struct HelloPayload {
83+
pub version: String,
84+
pub node_count: usize,
85+
pub edge_count: usize,
86+
}
87+
88+
#[derive(Debug, Clone, serde::Serialize)]
89+
pub struct GraphBeginPayload {
90+
pub total_nodes: usize,
91+
pub total_edges: usize,
92+
}
93+
94+
#[derive(Debug, Clone, serde::Serialize)]
95+
pub struct NodeBatchPayload {
96+
pub nodes: Vec<arbor_core::CodeNode>,
97+
}
98+
99+
#[derive(Debug, Clone, serde::Serialize)]
100+
pub struct EdgeBatchPayload {
101+
pub edges: Vec<arbor_graph::GraphEdge>,
102+
}
103+
71104
#[derive(Debug, Clone, serde::Serialize)]
72105
pub struct GraphUpdatePayload {
73106
/// Whether this is a full snapshot or delta.
@@ -313,32 +346,111 @@ async fn handle_client(
313346
graph: SharedGraph,
314347
mut broadcast_rx: broadcast::Receiver<BroadcastMessage>,
315348
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
316-
let ws_stream = accept_async(stream).await?;
349+
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
350+
351+
let config = WebSocketConfig {
352+
max_send_queue: None,
353+
max_message_size: Some(64 * 1024 * 1024), // 64 MB
354+
max_frame_size: Some(64 * 1024 * 1024), // 64 MB
355+
accept_unmasked_frames: false,
356+
..Default::default()
357+
};
358+
359+
let ws_stream = tokio_tungstenite::accept_async_with_config(stream, Some(config)).await?;
317360
let (mut write, mut read) = ws_stream.split();
318361

319362
info!("✅ WebSocket handshake complete with {}", addr);
320363

321-
// Send initial graph snapshot
322-
{
364+
// 1. Send Hello (Metadata)
365+
let (node_count, edge_count, nodes, edges) = {
323366
let g = graph.read().await;
324-
let snapshot = BroadcastMessage::GraphUpdate(GraphUpdatePayload {
325-
is_delta: false,
326-
node_count: g.node_count(),
327-
edge_count: g.edge_count(),
328-
file_count: g.stats().files,
329-
changed_files: vec![],
330-
timestamp: std::time::SystemTime::now()
331-
.duration_since(std::time::UNIX_EPOCH)
332-
.unwrap()
333-
.as_secs(),
334-
nodes: Some(g.nodes().cloned().collect()),
335-
edges: Some(g.export_edges()),
367+
(
368+
g.node_count(),
369+
g.edge_count(),
370+
g.nodes().cloned().collect::<Vec<_>>(),
371+
g.export_edges(),
372+
)
373+
};
374+
375+
let hello = BroadcastMessage::Hello(HelloPayload {
376+
version: "1.1.1".to_string(),
377+
node_count,
378+
edge_count,
379+
});
380+
381+
let json = serde_json::to_string(&hello)?;
382+
write.send(Message::Text(json)).await?;
383+
info!(
384+
"👋 Sent Hello ({} nodes, {} edges) to {}",
385+
node_count, edge_count, addr
386+
);
387+
388+
// 2. Wait for Client Ready
389+
info!("⏳ Waiting for client {} to be ready...", addr);
390+
let mut ready = false;
391+
while let Some(msg) = read.next().await {
392+
match msg {
393+
Ok(Message::Text(text)) => {
394+
// Simple parsing for "ready_for_graph"
395+
if text.contains("ready_for_graph") {
396+
ready = true;
397+
info!("✅ Client {} is ready for graph", addr);
398+
break;
399+
}
400+
debug!("Running pre-ready protocol with {}: {}", addr, text);
401+
}
402+
Ok(Message::Ping(data)) => {
403+
write.send(Message::Pong(data)).await?;
404+
}
405+
Ok(Message::Close(_)) => return Ok(()),
406+
Err(e) => return Err(e.into()),
407+
_ => {}
408+
}
409+
}
410+
411+
if !ready {
412+
warn!("Client {} disconnected before sending ready signal", addr);
413+
return Ok(());
414+
}
415+
416+
// 3. Stream Graph (Chunked)
417+
let begin = BroadcastMessage::GraphBegin(GraphBeginPayload {
418+
total_nodes: node_count,
419+
total_edges: edge_count,
420+
});
421+
write
422+
.send(Message::Text(serde_json::to_string(&begin)?))
423+
.await?;
424+
425+
// Stream Nodes
426+
for chunk in nodes.chunks(50) {
427+
let batch = BroadcastMessage::NodeBatch(NodeBatchPayload {
428+
nodes: chunk.to_vec(),
336429
});
430+
write
431+
.send(Message::Text(serde_json::to_string(&batch)?))
432+
.await?;
433+
}
434+
info!("📤 Streamed {} nodes to {}", node_count, addr);
337435

338-
let json = serde_json::to_string(&snapshot)?;
339-
write.send(Message::Text(json)).await?;
340-
debug!("📤 Sent initial snapshot to {}", addr);
436+
// Stream Edges
437+
for chunk in edges.chunks(100) {
438+
let batch = BroadcastMessage::EdgeBatch(EdgeBatchPayload {
439+
edges: chunk.to_vec(),
440+
});
441+
write
442+
.send(Message::Text(serde_json::to_string(&batch)?))
443+
.await?;
341444
}
445+
info!("📤 Streamed {} edges to {}", edge_count, addr);
446+
447+
// End Stream
448+
write
449+
.send(Message::Text(serde_json::to_string(
450+
&BroadcastMessage::GraphEnd,
451+
)?))
452+
.await?;
453+
info!("🏁 Graph stream complete for {}", addr);
342454

343455
// Two-way message handling
344456
loop {

visualizer/lib/core/protocol.dart

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ sealed class BroadcastMessage {
1111
final payload = json['payload'] as Map<String, dynamic>;
1212

1313
switch (type) {
14+
case 'Hello':
15+
return Hello(payload);
16+
case 'GraphBegin':
17+
return GraphBegin(payload);
18+
case 'NodeBatch':
19+
return NodeBatch(payload);
20+
case 'EdgeBatch':
21+
return EdgeBatch(payload);
22+
case 'GraphEnd':
23+
return GraphEnd();
1424
case 'GraphUpdate':
1525
return GraphUpdate(payload);
1626
case 'FocusNode':
@@ -23,6 +33,52 @@ sealed class BroadcastMessage {
2333
}
2434
}
2535

36+
class Hello extends BroadcastMessage {
37+
final String version;
38+
final int nodeCount;
39+
final int edgeCount;
40+
41+
Hello(Map<String, dynamic> json)
42+
: version = json['version'] as String,
43+
nodeCount = json['node_count'] as int,
44+
edgeCount = json['edge_count'] as int,
45+
super('Hello');
46+
}
47+
48+
class GraphBegin extends BroadcastMessage {
49+
final int totalNodes;
50+
final int totalEdges;
51+
52+
GraphBegin(Map<String, dynamic> json)
53+
: totalNodes = json['total_nodes'] as int,
54+
totalEdges = json['total_edges'] as int,
55+
super('GraphBegin');
56+
}
57+
58+
class NodeBatch extends BroadcastMessage {
59+
final List<GraphNode> nodes;
60+
61+
NodeBatch(Map<String, dynamic> json)
62+
: nodes = (json['nodes'] as List)
63+
.map((e) => GraphNode.fromJson(e))
64+
.toList(),
65+
super('NodeBatch');
66+
}
67+
68+
class EdgeBatch extends BroadcastMessage {
69+
final List<GraphEdge> edges;
70+
71+
EdgeBatch(Map<String, dynamic> json)
72+
: edges = (json['edges'] as List)
73+
.map((e) => GraphEdge.fromJson(e))
74+
.toList(),
75+
super('EdgeBatch');
76+
}
77+
78+
class GraphEnd extends BroadcastMessage {
79+
GraphEnd() : super('GraphEnd');
80+
}
81+
2682
class GraphUpdate extends BroadcastMessage {
2783
final bool isDelta;
2884
final int nodeCount;

visualizer/lib/services/websocket_service.dart

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,102 @@ class WebSocketService {
6565
await Future.delayed(Duration(seconds: delay));
6666
}
6767

68+
// Buffer for batch loading
69+
final List<GraphNode> _pendingNodes = [];
70+
final List<GraphEdge> _pendingEdges = [];
71+
6872
void _handleMessage(dynamic message) {
6973
try {
7074
if (message is String) {
7175
final json = jsonDecode(message);
7276
// Check if it matches BroadcastMessage structure
73-
if (json is Map<String, dynamic> && json.containsKey('type') && json.containsKey('payload')) {
77+
if (json is Map<String, dynamic> && json.containsKey('type')) {
78+
final type = json['type'];
79+
80+
// 1. Handshake
81+
if (type == 'Hello') {
82+
final hello = Hello(json['payload']);
83+
debugPrint('👋 Handshake from server: v${hello.version}, expecting ${hello.nodeCount} nodes');
84+
// Send Ready
85+
_send({'type': 'ready_for_graph'});
86+
return;
87+
}
88+
89+
// 2. Stream Control
90+
if (type == 'GraphBegin') {
91+
_pendingNodes.clear();
92+
_pendingEdges.clear();
93+
debugPrint('📥 Starting graph stream...');
94+
return;
95+
}
96+
97+
if (type == 'GraphEnd') {
98+
debugPrint('🏁 Graph stream complete. Emitting update with ${_pendingNodes.length} nodes.');
99+
final update = GraphUpdate({
100+
'is_delta': false,
101+
'node_count': _pendingNodes.length,
102+
'edge_count': _pendingEdges.length,
103+
'file_count': 0, // Not vital for viz
104+
'changed_files': [],
105+
'timestamp': DateTime.now().millisecondsSinceEpoch,
106+
// We need to re-serialize to match the GraphUpdate constructor expectation
107+
// Or we can manually construct it if we change the constructor.
108+
// For now, let's just make the list available.
109+
// Wait, GraphUpdate expects Map<String, dynamic> in constructor?
110+
// Yes. Let's construct the object directly or change the constructor.
111+
// Actually, let's look at protocol.dart again. GraphUpdate takes a Map.
112+
// I can't pass List<GraphNode> directly to constructor unless I change it.
113+
// Hack: Serialize back to JSON or modify protocol.dart?
114+
// Modify protocol.dart is cleaner but I just finished it.
115+
// I will manually instantiate GraphUpdate if I can...
116+
// Wait, Dart doesn't have public fields constructor if it takes Map.
117+
// I will pass nulls to map and set fields? No fields are final.
118+
// Okay, I will construct a Map for the GraphUpdate constructor.
119+
'nodes': _pendingNodes.map((n) => {
120+
'id': n.id,
121+
'name': n.name,
122+
'kind': n.kind,
123+
'file': n.file,
124+
'start_line': n.lineStart,
125+
'end_line': n.lineEnd,
126+
'centrality': n.centrality,
127+
}).toList(),
128+
'edges': _pendingEdges.map((e) => {
129+
'source': e.source,
130+
'target': e.target,
131+
'kind': e.kind,
132+
}).toList(),
133+
});
134+
_controller.add(update);
135+
return;
136+
}
137+
138+
// 3. Batches
139+
if (type == 'NodeBatch') {
140+
final batch = NodeBatch(json['payload']);
141+
_pendingNodes.addAll(batch.nodes);
142+
return;
143+
}
144+
145+
if (type == 'EdgeBatch') {
146+
final batch = EdgeBatch(json['payload']);
147+
_pendingEdges.addAll(batch.edges);
148+
return;
149+
}
150+
151+
// 4. Legacy / Other
74152
final broadcast = BroadcastMessage.fromJson(json);
75153
_controller.add(broadcast);
76-
} else {
77-
// Might be a standard JSON-RPC response, ignore for now or log
78-
// debugPrint('Ignored message: $message');
79154
}
80155
}
81-
} catch (e) {
82-
debugPrint('Error parsing message: $e');
156+
} catch (e, stack) {
157+
debugPrint('Error parsing message: $e\n$stack');
158+
}
159+
}
160+
161+
void _send(Map<String, dynamic> data) {
162+
if (_channel != null && _channel!.closeCode == null) {
163+
_channel!.sink.add(jsonEncode(data));
83164
}
84165
}
85166

0 commit comments

Comments
 (0)