fix(tmux): strengthen windows restart detection

This commit is contained in:
777genius 2026-04-14 21:44:03 +03:00
parent 44f4af1756
commit 51aac9b7a1
8 changed files with 392 additions and 27 deletions

View file

@ -17,6 +17,7 @@ const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch
const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
const RESTART_REQUIRED_PATTERNS = ['restart', 'reboot', 'перезагруз', 'требуется перезагрузка'];
class TmuxInstallCancelledError extends Error {
constructor() {
@ -325,13 +326,28 @@ export class TmuxInstallerRunnerAdapter
return status;
}
if (status.wsl?.rebootRequired) {
const rebootRequired =
elevationResult.restartRequired ||
status.wsl?.rebootRequired ||
this.#looksLikeRestartRequired(elevationResult.detail);
if (rebootRequired) {
const rebootStatus = status.wsl
? {
...status,
wsl: {
...status.wsl,
rebootRequired: true,
statusDetail: elevationResult.detail ?? status.wsl.statusDetail,
},
}
: status;
this.#setSnapshot({
phase: 'needs_restart',
strategy: 'wsl',
message: 'Restart Windows before continuing with tmux setup',
detail:
status.wsl.statusDetail ??
elevationResult.detail ??
status.wsl?.statusDetail ??
'WSL was installed, but Windows still needs a restart before tmux setup can continue.',
error: null,
canCancel: false,
@ -339,7 +355,7 @@ export class TmuxInstallerRunnerAdapter
inputPrompt: null,
inputSecret: false,
});
return status;
return rebootStatus;
}
if (!status.wsl?.wslInstalled) {
@ -464,6 +480,11 @@ export class TmuxInstallerRunnerAdapter
return status;
}
#looksLikeRestartRequired(value: string | null | undefined): boolean {
const lowered = value?.toLowerCase() ?? '';
return RESTART_REQUIRED_PATTERNS.some((pattern) => lowered.includes(pattern));
}
async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise<void> {
this.#setSnapshot({
phase: 'preparing',

View file

@ -467,4 +467,100 @@ describe('TmuxInstallerRunnerAdapter', () => {
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check');
});
it('switches Windows WSL setup into needs_restart when the elevated step reports a localized reboot message', async () => {
const presenter = createPresenter();
const initialStatus = createBaseStatus({
platform: 'win32',
autoInstall: {
supported: true,
strategy: 'wsl',
packageManagerLabel: 'WSL',
requiresTerminalInput: false,
requiresAdmin: true,
requiresRestart: true,
mayOpenExternalWindow: true,
reasonIfUnsupported: null,
manualHints: [],
},
wsl: {
wslInstalled: false,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: 'WSL is not installed yet.',
},
});
const refreshedStatus = createBaseStatus({
platform: 'win32',
autoInstall: initialStatus.autoInstall,
wsl: {
wslInstalled: true,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: 'WSL is available, but no Linux distribution is installed yet.',
},
});
let statusCallCount = 0;
const statusSource = {
getStatus: vi.fn(async () => {
statusCallCount += 1;
return statusCallCount === 1 ? initialStatus : refreshedStatus;
}),
invalidateStatus: vi.fn(),
};
const runner = new TmuxInstallerRunnerAdapter(
statusSource as never,
presenter as never,
{
resolve: vi.fn(async () => {
throw new Error('resolve() should not run before restart');
}),
} as never,
{
run: vi.fn(),
cancel: vi.fn(),
} as never,
{
run: vi.fn(),
writeLine: vi.fn(),
cancel: vi.fn(),
} as never,
{
persistPreferredDistro: vi.fn(async () => undefined),
} as never,
{
runWslCoreInstall: vi.fn(async () => ({
outcome: 'elevated_succeeded',
detail: 'WSL core installation command completed.',
restartRequired: true,
featureStates: [
{
featureName: 'VirtualMachinePlatform',
state: 'EnablePending',
restartRequired: 'Possible',
},
],
resultFilePath: 'C:\\temp\\result.json',
})),
} as never
);
await expect(runner.install()).resolves.toBeUndefined();
expect(runner.getSnapshot().phase).toBe('needs_restart');
expect(runner.getSnapshot().message).toContain('Restart Windows');
expect(runner.getSnapshot().detail).toContain('WSL core installation command completed');
});
});

View file

