How Xudanu delivers real-time events to clients: detector subscriptions, content watch, and CRDT collaborative relay.
Xudanu implements three distinct notification mechanisms, each designed for a different use case. They share a common WebSocket transport but differ in delivery model, scope, and complexity.
| Channel | Delivery | Scope | Trigger |
|---|---|---|---|
| Detector Events | Push (mpsc channel) | Per-work or per-edition | Mutation (grab, revise, fill) |
| Content Watch | Drain on request | Cross-work content match | Fingerprint lookup on revise |
| CRDT Relay | Client polls via CrdtSyncDiff | Same-document subscribers | Any subscriber edits |
Detector events are the original notification mechanism, modelled after the Udanax-Gold observer pattern. A client subscribes to a specific work or edition and receives push events whenever that target changes.
Detector class hierarchy. Xudanu reimplements it in Rust with async channels replacing C++ virtual callbacks.
| Detector Type | Events | Event Code | When Fired |
|---|---|---|---|
Status |
WorkGrabbed, WorkReleased |
0x01, 0x02 |
work_grab(), work_release(), disconnect() |
Revision |
WorkRevised |
0x03 |
revise_work() after edition update |
Fill |
RangeFilled, ElementFilled |
0x04, 0x05 |
Region or element populated |
The key architectural insight: the server core is synchronous (Mutex-protected). The ChannelDetector bridges sync to async by pushing into an mpsc channel. A dedicated writer task on the async side drains the channel and writes to the WebSocket.
// Subscribe to work status changes (grab/release)
ws.send(JSON.stringify({
v: 2, type: 'subscribe', id: nextId(),
payload: { detector_type: 'status', target_id: workId }
}));
// Response contains subscription_id
// Handle incoming events
ws.onmessage = function(msg) {
var data = JSON.parse(msg.data);
if (data.type === 'event') {
var event = data.event;
if (event.type === 'work_grabbed') {
console.log('Work grabbed by session', event.payload.session_id);
} else if (event.type === 'work_released') {
console.log('Work released');
}
}
};
// Unsubscribe when done
ws.send(JSON.stringify({ v: 2, type: 'unsubscribe', id: subId }));
The ChannelDetector is the sync-to-async bridge that makes detector events work. It implements the Detector trait but instead of processing the event itself, it wraps it in an EventMessage and sends it through a Tokio unbounded channel.
ChannelDetector is in src/server/transport/channel.rs. The Detector trait and DetectorList are in src/server/detector.rs. The writer task is spawned in src/server/transport/handler.rs:347.
Content Watch is the newest notification channel. When a user clicks Watch on a document, the server monitors for any other documents that share content with it — both past matches that already exist and future matches that appear as documents are edited.
Traditional hypertext systems require users to actively search for shared content. Content Watch inverts this: the server proactively notifies you when your content appears elsewhere. This is the Xanadu vision of automatic attribution and connection between documents.
var watchSubId = null;
var watchResults = [];
function startWatch(workId) {
var id = nextId();
ws.send(JSON.stringify({
v: 2, type: 'subscribe', id: id,
payload: {
detector_type: 'content_transcluders',
target_id: workId
}
}));
}
// Handle content_match events
if (msg.event && msg.event.type === 'content_match') {
var p = msg.event.payload;
watchResults.push({
edition_be_id: p.edition_be_id,
is_direct: p.is_direct
});
toast('Transclusion found in edition ' + p.edition_be_id.toString(16));
}
function stopWatch() {
if (watchSubId) {
ws.send(JSON.stringify({
v: 2, type: 'unsubscribe', id: watchSubId
}));
watchSubId = null;
}
}
The key data structure enabling Content Watch is fossil_by_fingerprint, a reverse index in the BackfollowEngine that maps content fingerprints to the fossil IDs watching for them.
When a recorder is planted during subscription, each content element's BLAKE3 fingerprint is mapped to the fossil ID. When any edition is revised, the new fingerprints are looked up in this index to find affected fossils in O(1) per element — no tree walk needed.
A critical design decision: content notifications are queued globally on the server, but drained per-session. Each client only receives notifications for its own fossils.
drain_content_notifications() used std::mem::take to drain ALL notifications. In a multi-client scenario, the first client to send a request would consume everyone's notifications. The fix partitions the queue by fossil ownership.
The WS handler maintains three maps to track subscriptions across all notification types. Proper cleanup on both explicit unsubscribe and WebSocket disconnect is essential to avoid resource leaks.
Unsubscribe handler only cleaned up subscriptions. Content subscriptions in content_subscriptions and fossil_to_sub were never cleaned up on explicit unsubscribe. Fossils remained planted, consuming CPU on every revise_work(). The fix adds complete cleanup for both unsubscribe and disconnect.
The CRDT system uses a fundamentally different notification model: relay. When one client edits a shared document, the server applies the change and returns a list of other subscribers who need to be notified. The receiving client then polls for the update.
Channel: mpsc event channel (push)
Encoding: EventPayload enum (typed)
Scope: Any subscriber to a work
Delivery: Async writer task pushes immediately
Channel: Returned in response (pull)
Encoding: Raw Yjs update bytes
Scope: Same-document subscribers only
Delivery: Client calls CrdtSyncDiff to fetch
Beyond document content, the CRDT system also relays awareness state: cursor positions, text selections, and typing indicators. Each subscriber's awareness is stored in the WorkDoc and relayed to all other subscribers.
// Open CRDT sync
ws.send(JSON.stringify({
v:2, type:'request', id:nextId(), op:'crdt_sync_open',
payload: { work_id: workId }
}));
// Response: { state_vector: [...], current_text: "hello world" }
// Send edits
ws.send(JSON.stringify({
v:2, type:'request', id:nextId(), op:'crdt_sync_update',
payload: { work_id: workId, update: Array.from(updateBytes) }
}));
// Response: { relay_count: 2 }
// Poll for changes (every 50-100ms during active editing)
ws.send(JSON.stringify({
v:2, type:'request', id:nextId(), op:'crdt_sync_diff',
payload: { work_id: workId, state_vector: Array.from(mySV) }
}));
// Response: { update: [...] }
// Send awareness (cursor)
ws.send(JSON.stringify({
v:2, type:'request', id:nextId(), op:'crdt_awareness_update',
payload: {
work_id: workId,
state: { session_id: sid, user_name: "Alice",
cursor: { index: 5 }, selection: null, is_typing: true }
}
}));
All notification types share a common wire format. Events are delivered as WebSocket messages with the Event message type (0x04).
{
"v": 2,
"type": "event",
"id": 42,
"event": {
"type": "content_match",
"payload": {
"fossil_id": 7,
"edition_be_id": "0000000000abc123",
"is_direct": true
}
}
}
| DetectorType | Subscribe Code | JSON String |
|---|---|---|
| Status | 0x01 | "status" |
| Revision | 0x02 | "revision" |
| Fill | 0x03 | "fill" |
| ContentTranscluders | 0x04 | "content_transcluders" |
| ContentWorks | 0x05 | "content_works" |
The notification system was significantly improved during development. Here is a summary of all enhancements over the original design:
drain_content_notifications() used std::mem::take to drain ALL notifications from the global queue.
Client A sends request → gets ALL notifications (including B's and C's). Clients B and C get nothing.
drain_content_notifications_for(fossil_set) partitions the queue. Matching notifications returned; non-matching left for their owners.
Each client only consumes its own notifications.
Unsubscribe only removed from subscriptions map. Content subscriptions in content_subscriptions and fossil_to_sub were never cleaned up.
Fossils stayed planted, consuming CPU on every revise_work().
Unsubscribe now checks both maps. Calls recorder_extinguish() to kill the fossil and remove_planted_recorder() to clean the fingerprint index.
Resources fully released on explicit unsubscribe.
Used canopy walking to find affected recorders — traversing the edition hierarchy for every edit.
check_recorders_by_content() uses the fossil_by_fingerprint reverse index for O(1) lookup per content element.
Past matches only searched by edition ID reference (RangeElement::edition(eid)), missing transcluders that share content but not the same edition reference.
process_agenda_with_engine() iterates over each watched_content element independently, finding all transcluders based on shared content.
TODO.md.
CrdtSyncDiff (typically every 50-100ms during active editing).
| File | Purpose |
|---|---|
src/server/detector.rs | Event enum, Detector trait, DetectorList |
src/server/transport/channel.rs | ChannelDetector (sync → async bridge) |
src/server/transport/handler.rs | WS handler: subscribe/unsubscribe/drain |
src/server/transport/protocol.rs | DetectorType, EventPayload, EventCode |
src/server/server.rs | Server: fire events, trigger recorders, drain notifications |
src/edition/recorder.rs | RecorderSystem, Fossil, RecorderQuery |
src/edition/backfollow.rs | BackfollowEngine, fingerprint index, plant/check recorders |
src/server/crdt_manager.rs | CRDT: relay, awareness, materialization |