Chathistory seeding and scroll back paging#131
Conversation
If no replies are sent there is no way to know which target a batch belongs to except for assuming that it's the next batch. Requested messages were reduced to 5 to simplify testing.
6603c6f to
f8ec18c
Compare
Other response channels should also use labels from now on if possible
f8ec18c to
bc8bf48
Compare
zealsprince
left a comment
There was a problem hiding this comment.
The Tags::parse consolidation is a real improvement - the per-command tag loops were already drifting apart, and centralizing the msgid/server-time fallbacks into one place makes the JOIN/PART/QUIT handlers much easier to follow. The push_batch early-return pattern keeps the history plumbing out of the normal message flow nicely. Great work!
Three things I'd fix before merge: the unrequested-batch assert panic (actor.rs:475 - a multiline message from another client kills the connection), QUIT messages being typed as Part (actor.rs:445), and scrollback messages appending twice in the store (irc.ts:144).
Broader points that don't need to block:
- The actor loop unwraps
handle_incoming, so every assert in the handlers (batch id match,batch.channelequality in push_batch,server_time.is_some()on playback JOINs) is a connection-killer if a server misbehaves. Fine for the prototype, but once we're past labeled-response bring-up these should become warn-and-skip. Happy to make a ticket for that. - The
is_chathistorychecks after apush_batchearly return (actor.rs:363, 381, 427, 463) can't be false anymore at those points - dropping them would make the flow clearer. - Preexisting, not this PR: the react/unreact branches look inverted (actor.rs:658 pushes the reactor on unreact and removes on react). Separate ticket?
I did notice some stray blank lines in core-shared/Cargo.toml (lines 2, 17), Tags::parse taking &Vec<Tag> over &[Tag], and channel_mut/user_mut/push_batch being async without awaiting anything; not a blocker but just for the sake of checking the diff I noticed these.
| const existingServer = serverMessages.get(id) ?? new Map() | ||
| const existingChannel = existingServer.get(history.channel) ?? [] | ||
|
|
||
| for (const message of history.messages) { |
There was a problem hiding this comment.
I think scrollback messages get appended twice: the on_data handler pushes them when the History event fires (line 99-110), and then this loop pushes the same history.messages from the resolved promise. The actor always does both - reply(...) and on_event(ServerEvent::History(...)) back to back in the BATCH close handler. Since we decided to dual-deliver on the Rust side, I think the store has to pick one path - probably let on_data own the appending and use the promise result just for "did it finish".
|
|
||
| async function requestScrollback(id: number, channel: string) { | ||
| try { | ||
| const history = await serverHandlers.value.get(id)?.history_before(channel, serverMessages.get(id)?.get(channel)?.at(0)?.metadata.msgid ?? "") |
There was a problem hiding this comment.
First click before any history has landed sends msgid= with an empty id - does the server FAIL that, or silently treat it as *? Might be worth skipping the request (or falling back to LATEST) when there's no oldest message yet.
| try { | ||
| const history = await serverHandlers.value.get(id)?.history_before(channel, serverMessages.get(id)?.get(channel)?.at(0)?.metadata.msgid ?? "") | ||
|
|
||
| if (!!!history) { |
There was a problem hiding this comment.
We really NOT NOT NOT want history? :)
| loop { | ||
| #[cfg(feature = "web")] | ||
| let mut timeout = gloo_timers::future::TimeoutFuture::new(1000).fuse(); | ||
| #[cfg(not(feature = "web"))] |
There was a problem hiding this comment.
This is the cfg gate that my above comment references.
| @@ -1,12 +1,21 @@ | |||
| use std::fmt; | |||
| #[cfg(feature = "default")] | |||
There was a problem hiding this comment.
Gating this import on feature = "default" while the use site (line 275) is gated on not(feature = "web") means a build with default-features = false and no web fails to compile, and a build with both features gets an unused-import warning. Do we want a named feature like native instead of leaning on default as a cfg? Then both gates can agree.
| if let Some(ref t) = message.tags { | ||
| tags = Tags::parse(t); | ||
| } | ||
| assert_eq!( |
There was a problem hiding this comment.
I'm not 100% sure about this one and I paste this over into Claude to walk me through it in detail but from what I understand, this assert is a crash path for any batch we didn't request. The BATCH open handler returns early without setting current_batch when the label lookup fails (line 557-564), so the first PRIVMSG inside such a batch has a batch tag while current_batch is None, the assert fires, and the unwrap() in run() takes the whole actor down. We request draft/multiline (line 1046), so a multiline message from any other client is enough to trigger it. A labeled-response wrapper batch around the chathistory batch would hit the same thing via the nested inner batch.
| let Some(idx) = self | ||
| .requested_batches | ||
| .iter() | ||
| .position(|b| b.label == tags.label) |
There was a problem hiding this comment.
Matching pending requests purely by label means that when labeled-response isn't enabled, any server-initiated unlabeled batch matches label == None and steals the pending history request, attributing its messages to the wrong channel. Should this also cross-check the channel from the batch param? Fine as a follow-up ticket if we're treating no-labeled-response servers as out of scope for now :)
| text: m.text.clone().map(|t| t.content)?, | ||
| username: m.metadata.user.clone(), | ||
| }) | ||
| .map(|m| MessageReference { |
There was a problem hiding this comment.
Behavior change worth confirming: a reply to a message we don't have now fabricates a MessageReference with "Unknown"/"Unknown", where before it produced no reference at all.
| .unix_timestamp() | ||
| } | ||
|
|
||
| pub fn msgid_with_fallback(&self, hash_extras: &[&str]) -> String { |
There was a problem hiding this comment.
The fallback hash covers server_time plus the extras, but not the message type - a JOIN and PART from the same user in the same second (extras are just [source] for both) produce the same msgid and collide in channel.messages and in the Vue :key. Probably want to add the type to the hash. Only matters for servers that don't send msgid, so no rush since our server for testing does enable it.
Brings up the question if we should have a stupid traditional IRC server to test against.
| .await | ||
| .context("Failed to send ActorMessage")?; | ||
|
|
||
| let resp = rx.await.context("Failed to await actor history message")?; |
This PR adds the history functionality:
HISTORY LATESTgets sent on channelJOINcore-wasmwhich sendsHISTORY BEFOREto enable scroll-back functionalityResponseChannelsnow times out channels after 1-2 seconds of no reply from the server, this means buggy command implementations don't freeze and can be logged but should be adjusted later to not fail on high latency connectionsResolves: #33
Notes:
on_eventcallback even if they are also returned byResponseChanneluntil we notice any performance cost to itActoris starting to get large, theactor.rsfile contains types that can be moved out but we might want to think about creating an abstraction that allows functionality to be separated by topic