@ -23,6 +23,17 @@ interface WslVerboseDistroEntry {
version: 1 | 2 | null;
}
interface WindowsOptionalFeatureState {
FeatureName?: string | null;
State?: string | null;
RestartRequired?: string | boolean | null;
}
interface WindowsOptionalFeatureProbe {
restartPending: boolean;
hasConfiguredWslFeature: boolean;
}
type ExecFileCallback = (
error: Error | null,
stdout: string | Buffer,
@ -48,6 +59,11 @@ export interface TmuxWslProbeResult {
const MAX_BUFFER_BYTES = 1024 * 1024;
const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.';
const WINDOWS_WSL_FEATURE_NAMES = ['Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform'];
const POWERSHELL_FEATURE_QUERY = [
'$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"',
'$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress',
].join('; ');
export class TmuxWslService {
readonly #execFile: ExecFileLike;
@ -64,11 +80,15 @@ export class TmuxWslService {
async probe(): Promise<TmuxWslProbeResult> {
const statusProbe = await this.#run(['--status'], 4_000);
const distroListProbe = await this.#run(['--list', '--quiet'], 4_000);
const featureProbe = await this.#queryWindowsOptionalFeatures();
const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro();
const wslInstalled = statusProbe.exitCode === 0 || distroListProbe.exitCode === 0;
const rebootRequired = this.#looksLikeRestartRequired(
`${statusProbe.stdout}\n${statusProbe.stderr}`
);
const wslInstalled =
statusProbe.exitCode === 0 ||
distroListProbe.exitCode === 0 ||
featureProbe?.hasConfiguredWslFeature === true;
const rebootRequired =
featureProbe?.restartPending === true ||
this.#looksLikeRestartRequired(`${statusProbe.stdout}\n${statusProbe.stderr}`);
if (!wslInstalled) {
if (persistedPreferredDistro) {
@ -438,7 +458,79 @@ export class TmuxWslService {
#looksLikeRestartRequired(output: string): boolean {
const lowered = output.toLowerCase();
return lowered.includes('restart') || lowered.includes('reboot');
return (
lowered.includes('restart') ||
lowered.includes('reboot') ||
lowered.includes('перезагруз') ||
lowered.includes('требуется перезагрузка')
);
}
async #queryWindowsOptionalFeatures(): Promise<WindowsOptionalFeatureProbe | null> {
const result = await this.#execPowerShell(
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
6_000
);
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
return null;
}
try {
const parsed = JSON.parse(result.stdout) as
| WindowsOptionalFeatureState
| WindowsOptionalFeatureState[];
const features = Array.isArray(parsed) ? parsed : [parsed];
const relevantFeatures = features.filter((feature) =>
WINDOWS_WSL_FEATURE_NAMES.includes(feature.FeatureName ?? '')
);
if (relevantFeatures.length === 0) {
return null;
}
return {
restartPending: relevantFeatures.some((feature) =>
String(feature.State ?? '')
.toLowerCase()
.includes('pending')
),
hasConfiguredWslFeature: relevantFeatures.some((feature) => {
const state = String(feature.State ?? '').toLowerCase();
return state.length > 0 && state !== 'disabled' && state !== 'disabledwithpayloadremoved';
}),
};
} catch {
return null;
}
}
async #execPowerShell(args: string[], timeout: number): Promise<ExecWslResult | null> {
return new Promise((resolve) => {
this.#execFile(
'powershell.exe',
args,
{
timeout,
windowsHide: true,
maxBuffer: MAX_BUFFER_BYTES,
encoding: 'buffer',
},
(error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined;
if (errorCode === 'ENOENT') {
resolve(null);
return;
}
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: this.#decodeOutput(stdout),
stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''),
});
}
);
});
}
#firstNonEmpty(...values: (string | null | undefined)[]): string {

View file

