Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,25 @@ roster, applied through shared discovery. That makes the channel a sidebar
member and enables the composer, which then receives focus. An accepted but
unconfirmed join reports that it may have taken effect and stays retryable.

On first run in a community, the session also joins the public starter channels
the community already has: `general`, `welcome-everyone` and `announcements`
(`starter-channels.ts`, mirroring the desktop's `ensure_starter_channels`). Once

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inaccurate parity claim. On block/buzz main (f3a7bab098), the desktop and mobile starters are only general and welcome-everyone (desktop/src-tauri/src/commands/channels.rs, mobile/lib/app.dart), and both hosts create them when they're missing. announcements exists only in block/buzz#8795, which is still open and also limits desktop to new users. Please describe this as buzz-app's own behavior, or cite #8795 once it merges. The PR body needs the same fix: it says this "matches what Buzz desktop and mobile do" and still says the marker requires that "every join succeeded".

the channel list is ready it looks each name up with `searchPublic(name, { exact })`,
which keeps only active public streams the viewer has not joined, and joins
each through the same lifecycle Join, one at a time. A bare `9021` takes the
channel's join role, so an announce channel (`guest`) makes the viewer a
read-only guest. It never creates a channel, and a missing starter is skipped.
Names match case-insensitively (the query is trimmed). The pass is silent and best
effort, skipped without a lifecycle writer (including cache-only sessions),
and ends with the connection. A `starter-channels-done` view-state marker per
viewer and community (`origin:viewer`) is saved only after a pass with no
failed join and no partial directory read (a lookup that hit the 20,000-event
bound cannot call a starter absent), so an incomplete pass retries next
session; leaving the community clears it with the rest of its view state. Starter
joins run as `background` lifecycle actions: the one-action-at-a-time rule makes
them wait for a viewer's own Join, Leave or Archive, and a viewer's action waits
for a background join already in flight, instead of failing as busy.

### Finite read recovery

A finite read that has no session recovery source retries transient failures
Expand Down
120 changes: 120 additions & 0 deletions src/features/relay/channel-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,6 +1826,126 @@ describe("join from a public preview", () => {
});
});

describe("background actions yield to foreground ones", () => {
/** A join whose publish is held until `gate` opens, so one action stays in flight. */
function held() {
const h = harness("member");
const metadata = h.record(39000, [
["d", id],
["t", "stream"],
["name", "fixture"],
["public"],
]);
const roster = (...members: string[]) =>
h.record(39002, [["d", id], ...members.map((member) => ["p", member])]);
h.setEvents([metadata, roster(other)]);
h.deny();
const gate = deferred<void>();
h.publish.mockImplementation(async (event: RelayEvent) => {
await gate.promise;
if (event.kind === 9021) h.setEvents([metadata, roster(other, viewer)]);
});
const started = () =>
vi.waitFor(() => expect(h.publish).toHaveBeenCalled());
// Lets anything that could wrongly proceed run before a negative assertion.
const settle = () => new Promise<void>((done) => setImmediate(done));
return { ...h, gate, started, settle };
}
const background = { background: true };

it("makes a foreground join wait for a background one, then succeed", async () => {
const h = held();
const order: string[] = [];
try {
const first = h.owner.capability
.run("join", id, undefined, undefined, background)
.then(() => order.push("background"));
await h.started();
const second = h.owner.capability
.run("join", id)
.then(() => order.push("foreground"));
await h.settle();
expect(order).toEqual([]);
expect(h.sign).toHaveBeenCalledOnce();
h.gate.resolve();
await Promise.all([first, second]);
expect(order).toEqual(["background", "foreground"]);
// The foreground join found the roster the background one confirmed.
expect(h.publish).toHaveBeenCalledOnce();
} finally {
h.gate.resolve();
h.owner.dispose();
}
});

it("makes a background join wait for a foreground action, then succeed", async () => {
const h = held();
const order: string[] = [];
try {
const first = h.owner.capability
.run("join", id)
.then(() => order.push("foreground"));
await h.started();
const second = h.owner.capability
.run("join", id, undefined, undefined, background)
.then(() => order.push("background"));
await h.settle();
expect(order).toEqual([]);
h.gate.resolve();
await Promise.all([first, second]);
expect(order).toEqual(["foreground", "background"]);
expect(h.publish).toHaveBeenCalledOnce();
} finally {
h.gate.resolve();
h.owner.dispose();
}
});

it("still refuses a foreground action while another foreground one runs", async () => {
const h = held();
try {
const first = h.owner.capability.run("join", id);
await h.started();
await expect(h.owner.capability.run("join", id)).rejects.toThrow(
"Another channel action is still in progress",
);
h.gate.resolve();
await first;
} finally {
h.gate.resolve();
h.owner.dispose();
}
});

it("rejects an aborted foreground wait without publishing for it", async () => {
const h = held();
const caller = new AbortController();
try {
const first = h.owner.capability.run(
"join",
id,
undefined,
undefined,
background,
);
await h.started();
const waiting = h.owner.capability.run("join", id, caller.signal);
const rejected = expect(waiting).rejects.toMatchObject({
name: "AbortError",
});
caller.abort(new DOMException("Cancelled", "AbortError"));
await rejected;
h.gate.resolve();
await first;
expect(h.publish).toHaveBeenCalledOnce();
expect(h.sign).toHaveBeenCalledOnce();
} finally {
h.gate.resolve();
h.owner.dispose();
}
});
});

