Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 89 additions & 73 deletions src/cli/commands/doctor/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@
*/
import type {
AttachMechanism,
FactoryValidationResult,
IAdapterFactory,
IEnvironment,
IFileSystem,
ILogger
} from '@debugmcp/shared';
import { computeModeAvailability, type LanguageModes } from '../../../utils/language-availability.js';
import { probeLanguageEntry, type LanguageModes } from '../../../utils/language-availability.js';
import { getDisabledLanguages } from '../../../utils/language-config.js';
import {
checkContainerWorkspace,
Expand Down Expand Up @@ -129,9 +128,7 @@ export async function diagnose(requested: string[], deps: DiagnoseDeps): Promise
const disabledSet = getDisabledLanguages(env);

const languages = await Promise.all(
entries.map((entry) =>
diagnoseLanguage(entry, disabledSet.has(entry.name), deps, collectExtras)
)
entries.map((entry) => diagnoseLanguage(entry, disabledSet, deps, collectExtras))
);

const platformChecks: PlatformCheckResult[] = [
Expand Down Expand Up @@ -165,57 +162,87 @@ export async function diagnose(requested: string[], deps: DiagnoseDeps): Promise

async function diagnoseLanguage(
entry: RegistryAdapterEntry,
disabled: boolean,
disabledSet: Set<string>,
deps: DiagnoseDeps,
collectExtras: (language: string, details: Record<string, unknown>) => Promise<Record<string, unknown>>
): Promise<LanguageDiagnosis> {
const probeStarted = Date.now();
// The validate/extras budget clock starts when validate actually runs, so a
// slow factory import cannot eat the budget and manufacture timeouts.
let validateStarted = probeStarted;

const base = {
language: entry.name,
package: entry.packageName,
installed: entry.installed,
disabled
disabled: disabledSet.has(entry.name)
};

if (disabled || !entry.installed) {
const modes = await computeModeAvailability({
language: entry.name,
packageName: entry.packageName,
installed: entry.installed,
disabled,
attach: entry.attach ?? 'none',
logger: deps.logger
});
// The shared probe (issue #435) — the same function the server's
// list_supported_languages runs, so the two views cannot drift apart.
// Doctor's wrapper adds what the server deliberately omits: a per-probe
// timeout (the probe carries the outcome back; computeModeAvailability
// fails open on the rethrow exactly as it does for the server). The outer
// timeout bounds everything else — chiefly a wedged getFactory dynamic
// import, which validate's inner timeout can never see.
let probe;
try {
probe = await withTimeout(
probeLanguageEntry(
{
language: entry.name,
packageName: entry.packageName,
installed: entry.installed,
attach: entry.attach
},
{
registry: deps.registry,
disabledSet,
runValidate: (_language, validate) => {
validateStarted = Date.now();
return withTimeout(validate(), deps.timeoutMs);
},
logger: deps.logger
}
),
deps.timeoutMs * 2
);
} catch (error) {
const outerTimedOut = error instanceof ProbeTimeoutError;
return {
...base,
verdict: disabled ? 'disabled' : 'missing',
verdict: 'broken',
errors: [
outerTimedOut
? `Adapter probe timed out after ${deps.timeoutMs * 2}ms while loading the adapter — ` +
`the ${entry.packageName} import may be wedged`
: `Adapter probe failed: ${error instanceof Error ? error.message : String(error)}`
],
warnings: [],
probe: { durationMs: Date.now() - probeStarted, timedOut: outerTimedOut, failed: !outerTimedOut }
};
}

if (probe.disabled || !entry.installed) {
return {
...base,
verdict: probe.disabled ? 'disabled' : 'missing',
errors: [],
warnings: [],
modes,
modes: probe.modes,
probe: { durationMs: 0, timedOut: false, failed: false }
};
}

let factoryLoadError: unknown;
const factory = await deps.registry.getFactory(entry.name).catch((error: unknown) => {
factoryLoadError = error;
return undefined;
});

if (!factory || typeof factory.validate !== 'function') {
if (!probe.factory) {
// An installed adapter whose factory cannot even be loaded cannot start
// any session — that is broken, and a gated run must fail. (The server
// fails open here; the modes below reflect that so the divergence stays
// fails open here; probe.modes reflects that so the divergence stays
// visible rather than silent.)
const modes = await computeModeAvailability({
language: entry.name,
packageName: entry.packageName,
installed: true,
disabled: false,
attach: entry.attach ?? 'none',
logger: deps.logger
});
const loadDetail =
factoryLoadError instanceof Error ? `: ${factoryLoadError.message}` : '';
probe.factoryLoadError instanceof Error
? `: ${probe.factoryLoadError.message}`
: ' (the registry returned no factory)';
return {
...base,
verdict: 'broken',
Expand All @@ -225,53 +252,41 @@ async function diagnoseLanguage(
`(The server assumes availability when it cannot probe.)`
],
warnings: [],
modes,
probe: { durationMs: 0, timedOut: false, failed: true }
modes: probe.modes,
probe: { durationMs: Date.now() - probeStarted, timedOut: false, failed: true }
};
}

const started = Date.now();
let validation: FactoryValidationResult | undefined;
let probeError: unknown;
let timedOut = false;
try {
validation = await withTimeout(factory.validate(), deps.timeoutMs);
} catch (error) {
probeError = error;
timedOut = error instanceof ProbeTimeoutError;
if (!probe.probeable) {
// The factory loaded but is not probeable — a different defect than a
// load failure, and 'reinstall' phrasing would misdiagnose it.
return {
...base,
verdict: 'broken',
errors: [
`Adapter factory loaded but exposes no validate() function — the installed ${entry.packageName} ` +
`is likely version-skewed relative to this server. ` +
`(The server assumes availability when it cannot probe.)`
],
warnings: [],
modes: probe.modes,
probe: { durationMs: Date.now() - probeStarted, timedOut: false, failed: true }
};
}
const failed = probeError !== undefined && !timedOut;

// Feed computeModeAvailability the same outcome the server would see: the
// memoized result, or a throwing probe so its fail-open path runs. A
// throwing getMetadata (malformed third-party factory) must not take the
// other languages down with it.
let metadataAttach: AttachMechanism | undefined;
try {
metadataAttach = factory.getMetadata().modes?.attach;
} catch {
metadataAttach = undefined;
}
const modes = await computeModeAvailability({
language: entry.name,
packageName: entry.packageName,
installed: true,
disabled: false,
attach: metadataAttach ?? entry.attach ?? 'none',
validate: validation
? async () => validation
: async () => {
throw probeError;
},
logger: deps.logger
});
const validation = probe.validation;
const probeError = probe.validationError;
let timedOut = probeError instanceof ProbeTimeoutError;
const failed = probeError !== undefined && !timedOut;
const modes = probe.modes;

let details = validation?.details ? { ...validation.details } : undefined;
if (validation) {
// Extras share the language's timeout budget: whatever validate() left
// over. A hung extras child is flagged via probe.timedOut so the handler's
// over (clocked from validate start, so factory-import time is excluded).
// A hung extras child is flagged via probe.timedOut so the handler's
// force-exit containment covers it too.
const remainingMs = Math.max(0, deps.timeoutMs - (Date.now() - started));
const remainingMs = Math.max(0, deps.timeoutMs - (Date.now() - validateStarted));
try {
const extras = await withTimeout(collectExtras(entry.name, details ?? {}), remainingMs);
if (extras && Object.keys(extras).length > 0) {
Expand All @@ -284,7 +299,8 @@ async function diagnoseLanguage(
}
}
}
const durationMs = Date.now() - started;
// Validate + extras — the toolchain's own cost, excluding the module import
const durationMs = Date.now() - validateStarted;

let verdict: DoctorVerdict;
let errors: string[];
Expand Down
61 changes: 34 additions & 27 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ import { LineReader, createLineReader } from './utils/line-reader.js';
import { getDisabledLanguages, isLanguageDisabled } from './utils/language-config.js';
import { ErrorMessages } from './utils/error-messages.js';
import {
computeModeAvailability,
probeLanguageEntry,
checkLaunchToolchain,
ValidationResultCache,
LanguageModes
LanguageModes,
ProbeableAdapterFactory
} from './utils/language-availability.js';
import { isContainerMode, getWorkspaceRoot } from './utils/container-path-utils.js';
import {
Expand Down Expand Up @@ -2811,7 +2812,7 @@ export class DebugMcpServer {

const dyn = adapterRegistry as unknown as {
listAvailableAdapters?: () => Promise<Array<{ name: string; packageName: string; description?: string; installed: boolean; attach?: 'none' | 'direct-connect' | 'spawn' }>>;
getFactory?: (language: string) => Promise<{ validate: () => Promise<{ valid: boolean; errors: string[]; warnings: string[] }>; getMetadata: () => { modes?: { attach: 'none' | 'direct-connect' | 'spawn' } } } | undefined>;
getFactory?: (language: string) => Promise<ProbeableAdapterFactory | undefined>;
} | undefined;

if (adapterRegistry && typeof dyn?.listAvailableAdapters === 'function') {
Expand All @@ -2829,31 +2830,37 @@ export class DebugMcpServer {
}
}

// Shared per-entry probe (issue #435): doctor consumes the same
// function, so the two views cannot drift apart. Probes run in
// parallel — on a cold cache each may import an adapter package and
// spawn a toolchain check, and this call should pay the max, not the
// sum (the doctor path already runs them concurrently).
const disabledSet = getDisabledLanguages();
const available: AvailableLanguage[] = [];
for (const entry of baseEntries) {
const disabled = disabledSet.has(entry.language);
// Only probe toolchains for installed, enabled adapters
const factory = !disabled && entry.installed && typeof dyn?.getFactory === 'function'
? await dyn.getFactory(entry.language).catch(() => undefined)
: undefined;
const modes = await computeModeAvailability({
language: entry.language,
packageName: entry.package,
installed: entry.installed,
disabled,
attach: factory?.getMetadata().modes?.attach ?? entry.attach,
validate: factory ? () => this.validationCache.get(entry.language, () => factory.validate()) : undefined,
logger: this.logger
});
available.push({
language: entry.language,
package: entry.package,
installed: entry.installed,
description: entry.description,
modes
});
}
const available: AvailableLanguage[] = await Promise.all(
baseEntries.map(async (entry) => {
const probe = await probeLanguageEntry(
{
language: entry.language,
packageName: entry.package,
installed: entry.installed,
attach: entry.attach
},
{
registry: dyn,
disabledSet,
runValidate: (language, validate) => this.validationCache.get(language, validate),
logger: this.logger
}
);
return {
language: entry.language,
package: entry.package,
installed: entry.installed,
description: entry.description,
modes: probe.modes
};
})
);

// Also build simple metadata array for backward compatibility with previous payload shape
const languageMetadata = await this.getLanguageMetadata();
Expand Down
Loading
Loading