fix: clear the relay roster per sole connection, not once per below-two regime
rosterCleared was a single boolean latched the first time the peer set fell below two connections. When the sole connection a was replaced by c between ticks (still length 1) the flag stayed set, so c's gateway never received the empty-roster push and kept the stale roster. Track the id of the connection that got the clear instead and push whenever the sole connection's id differs; reset once two or more connections relay again. Review finding: routes [a] → tick → routes [c] → tick pushed no roster clear to c.
This commit is contained in:
@@ -740,4 +740,33 @@ describe('the roster loop forgets a machine that left', () => {
|
|||||||
|
|
||||||
stopBotRelay()
|
stopBotRelay()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears the roster of a sole connection that replaced the previous sole one', async () => {
|
||||||
|
const calls = respondWith(call => {
|
||||||
|
if (call.method === 'profiles.list') {
|
||||||
|
return { profiles: [{ name: call.connectionId === 'a' ? 'default' : 'ops' }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { startBotRelay, stopBotRelay } = await loadRelay()
|
||||||
|
|
||||||
|
startBotRelay()
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
hostMock.profileRoutes = vi.fn(async () => [route('a')])
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
// a is swapped for c between ticks — still one connection, but c's
|
||||||
|
// gateway has never been told the roster is empty.
|
||||||
|
hostMock.profileRoutes = vi.fn(async () => [route('c')])
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
|
||||||
|
expect(calls.filter(call => call.method === 'bot_relay.roster.sync').map(call => call.connectionId)).toEqual([
|
||||||
|
'c'
|
||||||
|
])
|
||||||
|
|
||||||
|
stopBotRelay()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ interface RelayLifecycle {
|
|||||||
pushDebounceTimer: null | ReturnType<typeof setTimeout>
|
pushDebounceTimer: null | ReturnType<typeof setTimeout>
|
||||||
pushUnsub: (() => void) | null
|
pushUnsub: (() => void) | null
|
||||||
rosterBusy: boolean
|
rosterBusy: boolean
|
||||||
/** The below-two-connections roster clear went out; stays set until the peer set relays again. */
|
/** Id of the sole connection whose roster clear went out; null once the peer set relays again. */
|
||||||
rosterCleared: boolean
|
rosterClearedFor: null | string
|
||||||
rosterTimer: null | ReturnType<typeof setInterval>
|
rosterTimer: null | ReturnType<typeof setInterval>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ const relay: RelayLifecycle = {
|
|||||||
pushDebounceTimer: null,
|
pushDebounceTimer: null,
|
||||||
pushUnsub: null,
|
pushUnsub: null,
|
||||||
rosterBusy: false,
|
rosterBusy: false,
|
||||||
rosterCleared: false,
|
rosterClearedFor: null,
|
||||||
rosterTimer: null
|
rosterTimer: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,10 +264,11 @@ async function syncRelayRosters() {
|
|||||||
// Nothing to relay — but the gateways that remain still hold the last
|
// Nothing to relay — but the gateways that remain still hold the last
|
||||||
// pushed roster, so a departed machine's agents would stay in every
|
// pushed roster, so a departed machine's agents would stay in every
|
||||||
// bot's prompt (and as message_agent targets) until a second connection
|
// bot's prompt (and as message_agent targets) until a second connection
|
||||||
// reappears. Push the now-empty roster once so they forget it. An empty
|
// reappears. Push the now-empty roster once per sole connection so it
|
||||||
// route list (registry not loaded yet) must not spend the one clear.
|
// forgets it — a replacement sole connection has never been told. An
|
||||||
if (connections.length === 1 && !relay.rosterCleared) {
|
// empty route list (registry not loaded yet) must not spend the clear.
|
||||||
relay.rosterCleared = true
|
if (connections.length === 1 && connections[0].id !== relay.rosterClearedFor) {
|
||||||
|
relay.rosterClearedFor = connections[0].id
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
connections.map(async connection => {
|
connections.map(async connection => {
|
||||||
try {
|
try {
|
||||||
@@ -282,7 +283,7 @@ async function syncRelayRosters() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
relay.rosterCleared = false
|
relay.rosterClearedFor = null
|
||||||
|
|
||||||
const agentsByConnection = new Map<string, RelayAgentRow[]>()
|
const agentsByConnection = new Map<string, RelayAgentRow[]>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -486,7 +487,7 @@ function scheduleRelayPushDrain() {
|
|||||||
|
|
||||||
export function startBotRelay() {
|
export function startBotRelay() {
|
||||||
relay.disposed = false
|
relay.disposed = false
|
||||||
relay.rosterCleared = false
|
relay.rosterClearedFor = null
|
||||||
|
|
||||||
// Source-shape test harnesses evaluate plugin.js without DOM timers —
|
// Source-shape test harnesses evaluate plugin.js without DOM timers —
|
||||||
// the relay only runs where a real event loop exists.
|
// the relay only runs where a real event loop exists.
|
||||||
|
|||||||
Reference in New Issue
Block a user