fix(runtime): bound PM2 process restarts and identity

This commit is contained in:
2026-08-04 16:01:43 +00:00
parent 1e66c69d60
commit b589f2edb2
13 changed files with 343 additions and 50 deletions
@@ -13,7 +13,7 @@ import {
import { isRecord } from '@sammo-ts/common';
import type { BuildCommand, BuildRunner } from './buildRunner.js';
import type { ProcessManager } from './processManager.js';
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
import type {
GatewayClaimedProfileUpdate,
GatewayOperationRecord,
@@ -368,7 +368,7 @@ export const buildProcessDefinitions = (
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
const baseEnv = { ...(config.baseEnv ?? {}) };
const baseEnv = sanitizeManagedProcessEnv(config.baseEnv ?? {});
const frontendName = buildProcessName(profile.profileName, 'frontend');
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
@@ -1453,11 +1453,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildWorkspace: workspace.root,
};
const started = await this.startProfile(builtProfile, assertLease);
if (!started) {
await updateClaimedProfile({ status: 'STOPPED', lastError: 'Failed to start profile processes.' }, () =>
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
if (!ready) {
if (started) {
await this.stopProfile(builtProfile, assertLease);
}
const detail = started
? 'reset completed but profile processes failed readiness'
: 'reset completed but profile processes failed to start';
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
return { status: 'FAILED', detail: 'reset completed but profile processes failed to start' };
return { status: 'FAILED', detail };
}
await updateClaimedProfile({ lastError: null }, async () => {
await this.repository.updateLastError(profile.profileName, null);
@@ -1723,6 +1730,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
];
const attemptedNames: string[] = [];
try {
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
const existingNames = new Set(
(await this.processManager.list())
.filter((process) => expectedNames.has(process.name))
.map((process) => process.name)
);
for (const name of existingNames) {
await assertLease?.();
await this.processManager.delete(name);
await assertLease?.();
}
for (const definition of orderedDefinitions) {
await assertLease?.();
attemptedNames.push(definition.name);
@@ -1774,12 +1792,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.fetchImpl(frontendUrl),
this.processManager.list(),
]);
const online = new Set(
processes
.filter((process) => process.status.toLowerCase() === 'online')
.map((process) => process.name)
const expectedProcesses = processes.filter((process) => expectedNames.includes(process.name));
const safeProcesses = expectedProcesses.filter(
(process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0
);
if (api.ok && frontend.ok && expectedNames.every((name) => online.has(name))) {
if (
api.ok &&
frontend.ok &&
expectedProcesses.length === expectedNames.length &&
safeProcesses.length === expectedNames.length &&
new Set(safeProcesses.map((process) => process.name)).size === expectedNames.length
) {
return true;
}
} catch {
@@ -4,13 +4,13 @@ import type { GatewayOrchestratorConfig } from '../config.js';
import { createGatewayProfileRepository } from './profileRepository.js';
import { GatewayOrchestrator } from './gatewayOrchestrator.js';
import { Pm2ProcessManager } from './pm2ProcessManager.js';
import { sanitizeManagedProcessEnv } from './processManager.js';
import { PnpmBuildRunner } from './buildRunner.js';
import { resolveWorkspaceRoot } from './workspaceRoot.js';
import { GitWorkspaceManager } from './workspaceManager.js';
export const buildEnvMap = (env: NodeJS.ProcessEnv): Record<string, string> => {
const entries = Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string');
return Object.fromEntries(entries);
return sanitizeManagedProcessEnv(env);
};
export const createGatewayOrchestrator = (
@@ -1,7 +1,12 @@
import { createRequire } from 'node:module';
import type * as Pm2 from 'pm2';
import type { ProcessManager, ManagedProcessInfo, ProcessDefinition } from './processManager.js';
import {
sanitizePm2IdentityEnv,
type ProcessManager,
type ManagedProcessInfo,
type ProcessDefinition,
} from './processManager.js';
type Pm2Module = typeof Pm2;
@@ -27,6 +32,20 @@ const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> =
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name,
script: definition.script,
cwd: definition.cwd,
args: definition.args,
env: sanitizePm2IdentityEnv(definition.env ?? {}),
autorestart: true,
max_restarts: 5,
min_uptime: 10_000,
restart_delay: 2_000,
kill_timeout: 15_000,
time: true,
});
export class Pm2ProcessManager implements ProcessManager {
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
@@ -44,6 +63,7 @@ export class Pm2ProcessManager implements ProcessManager {
pid: item.pid ?? undefined,
cwd: item.pm2_env?.pm_cwd ?? undefined,
script: item.pm2_env?.pm_exec_path ?? undefined,
restartCount: item.pm2_env?.restart_time ?? 0,
})) ?? [];
resolve(normalized);
});
@@ -55,24 +75,26 @@ export class Pm2ProcessManager implements ProcessManager {
await withPm2(
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.start(
{
name: definition.name,
script: definition.script,
cwd: definition.cwd,
args: definition.args,
env: definition.env,
autorestart: true,
time: true,
},
(error) => {
if (error) {
reject(error);
return;
}
resolve();
pm2.list((listError, list) => {
if (listError) {
reject(listError);
return;
}
);
if (list?.some((item) => item.name === definition.name)) {
reject(new Error(`PM2 process name already exists: ${definition.name}`));
return;
}
pm2.start(
buildPm2StartOptions(definition),
(error) => {
if (error) {
reject(error);
return;
}
resolve();
}
);
});
})
);
}
@@ -4,6 +4,7 @@ export interface ManagedProcessInfo {
pid?: number;
cwd?: string;
script?: string;
restartCount?: number;
}
export interface ProcessDefinition {
@@ -20,3 +21,52 @@ export interface ProcessManager {
stop(name: string): Promise<void>;
delete(name: string): Promise<void>;
}
const PM2_INTERNAL_ENV_KEYS = new Set([
'NODE_APP_INSTANCE',
'autorestart',
'autostart',
'created_at',
'exec_interpreter',
'exec_mode',
'exit_code',
'instance_var',
'instances',
'merge_logs',
'name',
'namespace',
'node_args',
'node_version',
'pm_cwd',
'pm_err_log_path',
'pm_exec_path',
'pm_id',
'pm_out_log_path',
'pm_pid_path',
'pm_uptime',
'restart_time',
'status',
'unstable_restarts',
'version',
'vizion',
'watch',
]);
export const sanitizePm2IdentityEnv = (env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> =>
Object.fromEntries(
Object.entries(env).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string' &&
!entry[0].startsWith('axm_') &&
!entry[0].startsWith('pm_') &&
!PM2_INTERNAL_ENV_KEYS.has(entry[0])
)
);
export const sanitizeManagedProcessEnv = (env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> => {
const sanitized = sanitizePm2IdentityEnv(env);
delete sanitized.GATEWAY_ROLE;
delete sanitized.GAME_API_ROLE;
delete sanitized.GAME_ENGINE_ROLE;
return sanitized;
};