mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
fix(app): keep Updating messages hidden while an unchanged chat is checked on resume (#9242)
Every freshness check escalated to "pending" once FRESHNESS_CHECK_QUIET_MS (500 ms) passed without an answer, so any round trip slower than that flashed Updating messages for an idle chat the daemon then called current. On Android that fired on a sub-second app switch over a slower link, and after a reconnect it replaced Reconnecting to host with a second toast. Each check now records why it was asked. Only opening a chat escalates after the quiet period. A reconnect stays "reattaching" (Reconnecting to host) until the answer. A foreground resume on a socket that stayed connected stays silent: the chat never left its subscription, and a socket that died without closing fails the foreground liveness probe and becomes a reconnect. A "behind" answer still shows Updating messages until the fetch lands.
This commit is contained in:
@@ -1379,6 +1379,54 @@ test("foregrounding a current chat checks over the subscription instead of fetch
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("a slow answer to the foreground check stays silent while the socket stayed connected", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const world = freshnessWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
|
||||
world.show("pane", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
(await world.nextMembership()).succeed({ "agent-a": "current" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.sync.setActive(true);
|
||||
const check = await world.nextMembership();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(FRESHNESS_CHECK_QUIET_MS * 4);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
|
||||
|
||||
check.succeed({ "agent-a": "current" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
world.expectNoPendingFetch();
|
||||
world.sync.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a foreground check answered behind reports pending until the fetch lands", async () => {
|
||||
const world = freshnessWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
|
||||
world.show("pane", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
(await world.nextMembership()).succeed({ "agent-a": "current" });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.sync.setActive(true);
|
||||
(await world.nextMembership()).succeed({ "agent-a": "behind" });
|
||||
|
||||
const fetch = await world.nextFetch("agent-a");
|
||||
expect(fetch.request).toMatchObject({ direction: "after", cursor: { seq: 42 } });
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
fetch.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("the old subscription keeps delivering until the daemon accepts its replacement", async () => {
|
||||
const world = freshnessWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 1 });
|
||||
@@ -1493,22 +1541,33 @@ test("a failed check reports the error and retries through membership", async ()
|
||||
});
|
||||
|
||||
test("reconnecting reports reattaching instead of pending until the check answers", async () => {
|
||||
const world = freshnessWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
|
||||
world.show("pane", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
(await world.nextMembership()).succeed({ "agent-a": "current" });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const world = freshnessWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
|
||||
world.show("pane", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
(await world.nextMembership()).succeed({ "agent-a": "current" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
|
||||
world.sync.setConnected(false);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
|
||||
world.sync.setConnected(true);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
|
||||
world.sync.setConnected(false);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
|
||||
world.sync.setConnected(true);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
|
||||
|
||||
(await world.nextMembership()).succeed({ "agent-a": "current" });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.expectNoPendingFetch();
|
||||
world.sync.dispose();
|
||||
const check = await world.nextMembership();
|
||||
await vi.advanceTimersByTimeAsync(FRESHNESS_CHECK_QUIET_MS * 4);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
|
||||
|
||||
check.succeed({ "agent-a": "current" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
world.expectNoPendingFetch();
|
||||
world.sync.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("without the daemon capability nothing changes: no cursors, no checking, fetch on foreground", async () => {
|
||||
|
||||
@@ -407,6 +407,12 @@ export interface ViewedTimelineSyncPorts {
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a freshness check was asked: opening a chat, the socket coming back, or the app
|
||||
* returning to the foreground on a socket that stayed connected.
|
||||
*/
|
||||
type FreshnessCheckReason = "open" | "reconnect" | "resume";
|
||||
|
||||
/**
|
||||
* "checking": the daemon has not said whether this chat is current; nothing to report yet.
|
||||
* "reattaching": the same question right after a reconnect, so the chat is still not back.
|
||||
@@ -599,11 +605,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const manualRetries = new Set<string>();
|
||||
const loadedCache = new Set<string>();
|
||||
const cacheLoads = new Map<string, Promise<void>>();
|
||||
// Chats whose freshness answer is outstanding. No catch-up starts for them until it arrives;
|
||||
// `quiet` holds the status short of "pending" until FRESHNESS_CHECK_QUIET_MS elapses.
|
||||
// Chats whose freshness answer is outstanding. No catch-up starts for them until it arrives.
|
||||
// An "open" check stays `quiet`, short of "pending", until FRESHNESS_CHECK_QUIET_MS elapses.
|
||||
const freshnessChecks = new Map<
|
||||
string,
|
||||
{ quiet: boolean; reattaching: boolean; timer: ReturnType<typeof setTimeout> | null }
|
||||
{
|
||||
reason: FreshnessCheckReason;
|
||||
quiet: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
let active = true;
|
||||
@@ -673,16 +683,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
ports.onCatchUpOwed(agentId);
|
||||
};
|
||||
|
||||
const startFreshnessCheck = (agentId: string, reattaching = false) => {
|
||||
const startFreshnessCheck = (agentId: string, reason: FreshnessCheckReason = "open") => {
|
||||
if (freshnessChecks.has(agentId)) return;
|
||||
freshnessChecks.set(agentId, { quiet: true, reattaching, timer: null });
|
||||
freshnessChecks.set(agentId, { reason, quiet: true, timer: null });
|
||||
};
|
||||
|
||||
// The delay measures the daemon's answer, so it starts when the question is sent. On a cold
|
||||
// start the cache read that precedes the question can outlast it on its own.
|
||||
// start the cache read that precedes the question can outlast it on its own. Only opening a
|
||||
// chat escalates. A reconnect already shows Reconnecting to host until the answer. A resume
|
||||
// kept its socket subscribed, so a slow answer is no reason to doubt the chat; a socket that
|
||||
// died quietly is caught by the foreground liveness probe and becomes a reconnect.
|
||||
const startFreshnessCheckDelay = (agentId: string) => {
|
||||
const check = freshnessChecks.get(agentId);
|
||||
if (!check || check.timer) return;
|
||||
if (!check || check.timer || check.reason !== "open") return;
|
||||
check.timer = setTimeout(() => {
|
||||
check.quiet = false;
|
||||
notifyListeners();
|
||||
@@ -1012,8 +1025,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const getUnconfirmedStatus = (agentId: string): ViewedTimelineStatus => {
|
||||
const check = freshnessChecks.get(agentId);
|
||||
if (check) {
|
||||
if (!check.quiet) return "pending";
|
||||
return check.reattaching ? "reattaching" : "checking";
|
||||
if (check.reason === "reconnect") return "reattaching";
|
||||
return check.quiet ? "checking" : "pending";
|
||||
}
|
||||
if (!ports.canCheckFreshness()) return "pending";
|
||||
if (!connected) return "reattaching";
|
||||
@@ -1072,7 +1085,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
ports.readCursor(agentId)
|
||||
) {
|
||||
catchUps.delete(agentId);
|
||||
startFreshnessCheck(agentId);
|
||||
startFreshnessCheck(agentId, "resume");
|
||||
freshnessCheckOwed = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1100,7 +1113,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
}
|
||||
if (ports.canCheckFreshness()) {
|
||||
for (const agentId of desired) {
|
||||
if (visibilityCatchUpPending.has(agentId)) startFreshnessCheck(agentId, true);
|
||||
if (visibilityCatchUpPending.has(agentId)) startFreshnessCheck(agentId, "reconnect");
|
||||
}
|
||||
}
|
||||
membershipGeneration += 1;
|
||||
|
||||
Reference in New Issue
Block a user