From a2b96aacae6ff086c8699f8800345d64a701b428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:11:17 +0000 Subject: [PATCH 1/2] Catch-up: send CHATHISTORY TARGETS newer bound first (#216) TARGETS takes BETWEEN's semantics: both bounds exclusive, the limit counted from the first argument, the argument order sets the direction. Seance sent epoch then now + 1 day and expected the newest 100; once the ircd follows the spec (the fork's W5 Push E), that order returns the oldest 100, so an account in more than 100 channels would have its catch-up ordered by the wrong slice. Send now + 1 day first, the spec's own reconnect example. The answer is read into a map, so its order, its page edges and a missing draft/chathistory-end change nothing. Closes evilnet/seance#216 --- client/js/irc/catchup.ts | 7 +++++-- docs/projects/connect-burst.md | 15 ++++++++++++--- test/irc/catchup.ts | 15 ++++++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/client/js/irc/catchup.ts b/client/js/irc/catchup.ts index faa80afb6..feda6a696 100644 --- a/client/js/irc/catchup.ts +++ b/client/js/irc/catchup.ts @@ -544,7 +544,10 @@ function sendTargets(client: IrcClient, state: TargetsState): void { // From the start: the server's index moves on messages only (not joins, // parts, topics, redactions), so the newest line we hold can be well past // a quiet channel's newest message, and a window starting there would - // leave it out. Newest first, so a long history costs nothing extra. + // leave it out. TARGETS takes BETWEEN's semantics: both bounds exclusive, + // the limit counted from the first one, so the newer bound goes first to + // get the newest `limit` targets (the spec's reconnect example). Sent the + // other way round, a server that follows the spec answers the oldest. const since = new Date(0); const until = new Date(Date.now() + TARGETS_AHEAD_MS); const tags: Record = {}; @@ -559,8 +562,8 @@ function sendTargets(client: IrcClient, state: TargetsState): void { command: "CHATHISTORY", params: [ "TARGETS", - `timestamp=${since.toISOString()}`, `timestamp=${until.toISOString()}`, + `timestamp=${since.toISOString()}`, String(historyLimit(client, TARGETS_LIMIT)), ], }); diff --git a/docs/projects/connect-burst.md b/docs/projects/connect-burst.md index 8cd686266..ff68c9a73 100644 --- a/docs/projects/connect-burst.md +++ b/docs/projects/connect-burst.md @@ -200,7 +200,7 @@ The background catch-up used to go in JOIN order, so the channel that had been written to waited behind every quiet one, and a reconnect fetched every channel whether anything had happened in it or not. Now the first channel to need the background queue on a connection asks one -`CHATHISTORY TARGETS timestamp= timestamp= ` +`CHATHISTORY TARGETS timestamp= timestamp= ` (at the front of the queue) and the catch-up of every other channel is parked until it answers (`catchup.ts`): @@ -210,8 +210,17 @@ parked until it answers (`catchup.ts`): now because nefarious2 drops a target whose newest message is later than it (`filter_targets_window`), and a server clock ahead of ours would lose exactly the channel just written to. The limit is 100 (capped by ISUPPORT - `CHATHISTORY`). nefarious2 lists the channels we may read history of: - those we are a member of, and `+H` (public history) channels without + `CHATHISTORY`). The newer bound goes first: TARGETS takes BETWEEN's + semantics (both bounds exclusive, the limit counted from the first + argument, the order sets the direction), so this asks for the newest + `limit` targets, the spec's own reconnect example. Sent oldest first, as + it was until 2026-11-08 (evilnet/seance#216), a server that follows the + spec answers the oldest `limit` instead; nefarious2 returned the newest + whatever the order until its fork's W5 Push E. A page edge never splits + targets that share a latest timestamp, and a page missing a storage + peer's answer comes without `draft/chathistory-end`; the order reads + the rows into a map and needs neither. nefarious2 lists the channels we + may read history of: those we are a member of, and `+H` (public history) channels without membership (`check_history_access`). So it is asked once the JOINs are in, newest first; a listed `+H` channel we are not in is simply not among the parked ones. diff --git a/test/irc/catchup.ts b/test/irc/catchup.ts index 1dc660edd..60cfd95ab 100644 --- a/test/irc/catchup.ts +++ b/test/irc/catchup.ts @@ -189,11 +189,12 @@ describe("Connect burst: batched JOIN, catch-up paced by fake lag, lazy MODE (ir "#four", ]); - // One TARGETS, first in the queue, from the start of time (nothing is - // held yet) to a day past now. + // One TARGETS, first in the queue, from a day past now back to the + // start of time (nothing is held yet): newer bound first, so a server + // following the spec counts the limit from the newest (issue #216). const [targets] = collect(h, 1).map((s) => s.line); expect(targets).to.match( - /^@label=tg\d+ CHATHISTORY TARGETS timestamp=1970-01-01T00:00:00\.000Z timestamp=2026-08-28T12:00:\d\d\.\d{3}Z 100$/ + /^@label=tg\d+ CHATHISTORY TARGETS timestamp=2026-08-28T12:00:\d\d\.\d{3}Z timestamp=1970-01-01T00:00:00\.000Z 100$/ ); answerTargets(h, targets, [ ["#three", "2026-08-27T11:59:00.000Z"], @@ -604,7 +605,9 @@ describe("Connect burst: batched JOIN, catch-up paced by fake lag, lazy MODE (ir const h = heldThenDropped(HELD); const [targets] = collect(h, 1, isTargets).map((s) => s.line); // From the start: events move what we hold, not the server's index. - expect(targets).to.include("TARGETS timestamp=1970-01-01T00:00:00.000Z "); + expect(targets).to.match( + / TARGETS timestamp=\S+ timestamp=1970-01-01T00:00:00\.000Z \d+$/ + ); answerTargets(h, targets, [ ["#three", "2026-08-27T12:05:00.000Z"], // written to since ["#seance", "2026-08-27T12:04:00.000Z"], // the active one: served already @@ -664,7 +667,9 @@ describe("Connect burst: batched JOIN, catch-up paced by fake lag, lazy MODE (ir h.sent(); joinAll(h); const [targets] = collect(h, 1, isTargets).map((s) => s.line); - expect(targets).to.include("TARGETS timestamp=1970-01-01T00:00:00.000Z "); + expect(targets).to.match( + / TARGETS timestamp=\S+ timestamp=1970-01-01T00:00:00\.000Z \d+$/ + ); answerTargets(h, targets, []); expect(fetched(collect(h, 20).map((s) => s.line))).to.deep.equal([ "#two", From b7f19e8f608e1b64172a61e8dfee979c65160e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:18:39 +0000 Subject: [PATCH 2/2] Catch-up: shorten the TARGETS order comment and note --- client/js/irc/catchup.ts | 6 ++---- docs/projects/connect-burst.md | 13 +++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/client/js/irc/catchup.ts b/client/js/irc/catchup.ts index feda6a696..6071d09a6 100644 --- a/client/js/irc/catchup.ts +++ b/client/js/irc/catchup.ts @@ -544,10 +544,8 @@ function sendTargets(client: IrcClient, state: TargetsState): void { // From the start: the server's index moves on messages only (not joins, // parts, topics, redactions), so the newest line we hold can be well past // a quiet channel's newest message, and a window starting there would - // leave it out. TARGETS takes BETWEEN's semantics: both bounds exclusive, - // the limit counted from the first one, so the newer bound goes first to - // get the newest `limit` targets (the spec's reconnect example). Sent the - // other way round, a server that follows the spec answers the oldest. + // leave it out. The limit counts from the first bound, so the newer one + // goes first: the newest `limit` targets, as in the spec's example. const since = new Date(0); const until = new Date(Date.now() + TARGETS_AHEAD_MS); const tags: Record = {}; diff --git a/docs/projects/connect-burst.md b/docs/projects/connect-burst.md index ff68c9a73..d91bbc66e 100644 --- a/docs/projects/connect-burst.md +++ b/docs/projects/connect-burst.md @@ -211,16 +211,9 @@ parked until it answers (`catchup.ts`): it (`filter_targets_window`), and a server clock ahead of ours would lose exactly the channel just written to. The limit is 100 (capped by ISUPPORT `CHATHISTORY`). The newer bound goes first: TARGETS takes BETWEEN's - semantics (both bounds exclusive, the limit counted from the first - argument, the order sets the direction), so this asks for the newest - `limit` targets, the spec's own reconnect example. Sent oldest first, as - it was until 2026-11-08 (evilnet/seance#216), a server that follows the - spec answers the oldest `limit` instead; nefarious2 returned the newest - whatever the order until its fork's W5 Push E. A page edge never splits - targets that share a latest timestamp, and a page missing a storage - peer's answer comes without `draft/chathistory-end`; the order reads - the rows into a map and needs neither. nefarious2 lists the channels we - may read history of: those we are a member of, and `+H` (public history) channels without + semantics, the limit counting from the first argument, so oldest first + would get the oldest `limit` from a server that follows the spec (#216). + nefarious2 lists the channels we may read history of: those we are a member of, and `+H` (public history) channels without membership (`check_history_access`). So it is asked once the JOINs are in, newest first; a listed `+H` channel we are not in is simply not among the parked ones.