@ -15,9 +15,18 @@ interface ExecResult {
stderr: string;
}
interface PersistedFeatureState {
featureName?: string | null;
state?: string | null;
restartRequired?: string | boolean | null;
}
interface PersistedElevationResult {
ok?: boolean;
detail?: string | null;
restartRequired?: boolean | null;
featureStates?: PersistedFeatureState[] | null;
commandExitCode?: number | null;
}
type ExecFileCallback = (
@ -47,6 +56,8 @@ export interface WindowsElevatedStepResult {
| 'elevated_failed'
| 'elevated_unknown_outcome';
detail: string | null;
restartRequired: boolean;
featureStates: PersistedFeatureState[];
resultFilePath: string | null;
}
@ -84,6 +95,8 @@ export class WindowsElevatedStepRunner {
return {
outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed',
detail: persistedResult.detail ?? null,
restartRequired: persistedResult.restartRequired === true,
featureStates: persistedResult.featureStates ?? [],
resultFilePath,
};
}
@ -92,6 +105,8 @@ export class WindowsElevatedStepRunner {
return {
outcome: 'elevated_cancelled',
detail: 'Administrator permission request was cancelled.',
restartRequired: false,
featureStates: [],
resultFilePath: null,
};
}
@ -103,6 +118,8 @@ export class WindowsElevatedStepRunner {
return {
outcome: 'elevated_unknown_outcome',
detail: this.#firstNonEmpty(result.stderr, result.stdout),
restartRequired: false,
featureStates: [],
resultFilePath: null,
};
}
@ -170,9 +187,14 @@ export class WindowsElevatedStepRunner {
'$ErrorActionPreference = "Stop"',
`$resultFile = '${escapedResultFilePath}'`,
`$wslArgs = @(${quotedArgs})`,
'$result = @{ ok = $false; detail = $null }',
'$featureNames = @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform")',
'$result = @{ ok = $false; detail = $null; restartRequired = $false; featureStates = @(); commandExitCode = $null }',
'try {',
' & wsl.exe @wslArgs',
' $result.commandExitCode = $LASTEXITCODE',
' $features = @(Get-WindowsOptionalFeature -Online -FeatureName $featureNames | Select-Object FeatureName, State, RestartRequired)',
' $result.featureStates = @($features | ForEach-Object { @{ featureName = $_.FeatureName; state = [string]$_.State; restartRequired = $_.RestartRequired } })',
' $result.restartRequired = @($features | Where-Object { "$($_.State)" -like "*Pending*" }).Count -gt 0',
' if ($LASTEXITCODE -eq 0) {',
' $result.ok = $true',
' $result.detail = "WSL core installation command completed."',

View file

@ -174,4 +174,81 @@ describe('TmuxWslService', () => {
expect(result.preference?.source).toBe('default');
expect(preferenceStore.getPreferredDistroSync()).toBeNull();
});
it('detects a reboot requirement from localized Windows output', async () => {
const service = new TmuxWslService(
createExecFileMock({
'--status': {
stdout:
'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.',
},
'--list --quiet': { stdout: '' },
}),
createPreferenceStore() as never
);
const result = await service.probe();
expect(result.status.wslInstalled).toBe(true);
expect(result.status.rebootRequired).toBe(true);
expect(result.status.statusDetail).toContain('restart');
});
it('detects a reboot requirement from pending Windows optional feature state', async () => {
const execFileMock = (
command: string,
args: string[],
_options: {
timeout: number;
windowsHide: boolean;
maxBuffer: number;
encoding: 'buffer';
},
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
) => {
if (command === 'powershell.exe') {
callback(
null,
JSON.stringify([
{
FeatureName: 'Microsoft-Windows-Subsystem-Linux',
State: 'EnablePending',
RestartRequired: 'Possible',
},
{
FeatureName: 'VirtualMachinePlatform',
State: 'EnablePending',
RestartRequired: 'Possible',
},
]),
''
);
return;
}
const key = args.join(' ');
if (key === '--status') {
callback(null, 'ok', '');
return;
}
if (key === '--list --quiet') {
callback(null, '', '');
return;
}
callback(
Object.assign(new Error(`Unexpected command: ${command} ${key}`), { code: 'EFAIL' }),
'',
''
);
};
const service = new TmuxWslService(execFileMock, createPreferenceStore() as never);
const result = await service.probe();
expect(result.status.wslInstalled).toBe(true);
expect(result.status.rebootRequired).toBe(true);
expect(result.status.statusDetail).toContain('restart');
});
});

View file

