fix(doctor): check only the current platform's Hopper launcher candidates (#6253)

* fix(doctor): check only the current platform's Hopper launcher candidates

Doctor built one launcher list for every platform, so on macOS it could
report a Linux-only path as a healthy Hopper ahead of a renamed bundle,
and setup then registered that path. Give per-platform automatic
candidates one owner that runtime configuration and doctor both read,
scan renamed macOS bundles and probe the Homebrew cask only on macOS,
and share runtime's renamed-bundle scan with the doctor host.

Fixes #6228

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): keep the renamed Hopper bundle scan asynchronous

Sharing runtime's synchronous scan made the doctor host read
~/Applications with readdirSync, which blocks the event loop on a slow
or network-mounted home. Give MacHopperPaths an asynchronous scan for
doctor beside runtime's lazy synchronous one, sharing the directory
filter and candidate builder, and check that both return the same
candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(doctor): model renamed launchers with runtime's POSIX joins

Since #5235, runtime builds macOS launcher paths with posix.join, so on
Windows the discovery table's native-join renamed launcher no longer
matched runtime's candidate. Model launchers with posix.join and keep
filesystem setup native, as MacHopperPaths.test.ts does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paritosh Lahre
2026-10-23 20:11:54 +05:30
committed by GitHub
parent c9204b96d4
commit 8f91e60558
9 changed files with 205 additions and 105 deletions
+3 -1
View File
@@ -736,7 +736,9 @@ and skips directories.
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
fallback. `HOPPER_LAUNCHER_PATH` overrides those choices:
fallback. `rea doctor` checks only the current platform's candidates, in the
same order; other platforms have no automatic candidates.
`HOPPER_LAUNCHER_PATH` overrides those choices:
```bash
export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
+84 -10
View File
@@ -1,11 +1,16 @@
import { mkdir } from "node:fs/promises";
import { join, posix } from "node:path";
import { describe, expect, it } from "vitest";
import { defaultHopperLauncherPath } from "../config/parseConfig.js";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
import { err } from "../domain/result.js";
import { HopperReleaseError } from "./HopperReleaseFeed.js";
import { MAC_HOPPER_SYSTEM_LAUNCHER } from "./MacHopperPaths.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
describe("doctor Hopper startup timeout", () => {
const timeoutHost = (
@@ -1042,18 +1047,87 @@ describe("doctor installed Hopper discovery", () => {
);
expect(result.hopperPath).toBe(path);
});
});
it("detects a manually installed Hopper application", async () => {
const path = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
const result = await runDoctor(
undefined,
host({
executable: (candidate) => Promise.resolve(candidate === path),
manualHopperPaths: () => Promise.resolve([path]),
}),
);
expect(result.hopperPath).toBe(path);
describe("doctor Hopper discovery matches runtime selection", () => {
// Model launchers with runtime's POSIX joins; filesystem work stays native.
const launchers = (home: string) => ({
macSystem: MAC_HOPPER_SYSTEM_LAUNCHER,
renamed: posix.join(
home,
"Applications/Hopper 5.app/Contents/MacOS/hopper",
),
linuxSystem: "/opt/hopper-v4/bin/hopper",
});
type Launcher = keyof ReturnType<typeof launchers>;
it.each<[NodeJS.Platform, readonly Launcher[], Launcher | undefined]>([
["darwin", ["linuxSystem"], undefined],
["darwin", ["linuxSystem", "renamed"], "renamed"],
["darwin", ["linuxSystem", "macSystem"], "macSystem"],
["darwin", ["renamed", "macSystem"], "macSystem"],
["linux", ["macSystem", "renamed"], undefined],
["linux", ["macSystem", "linuxSystem"], "linuxSystem"],
["win32", ["macSystem", "linuxSystem", "renamed"], undefined],
])(
"on %s with %j executable, selects %s",
async (platform, executable, expected) => {
// Runtime enumerates renamed bundles from disk; doctor reads its host scan.
const home = await createTestTempDirectory("rea-doctor-hopper-");
await mkdir(join(home, "Applications/Hopper 5.app"), { recursive: true });
const paths = launchers(home);
const executables = new Set(executable.map((name) => paths[name]));
const probed: string[] = [];
let renamedScans = 0;
let brewProbes = 0;
const result = await runDoctor(
undefined,
host({
platform,
homeDirectory: home,
executable: (path) => {
probed.push(path);
return Promise.resolve(executables.has(path));
},
manualHopperPaths: () => {
renamedScans += 1;
return Promise.resolve([paths.renamed]);
},
brewHopperPath: () => {
brewProbes += 1;
return Promise.resolve(undefined);
},
}),
);
const runtime = defaultHopperLauncherPath(platform, home, (path) =>
executables.has(path),
);
expect(result.hopperPath).toBe(
expected === undefined ? undefined : paths[expected],
);
// Hopper has no automatic launcher on other platforms; runtime's fixed
// fallback there is never probed.
if (platform === "win32") expect(probed).toEqual([]);
else
expect(result.hopperPath).toBe(
executables.has(runtime) ? runtime : undefined,
);
expect(renamedScans).toBe(
platform === "darwin" && expected !== "macSystem" ? 1 : 0,
);
expect(brewProbes).toBe(
platform === "darwin" && expected === undefined ? 1 : 0,
);
if (expected === undefined)
expect(
result.checks.find(({ name }) => name === "hopper"),
).toMatchObject({
ok: false,
classification: "missing_analysis_engine",
});
},
);
});
describe("doctor target admission on Linux", () => {
+6 -25
View File
@@ -8,7 +8,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
import { constants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { access, stat } from "node:fs/promises";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { RetainedResourceScope } from "./AnalysisProvider.js";
import { nativeAnalysisConfigurationError } from "../config/parseConfig.js";
@@ -27,10 +27,7 @@ import {
type JavaScriptRuntime,
} from "../domain/runtimeVersion.js";
import { installedCaskAppdir, probeHomebrew } from "./homebrew.js";
import {
macHopperApplicationRoots,
macHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
import { readMacHopperRenamedLauncherCandidates } from "./MacHopperPaths.js";
import {
inspectLinuxHopperLauncher,
linuxSharedLibrariesAvailable,
@@ -128,7 +125,9 @@ export interface DoctorHost {
path: string,
): Promise<Result<VerifiedLinuxHopperLauncher, HopperReleaseError>>;
linuxDemoRuntimeCheck(): Promise<DoctorCheck>;
/** Homebrew cask launcher; consulted only where macOS bundles are probed. */
brewHopperPath(): Promise<string | undefined>;
/** Renamed `Hopper*.app` launchers; consulted only where macOS bundles are probed. */
manualHopperPaths(): Promise<readonly string[]>;
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
/** Resolve a parsed upstream request budget from the effective client environment. */
@@ -645,7 +644,8 @@ export const systemDoctorHost = (
: join(appdir, "Hopper Disassembler.app/Contents/MacOS/hopper");
});
},
manualHopperPaths: () => manualHopperPaths(homeDirectory),
manualHopperPaths: () =>
readMacHopperRenamedLauncherCandidates(homeDirectory),
...(options.providerInspections === undefined
? {}
: {
@@ -777,25 +777,6 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
remediation: "Run rea doctor through the production CLI adapter.",
});
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
const roots = macHopperApplicationRoots(home);
const bundleNamesByRoot = new Map<string, readonly string[]>();
for (const root of roots) {
try {
bundleNamesByRoot.set(
root,
(await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map(({ name }) => name),
);
} catch (cause: unknown) {
// A missing or unreadable optional application directory is not fatal.
void cause;
}
}
return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot);
};
const uniqueLines = (value: string): string[] => [
...new Set(
value
+10 -30
View File
@@ -3,16 +3,8 @@ import {
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js";
import {
LINUX_HOPPER_SETUP_HOSTS,
linuxHopperLauncherCandidates,
systemLinuxHopperLauncherCandidates,
supportsReaHost,
} from "./LinuxHopper.js";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperCanonicalLauncherCandidates,
} from "./MacHopperPaths.js";
import { hopperLauncherDiscovery } from "./HopperLauncherDiscovery.js";
import { LINUX_HOPPER_SETUP_HOSTS, supportsReaHost } from "./LinuxHopper.js";
import type {
DoctorCheck,
DoctorHost,
@@ -154,26 +146,14 @@ const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
};
const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
const candidates =
host.configuredHopperPath === undefined
? [
...(host.platform === "darwin"
? macHopperCanonicalLauncherCandidates(host.homeDirectory)
: [MAC_HOPPER_SYSTEM_LAUNCHER]),
...(host.platform === "linux"
? linuxHopperLauncherCandidates(host.homeDirectory)
: [...systemLinuxHopperLauncherCandidates]),
...(host.platform === "darwin"
? []
: macHopperCanonicalLauncherCandidates(host.homeDirectory).slice(
1,
)),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
const hopperPath = await firstExecutable(host, candidates);
if (hopperPath !== undefined || host.configuredHopperPath !== undefined)
return hopperPath;
if (host.configuredHopperPath !== undefined)
return firstExecutable(host, [host.configuredHopperPath]);
const discovery = hopperLauncherDiscovery(host.platform, host.homeDirectory);
const fixed = await firstExecutable(host, discovery.candidates);
if (fixed !== undefined || !discovery.probesMacApplicationBundles)
return fixed;
const renamed = await firstExecutable(host, await host.manualHopperPaths());
if (renamed !== undefined) return renamed;
const brewCandidate = await host.brewHopperPath();
return firstExecutable(
host,
@@ -0,0 +1,49 @@
import {
linuxHopperLauncherCandidates,
REA_MANAGED_LINUX_HOPPER_LAUNCHER,
} from "./LinuxHopper.js";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperCanonicalLauncherCandidates,
} from "./MacHopperPaths.js";
/**
* Where automatic Hopper discovery looks on one platform. Runtime configuration
* and doctor both read it, so doctor checks the same candidates, in the same
* order, as CLI/MCP startup.
*/
export interface HopperLauncherDiscovery {
/** Fixed launcher paths, probed in order. */
readonly candidates: readonly string[];
/**
* Whether renamed `Hopper*.app` bundles are probed after the fixed paths.
* Doctor also reports the Homebrew cask application only when this is set.
*/
readonly probesMacApplicationBundles: boolean;
/** Launcher runtime configures when no candidate is executable. */
readonly fallback: string;
}
/** Select the automatic Hopper launcher candidates for one platform. */
export const hopperLauncherDiscovery = (
platform: NodeJS.Platform,
homeDirectory: string,
): HopperLauncherDiscovery => {
if (platform === "darwin")
return {
candidates: macHopperCanonicalLauncherCandidates(homeDirectory),
probesMacApplicationBundles: true,
fallback: MAC_HOPPER_SYSTEM_LAUNCHER,
};
if (platform === "linux")
return {
candidates: linuxHopperLauncherCandidates(homeDirectory),
probesMacApplicationBundles: false,
fallback: REA_MANAGED_LINUX_HOPPER_LAUNCHER,
};
return {
candidates: [],
probesMacApplicationBundles: false,
fallback: MAC_HOPPER_SYSTEM_LAUNCHER,
};
};
+1 -1
View File
@@ -463,7 +463,7 @@ export const linuxHopperLauncherPath = (home: string): string =>
export const REA_MANAGED_LINUX_HOPPER_LAUNCHER = "/opt/hopper/bin/Hopper";
/** Other system-wide Hopper paths used by package installations. */
export const systemLinuxHopperLauncherCandidates: readonly string[] = [
const systemLinuxHopperLauncherCandidates: readonly string[] = [
"/opt/hopper-v4/bin/hopper",
"/usr/bin/hopper",
];
+15 -12
View File
@@ -1,12 +1,13 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
localMacHopperRenamedLauncherCandidates,
macHopperCanonicalLauncherCandidates,
macHopperLauncherCandidates,
macHopperRenamedLauncherCandidates,
readMacHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
describe("macOS Hopper launcher paths", () => {
@@ -35,13 +36,7 @@ describe("macOS Hopper launcher paths", () => {
]);
});
it("always includes the installer's canonical user destination", () => {
expect(macHopperLauncherCandidates(home)).toContain(
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
});
it("finds renamed user bundles through the runtime filesystem scan", async () => {
it("finds the same renamed user bundles through runtime and doctor scans", async () => {
// Keep native ownership for all I/O, including UNC temporary directories.
// On Windows, a checkout-relative home models POSIX separators without
// discarding the drive/share or normalizing a UNC prefix through posix.join.
@@ -53,14 +48,22 @@ describe("macOS Hopper launcher paths", () => {
process.platform === "win32"
? relative(process.cwd(), temporary).replaceAll("\\", "/")
: temporary;
const appDirectory = join(temporary, "Applications", "Hopper Preview.app");
const applications = join(temporary, "Applications");
try {
await mkdir(appDirectory, { recursive: true });
expect([...macHopperLauncherCandidates(home)]).toContain(
await mkdir(join(applications, "Hopper Preview.app"), {
recursive: true,
});
await mkdir(join(applications, "Hopper Disassembler.app"));
await writeFile(join(applications, "Hopper File.app"), "");
const runtime = [...localMacHopperRenamedLauncherCandidates(home)];
expect(runtime.filter((path) => path.startsWith(home))).toEqual([
posix.join(
home,
"Applications/Hopper Preview.app/Contents/MacOS/hopper",
),
]);
expect(await readMacHopperRenamedLauncherCandidates(home)).toEqual(
runtime,
);
} finally {
await rm(temporary, { recursive: true, force: true });
+27 -9
View File
@@ -1,4 +1,5 @@
import { readdirSync } from "node:fs";
import { readdirSync, type Dirent } from "node:fs";
import { readdir } from "node:fs/promises";
import { posix } from "node:path";
export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications";
@@ -52,13 +53,12 @@ export const macHopperRenamedLauncherCandidates = (
.map((name) => posix.join(root, name, "Contents/MacOS/hopper")),
);
const directoryNames = (entries: readonly Dirent[]): readonly string[] =>
entries.filter((entry) => entry.isDirectory()).map(({ name }) => name);
const localBundleNames = (root: string): readonly string[] => {
try {
return readdirSync(root, { withFileTypes: true })
.filter(
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
)
.map(({ name }) => name);
return directoryNames(readdirSync(root, { withFileTypes: true }));
} catch (cause: unknown) {
// Missing or unreadable optional app directories contain no candidates.
void cause;
@@ -66,14 +66,32 @@ const localBundleNames = (root: string): readonly string[] => {
}
};
/** Yield canonical paths first; enumerate renamed bundles only if needed. */
export function* macHopperLauncherCandidates(
/** Yield renamed bundles from the local filesystem, reading each root only if needed. */
export function* localMacHopperRenamedLauncherCandidates(
homeDirectory: string,
): Generator<string> {
yield* macHopperCanonicalLauncherCandidates(homeDirectory);
for (const root of macHopperApplicationRoots(homeDirectory))
yield* macHopperRenamedLauncherCandidates(
homeDirectory,
new Map([[root, localBundleNames(root)]]),
);
}
/** Read the same renamed bundles without blocking the event loop, for doctor. */
export const readMacHopperRenamedLauncherCandidates = async (
homeDirectory: string,
): Promise<readonly string[]> => {
const bundleNamesByRoot = new Map<string, readonly string[]>();
for (const root of macHopperApplicationRoots(homeDirectory)) {
try {
bundleNamesByRoot.set(
root,
directoryNames(await readdir(root, { withFileTypes: true })),
);
} catch (cause: unknown) {
// Missing or unreadable optional app directories contain no candidates.
void cause;
}
}
return macHopperRenamedLauncherCandidates(homeDirectory, bundleNamesByRoot);
};
+10 -17
View File
@@ -8,14 +8,8 @@ import { DEFAULT_BRIDGE_MAX_REQUEST } from "./bridgeMaxRequestBytes.js";
import { DEFAULT_REGEX_MATCHING_DEADLINE } from "./regexMatchingDeadline.js";
import { DEFAULT_REGEX_WORKER_HEAP_REQUEST } from "./regexWorkerHeapRequest.js";
import type { AppConfig } from "./types.js";
import {
linuxHopperLauncherCandidates,
REA_MANAGED_LINUX_HOPPER_LAUNCHER,
} from "../application/LinuxHopper.js";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperLauncherCandidates,
} from "../application/MacHopperPaths.js";
import { hopperLauncherDiscovery } from "../application/HopperLauncherDiscovery.js";
import { localMacHopperRenamedLauncherCandidates } from "../application/MacHopperPaths.js";
const executableAvailable = (path: string): boolean => {
try {
accessSync(path, constants.X_OK);
@@ -30,16 +24,15 @@ export const defaultHopperLauncherPath = (
homeDirectory: string,
executable: (path: string) => boolean = executableAvailable,
): string => {
if (platform === "darwin") {
for (const candidate of macHopperLauncherCandidates(homeDirectory))
const discovery = hopperLauncherDiscovery(platform, homeDirectory);
for (const candidate of discovery.candidates)
if (executable(candidate)) return candidate;
if (discovery.probesMacApplicationBundles)
for (const candidate of localMacHopperRenamedLauncherCandidates(
homeDirectory,
))
if (executable(candidate)) return candidate;
return MAC_HOPPER_SYSTEM_LAUNCHER;
}
if (platform !== "linux") return MAC_HOPPER_SYSTEM_LAUNCHER;
return (
linuxHopperLauncherCandidates(homeDirectory).find(executable) ??
REA_MANAGED_LINUX_HOPPER_LAUNCHER
);
return discovery.fallback;
};
/**