diff --git a/src/main/telemetry/wsl-usage-reporter.ts b/src/main/telemetry/wsl-usage-reporter.ts index 6c295cd9eac..abc1bc94f80 100644 --- a/src/main/telemetry/wsl-usage-reporter.ts +++ b/src/main/telemetry/wsl-usage-reporter.ts @@ -16,6 +16,7 @@ import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../windows-native-reg import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' import { listWslHostDistros } from '../wsl/wsl-host-distro-list' import { + wslHostsAppliedMovedProjects, wslHostsAppliedPolicyState, wslHostsGateOpen, wslHostsGateState @@ -167,7 +168,8 @@ export async function gatherWslUsageSnapshotInput( windowsDriveReposWithWslWorktrees: await countWindowsDriveReposWithWslWorktrees(store), wslHostsGateOpen: gateOpen, wslHostsGateState: wslHostsGateState(), - wslHostsPolicy: wslHostsAppliedPolicyState() + wslHostsPolicy: wslHostsAppliedPolicyState(), + wslHostsMovedProjects: wslHostsAppliedMovedProjects() } } diff --git a/src/main/telemetry/wsl-usage-snapshot.test.ts b/src/main/telemetry/wsl-usage-snapshot.test.ts index 9b6e919dc51..91d519cd351 100644 --- a/src/main/telemetry/wsl-usage-snapshot.test.ts +++ b/src/main/telemetry/wsl-usage-snapshot.test.ts @@ -134,6 +134,25 @@ describe('buildWslUsageSnapshot', () => { } ) + it.each(['keep', 'bring-back'] as const)( + 'reports what a block asks for moved projects (%s) as an optional field', + (wslHostsMovedProjects) => { + const props = buildWslUsageSnapshot({ + ...baseInput, + wslHostsPolicy: 'block', + wslHostsMovedProjects + }) + expect(props.wsl_hosts_moved_projects).toBe(wslHostsMovedProjects) + expect(validate('wsl_usage_snapshot', props).ok).toBe(true) + } + ) + + it('omits moved projects when nothing blocks', () => { + expect( + buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: 'allow', wslHostsMovedProjects: null }) + ).not.toHaveProperty('wsl_hosts_moved_projects') + }) + it('omits the remote policy when the internal gate left it unread', () => { expect(buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: null })).not.toHaveProperty( 'wsl_hosts_policy' diff --git a/src/main/telemetry/wsl-usage-snapshot.ts b/src/main/telemetry/wsl-usage-snapshot.ts index c374bedbfa2..7d12c82ee0b 100644 --- a/src/main/telemetry/wsl-usage-snapshot.ts +++ b/src/main/telemetry/wsl-usage-snapshot.ts @@ -1,6 +1,6 @@ // Count distro identities locally; only buckets/booleans reach the telemetry payload. import type { WslHostsGateState } from '../../shared/wsl-host-internal-gate' -import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy' import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isDrvfsLinuxPath, parseWslUncPath } from '../../shared/wsl-paths' @@ -37,6 +37,8 @@ export type WslUsageSnapshotInput = { wslHostsGateState?: WslHostsGateState /** The cached remote policy applied at launch; absent when the internal gate left it unread. */ wslHostsPolicy?: WslHostsPolicyState | null + /** What an applied block asks for moved projects; absent when nothing blocks. */ + wslHostsMovedProjects?: WslHostsMovedProjects | null } /** `2.4.13.0` → `2.4`; anything else is dropped rather than sent. */ @@ -120,6 +122,9 @@ export function buildWslUsageSnapshot(input: WslUsageSnapshotInput): WslUsageSna project_distros_used: bucketWslUsageCount(projectDistros.size), wsl_hosts_gate_open: input.wslHostsGateOpen, ...(input.wslHostsGateState ? { wsl_hosts_gate_state: input.wslHostsGateState } : {}), - ...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {}) + ...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {}), + ...(input.wslHostsMovedProjects + ? { wsl_hosts_moved_projects: input.wslHostsMovedProjects } + : {}) } } diff --git a/src/main/wsl/wsl-host-gate-startup.test.ts b/src/main/wsl/wsl-host-gate-startup.test.ts index ba8a7c6a60f..9407b0ca5b2 100644 --- a/src/main/wsl/wsl-host-gate-startup.test.ts +++ b/src/main/wsl/wsl-host-gate-startup.test.ts @@ -9,6 +9,7 @@ import { wslMoveJournalDirectory } from '../persistence/moving-wsl-projects/wsl- import { resetWslHostsGateForTests, wslHostsAppliedPolicyState, + wslHostsBringBackMovedProjects, wslHostsGateOpen, wslHostsGateState, wslHostsPolicyBlocked @@ -68,10 +69,10 @@ describe('WSL hosts gate startup promotion', () => { }) describe('with the internal gate open and a cached remote policy', () => { - const cachePolicy = (wslHosts: 'allow' | 'block') => + const cachePolicy = (wslHosts: 'allow' | 'block', movedProjects?: 'keep' | 'bring-back') => writeFileSync( join(userDataPath, wslHostsPolicyCacheFileName('stable')), - JSON.stringify({ version: 1, revision: 1, wslHosts }) + JSON.stringify({ version: 1, revision: 1, wslHosts, movedProjects }) ) const seedSavedHost = () => writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { @@ -132,6 +133,23 @@ describe('WSL hosts gate startup promotion', () => { expect(wslHostsGateOpen()).toBe(false) }) + it('reads a block that asks for moved projects back', () => { + cachePolicy('block', 'bring-back') + seedSavedHost() + configureWslHostsGateFromDisk(userDataPath, STABLE) + + expect(wslHostsGateState()).toBe('serve-only') + expect(wslHostsBringBackMovedProjects()).toBe(true) + }) + + it('ignores bring-back on an allow', () => { + cachePolicy('allow', 'bring-back') + configureWslHostsGateFromDisk(userDataPath, STABLE) + + expect(wslHostsGateState()).toBe('open') + expect(wslHostsBringBackMovedProjects()).toBe(false) + }) + it('ignores a block cached for another channel', () => { cachePolicy('block') configureWslHostsGateFromDisk(userDataPath, '1.4.3-rc.1') diff --git a/src/main/wsl/wsl-host-gate-startup.ts b/src/main/wsl/wsl-host-gate-startup.ts index ec88b8a6b2b..f482d7b2481 100644 --- a/src/main/wsl/wsl-host-gate-startup.ts +++ b/src/main/wsl/wsl-host-gate-startup.ts @@ -1,15 +1,24 @@ import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' -import { wslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import { + wslHostsPolicyMovedProjects, + wslHostsPolicyState, + type WslHostsPolicy +} from '../../shared/wsl-hosts-policy' import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' import { configureWslHostsGate } from './wsl-host-gate' import { readCachedWslHostsPolicy } from './wsl-hosts-policy-live-update' /** Reads the policy only with the internal gate open, and saved work only once it is closed. */ export function configureWslHostsGateFromDisk(userDataPath: string, appVersion: string): void { + // One read serves both facts, so they can't disagree about a cache rewritten in between. + let cached: { policy: WslHostsPolicy | null } | null = null + const readPolicy = (): WslHostsPolicy | null => + (cached ??= { policy: readCachedWslHostsPolicy(userDataPath, appVersion) }).policy configureWslHostsGate({ hasSavedWslHost: () => readWslHostDeployments(userDataPath).deployments.length > 0, // Any journal entry, committed or not: recovery must reset an in-flight move whose host is gone. hasWslMoveCutover: () => listWslMoveCutovers(userDataPath).length > 0, - policy: () => wslHostsPolicyState(readCachedWslHostsPolicy(userDataPath, appVersion)) + policy: () => wslHostsPolicyState(readPolicy()), + movedProjects: () => wslHostsPolicyMovedProjects(readPolicy()) }) } diff --git a/src/main/wsl/wsl-host-gate.test.ts b/src/main/wsl/wsl-host-gate.test.ts index 995982c17ad..f25a5b856a9 100644 --- a/src/main/wsl/wsl-host-gate.test.ts +++ b/src/main/wsl/wsl-host-gate.test.ts @@ -5,7 +5,9 @@ import { isGatedOffWslHost, resetWslHostsGateForTests, wslHostsGateOpen, + wslHostsAppliedMovedProjects, wslHostsAppliedPolicyState, + wslHostsBringBackMovedProjects, wslHostsGateState, wslHostsPolicyBlocked, wslHostsServing, @@ -209,4 +211,55 @@ describe('WSL hosts gate', () => { expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBeUndefined() }) }) + + describe('moved projects under a block', () => { + it('asks for moved projects back only when the block says so', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => 'bring-back' + }) + + expect(wslHostsBringBackMovedProjects()).toBe(true) + expect(wslHostsAppliedMovedProjects()).toBe('bring-back') + expect(wslHostsGateState()).toBe('serve-only') + }) + + it('never reads it without a block', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + const movedProjects = vi.fn(() => 'bring-back' as const) + configureWslHostsGate({ ...facts(true, true), policy: () => 'allow', movedProjects }) + + expect(movedProjects).not.toHaveBeenCalled() + expect(wslHostsBringBackMovedProjects()).toBe(false) + expect(wslHostsAppliedMovedProjects()).toBeNull() + }) + + it('keeps moved projects served when the choice cannot be read', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => { + throw new Error('unreadable') + } + }) + + expect(wslHostsBringBackMovedProjects()).toBe(false) + expect(wslHostsAppliedMovedProjects()).toBe('keep') + }) + + it('clears on reset', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => 'bring-back' + }) + resetWslHostsGateForTests() + + expect(wslHostsBringBackMovedProjects()).toBe(false) + }) + }) }) diff --git a/src/main/wsl/wsl-host-gate.ts b/src/main/wsl/wsl-host-gate.ts index 2b02c95ca12..f354bd3a012 100644 --- a/src/main/wsl/wsl-host-gate.ts +++ b/src/main/wsl/wsl-host-gate.ts @@ -9,7 +9,7 @@ import { type WslHostsGateState } from '../../shared/wsl-host-internal-gate' import type { OrcadDeploymentLink } from '../../shared/runtime-environments' -import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy' export const WSL_HOSTS_GATED_OFF_MESSAGE = 'WSL hosts are not available in this build.' @@ -19,6 +19,8 @@ let promoted: { hasSavedWslHost: boolean; hasWslMoveCutover: boolean } | null = let policyBlocked = false // The cached remote policy this launch read; null when the internal gate was closed, so none was read. let policyState: WslHostsPolicyState | null = null +// What a block asks for already-moved projects; `keep` whenever nothing blocks. +let movedProjects: WslHostsMovedProjects = 'keep' // Unreadable state counts as absent: today's closed behavior. function readFact(fact: () => boolean): boolean { @@ -41,10 +43,21 @@ function readPolicy(read: (() => WslHostsPolicyState) | undefined): WslHostsPoli } } -function notePolicy(state: WslHostsPolicyState | null): void { +function notePolicy( + state: WslHostsPolicyState | null, + moved: (() => WslHostsMovedProjects) | undefined +): void { policyState = state policyBlocked = state === 'block' noteWslHostsPolicyBlocked(policyBlocked) + movedProjects = 'keep' + if (policyBlocked && moved) { + try { + movedProjects = moved() + } catch { + // Unreadable: moved projects stay served, which is what a block alone does. + } + } } /** @@ -55,8 +68,10 @@ export function configureWslHostsGate(facts: { hasSavedWslHost: () => boolean hasWslMoveCutover: () => boolean policy?: () => WslHostsPolicyState + /** Read only when the policy blocks. */ + movedProjects?: () => WslHostsMovedProjects }): void { - notePolicy(readPolicy(facts.policy)) + notePolicy(readPolicy(facts.policy), facts.movedProjects) promoted = wslHostsGateOpen() ? null : { @@ -72,7 +87,7 @@ export function configureWslHostsGate(facts: { export function resetWslHostsGateForTests(): void { promoted = null noteWslHostsServeOnly(false) - notePolicy(null) + notePolicy(null, undefined) publishWslHostsGateState(process.env, null) } @@ -107,6 +122,16 @@ export function wslHostsAppliedPolicyState(): WslHostsPolicyState | null { return policyState } +/** This run's block asks for already-moved projects to come back to this computer. */ +export function wslHostsBringBackMovedProjects(): boolean { + return movedProjects === 'bring-back' +} + +/** What this run's block asks for moved projects; null when nothing blocks. */ +export function wslHostsAppliedMovedProjects(): WslHostsMovedProjects | null { + return policyBlocked ? movedProjects : null +} + /** Saved hosts and moved projects stay reachable: `open` or `serve-only`. */ export function wslHostsServing(): boolean { return wslHostsGateState() !== 'closed' diff --git a/src/main/wsl/wsl-hosts-policy-live-update.test.ts b/src/main/wsl/wsl-hosts-policy-live-update.test.ts index a5fdaacab4b..5f9e5aa3adb 100644 --- a/src/main/wsl/wsl-hosts-policy-live-update.test.ts +++ b/src/main/wsl/wsl-hosts-policy-live-update.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { parseWslHostsPolicy, wslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import { + parseWslHostsPolicy, + wslHostsPolicyMovedProjects, + wslHostsPolicyState +} from '../../shared/wsl-hosts-policy' import { readCachedWslHostsPolicy, WslHostsPolicyLiveUpdater, @@ -36,6 +40,28 @@ describe('WSL hosts policy parsing', () => { expect(parseWslHostsPolicy(text).ok).toBe(false) }) + it('reads moved projects as keep unless a block asks for them back', () => { + const parse = (fields: Record) => { + const parsed = parseWslHostsPolicy(JSON.stringify({ version: 1, revision: 1, ...fields })) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.policy + } + expect(wslHostsPolicyMovedProjects(null)).toBe('keep') + expect(wslHostsPolicyMovedProjects(parse({ wslHosts: 'block' }))).toBe('keep') + expect( + wslHostsPolicyMovedProjects(parse({ wslHosts: 'block', movedProjects: 'bring-back' })) + ).toBe('bring-back') + expect( + wslHostsPolicyMovedProjects(parse({ wslHosts: 'allow', movedProjects: 'bring-back' })) + ).toBe('keep') + // A later value must not drop the block it rides on. + const later = parse({ wslHosts: 'block', movedProjects: 'some-later-mode' }) + expect(wslHostsPolicyState(later)).toBe('block') + expect(wslHostsPolicyMovedProjects(later)).toBe('keep') + }) + it('reads no policy as missing, never as a block', () => { expect(wslHostsPolicyState(null)).toBe('missing') expect(wslHostsPolicyState({ version: 1, revision: 1, wslHosts: 'allow' })).toBe('allow') diff --git a/src/shared/telemetry-wsl-usage-event-schemas.ts b/src/shared/telemetry-wsl-usage-event-schemas.ts index 235613ddb75..878a498eea1 100644 --- a/src/shared/telemetry-wsl-usage-event-schemas.ts +++ b/src/shared/telemetry-wsl-usage-event-schemas.ts @@ -49,7 +49,9 @@ export const wslUsageSnapshotSchema = z /** open / serve-only / closed. Optional, so older builds' payloads still validate. */ wsl_hosts_gate_state: z.enum(['open', 'serve-only', 'closed']).optional(), /** The cached remote WSL hosts policy applied at launch. Optional, for older builds. */ - wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional() + wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional(), + /** With a block only: whether it asks for moved projects back. Optional, for older builds. */ + wsl_hosts_moved_projects: z.enum(['keep', 'bring-back']).optional() }) .strict() diff --git a/src/shared/wsl-hosts-policy.ts b/src/shared/wsl-hosts-policy.ts index ba5bc81951e..a0a1cfa43e0 100644 --- a/src/shared/wsl-hosts-policy.ts +++ b/src/shared/wsl-hosts-policy.ts @@ -11,7 +11,12 @@ const WslHostsPolicySchema = z.object({ version: z.literal(1), /** Monotonic: a lower or equal revision never replaces the cached one. */ revision: z.number().int().positive(), - wslHosts: z.enum(['allow', 'block']) + wslHosts: z.enum(['allow', 'block']), + /** + * With `block` only: `bring-back` returns already-moved projects to this computer. An unknown + * value reads as absent (`keep`), so a later value never drops the block itself. + */ + movedProjects: z.enum(['keep', 'bring-back']).optional().catch(undefined) }) export type WslHostsPolicy = z.infer @@ -37,3 +42,12 @@ export type WslHostsPolicyState = 'allow' | 'block' | 'missing' export function wslHostsPolicyState(policy: WslHostsPolicy | null): WslHostsPolicyState { return policy?.wslHosts ?? 'missing' } + +export type WslHostsMovedProjects = 'keep' | 'bring-back' + +/** `keep` unless the policy blocks and asks for moved projects back. */ +export function wslHostsPolicyMovedProjects(policy: WslHostsPolicy | null): WslHostsMovedProjects { + return policy?.wslHosts === 'block' && policy.movedProjects === 'bring-back' + ? 'bring-back' + : 'keep' +}