import type { HostConfig } from "../types.js";
import type { ComposeDiscovery } from "./compose-discovery.js";
/**
* Timeout for host resolution operations.
* Defaults to 30s, overridable via COMPOSE_HOST_RESOLUTION_TIMEOUT_MS.
*/
function getResolutionTimeoutMs(): number {
const raw = process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS;
if (!raw) return 30000;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return 30000;
return parsed;
}
/**
* Grace period (ms) after first match to collect concurrent matches
* for conflict detection. Defaults to 500ms unless overridden.
*/
function getConflictGraceMs(): number {
const raw = process.env.COMPOSE_CONFLICT_GRACE_MS;
if (!raw) return 500;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 500;
return parsed;
}
/**
* Error thrown when host resolution fails
*/
export class HostResolutionError extends Error {
constructor(message: string) {
super(message);
this.name = "HostResolutionError";
}
}
/**
* Resolves which host contains a compose project when not explicitly specified
*/
export class HostResolver {
constructor(
private readonly discovery: ComposeDiscovery,
private readonly hosts: HostConfig[]
) {}
/**
* Resolve which host to use for a compose operation
*
* @param projectName - Name of the compose project
* @param specifiedHost - Optional host name explicitly provided by user
* @returns The resolved host configuration
* @throws {HostResolutionError} If resolution fails
*/
async resolveHost(projectName: string, specifiedHost?: string): Promise<HostConfig> {
// If host is specified, validate and return it
if (specifiedHost) {
const host = this.hosts.find((h) => h.name === specifiedHost);
if (!host) {
throw new HostResolutionError(`Host "${specifiedHost}" not found in configuration`);
}
return host;
}
// Auto-resolve: check all hosts in parallel
if (this.hosts.length === 0) {
throw new HostResolutionError("No hosts configured");
}
const matchingHosts = await this.findMatchingHosts(projectName);
if (matchingHosts.length === 0) {
throw new HostResolutionError(`Project "${projectName}" not found on any configured host`);
}
if (matchingHosts.length > 1) {
const hostNames = matchingHosts.map((h) => h.name).join(", ");
throw new HostResolutionError(
`Project "${projectName}" found on multiple hosts: ${hostNames}. Please specify which host to use.`
);
}
return matchingHosts[0];
}
/**
* Find all hosts that contain the specified project.
*
* Uses an AbortController to cancel remaining checks on early exit or timeout.
* SECURITY (CWE-404): Timer is properly cleared when resolution settles
* to prevent dangling promise rejection and memory leak.
*/
private async findMatchingHosts(projectName: string): Promise<HostConfig[]> {
const resolutionTimeoutMs = getResolutionTimeoutMs();
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout | undefined;
const checkPromise = this.checkAllHosts(projectName, abortController.signal);
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
abortController.abort();
reject(new HostResolutionError(`Host resolution timed out after ${resolutionTimeoutMs}ms`));
}, resolutionTimeoutMs);
});
try {
const result = await Promise.race([checkPromise, timeoutPromise]);
return result;
} finally {
abortController.abort();
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Check all hosts in parallel for the project with early-exit.
*
* When the first match arrives, starts a short grace period to collect
* any near-simultaneous matches (for conflict detection). After the
* grace period or when all checks settle (whichever is first), resolves
* with all collected matches.
*
* If all checks fail before any match, resolves immediately with
* an empty array.
*
* This avoids blocking on slow/offline hosts while still detecting
* conflicts between responsive hosts.
*/
private checkAllHosts(projectName: string, signal: AbortSignal): Promise<HostConfig[]> {
const matchingHosts: HostConfig[] = [];
let settledCount = 0;
const totalChecks = this.hosts.length;
const conflictGraceMs = getConflictGraceMs();
return new Promise<HostConfig[]>((resolve) => {
if (totalChecks === 0) {
resolve([]);
return;
}
let resolved = false;
let graceTimeoutId: NodeJS.Timeout | undefined;
const doResolve = (): void => {
if (resolved) return;
resolved = true;
if (graceTimeoutId) {
clearTimeout(graceTimeoutId);
}
resolve(matchingHosts);
};
const startGracePeriod = (): void => {
if (graceTimeoutId) return; // Already started
graceTimeoutId = setTimeout(doResolve, conflictGraceMs);
};
const tryResolve = (): void => {
if (resolved || signal.aborted) return;
settledCount++;
// All checks settled — no more results possible
if (settledCount === totalChecks) {
doResolve();
return;
}
// First match arrived — start grace period for conflict detection
if (matchingHosts.length >= 1 && !graceTimeoutId) {
startGracePeriod();
}
};
for (const host of this.hosts) {
this.discovery
.resolveProjectPath(host, projectName)
.then(() => {
if (!resolved && !signal.aborted) {
matchingHosts.push(host);
}
tryResolve();
})
.catch(() => {
tryResolve();
});
}
});
}
}