Skip to main content

open_chess_server/http/
streams.rs

1//! NDJSON streams: a player's event stream, a game's board/bot stream and
2//! the public move stream.
3//!
4//! Each stream is a task writing lines into a channel; it ends when the
5//! client goes (a send fails) or, for a game, when the game is over.
6//! Changes reach every replica through Postgres notifications; a stream
7//! reloads the game from the database on each one, so it never relies on
8//! a note's contents for state.
9
10use std::collections::HashSet;
11use std::time::Duration;
12
13use axum::extract::{MatchedPath, Path, State};
14use axum::response::Response;
15use bytes::Bytes;
16use chess_core::{Game, Side};
17use chess_proto as p;
18use serde_json::json;
19use tokio::sync::broadcast::error::RecvError;
20use tokio::sync::mpsc;
21use tracing::{debug, warn};
22
23use crate::app::{Ctx, Who};
24use crate::db::Note;
25use crate::error::{ApiError, ApiResult};
26use crate::http::{line, ndjson};
27use crate::metrics::StreamGuard;
28use crate::model::{self, GameRow};
29use crate::play::{self, Api};
30
31const KEEPALIVE: Duration = Duration::from_secs(6);
32
33// ─── /api/stream/event ───
34
35pub async fn events(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
36    let id = who.need(&["challenge:read", "bot:play", "board:play"])?.clone();
37    let c = ctx.db.conn().await?;
38    model::ensure_account(&ctx.db, &*c, id.player).await?;
39    drop(c);
40    let slot = ctx.slot(id.player, "event")?;
41    let (tx, rx) = mpsc::channel::<Bytes>(64);
42    tokio::spawn(async move {
43        event_stream(ctx, id.player, tx).await;
44        drop(slot);
45    });
46    Ok(ndjson(rx))
47}
48
49/// What one event stream has told its client, so a catch-up after missed
50/// notifications sends what's missing and nothing twice.
51#[derive(Default)]
52struct Sent {
53    games: HashSet<String>,
54    challenges: HashSet<String>,
55    /// Games and challenges whose end (gameFinish, challengeDeclined,
56    /// challengeCanceled, or a challenge turned into a game) was sent.
57    ended: HashSet<String>,
58}
59
60impl Sent {
61    /// Record an event; false if it was already sent.
62    fn note(&mut self, event: &serde_json::Value) -> bool {
63        let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
64        let game = event.pointer("/game/id").and_then(|v| v.as_str()).map(str::to_owned);
65        let challenge = event.pointer("/challenge/id").and_then(|v| v.as_str()).map(str::to_owned);
66        match (kind, game, challenge) {
67            (p::GameEvent::START, Some(g), _) => self.games.insert(g),
68            (p::GameEvent::FINISH, Some(g), _) => {
69                self.games.insert(g.clone());
70                self.ended.insert(g)
71            }
72            (p::ChallengeEvent::CREATED, _, Some(c)) => self.challenges.insert(c),
73            (p::ChallengeEvent::DECLINED | p::ChallengeEvent::CANCELED, _, Some(c)) => {
74                self.challenges.insert(c.clone());
75                self.ended.insert(c)
76            }
77            _ => true,
78        }
79    }
80}
81
82async fn event_stream(ctx: Ctx, player: i64, tx: mpsc::Sender<Bytes>) {
83    let _g = StreamGuard::new(&ctx.metrics, "event");
84    // Subscribe first, so nothing between the catch-up and the loop is lost.
85    let mut notes = ctx.db.subscribe();
86    let mut sent = Sent::default();
87    if catch_up(&ctx, player, &tx, &mut sent).await.is_err() {
88        return;
89    }
90    let mut keep = tokio::time::interval(KEEPALIVE);
91    let mut seen = tokio::time::interval(Duration::from_secs(30));
92    loop {
93        tokio::select! {
94            n = notes.recv() => match n {
95                Ok(Note::User { player: p, event }) if p == player => {
96                    if sent.note(&event) && tx.send(line(&event)).await.is_err() {
97                        break;
98                    }
99                }
100                Ok(Note::Resync) | Err(RecvError::Lagged(_)) => {
101                    if catch_up(&ctx, player, &tx, &mut sent).await.is_err() {
102                        break;
103                    }
104                }
105                Ok(_) => {}
106                Err(RecvError::Closed) => break,
107            },
108            _ = keep.tick() => {
109                if tx.send(Bytes::from_static(b"\n")).await.is_err() {
110                    break;
111                }
112            }
113            _ = seen.tick() => {
114                if let Ok(c) = ctx.db.conn().await {
115                    let _ = c.execute(&ctx.db.sql("UPDATE chess.accounts SET seen_at = now() WHERE player_id = $1"), &[&player]).await;
116                }
117            }
118        }
119    }
120    debug!(player, "event stream closed");
121}
122
123/// Bring the client up to date from the database: ongoing games
124/// (gameStart), open challenges, and the ends of games and challenges it
125/// was told about whose end notification may have been missed.
126async fn catch_up(ctx: &Ctx, player: i64, tx: &mpsc::Sender<Bytes>, sent: &mut Sent) -> Result<(), ()> {
127    let mut out: Vec<serde_json::Value> = Vec::new();
128    let r: anyhow::Result<()> = async {
129        let c = ctx.db.conn().await?;
130        c.execute(&ctx.db.sql("UPDATE chess.accounts SET seen_at = now() WHERE player_id = $1"), &[&player])
131            .await?;
132        let ongoing = model::ongoing_games(&ctx.db, &*c, player).await?;
133        let live: HashSet<String> = ongoing.iter().map(|g| g.id.clone()).collect();
134        out.extend(ongoing.iter().filter_map(|g| g.event(player, p::GameEvent::START)));
135        // Games we announced that have ended since.
136        let gone: Vec<String> =
137            sent.games.iter().filter(|g| !live.contains(*g) && !sent.ended.contains(*g)).cloned().collect();
138        for id in gone {
139            if let Some(g) = model::game(&ctx.db, &*c, &id).await?
140                && !g.status.is_ongoing()
141                && let Some(e) = g.event(player, p::GameEvent::FINISH)
142            {
143                out.push(e);
144            }
145        }
146        let (incoming, outgoing) = crate::challenges::list(ctx, player).await?;
147        let open: HashSet<String> = incoming.iter().chain(&outgoing).map(|c| c.id.clone()).collect();
148        for ch in incoming.into_iter().chain(outgoing) {
149            out.push(json!({"type": "challenge", "challenge": ch, "compat": {"bot": true, "board": true}}));
150        }
151        // Challenges we announced that were answered since.
152        let answered: Vec<String> = sent
153            .challenges
154            .iter()
155            .filter(|c| !open.contains(*c) && !sent.ended.contains(*c))
156            .cloned()
157            .collect();
158        for id in answered {
159            let Some(ch) = model::challenge(&ctx.db, &*c, &id, false).await? else { continue };
160            let kind = match ch.status.as_str() {
161                "declined" => p::ChallengeEvent::DECLINED,
162                "canceled" | "offline" => p::ChallengeEvent::CANCELED,
163                _ => {
164                    // Accepted: its gameStart (above, or already sent) says so.
165                    sent.ended.insert(id);
166                    continue;
167                }
168            };
169            let ratings = model::challenge_ratings(&ctx.db, &*c, &ch).await?;
170            out.push(
171                json!({"type": kind, "challenge": ch.json(ctx.cfg.public_url(), Some(player), ratings)}),
172            );
173        }
174        Ok(())
175    }
176    .await;
177    if let Err(e) = r {
178        warn!("event stream catch-up: {e:#}");
179    }
180    for e in out {
181        if sent.note(&e) && tx.send(line(&e)).await.is_err() {
182            return Err(());
183        }
184    }
185    Ok(())
186}
187
188// ─── /api/{board,bot}/game/stream/{id} ───
189
190pub async fn board_game(
191    State(ctx): State<Ctx>,
192    mp: MatchedPath,
193    who: Who,
194    Path(id): Path<String>,
195) -> ApiResult<Response> {
196    game_stream(ctx, &mp, who, id, Api::Board).await
197}
198
199pub async fn bot_game(
200    State(ctx): State<Ctx>,
201    mp: MatchedPath,
202    who: Who,
203    Path(id): Path<String>,
204) -> ApiResult<Response> {
205    game_stream(ctx, &mp, who, id, Api::Bot).await
206}
207
208async fn game_stream(ctx: Ctx, _mp: &MatchedPath, who: Who, id: String, api: Api) -> ApiResult<Response> {
209    let scope = if api == Api::Bot { "bot:play" } else { "board:play" };
210    let me = who.need(&[scope])?.player;
211    // Subscribe before loading, so a change made in between still arrives
212    // (at worst as a reload that finds nothing new).
213    let notes = ctx.db.subscribe();
214    let c = ctx.db.conn().await?;
215    let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
216    drop(c);
217    let side = row.side_of(me).ok_or_else(|| ApiError::not_found("game"))?;
218    api.check(row.player(side))?;
219    let slot = ctx.slot(me, "game")?;
220    let (tx, rx) = mpsc::channel::<Bytes>(64);
221    let first = line(&row.full(ctx.first_move_ms()));
222    let _ = tx.send(first).await;
223    if row.status.is_ongoing() {
224        tokio::spawn(async move {
225            follow_game(ctx, row, me, side, tx, notes).await;
226            drop(slot);
227        });
228    }
229    Ok(ndjson(rx))
230}
231
232async fn follow_game(
233    ctx: Ctx,
234    mut row: GameRow,
235    me: i64,
236    side: Side,
237    tx: mpsc::Sender<Bytes>,
238    mut notes: tokio::sync::broadcast::Receiver<Note>,
239) {
240    let _g = StreamGuard::new(&ctx.metrics, "game");
241    let fm = ctx.first_move_ms();
242    let opp = row.player(side.opposite()).player_id;
243    let state_key = |r: &GameRow| serde_json::to_string(&strip_clock(r.state(fm))).unwrap_or_default();
244    let mut last = state_key(&row);
245    let mut gone: Option<Option<i64>> = None;
246    let _ = play::touch(&ctx, &row.id, me).await;
247    let mut keep = tokio::time::interval(KEEPALIVE);
248    // Refresh presence well inside the "gone" window, however it's set.
249    let beat_ms = (ctx.cfg.gone_after_secs * 1000 / 4).clamp(250, 5000);
250    let mut beat = tokio::time::interval(Duration::from_millis(beat_ms));
251    let mut check = tokio::time::interval(Duration::from_secs(2));
252    loop {
253        let reload = tokio::select! {
254            n = notes.recv() => match n {
255                Ok(Note::Game(g)) => g == row.id,
256                Ok(Note::Chat { game, line: l }) if game == row.id => {
257                    if tx.send(line(&l)).await.is_err() {
258                        break;
259                    }
260                    false
261                }
262                Ok(Note::Resync) | Err(RecvError::Lagged(_)) => true,
263                Ok(_) => false,
264                Err(RecvError::Closed) => break,
265            },
266            _ = keep.tick() => {
267                if tx.send(Bytes::from_static(b"\n")).await.is_err() {
268                    break;
269                }
270                false
271            }
272            _ = beat.tick() => {
273                let _ = play::touch(&ctx, &row.id, me).await;
274                false
275            }
276            _ = check.tick() => {
277                // Is the opponent still here?
278                if let Ok(c) = ctx.db.conn().await
279                    && let Ok(absent) = play::absent_secs(&ctx, &*c, &row, opp).await
280                {
281                    let now = play::claim_in(&ctx, absent);
282                    let changed = match (gone, now) {
283                        (None, _) => now.is_some(),
284                        (Some(a), b) => a.is_some() != b.is_some() || (a != Some(0) && b == Some(0)),
285                    };
286                    gone = Some(now);
287                    if changed {
288                        let ev = p::OpponentGone {
289                            kind: p::OpponentGone::TYPE.into(),
290                            gone: now.is_some(),
291                            claim_win_in_seconds: now,
292                        };
293                        if tx.send(line(&ev)).await.is_err() {
294                            break;
295                        }
296                    }
297                }
298                false
299            }
300        };
301        if !reload {
302            continue;
303        }
304        let fresh = match ctx.db.conn().await {
305            Ok(c) => model::game(&ctx.db, &*c, &row.id).await,
306            Err(e) => Err(e),
307        };
308        match fresh {
309            Ok(Some(r)) => row = r,
310            Ok(None) => break,
311            Err(e) => {
312                warn!("game stream reload: {e:#}");
313                continue;
314            }
315        }
316        let key = state_key(&row);
317        if key != last {
318            last = key;
319            if tx.send(line(&row.state(fm))).await.is_err() {
320                break;
321            }
322        }
323        if !row.status.is_ongoing() {
324            break;
325        }
326    }
327}
328
329/// A state without live clocks, to tell real changes from time passing.
330fn strip_clock(mut s: p::GameState) -> p::GameState {
331    s.wtime = 0;
332    s.btime = 0;
333    s.expiration = None;
334    s
335}
336
337// ─── /api/stream/game/{id} (public, delayed while live) ───
338
339pub async fn moves(State(ctx): State<Ctx>, Path(id): Path<String>) -> ApiResult<Response> {
340    let notes = ctx.db.subscribe();
341    let c = ctx.db.conn().await?;
342    let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
343    drop(c);
344    let (tx, rx) = mpsc::channel::<Bytes>(64);
345    tokio::spawn(move_stream(ctx, row, tx, notes));
346    Ok(ndjson(rx))
347}
348
349/// The game description line. `delay` plies are held back while the game
350/// is live, so the position shown is as old as the move lines.
351pub(crate) fn describe(row: &GameRow, delay: usize) -> serde_json::Value {
352    let lines = move_lines(row);
353    let shown = if row.status.is_ongoing() { lines.len().saturating_sub(delay).max(1) } else { lines.len() };
354    let at = lines.get(shown.saturating_sub(1));
355    let first = chess_core::Game::new(row.initial_fen.as_deref()).map_or(Side::White, |g| g.first_to_move());
356    let turns = shown.saturating_sub(1);
357    let player = |s: Side| {
358        let a = row.player(s);
359        let (rating, prov, diff) = row.rating(s);
360        let mut v = json!({"user": {"name": a.username, "id": a.id()}, "rating": rating});
361        if let Some(t) = a.title() {
362            v["user"]["title"] = json!(t);
363        }
364        if prov {
365            v["provisional"] = json!(true);
366        }
367        if let Some(d) = diff {
368            v["ratingDiff"] = json!(d);
369        }
370        v
371    };
372    let mut v = json!({
373        "id": row.id,
374        "variant": row.variant(),
375        "speed": row.speed,
376        "perf": row.perf_key(),
377        "rated": row.rated,
378        "initialFen": row.initial_fen.clone().unwrap_or_else(|| chess_core::STARTPOS_FEN.into()),
379        "fen": at.map_or_else(|| row.fen.clone(), |l| l.fen.clone()),
380        "player": Side::at_ply(first, turns),
381        "turns": turns,
382        "source": row.source,
383        "status": p::StatusRef::from(row.status),
384        "createdAt": row.created_ms,
385        "players": {"white": player(Side::White), "black": player(Side::Black)},
386    });
387    if let Some(w) = row.winner {
388        v["winner"] = json!(w);
389    }
390    if let Some(lm) = at.and_then(|l| l.lm.as_ref()) {
391        v["lastMove"] = json!(lm);
392    }
393    if let (Some(i), Some(inc)) = (row.clock_initial_ms, row.clock_increment_ms) {
394        v["clock"] =
395            json!({"initial": i / 1000, "increment": inc / 1000, "totalTime": i / 1000 + 40 * inc / 1000});
396    }
397    v
398}
399
400/// FEN after each ply and both clocks (seconds) at that point.
401fn move_lines(row: &GameRow) -> Vec<p::MoveLine> {
402    let Ok(mut g) = Game::new(row.initial_fen.as_deref()) else { return Vec::new() };
403    let first = g.first_to_move();
404    let init = row.clock_initial_ms.unwrap_or(0) / 1000;
405    let (mut wc, mut bc) = (init, init);
406    let mut out = vec![p::MoveLine { fen: g.fen(), lm: None, wc, bc }];
407    for (i, m) in row.moves.split_whitespace().enumerate() {
408        let Ok(played) = g.play_uci(m) else { break };
409        if row.has_clock()
410            && let Some(ms) = row.clock_ms.get(i)
411        {
412            match Side::at_ply(first, i) {
413                Side::White => wc = ms / 1000,
414                Side::Black => bc = ms / 1000,
415            }
416        }
417        out.push(p::MoveLine { fen: g.fen(), lm: Some(played.uci), wc, bc });
418    }
419    out
420}
421
422async fn move_stream(
423    ctx: Ctx,
424    mut row: GameRow,
425    tx: mpsc::Sender<Bytes>,
426    mut notes: tokio::sync::broadcast::Receiver<Note>,
427) {
428    let _g = StreamGuard::new(&ctx.metrics, "moves");
429    let delay = ctx.cfg.public_delay_plies;
430    if tx.send(line(&describe(&row, delay))).await.is_err() {
431        return;
432    }
433    // Lines sent so far (the starting position is line 0).
434    let mut sent = 0usize;
435    let mut keep = tokio::time::interval(KEEPALIVE);
436    loop {
437        let lines = move_lines(&row);
438        let visible =
439            if row.status.is_ongoing() { lines.len().saturating_sub(delay).max(1) } else { lines.len() };
440        for l in lines.iter().take(visible).skip(sent) {
441            if tx.send(line(l)).await.is_err() {
442                return;
443            }
444        }
445        sent = sent.max(visible);
446        if !row.status.is_ongoing() {
447            let _ = tx.send(line(&describe(&row, delay))).await;
448            return;
449        }
450        let reload = loop {
451            tokio::select! {
452                n = notes.recv() => match n {
453                    Ok(Note::Game(g)) if g == row.id => break true,
454                    Ok(Note::Resync) | Err(RecvError::Lagged(_)) => break true,
455                    Err(RecvError::Closed) => return,
456                    Ok(_) => {}
457                },
458                _ = keep.tick() => {
459                    if tx.send(Bytes::from_static(b"\n")).await.is_err() {
460                        return;
461                    }
462                }
463            }
464        };
465        if reload {
466            match ctx.db.conn().await {
467                Ok(c) => match model::game(&ctx.db, &*c, &row.id).await {
468                    Ok(Some(r)) => row = r,
469                    Ok(None) => return,
470                    Err(e) => warn!("move stream reload: {e:#}"),
471                },
472                Err(e) => warn!("move stream reload: {e:#}"),
473            }
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn sent_events_are_not_repeated() {
484        let mut s = Sent::default();
485        let start = json!({"type": "gameStart", "game": {"id": "g1"}});
486        let finish = json!({"type": "gameFinish", "game": {"id": "g1"}});
487        let ch = json!({"type": "challenge", "challenge": {"id": "c1"}});
488        let declined = json!({"type": "challengeDeclined", "challenge": {"id": "c1"}});
489        assert!(s.note(&start));
490        assert!(!s.note(&start), "a gameStart once");
491        assert!(s.note(&finish));
492        assert!(!s.note(&finish), "a gameFinish once");
493        assert!(s.note(&ch) && s.note(&declined) && !s.note(&declined));
494        assert!(s.note(&json!({"type": "somethingNew"})), "unknown kinds pass");
495        assert!(s.ended.contains("g1") && s.ended.contains("c1"));
496    }
497}