fix: resolve ERR_INSUFFICIENT_RESOURCES connection exhaustion

- Add concurrency limiter (max 4 GET requests) to API layer, leaving
  slots for SSE and health checks. Write ops bypass the limiter.
- Add AbortController to ContainerStats, project detail page, and
  dashboard to cancel in-flight requests on navigation/unmount.
- Move global SSE connection from layout to events page (only consumer).
- Add 30s heartbeat to SSE endpoint to detect zombie connections.
- Serialize dashboard project fetches to avoid parallel burst.
- Rebuild frontend in dev-server.sh so go:embed stays in sync.
This commit is contained in:
2026-04-13 00:12:14 +03:00
parent 791cd4d6af
commit 96fd910603
7 changed files with 233 additions and 87 deletions
+84 -23
View File
@@ -43,7 +43,58 @@ class ApiError extends Error {
import { getAuthToken, clearAuth } from './auth';
// ── Concurrency limiter ────────────────────────────────────────────
// Chrome allows only 6 concurrent HTTP/1.1 connections per host.
// Reserve slots for the persistent SSE stream and health checks by
// capping regular API requests to 4 concurrent.
const MAX_CONCURRENT = 4;
let inflight = 0;
const queue: Array<() => void> = [];
function acquireSlot(signal?: AbortSignal | null): Promise<void> {
if (inflight < MAX_CONCURRENT) {
inflight++;
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const entry = () => { inflight++; resolve(); };
queue.push(entry);
signal?.addEventListener('abort', () => {
const idx = queue.indexOf(entry);
if (idx !== -1) {
queue.splice(idx, 1);
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
}
}, { once: true });
});
}
function releaseSlot(): void {
const next = queue.shift();
if (next) {
next();
} else {
inflight--;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
// Write operations (user-initiated) bypass the concurrency limiter
// so they are never blocked behind background polling.
const method = init?.method?.toUpperCase() ?? 'GET';
if (method !== 'GET') {
return requestInner<T>(path, init);
}
await acquireSlot(init?.signal);
try {
return await requestInner<T>(path, init);
} finally {
releaseSlot();
}
}
async function requestInner<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -82,8 +133,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return envelope.data as T;
}
function get<T>(path: string): Promise<T> {
return request<T>(path);
function get<T>(path: string, signal?: AbortSignal): Promise<T> {
return request<T>(path, signal ? { signal } : undefined);
}
function post<T>(path: string, body?: unknown): Promise<T> {
@@ -106,12 +157,12 @@ function del<T>(path: string): Promise<T> {
// ── Projects ────────────────────────────────────────────────────────
export function listProjects(): Promise<Project[]> {
return get<Project[]>('/api/projects');
export function listProjects(signal?: AbortSignal): Promise<Project[]> {
return get<Project[]>('/api/projects', signal);
}
export function getProject(id: string): Promise<ProjectDetail> {
return get<ProjectDetail>(`/api/projects/${id}`);
export function getProject(id: string, signal?: AbortSignal): Promise<ProjectDetail> {
return get<ProjectDetail>(`/api/projects/${id}`, signal);
}
export function createProject(data: Partial<Project>): Promise<Project> {
@@ -142,8 +193,8 @@ export function deleteStage(projectId: string, stageId: string): Promise<void> {
// ── Instances ───────────────────────────────────────────────────────
export function listInstances(projectId: string, stageId: string): Promise<Instance[]> {
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`);
export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise<Instance[]> {
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`, signal);
}
export function deployInstance(
@@ -198,8 +249,8 @@ export function restartInstance(
// ── Deploys ─────────────────────────────────────────────────────────
export function listDeploys(limit = 50): Promise<Deploy[]> {
return get<Deploy[]>(`/api/deploys?limit=${limit}`);
export function listDeploys(limit = 50, signal?: AbortSignal): Promise<Deploy[]> {
return get<Deploy[]>(`/api/deploys?limit=${limit}`, signal);
}
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
@@ -246,7 +297,9 @@ export function testRegistry(id: string): Promise<{ status: string }> {
}
export function listRegistryTags(registryId: string, image: string): Promise<string[]> {
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
// Image contains slashes (e.g. "owner/name") — don't encode them;
// the backend route uses a wildcard (/tags/*) that expects path segments.
return get<string[]>(`/api/registries/${registryId}/tags/${image}`);
}
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
@@ -255,8 +308,8 @@ export function listRegistryImages(registryId: string): Promise<RegistryImage[]>
// ── Settings ────────────────────────────────────────────────────────
export function getSettings(): Promise<Settings> {
return get<Settings>('/api/settings');
export function getSettings(signal?: AbortSignal): Promise<Settings> {
return get<Settings>('/api/settings', signal);
}
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
@@ -285,14 +338,14 @@ export function fetchContainerLogs(
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
}
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
export function listProjectImages(projectId: string, signal?: AbortSignal): Promise<LocalImage[]> {
return get<LocalImage[]>(`/api/projects/${projectId}/images`, signal);
}
export function getUnusedImageStats(): Promise<{
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
}> {
return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images');
return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images', signal);
}
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
@@ -580,8 +633,8 @@ export function clearAllEvents(): Promise<{ status: string; count: number }> {
// ── Stale Containers ────────────────────────────────────────────────
export function fetchStaleContainers(): Promise<StaleContainer[]> {
return get<StaleContainer[]>('/api/containers/stale');
export function fetchStaleContainers(signal?: AbortSignal): Promise<StaleContainer[]> {
return get<StaleContainer[]>('/api/containers/stale', signal);
}
export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> {
@@ -597,10 +650,12 @@ export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
export function fetchContainerStats(
projectId: string,
stageId: string,
instanceId: string
instanceId: string,
signal?: AbortSignal
): Promise<ContainerStats> {
return get<ContainerStats>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`,
signal
);
}
@@ -608,8 +663,8 @@ export function fetchContainerStats(
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
export function listStaticSites(): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites');
export function listStaticSites(signal?: AbortSignal): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites', signal);
}
export function getStaticSite(id: string): Promise<StaticSite> {
@@ -710,4 +765,10 @@ export function deleteStaticSiteSecret(
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
}
export function getStaticSiteStorage(
siteId: string
): Promise<import('./types').StaticSiteStorageUsage> {
return get<import('./types').StaticSiteStorageUsage>(`/api/sites/${siteId}/storage`);
}
export { ApiError };