it("retires queued entries and waits only after confirmed removal, including pending validation", async () => {
const h = harness();
const deactivate = vi.fn();
Expand Down
39 changes: 35 additions & 4 deletions src/features/relay/channel-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export interface ChannelLifecycleCapability {
signal?: AbortSignal,
/** Checked synchronously right before publishing; false publishes nothing. */
active?: () => boolean,
/** Background work yields to every other action and waits its turn. A
* foreground action waits only for a background one. */
options?: { background?: boolean },
): Promise<void>;
snapshot(): DmVisibility;
subscribe(listener: () => void): () => void;
Expand All @@ -70,7 +73,8 @@ export function createChannelLifecycle({
removed(id: string): void;
}) {
let closed = false;
let busy = false;
// The one action in flight; `done` settles when it ends, however it ends.
let inflight: { background: boolean; done: Promise<void> } | undefined;
let epoch = 0;
// Each in-flight operation and the channel it concerns; visibility reads have none.
const controllers = new Map<AbortController, string | undefined>();
Expand Down Expand Up @@ -357,12 +361,38 @@ export function createChannelLifecycle({
value: string,
caller?: AbortSignal,
active?: () => boolean,
options?: { background?: boolean },
) {
if (!available || !writer)
throw new Error("Channel actions are unavailable on this connection");
if (busy) throw new Error("Another channel action is still in progress");
const id = lifecycleChannelId(value);
busy = true;
const background = options?.background === true;
// Wait out what yields, then look again: another caller may have started.
for (;;) {
const held = inflight;
if (!held) break;
if (!background && !held.background)
throw new Error("Another channel action is still in progress");
caller?.throwIfAborted();
let abort = () => {};
try {
await Promise.race([
held.done,
new Promise<never>((_, reject) => {
abort = () => reject(caller?.reason);
caller?.addEventListener("abort", abort, { once: true });
}),
]);
} finally {
caller?.removeEventListener("abort", abort);
}
}
let release = () => {};
const mine = {
background,
done: new Promise<void>((resolve) => (release = resolve)),
};
inflight = mine;
let publicationStarted = false;
try {
await owned({ id, caller }, async (signal) => {
Expand Down Expand Up @@ -501,7 +531,8 @@ export function createChannelLifecycle({
throw new ChannelLifecycleUnconfirmed(error);
throw error;
} finally {
busy = false;
inflight = undefined;
release();
}
},
});
Expand Down
8 changes: 8 additions & 0 deletions src/features/relay/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { isTransientFailure, ReadError, readErrorKind } from "./errors";
import { RECOVERY_DELAYS_MS } from "./recovery";
import type { ReadTransport } from "./transport";
import { ensureStarterChannels } from "./starter-channels";
const WAKE_TICK_MS = 5_000;
const WAKE_GAP_MS = 15_000;

Expand Down Expand Up @@ -279,6 +280,13 @@ export function provideRelay(
session: store.session,
});
store.session.channels.ensureList();
// First run in this community: join the starter channels it has.
// `signal` ends with the connection, so a replaced session stops.
void ensureStarterChannels(
store.session,
transportPartition(transport),
signal,
);
})
.catch(async (error) => {
await restoring;
Expand Down
Loading
Loading