fix(managed-server): let a forced update or rollback cross mixed daemon generations (#34664)

The terminal census collapsed sessions spread over more than one daemon
generation to a null protocol, so a host that kept a legacy daemon alive
across an earlier update refused every forced update and rollback while any
terminal ran. The census now reports every live protocol, and the crossing
check requires the incoming build to attach each one. The single-protocol
field stays for older desktops.
This commit is contained in:
Neil
2026-10-28 01:40:40 -07:00
committed by GitHub
parent 612febdadd
commit bd4702fbb5
6 changed files with 98 additions and 27 deletions
+10 -4
View File
@@ -19,6 +19,7 @@ describe('orcad terminal census', () => {
liveSessions: 3,
startedSinceActivation: 2,
daemonProtocolVersion: 7,
daemonProtocolVersions: [7],
inProcessSessions: 0
})
})
@@ -34,18 +35,22 @@ describe('orcad terminal census', () => {
liveSessions: 0,
startedSinceActivation: 0,
daemonProtocolVersion: null,
daemonProtocolVersions: [],
inProcessSessions: 0
})
})
it('leaves the protocol unknown when sessions span daemon generations', async () => {
it('names every daemon generation when sessions span them', async () => {
const census = await collectOrcadTerminalCensus(
0,
async () => [session(1, 7), session(2, 6)],
async () => [session(1, 7), session(2, 6), session(3, 7)],
async () => 0
)
expect(census.daemonProtocolVersion).toBeNull()
expect(census.liveSessions).toBe(2)
expect(census).toMatchObject({
liveSessions: 3,
daemonProtocolVersion: null,
daemonProtocolVersions: [6, 7]
})
})
it('leaves the since-activation count unknown when a session has no creation time', async () => {
@@ -85,6 +90,7 @@ describe('orcad terminal census', () => {
liveSessions: 1,
startedSinceActivation: null,
daemonProtocolVersion: null,
daemonProtocolVersions: [],
inProcessSessions: 1
})
})
+6 -4
View File
@@ -31,8 +31,9 @@ export async function collectOrcadTerminalCensus(
const timestampsKnown = sessions.every(
(session) => Number.isFinite(session.createdAt) && session.createdAt > 0
)
const protocols = new Set(sessions.map((session) => session.protocolVersion))
const [protocol] = protocols
const protocols = [...new Set(sessions.map((session) => session.protocolVersion))].sort(
(a, b) => a - b
)
return {
liveSessions: sessions.length + inProcess,
// Fallback terminals lack creation times, so activation-relative counts remain unknown.
@@ -40,8 +41,9 @@ export async function collectOrcadTerminalCensus(
timestampsKnown && inProcess === 0
? sessions.filter((session) => session.createdAt >= activatedAt).length
: null,
// Mixed daemon generations cannot establish one protocol owner for build compatibility.
daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null,
// Older desktops read only the scalar, which cannot name mixed generations.
daemonProtocolVersion: protocols.length === 1 ? protocols[0] : null,
daemonProtocolVersions: protocols,
inProcessSessions: inProcess
}
}
+27 -10
View File
@@ -26,10 +26,18 @@ export function orcadBuildCanAttachDaemon(
}
export type OrcadLiveDaemonCrossing =
| 'no-live-terminals'
| 'attachable'
| 'strands-live-terminals'
| 'unverifiable'
| { state: 'no-live-terminals' }
| { state: 'attachable' }
| { state: 'strands-live-terminals'; unattachable: readonly number[] }
| { state: 'unverifiable' }
/** Hosts predating the protocol set report only a single-generation scalar. */
function liveDaemonProtocols(census: OrcadTerminalCensus): readonly number[] | null {
if (census.daemonProtocolVersions !== undefined) {
return census.daemonProtocolVersions
}
return census.daemonProtocolVersion === null ? null : [census.daemonProtocolVersion]
}
/** An unknown session count or daemon protocol is planned for as live and unattachable. */
export function assessOrcadLiveDaemonCrossing(
@@ -37,12 +45,21 @@ export function assessOrcadLiveDaemonCrossing(
incoming: OrcadDaemonProtocolFacts
): OrcadLiveDaemonCrossing {
if (census.liveSessions === 0) {
return 'no-live-terminals'
return { state: 'no-live-terminals' }
}
if (census.daemonProtocolVersion === null) {
return 'unverifiable'
const protocols = liveDaemonProtocols(census)
if (protocols === null || protocols.length === 0) {
return { state: 'unverifiable' }
}
return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion)
? 'attachable'
: 'strands-live-terminals'
// Every generation must stay reachable; one unattachable daemon strands its terminals.
const unattachable = protocols.filter(
(protocol) => !orcadBuildCanAttachDaemon(incoming, protocol)
)
return unattachable.length === 0
? { state: 'attachable' }
: { state: 'strands-live-terminals', unattachable }
}
export function describeDaemonProtocols(protocols: readonly number[]): string {
return protocols.length === 1 ? `protocol ${protocols[0]}` : `protocols ${protocols.join(', ')}`
}
+43
View File
@@ -291,6 +291,49 @@ describe('D7 daemon protocol crossing', () => {
})
})
const mixed = (daemonProtocolVersions: number[]) => ({
liveSessions: 80,
startedSinceActivation: 0,
daemonProtocolVersion: null,
daemonProtocolVersions
})
it('forces an update past mixed daemon generations only when the candidate attaches each', () => {
const plan = (daemonProtocolVersions: number[]) =>
planOrcadUpdate({
record: record(),
candidateVersion: '0.3.0+cc01',
candidateDaemonProtocol: PROTOCOL,
census: mixed(daemonProtocolVersions),
force: true
})
expect(plan([2, 3])).toMatchObject({ action: 'proceed', preservesLiveDaemon: true })
expect(plan([3, 4])).toMatchObject({
action: 'defer',
code: 'orcad_update_strands_live_terminals'
})
expect(plan([])).toMatchObject({
action: 'defer',
code: 'orcad_update_daemon_protocol_unverifiable'
})
})
it('rolls back over mixed daemon generations only when the target attaches each', () => {
const rollback = (daemonProtocolVersions: number[]) =>
assessOrcadRollback({
record: record(),
snapshotPresent: true,
census: mixed(daemonProtocolVersions),
targetDaemonProtocol: PROTOCOL,
stateWritesSinceActivation: false
})
expect(rollback([1, 3])).toMatchObject({ safety: 'clean' })
expect(rollback([2, 4])).toMatchObject({
safety: 'unsafe',
code: 'orcad_rollback_strands_live_terminals'
})
})
it('needs no protocol answer when no terminals are running', () => {
const plan = planOrcadUpdate({
record: record(),
+9 -8
View File
@@ -3,6 +3,7 @@ import type { OrcadActivationRecord } from '../managed-server/activation/orcad-a
import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census'
import {
assessOrcadLiveDaemonCrossing,
describeDaemonProtocols,
type OrcadDaemonProtocolFacts
} from './orcad-daemon-protocol-crossing'
@@ -55,18 +56,18 @@ export function planOrcadUpdate(input: {
}
const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol)
// Force can accept mixed builds, never unreachable terminals.
if (crossing === 'strands-live-terminals') {
if (crossing.state === 'strands-live-terminals') {
return {
action: 'defer',
code: 'orcad_update_strands_live_terminals',
reason:
`The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` +
`A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` +
`which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` +
'every running terminal unreachable. Update when no terminals are running.'
'its running terminals unreachable. Update when no terminals are running.'
}
}
const { liveSessions } = input.census
if (input.force && crossing === 'unverifiable') {
if (input.force && crossing.state === 'unverifiable') {
return {
action: 'defer',
code: 'orcad_update_daemon_protocol_unverifiable',
@@ -209,7 +210,7 @@ export function assessOrcadRollback(input: {
}
}
const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol)
if (crossing === 'unverifiable') {
if (crossing.state === 'unverifiable') {
return {
safety: 'unsafe',
code: 'orcad_rollback_daemon_protocol_unverifiable',
@@ -218,13 +219,13 @@ export function assessOrcadRollback(input: {
`${target} would reach the terminals still running. Retry when the daemon answers.`
}
}
if (crossing === 'strands-live-terminals') {
if (crossing.state === 'strands-live-terminals') {
return {
safety: 'unsafe',
code: 'orcad_rollback_strands_live_terminals',
reason:
`The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` +
`which ${target} does not list. Rolling back now would leave every running terminal ` +
`A live terminal daemon speaks ${describeDaemonProtocols(crossing.unattachable)}, ` +
`which ${target} does not list. Rolling back now would leave its running terminals ` +
'unreachable. Roll back when no terminals are running, or deploy forward.'
}
}
+3 -1
View File
@@ -9,8 +9,10 @@ export const OrcadTerminalCensusSchema = z.object({
liveSessions: CountSchema,
/** Of those, how many started at or after the active version's activation. */
startedSinceActivation: CountSchema,
/** Protocol of the daemon that owns those sessions. */
/** Protocol of the daemon that owns those sessions; null unless exactly one generation owns them. */
daemonProtocolVersion: z.number().int().positive().nullable(),
/** Protocol of every daemon generation owning those sessions; absent on older hosts. */
daemonProtocolVersions: z.array(z.number().int().positive()).optional(),
/** In-process fallback sessions end with orcad, unlike daemon sessions; absent on older hosts. */
inProcessSessions: CountSchema.optional(),
/** Admission-fence expiry, epoch ms; 0 means reopened. Absence never proves older servers fenced admission. */