mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl-host): prove an unrecorded orcad by kernel identity before adopting it (#31685)
* fix(wsl-host): prove an unrecorded orcad by kernel identity before adopting it A WSL connect adopted the orcad named by a leftover instance lock whenever a bare kill -0 found its PID. After a distro restart PIDs are reused, and a stale lock plus a stale readiness file that name the same PID passed every other check, so the record was repointed at a dead build. The holder is now live only when the lock's boot id and start ticks match the PID's; an unanswered guest or an older lock without them is unverifiable and is never adopted. * fix(wsl-host): compare a lock's boot id case-insensitively
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* booted: the guest's wall clock can jump after a host sleep (microsoft/WSL#4179, #10006), and
|
||||
* a wall-clock start time would then mistake a live process for a new one, or the reverse.
|
||||
*/
|
||||
import type { OrcadProcessIncarnation } from '../orcad/orcad-process-start-time'
|
||||
import type { OrcadHostTransport } from '../ssh/orcad-host-transport'
|
||||
import { execCommand } from '../ssh/remote-exec/remote-exec-command'
|
||||
|
||||
@@ -68,6 +69,15 @@ export function parseWslGuestIdentity(output: string): WslGuestIdentity | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The asked-for PID's identity in the form orcad's instance lock records; null when it is gone. */
|
||||
export function wslGuestOrcadIncarnation(
|
||||
identity: WslGuestIdentity
|
||||
): OrcadProcessIncarnation | null {
|
||||
const linuxStartTicks = identity.orcad?.split(':')[1]
|
||||
const bootId = identity.distroInstance.split(':')[0]
|
||||
return linuxStartTicks && bootId ? { linuxStartTicks, bootId } : null
|
||||
}
|
||||
|
||||
/** null when the guest could not answer: that is unverifiable, never evidence of an exit. */
|
||||
export async function readWslGuestIdentity(
|
||||
conn: OrcadHostTransport,
|
||||
|
||||
@@ -9,6 +9,9 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
type FakeHost = {
|
||||
lock: string | null
|
||||
alive: Set<number>
|
||||
/** Start ticks of each live PID; a restarted distro gives a reused PID other ticks. */
|
||||
ticks: Map<number, string>
|
||||
guestAnswers: boolean
|
||||
pidFiles: Map<string, number>
|
||||
record: unknown
|
||||
transaction: unknown
|
||||
@@ -23,6 +26,8 @@ type FakeHost = {
|
||||
const host = vi.hoisted((): FakeHost => ({
|
||||
lock: null,
|
||||
alive: new Set(),
|
||||
ticks: new Map(),
|
||||
guestAnswers: true,
|
||||
pidFiles: new Map(),
|
||||
record: null,
|
||||
transaction: null,
|
||||
@@ -38,9 +43,27 @@ vi.mock('../managed-server/install/orcad-remote-record-file', () => ({
|
||||
readBoundedOrcadRemoteRecord: async () =>
|
||||
host.lock === null ? { state: 'absent' } : { state: 'present', raw: host.lock }
|
||||
}))
|
||||
const BOOT_ID = '0f6e1d2c-3b4a-4958-8776-655443322110'
|
||||
const LOCK_TICKS = '4242'
|
||||
function procStat(pid: number, ticks: string): string {
|
||||
return `${pid} (node) S ${Array.from({ length: 18 }, () => '0').join(' ')} ${ticks} 0`
|
||||
}
|
||||
vi.mock('../ssh/remote-exec/remote-exec-command', () => ({
|
||||
execCommand: async (_conn: unknown, command: string) => {
|
||||
host.commands.push(command)
|
||||
if (!host.guestAnswers) {
|
||||
throw new Error('wsl.exe did not answer')
|
||||
}
|
||||
const pid = Number(/cat \/proc\/(\d+)\/stat 2>/.exec(command)?.[1])
|
||||
const stat = host.alive.has(pid) ? procStat(pid, host.ticks.get(pid) ?? LOCK_TICKS) : ''
|
||||
const section = '--orca-identity--'
|
||||
return `${BOOT_ID}\n${section}\n${procStat(1, '1')}\n${section}\n${stat}\n${section}\n`
|
||||
}
|
||||
}))
|
||||
vi.mock('../managed-server/install/orcad-remote-runtime-control', () => ({
|
||||
execOrcadRemote: async (_slot: unknown, command: string) => {
|
||||
host.commands.push(command)
|
||||
// A bare kill -0 sees any process with the PID, whatever it now is.
|
||||
const kill = /kill -0 (\d+)/.exec(command)
|
||||
if (kill) {
|
||||
return host.alive.has(Number(kill[1])) ? 'LIVE\n' : 'DEAD\n'
|
||||
@@ -121,6 +144,8 @@ function lockFor(pid: number, version: string): string {
|
||||
return JSON.stringify({
|
||||
pid,
|
||||
startedAtMs: 1,
|
||||
linuxStartTicks: LOCK_TICKS,
|
||||
bootId: BOOT_ID,
|
||||
identity: '1000',
|
||||
version,
|
||||
acquiredAt: '2026-10-11T00:00:00.000Z',
|
||||
@@ -153,6 +178,8 @@ describe('adopting an orcad the activation record does not name', () => {
|
||||
beforeEach(() => {
|
||||
host.lock = null
|
||||
host.alive.clear()
|
||||
host.ticks.clear()
|
||||
host.guestAnswers = true
|
||||
host.pidFiles.clear()
|
||||
host.transaction = null
|
||||
host.stopTarget = null
|
||||
@@ -176,7 +203,7 @@ describe('adopting an orcad the activation record does not name', () => {
|
||||
expect(host.commands).toContain(
|
||||
`(umask 077 && printf '%s\\n' 500 > '/home/u/.orca-remote/orcad-${OLD}/.orcad-pid')`
|
||||
)
|
||||
expect(host.commands.some((command) => /\bkill\b(?! -0)/.test(command))).toBe(false)
|
||||
expect(host.commands.some((command) => /\bkill\b/.test(command))).toBe(false)
|
||||
})
|
||||
|
||||
it('records an orcad from a different version as active, keeping the recorded one as previous', async () => {
|
||||
@@ -222,6 +249,51 @@ describe('adopting an orcad the activation record does not name', () => {
|
||||
expect(host.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a lock that recorded its boot id in upper case', async () => {
|
||||
host.lock = JSON.stringify({ ...JSON.parse(lockFor(530, OLD)), bootId: BOOT_ID.toUpperCase() })
|
||||
host.alive.add(530)
|
||||
host.stopTarget = stopTargetFor(530, OLD)
|
||||
await expect(adopt(record())).resolves.toMatchObject({ outcome: 'adopted', pid: 530 })
|
||||
})
|
||||
|
||||
it('does not adopt a lock whose PID a distro restart gave to another process', async () => {
|
||||
// The stale lock and readiness both still name pid 500, so only process identity can tell.
|
||||
host.lock = lockFor(500, OLD)
|
||||
host.alive.add(500)
|
||||
host.ticks.set(500, '977')
|
||||
host.stopTarget = stopTargetFor(500, OLD)
|
||||
await expect(adopt(record({ active: NEW }))).resolves.toEqual({ outcome: 'none' })
|
||||
expect(host.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('never reads an unanswered guest or an unprovable lock as a live holder', async () => {
|
||||
host.lock = lockFor(510, OLD)
|
||||
host.alive.add(510)
|
||||
host.stopTarget = stopTargetFor(510, OLD)
|
||||
host.guestAnswers = false
|
||||
await expect(adopt(record({ active: NEW }))).resolves.toEqual({ outcome: 'none' })
|
||||
host.guestAnswers = true
|
||||
// A writer that predates the kernel incarnation leaves only a drifting wall-clock start.
|
||||
const { linuxStartTicks: _ticks, bootId: _boot, ...legacy } = JSON.parse(lockFor(510, OLD))
|
||||
host.lock = JSON.stringify(legacy)
|
||||
await expect(adopt(record({ active: NEW }))).resolves.toEqual({ outcome: 'none' })
|
||||
expect(host.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses to adopt when the holder cannot be re-proven under the fence', async () => {
|
||||
host.lock = lockFor(520, OLD)
|
||||
host.alive.add(520)
|
||||
host.stopTarget = stopTargetFor(520, OLD)
|
||||
const reads = { count: 0 }
|
||||
host.record = record()
|
||||
const answers = vi.spyOn(host.alive, 'has').mockImplementation(() => reads.count++ === 0)
|
||||
await expect(adoptUnrecordedWslOrcad(slot, record(), () => NOW)).rejects.toThrow(
|
||||
/changed while it was being adopted/
|
||||
)
|
||||
answers.mockRestore()
|
||||
expect(host.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses, writing nothing, when the running orcad cannot be proven to be its slot', async () => {
|
||||
host.lock = lockFor(900, OLD)
|
||||
host.alive.add(900)
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
* installed build must all name the same process. Then the record and pid file are rewritten under
|
||||
* the activation fence. A different version is then handled by the usual update gate.
|
||||
*/
|
||||
import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock'
|
||||
import {
|
||||
ORCAD_LOCK_FILE_NAME,
|
||||
parseOrcadInstanceLockRecord,
|
||||
type OrcadLockRecord
|
||||
} from '../orcad/orcad-instance-lock'
|
||||
import { compareOrcadProcessIdentity } from '../orcad/orcad-process-start-time'
|
||||
import type { OrcadActivationRecord } from '../managed-server/activation/orcad-activation-record'
|
||||
import {
|
||||
readOrcadActivationRecord,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
} from '../managed-server/install/remote-install-model'
|
||||
import { quotePosixShell } from '../../shared/shell-argument-quoting'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { readWslGuestIdentity, wslGuestOrcadIncarnation } from './wsl-guest-process-identity'
|
||||
|
||||
const LOCK_RECORD_MAX_BYTES = 64 * 1024
|
||||
const UNRECORDED_ORCAD_CODE = 'wsl_orcad_unrecorded_instance_unverifiable'
|
||||
@@ -62,11 +68,37 @@ async function readLiveLockHolder(slot: OrcadSlotOptions): Promise<LockHolder |
|
||||
if (!lock || lock.role === 'desktop') {
|
||||
return null
|
||||
}
|
||||
const alive = await execOrcadRemote(
|
||||
slot,
|
||||
`if kill -0 ${String(lock.pid)} 2>/dev/null; then echo LIVE; else echo DEAD; fi`
|
||||
)
|
||||
return alive.trim().split('\n').pop() === 'LIVE' ? { pid: lock.pid, version: lock.version } : null
|
||||
// Not live is never adopted; the launch's own instance lock then decides, on the host.
|
||||
return (await lockHolderLiveness(slot, lock)) === 'live'
|
||||
? { pid: lock.pid, version: lock.version }
|
||||
: null
|
||||
}
|
||||
|
||||
type LockHolderLiveness = 'live' | 'unverifiable' | 'exited'
|
||||
|
||||
/** A PID is no identity after a distro restart reuses it: the lock's boot id and start ticks are. */
|
||||
async function lockHolderLiveness(
|
||||
slot: OrcadSlotOptions,
|
||||
lock: OrcadLockRecord
|
||||
): Promise<LockHolderLiveness> {
|
||||
const guest = await readWslGuestIdentity(slot.conn, lock.pid)
|
||||
if (!guest) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
const incarnation = wslGuestOrcadIncarnation(guest)
|
||||
// The guest answered, and no process has that PID.
|
||||
if (!incarnation) {
|
||||
return 'exited'
|
||||
}
|
||||
// Why lowercased: the guest's boot id is read lowercased, and a writer may record it otherwise.
|
||||
const recorded = lock.bootId ? { ...lock, bootId: lock.bootId.toLowerCase() } : lock
|
||||
const verdict = compareOrcadProcessIdentity(lock.pid, recorded, {
|
||||
incarnation: () => incarnation,
|
||||
// The guest's wall clock jumps after a host sleep, so it is not read.
|
||||
startedAtMs: () => null,
|
||||
platform: 'linux'
|
||||
})
|
||||
return verdict === 'matches' ? 'live' : verdict === 'differs' ? 'exited' : 'unverifiable'
|
||||
}
|
||||
|
||||
async function readSlotPid(slot: OrcadSlotOptions, version: string): Promise<number | null> {
|
||||
|
||||
Reference in New Issue
Block a user