@ -14,7 +14,18 @@ describe('WindowsElevatedStepRunner', () => {
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
await fsp.writeFile(
resultFilePath,
JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' }),
JSON.stringify({
ok: true,
detail: 'WSL core installation command completed.',
restartRequired: false,
featureStates: [
{
featureName: 'Microsoft-Windows-Subsystem-Linux',
state: 'Enabled',
restartRequired: false,
},
],
}),
'utf8'
);
callback(null, '', '');
@ -26,6 +37,8 @@ describe('WindowsElevatedStepRunner', () => {
expect(result.outcome).toBe('elevated_succeeded');
expect(result.detail).toContain('completed');
expect(result.restartRequired).toBe(false);
expect(result.featureStates[0]?.featureName).toBe('Microsoft-Windows-Subsystem-Linux');
expect(result.resultFilePath).toContain('result.json');
});
@ -36,7 +49,18 @@ describe('WindowsElevatedStepRunner', () => {
const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json');
await fsp.writeFile(
resultFilePath,
`\uFEFF${JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' })}`,
`\uFEFF${JSON.stringify({
ok: true,
detail: 'WSL core installation command completed.',
restartRequired: true,
featureStates: [
{
featureName: 'VirtualMachinePlatform',
state: 'EnablePending',
restartRequired: 'Possible',
},
],
})}`,
'utf8'
);
callback(null, '', '');
@ -48,6 +72,8 @@ describe('WindowsElevatedStepRunner', () => {
expect(result.outcome).toBe('elevated_succeeded');
expect(result.detail).toContain('completed');
expect(result.restartRequired).toBe(true);
expect(result.featureStates[0]?.state).toBe('EnablePending');
});
it('treats a missing result file plus cancel text as elevation cancellation', async () => {
@ -66,6 +92,8 @@ describe('WindowsElevatedStepRunner', () => {
expect(result.outcome).toBe('elevated_cancelled');
expect(result.detail).toContain('cancelled');
expect(result.restartRequired).toBe(false);
expect(result.featureStates).toEqual([]);
expect(result.resultFilePath).toBeNull();
});
@ -90,6 +118,7 @@ describe('WindowsElevatedStepRunner', () => {
expect(result.outcome).toBe('elevated_unknown_outcome');
expect(result.detail).toContain('Требуемая операция выполнена успешно');
expect(result.restartRequired).toBe(false);
} finally {
consoleWarnSpy.mockRestore();
}

View file

@ -58,19 +58,19 @@ export class TmuxInstallerBannerAdapter {
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
const status = input.status;
const snapshot = input.snapshot;
const hasActiveInstallFlow =
snapshot.phase !== 'idle' && snapshot.phase !== 'completed' && snapshot.phase !== 'cancelled';
const tmuxMissing = status ? !status.effective.available : !input.loading;
const visible =
snapshot.phase !== 'idle' ||
(!input.loading && (status ? !status.effective.runtimeReady : true));
hasActiveInstallFlow || (snapshot.phase !== 'completed' && !input.loading && tmuxMissing);
const title =
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
? 'tmux needs one more step'
: snapshot.message &&
(snapshot.phase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' ||
snapshot.phase === 'needs_restart' ||
snapshot.phase === 'needs_manual_step')
? snapshot.message
: formatTmuxInstallerTitle(snapshot.phase);
snapshot.message &&
(snapshot.phase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' ||
snapshot.phase === 'needs_restart' ||
snapshot.phase === 'needs_manual_step')
? snapshot.message
: formatTmuxInstallerTitle(snapshot.phase);
const primaryGuideUrl =
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
const body =
@ -82,7 +82,7 @@ export class TmuxInstallerBannerAdapter {
status?.wsl?.statusDetail ??
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
const benefitsBody =
status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null;
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
const runtimeReadyLabel = status
? status.effective.runtimeReady
? 'Ready for persistent teammates'

View file

@ -174,7 +174,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.showRefreshButton).toBe(true);
});
it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => {
it('hides the banner when tmux is already installed', () => {
const adapter = TmuxInstallerBannerAdapter.create();
const result = adapter.adapt({
@ -196,12 +196,40 @@ describe('TmuxInstallerBannerAdapter', () => {
detailsOpen: false,
});
expect(result.visible).toBe(true);
expect(result.title).toBe('tmux needs one more step');
expect(result.visible).toBe(false);
expect(result.locationLabel).toBe('Host runtime');
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
expect(result.versionLabel).toBe('tmux 3.4');
expect(result.benefitsBody).toContain('With tmux in WSL');
expect(result.benefitsBody).toBeNull();
});
it('hides a completed installer banner once tmux is available', () => {
const adapter = TmuxInstallerBannerAdapter.create();
const result = adapter.adapt({
status: {
...baseStatus,
effective: {
available: true,
location: 'host',
version: 'tmux 3.6a',
binaryPath: '/opt/homebrew/bin/tmux',
runtimeReady: true,
detail: 'tmux is available for persistent teammates.',
},
},
snapshot: {
...idleSnapshot,
phase: 'completed',
strategy: 'homebrew',
message: 'tmux installed',
},
loading: false,
error: null,
detailsOpen: false,
});
expect(result.visible).toBe(false);
});
it('exposes installer input metadata for interactive privilege flows', () => {