fix: support daemon idle timeout config
This commit is contained in:
parent
31bbaa804f
commit
a1201d1955
@ -7,6 +7,7 @@
|
||||
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
|
||||
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
|
||||
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)
|
||||
- Add the documented top-level `daemonIdleTimeoutMs` config to shut down inactive keep-alive daemons. (Issue #174, thanks @jarek083)
|
||||
|
||||
## [0.11.1] - 2026-05-14
|
||||
|
||||
|
||||
@ -241,6 +241,8 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
|
||||
mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtime’s streaming expectations.
|
||||
String-valued config fields support `${VAR}` and `${VAR:-fallback}` placeholders. Secret-bearing `headers`, `env`, and bearer-token placeholders are preserved in `config get`/`config list` output and resolved only when the transport runs; `*Env` fields name environment variables and are not expanded.
|
||||
|
||||
Top-level `daemonIdleTimeoutMs` (or `daemon_idle_timeout_ms`) shuts down the keep-alive daemon after that many milliseconds without daemon requests. Per-server `lifecycle.idleTimeoutMs` still controls when individual keep-alive transports are closed.
|
||||
|
||||
### Refreshable Bearer Tokens
|
||||
|
||||
Use `auth: "refreshable_bearer"` when you already seeded OAuth tokens with `mcporter vault set <server>` or `tokenCacheDir`, and the server should receive only a fresh bearer token at runtime. HTTP servers get `Authorization: Bearer <token>` when no authorization header is already configured. STDIO servers require `refresh.accessTokenEnv`; mcporter refreshes before spawning the process and injects that env var with the raw access token.
|
||||
|
||||
@ -49,7 +49,7 @@ read_when:
|
||||
- **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout).
|
||||
- **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26.
|
||||
- **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A top-level config `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Logging:** Daemon writes structured logs under the daemon runtime directory plus per-server logs for STDIO stderr so users can debug crashing servers.
|
||||
|
||||
## Agent Isolation
|
||||
|
||||
@ -359,6 +359,18 @@
|
||||
},
|
||||
"description": "Map of server names to their configurations"
|
||||
},
|
||||
"daemonIdleTimeoutMs": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"daemon_idle_timeout_ms": {
|
||||
"description": "Idle timeout in milliseconds before shutting down an inactive daemon",
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"imports": {
|
||||
"description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports",
|
||||
"type": "array",
|
||||
|
||||
@ -175,6 +175,18 @@ export const RawEntrySchema = z
|
||||
export const RawConfigSchema = z
|
||||
.object({
|
||||
mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'),
|
||||
daemonIdleTimeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
daemon_idle_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Idle timeout in milliseconds before shutting down an inactive daemon'),
|
||||
imports: z
|
||||
.array(ImportKindSchema)
|
||||
.optional()
|
||||
|
||||
@ -105,6 +105,23 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
return servers;
|
||||
}
|
||||
|
||||
export interface DaemonConfig {
|
||||
readonly idleTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function loadDaemonConfig(options: LoadConfigOptions = {}): Promise<DaemonConfig> {
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const layers = await loadConfigLayers(options, rootDir);
|
||||
let idleTimeoutMs: number | undefined;
|
||||
for (const layer of layers) {
|
||||
const raw = layer.config.daemonIdleTimeoutMs ?? layer.config.daemon_idle_timeout_ms;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
idleTimeoutMs = Math.trunc(raw);
|
||||
}
|
||||
}
|
||||
return { idleTimeoutMs };
|
||||
}
|
||||
|
||||
export async function loadRawConfig(
|
||||
options: LoadConfigOptions = {}
|
||||
): Promise<{ config: RawConfig; path: string; explicit: boolean }> {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { ServerDefinition } from '../config.js';
|
||||
import { loadDaemonConfig, type ServerDefinition } from '../config.js';
|
||||
import { writeJsonFile } from '../fs-json.js';
|
||||
import { isKeepAliveServer } from '../lifecycle.js';
|
||||
import { createRuntime, type Runtime } from '../runtime.js';
|
||||
@ -26,9 +26,11 @@ import type {
|
||||
} from './protocol.js';
|
||||
import {
|
||||
buildErrorResponse,
|
||||
daemonIdleWatcherInterval,
|
||||
ensureManaged,
|
||||
evictIdleServers,
|
||||
markActivity,
|
||||
shouldShutdownDaemonForIdle,
|
||||
type ServerActivity,
|
||||
} from './request-utils.js';
|
||||
|
||||
@ -48,6 +50,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
rootDir: options.rootDir,
|
||||
});
|
||||
const daemonConfig = await loadDaemonConfig({
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
rootDir: options.rootDir,
|
||||
});
|
||||
const runtime = await createRuntime({
|
||||
configPath: options.configExplicit ? options.configPath : undefined,
|
||||
rootDir: options.rootDir,
|
||||
@ -86,9 +92,37 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
activity.set(definition.name, { connected: false });
|
||||
}
|
||||
|
||||
const idleWatcher = setInterval(() => {
|
||||
void evictIdleServers(runtime, managedServers, activity);
|
||||
}, 30_000);
|
||||
let shuttingDown = false;
|
||||
let idleWatcher: NodeJS.Timeout | undefined;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
logEvent(logContext, 'Shutting down daemon host.');
|
||||
if (idleWatcher) {
|
||||
clearInterval(idleWatcher);
|
||||
}
|
||||
server.close();
|
||||
await runtime.close().catch(() => {});
|
||||
await disposeLogContext(logContext).catch(() => {});
|
||||
await cleanupArtifacts(options);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
let lastDaemonActivityAt = Date.now();
|
||||
let activeDaemonRequests = 0;
|
||||
idleWatcher = setInterval(() => {
|
||||
void (async () => {
|
||||
await evictIdleServers(runtime, managedServers, activity);
|
||||
if (
|
||||
shouldShutdownDaemonForIdle(lastDaemonActivityAt, Date.now(), daemonConfig.idleTimeoutMs, activeDaemonRequests)
|
||||
) {
|
||||
logEvent(logContext, 'Daemon idle timeout reached.');
|
||||
await shutdown();
|
||||
}
|
||||
})();
|
||||
}, daemonIdleWatcherInterval(daemonConfig.idleTimeoutMs));
|
||||
idleWatcher.unref();
|
||||
|
||||
logEvent(logContext, 'Daemon host started.');
|
||||
@ -115,6 +149,8 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
handled = true;
|
||||
lastDaemonActivityAt = Date.now();
|
||||
activeDaemonRequests += 1;
|
||||
void handleSocketRequest(
|
||||
trimmed,
|
||||
socket,
|
||||
@ -132,7 +168,10 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
logContext,
|
||||
shutdown,
|
||||
parsedRequest
|
||||
);
|
||||
).finally(() => {
|
||||
activeDaemonRequests -= 1;
|
||||
lastDaemonActivityAt = Date.now();
|
||||
});
|
||||
};
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
@ -167,21 +206,6 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
configMtimeMs,
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
logEvent(logContext, 'Shutting down daemon host.');
|
||||
clearInterval(idleWatcher);
|
||||
server.close();
|
||||
await runtime.close().catch(() => {});
|
||||
await disposeLogContext(logContext).catch(() => {});
|
||||
await cleanupArtifacts(options);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
process.once('SIGQUIT', shutdown);
|
||||
|
||||
@ -49,6 +49,27 @@ export async function evictIdleServers(
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShutdownDaemonForIdle(
|
||||
lastActivityAt: number,
|
||||
now: number,
|
||||
idleTimeoutMs: number | undefined,
|
||||
activeRequests = 0
|
||||
): boolean {
|
||||
return (
|
||||
activeRequests <= 0 &&
|
||||
typeof idleTimeoutMs === 'number' &&
|
||||
idleTimeoutMs > 0 &&
|
||||
now - lastActivityAt >= idleTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
export function daemonIdleWatcherInterval(idleTimeoutMs: number | undefined): number {
|
||||
if (!idleTimeoutMs) {
|
||||
return 30_000;
|
||||
}
|
||||
return Math.min(30_000, Math.max(100, Math.floor(idleTimeoutMs / 2)));
|
||||
}
|
||||
|
||||
export function buildErrorResponse(id: string, code: string, error?: unknown): DaemonResponse {
|
||||
let message = code;
|
||||
if (error instanceof Error) {
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadServerDefinitions } from '../src/config.js';
|
||||
import { loadDaemonConfig, loadServerDefinitions } from '../src/config.js';
|
||||
|
||||
const TEMP_DIR = path.join(os.tmpdir(), 'mcporter-config-test');
|
||||
|
||||
@ -159,6 +159,27 @@ describe('config normalization', () => {
|
||||
expect(servers.find((entry) => entry.name === 'defaulted')?.httpFetch).toBe('default');
|
||||
});
|
||||
|
||||
it('loads daemon idle timeout from config layers', async () => {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-daemon-idle-'));
|
||||
const configDir = path.join(rootDir, 'config');
|
||||
const configPath = path.join(configDir, 'mcporter.json');
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
daemonIdleTimeoutMs: 12_345,
|
||||
mcpServers: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(loadDaemonConfig({ rootDir })).resolves.toEqual({ idleTimeoutMs: 12_345 });
|
||||
});
|
||||
|
||||
it('normalizes refreshable bearer config for stdio servers', async () => {
|
||||
await fs.mkdir(TEMP_DIR, { recursive: true });
|
||||
const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json');
|
||||
|
||||
18
tests/daemon-request-utils.test.ts
Normal file
18
tests/daemon-request-utils.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { daemonIdleWatcherInterval, shouldShutdownDaemonForIdle } from '../src/daemon/request-utils.js';
|
||||
|
||||
describe('daemon idle helpers', () => {
|
||||
it('detects global daemon idle timeout', () => {
|
||||
expect(shouldShutdownDaemonForIdle(1_000, 4_000, 3_000)).toBe(true);
|
||||
expect(shouldShutdownDaemonForIdle(1_000, 3_999, 3_000)).toBe(false);
|
||||
expect(shouldShutdownDaemonForIdle(1_000, 10_000, undefined)).toBe(false);
|
||||
expect(shouldShutdownDaemonForIdle(1_000, 10_000, 3_000, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses short watcher intervals for short configured timeouts', () => {
|
||||
expect(daemonIdleWatcherInterval(undefined)).toBe(30_000);
|
||||
expect(daemonIdleWatcherInterval(150)).toBe(100);
|
||||
expect(daemonIdleWatcherInterval(2_000)).toBe(1_000);
|
||||
expect(daemonIdleWatcherInterval(120_000)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user