From bd4702fbb536a8034d647116efe0064cf3b13f24 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:40:40 -0700 Subject: [PATCH] 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. --- src/main/orcad/orcad-terminal-census.test.ts | 14 ++++-- src/main/orcad/orcad-terminal-census.ts | 10 +++-- .../ssh/orcad-daemon-protocol-crossing.ts | 37 +++++++++++----- src/main/ssh/orcad-update-plan.test.ts | 43 +++++++++++++++++++ src/main/ssh/orcad-update-plan.ts | 17 ++++---- src/shared/orcad-terminal-census.ts | 4 +- 6 files changed, 98 insertions(+), 27 deletions(-) diff --git a/src/main/orcad/orcad-terminal-census.test.ts b/src/main/orcad/orcad-terminal-census.test.ts index b2465d47902..c70ee02ba32 100644 --- a/src/main/orcad/orcad-terminal-census.test.ts +++ b/src/main/orcad/orcad-terminal-census.test.ts @@ -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 }) }) diff --git a/src/main/orcad/orcad-terminal-census.ts b/src/main/orcad/orcad-terminal-census.ts index f10e733e34e..1629f2b4ed8 100644 --- a/src/main/orcad/orcad-terminal-census.ts +++ b/src/main/orcad/orcad-terminal-census.ts @@ -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 } } diff --git a/src/main/ssh/orcad-daemon-protocol-crossing.ts b/src/main/ssh/orcad-daemon-protocol-crossing.ts index 5dd5653ccef..6517234a21b 100644 --- a/src/main/ssh/orcad-daemon-protocol-crossing.ts +++ b/src/main/ssh/orcad-daemon-protocol-crossing.ts @@ -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(', ')}` } diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index b476a7b54fb..a4f85337df8 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -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(), diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index 83e03a92e08..4a6bd61fa4a 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -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.' } } diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts index 73b0c6f4384..bc50929a75c 100644 --- a/src/shared/orcad-terminal-census.ts +++ b/src/shared/orcad-terminal-census.ts @@ -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. */