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:
Neil
2026-10-21 19:14:43 -07:00
committed by GitHub
parent ed13ca1e6f
commit 75e1f1c0f4
3 changed files with 121 additions and 7 deletions
@@ -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)
+38 -6
View File
@@ -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> {