diff --git a/src/main/wsl/wsl-guest-process-identity.ts b/src/main/wsl/wsl-guest-process-identity.ts index f4c9b8c18ac..5ec5f085427 100644 --- a/src/main/wsl/wsl-guest-process-identity.ts +++ b/src/main/wsl/wsl-guest-process-identity.ts @@ -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, diff --git a/src/main/wsl/wsl-orcad-instance-adoption.test.ts b/src/main/wsl/wsl-orcad-instance-adoption.test.ts index cca28ec5647..0301d1c9de7 100644 --- a/src/main/wsl/wsl-orcad-instance-adoption.test.ts +++ b/src/main/wsl/wsl-orcad-instance-adoption.test.ts @@ -9,6 +9,9 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' type FakeHost = { lock: string | null alive: Set + /** Start ticks of each live PID; a restarted distro gives a reused PID other ticks. */ + ticks: Map + guestAnswers: boolean pidFiles: Map 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) diff --git a/src/main/wsl/wsl-orcad-instance-adoption.ts b/src/main/wsl/wsl-orcad-instance-adoption.ts index 2c8438604ba..60676c65bd0 100644 --- a/src/main/wsl/wsl-orcad-instance-adoption.ts +++ b/src/main/wsl/wsl-orcad-instance-adoption.ts @@ -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/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 { + 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 {