@@ -1,6 +1,7 @@
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
4import type { HostOpts, HostReply } from "./host";
5import type { HostOpts, HostReply } from "./host";
5import { SETUP_ERRORS } from "./host";
6import { SETUP_ERRORS } from "./host";
6import type { Settings } from "./settings";
7import type { Settings } from "./settings";
@@ -113,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
113 if (g.userNamed) return false;
114 if (g.userNamed) return false;
114 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115 if (sameLabel(label, g.title)) return false;
116 if (sameLabel(label, g.title)) return false;
117+ // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
116− const before = new Set(g.memberUrls ?? []);
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
117− const after = new Set(urls);
119+ const after = new Set(urls.map(pathKey));
118 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
119 return changed; // a paraphrase with the same members keeps the old title
121 return changed; // a paraphrase with the same members keeps the old title
@@ -214,18 +216,32 @@ export class Naming {
215 const now = this.d.now();
217 const now = this.d.now();
216 if (!force) {
218 if (!force) {
219+ // A refusal waits for the members to change, which only a look at them can tell.
217− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
218 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
219 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
222 if (!members) return;
225 if (!members) return;
223− if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
226+ if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
224− if (!force && membersHash(members) === g.membersHash) {
227+ if (members.length < 2) {
228+ // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
229+ if (g.dirty && !force) {
225− g.dirty = false;
230+ g.dirty = false;
235+ const hash = membersHash(members);
236+ if (!force && hash === g.membersHash) {
226 this.d.commit();
238 this.d.commit();
241+ if (g.refusedHash && !force) {
242+ if (hash === g.refusedHash && g.nextAttemptAt && g.nextAttemptAt > now) return;
243+ clearFailures(g); // different tabs now: Apple's filter may well take them
229 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
230 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
231 void this.pump();
247 void this.pump();
@@ -261,7 +277,7 @@ export class Naming {
261 if (!members || members.length < 2 || !members.every(settled)) return;
277 if (!members || members.length < 2 || !members.every(settled)) return;
262 const hash = membersHash(members);
278 const hash = membersHash(members);
263 if (!force && hash === g.membersHash) {
279 if (!force && hash === g.membersHash) {
264− g.dirty = false;
280+ settle(g);
265 this.d.commit();
281 this.d.commit();
@@ -298,7 +314,7 @@ export class Naming {
298 retried.output = true;
314 retried.output = true;
299 continue;
315 continue;
301− this.fail(g, e);
317+ this.fail(g, e, hash);
@@ -309,7 +325,7 @@ export class Naming {
309 retried.output = true;
325 retried.output = true;
310 continue;
326 continue;
312− this.fail(g, e);
328+ this.fail(g, e, hash);
315 const emoji = safeEmoji(reply.result.emoji);
331 const emoji = safeEmoji(reply.result.emoji);
@@ -324,12 +340,15 @@ export class Naming {
324 finalLabel = suffixLabel(label, siblings);
340 finalLabel = suffixLabel(label, siblings);
326 this.d.hostOk();
342 this.d.hostOk();
327− await this.apply(g, finalLabel, emoji, members, hash);
343+ const written = await this.apply(g, finalLabel, emoji, members, hash);
328 g = this.d.state().groups[groupId];
344 g = this.d.state().groups[groupId];
329− if (g) {
345+ if (!g) return;
347+ // The strip refused the title (a drag in progress, the window closing): try again shortly.
348+ g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
349+ this.d.scheduleFallback(g.nextAttemptAt);
330− if (g.dirtySeq === seq) g.dirty = false;
350+ } else if (g.dirtySeq === seq) g.dirty = false;
331− else this.touch(groupId); // dirtied while in flight: go again with the new membership
351+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
335 } finally {
354 } finally {
@@ -340,16 +359,20 @@ export class Naming {
362+ /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
343− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
363+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
344 const settings = this.d.settings();
364 const settings = this.d.settings();
345 const urls = memberUrls(members);
365 const urls = memberUrls(members);
346− if (g.userNamed) return; // renamed by hand while the model was thinking
366+ if (g.userNamed) return true; // renamed by hand while the model was thinking
347 if (shouldApply(g, label, urls)) {
367 if (shouldApply(g, label, urls)) {
348 const title = stripTitle(label, emoji, settings.emoji);
368 const title = stripTitle(label, emoji, settings.emoji);
349 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
369 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
350 this.d.commit();
370 this.d.commit();
351 const ok = await this.d.writeTitle(g.id, title);
371 const ok = await this.d.writeTitle(g.id, title);
352− if (!ok) return;
372+ if (!ok) {
373+ delete this.d.state().ownWrites[g.id];
353 g.title = label;
376 g.title = label;
354 g.emoji = emoji;
377 g.emoji = emoji;
355 g.stripTitle = title;
378 g.stripTitle = title;
@@ -357,21 +380,24 @@ export class Naming {
357 g.lastNamedAt = this.d.now();
380 g.lastNamedAt = this.d.now();
358 g.membersHash = hash;
381 g.membersHash = hash;
359 g.memberUrls = urls;
382 g.memberUrls = urls;
360− g.nameAttempts = 0;
383+ clearFailures(g);
361− g.firstFailureAt = undefined;
384+ return true;
362− g.nextAttemptAt = undefined;
365− private fail(g: GroupRecord, e: HostError): void {
387+ private fail(g: GroupRecord, e: HostError, hash: string): void {
366 const now = this.d.now();
388 const now = this.d.now();
367 this.d.hostFailed(e);
389 this.d.hostFailed(e);
368 if (e.code === "BAD_REQUEST") {
390 if (e.code === "BAD_REQUEST") {
369 g.dirty = false; // a bug on our side: log and drop
391 g.dirty = false; // a bug on our side: log and drop
394+ // Setup errors pause every model call until the host is fixed, and its recovery picks this group up
395+ // again: they say nothing about this group, so they don't use up its retries.
396+ if (SETUP_ERRORS.has(e.code)) return;
372 if (e.code === "GUARDRAIL") {
397 if (e.code === "GUARDRAIL") {
373 g.nameAttempts = MAX_ATTEMPTS;
398 g.nameAttempts = MAX_ATTEMPTS;
374 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
399 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
400+ g.refusedHash = hash;
375 this.d.scheduleFallback(g.nextAttemptAt);
401 this.d.scheduleFallback(g.nextAttemptAt);
@@ -381,11 +407,24 @@ export class Naming {
381 g.dirty = false; // retried for a day: keep the provisional title
407 g.dirty = false; // retried for a day: keep the provisional title
384− const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
385− g.nextAttemptAt = now + wait;
410+ g.nextAttemptAt = now + backoffFor(g.nameAttempts);
386 this.d.scheduleFallback(g.nextAttemptAt);
411 this.d.scheduleFallback(g.nextAttemptAt);
415+/** The retry bookkeeping of earlier failures, which no longer applies. */
416+function clearFailures(g: GroupRecord): void {
418+ g.firstFailureAt = undefined;
419+ g.nextAttemptAt = undefined;
420+ g.refusedHash = undefined;
423+/** The members are the ones last named: nothing to do, and nothing left to retry. */
424+function settle(g: GroupRecord): void {
390export const eligible = (g: GroupRecord, settings: Settings): boolean =>
429export const eligible = (g: GroupRecord, settings: Settings): boolean =>
391 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
430 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";