fix(tinode): auto-recover from cluster-failover topic proxy teardown

Native Cluster failover tears down a topic's server-side proxy session
on the surviving nodes without notifying the client -- the SDK's local
_attached flag is never reset on this path, so isSubscribed()/
ensureTopicAttached() keep reading a stale true and can't catch it in
advance. The server replies 409 "must attach first" to the {pub}
itself; publishWithReattach() catches exactly that, forces a fresh
subscribe(), and retries the same msg object once (safe: publishDraft's
own _noForwarding guard prevents a duplicate local echo, and the
unchanged x-im-client-mid still dedupes server-side). Wired into all 9
publishDraft call sites (send.ts, attachments.ts, calls.ts).

Confirmed live on an independent native-Cluster verification
deployment: killing the leader pod mid-conversation previously caused
every send to fail until a manual resubscribe; with this fix, 36/36
probes across the same failover succeeded with no manual intervention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-21 16:44:39 +08:00
co-authored by Claude Sonnet 5
parent 74351af905
commit ed029c59ef
3 changed files with 35 additions and 11 deletions
+4 -4
View File
@@ -2,7 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk';
import { uploadFile, upgradeToHttps } from '@/lib/api/upload'; import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config'; import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
import { newClientMid } from '@/lib/tinode/send'; import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
function fileToBase64(file: File): Promise<string> { function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -51,7 +51,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
}); });
const msg = topic.createMessage(content, false); const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
export async function sendFile(topic: Topic, file: File): Promise<unknown> { export async function sendFile(topic: Topic, file: File): Promise<unknown> {
@@ -61,7 +61,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
: Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size }); : Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size });
const msg = topic.createMessage(content, false); const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
/** /**
@@ -86,5 +86,5 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
}); });
const msg = topic.createMessage(content, false); const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk'; import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
import { newClientMid } from '@/lib/tinode/send'; import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
/** /**
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines * Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
@@ -63,7 +63,7 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason. // not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
const msg = topic.createMessage(content as unknown as DraftyContent, false); const msg = topic.createMessage(content as unknown as DraftyContent, false);
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>; return publishWithReattach(topic, msg) as Promise<{ code: number; params?: { seq: number } }>;
} }
/** /**
+29 -5
View File
@@ -28,6 +28,30 @@ export function newClientMid(): string {
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; : `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
} }
/**
* Wraps `topic.publishDraft` with a single retry after a forced re-subscribe. Confirmed live,
* 2026-09-21, on an independent native-Cluster verification deployment: killing the Pod holding a
* topic's Cluster master tears down that topic's server-side proxy session on the surviving nodes
* (`server/session.go`'s `s.publish` logs "must attach first" and replies with a 409/`ErrAttachFirst`)
* WITHOUT notifying the client -- the SDK's local `_attached` flag is never reset on this path (only
* `_gone()`/topic-deleted flows clear it), so `isSubscribed()`/`ensureTopicAttached()` keep reading a
* stale `true` and can't catch this in advance. Every already-open conversation kept failing to send
* until a fresh `subscribe()` was issued by hand. `publishDraft`'s own `_noForwarding` guard (set on
* the first call) makes calling it again on the SAME `msg` object safe -- it re-sends the `{pub}`
* without re-inserting a duplicate local echo, and the message's `x-im-client-mid` is unchanged so a
* network-level duplicate is still deduped server-side. Only 409 + this exact message is treated as
* recoverable; any other rejection (permissions, malformed content, network down) passes through
* unchanged so it still surfaces via the caller's existing `showError` handling.
*/
export function publishWithReattach(topic: Topic, msg: StoredMessage): Promise<unknown> {
return topic.publishDraft(msg, Promise.resolve()).catch((err: unknown) => {
const code = err instanceof Error ? (err as Error & { code?: number }).code : undefined;
const recoverable = code === 409 && err instanceof Error && err.message.includes('attach');
if (!recoverable) throw err;
return topic.subscribe().then(() => topic.publishDraft(msg, Promise.resolve()));
});
}
/** /**
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old * Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
* app's own choice -- publishDraft inserts the message into the local cache and fires onData * app's own choice -- publishDraft inserts the message into the local cache and fires onData
@@ -38,7 +62,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed; const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
const msg = topic.createMessage(content, false); const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
/** /**
@@ -51,7 +75,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> { export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
const msg = topic.createMessage(content, false); const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
/** /**
@@ -73,7 +97,7 @@ export function retryMessage(topic: Topic, seq: number): Promise<unknown> | unde
msg.head = Object.assign(msg.head || {}, failed.head, { msg.head = Object.assign(msg.head || {}, failed.head, {
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(), 'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
}); });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean { export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
@@ -95,7 +119,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
const finalContent = Drafty.append(sanitized, Drafty.parse(text)); const finalContent = Drafty.append(sanitized, Drafty.parse(text));
const msg = topic.createMessage(finalContent, false); const msg = topic.createMessage(finalContent, false);
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() }); msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()); return publishWithReattach(topic, msg);
} }
/** /**
@@ -109,5 +133,5 @@ export function forwardMessage(sourceTopic: string, msg: StoredMessage, target:
const content = Drafty.forwardedContent(toDraftyContent(msg.content)); const content = Drafty.forwardedContent(toDraftyContent(msg.content));
const newMsg = target.createMessage(content, false); const newMsg = target.createMessage(content, false);
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
return target.publishDraft(newMsg, Promise.resolve()); return publishWithReattach(target, newMsg);
} }