mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-13 17:33:54 -07:00
feat(hot-runner): implement hot runner protocol with registry, health monitoring, and job dispatch (#791)
Adds persistent Unity editor instance support to reduce build iteration time by eliminating cold-start overhead. Includes: - HotRunnerTypes: interfaces for config, status, job request/result, transport - HotRunnerRegistry: in-memory runner management with file-based persistence - HotRunnerHealthMonitor: periodic health checks, idle recycling, job-count recycling - HotRunnerDispatcher: job routing with wait-for-runner, timeout, and output streaming - HotRunnerService: high-level API integrating registry, health, and dispatch - 34 unit tests covering registration, filtering, health, dispatch, timeout, fallback - action.yml inputs for hot runner configuration (7 new inputs) - Input/BuildParameters integration for hot runner settings - index.ts wiring with cold-build fallback when hot runner unavailable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+688
-10
@@ -38,6 +38,7 @@ const model_1 = __nccwpck_require__(41359);
|
||||
const cli_1 = __nccwpck_require__(55651);
|
||||
const mac_builder_1 = __importDefault(__nccwpck_require__(39364));
|
||||
const platform_setup_1 = __importDefault(__nccwpck_require__(64423));
|
||||
const hot_runner_1 = __nccwpck_require__(74283);
|
||||
async function runMain() {
|
||||
try {
|
||||
if (cli_1.Cli.InitCliMode()) {
|
||||
@@ -50,17 +51,42 @@ async function runMain() {
|
||||
const buildParameters = await model_1.BuildParameters.create();
|
||||
const baseImage = new model_1.ImageTag(buildParameters);
|
||||
let exitCode = -1;
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
// Hot runner path: attempt to use a persistent Unity editor instance
|
||||
if (buildParameters.hotRunnerEnabled) {
|
||||
core.info('[HotRunner] Hot runner mode enabled, attempting hot build...');
|
||||
const hotRunnerConfig = {
|
||||
enabled: true,
|
||||
transport: buildParameters.hotRunnerTransport,
|
||||
host: buildParameters.hotRunnerHost,
|
||||
port: buildParameters.hotRunnerPort,
|
||||
healthCheckInterval: buildParameters.hotRunnerHealthInterval,
|
||||
maxIdleTime: buildParameters.hotRunnerMaxIdle,
|
||||
maxJobsBeforeRecycle: 0, // no automatic recycle by job count
|
||||
};
|
||||
const hotRunnerService = new hot_runner_1.HotRunnerService();
|
||||
try {
|
||||
await hotRunnerService.initialize(hotRunnerConfig);
|
||||
const result = await hotRunnerService.submitBuild(buildParameters, (output) => {
|
||||
core.info(output);
|
||||
});
|
||||
exitCode = result.exitCode;
|
||||
core.info(`[HotRunner] Build completed with exit code ${exitCode}`);
|
||||
await hotRunnerService.shutdown();
|
||||
}
|
||||
catch (hotRunnerError) {
|
||||
await hotRunnerService.shutdown();
|
||||
if (buildParameters.hotRunnerFallbackToCold) {
|
||||
core.warning(`[HotRunner] Hot runner failed: ${hotRunnerError.message}. Falling back to cold build.`);
|
||||
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
|
||||
}
|
||||
else {
|
||||
throw hotRunnerError;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (buildParameters.providerStrategy === 'local') {
|
||||
core.info('Building locally');
|
||||
await platform_setup_1.default.setup(buildParameters, actionFolder);
|
||||
exitCode =
|
||||
process.platform === 'darwin'
|
||||
? await mac_builder_1.default.run(actionFolder)
|
||||
: await model_1.Docker.run(baseImage.toString(), {
|
||||
workspace,
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
exitCode = await runColdBuild(buildParameters, baseImage, workspace, actionFolder);
|
||||
}
|
||||
else {
|
||||
await model_1.Orchestrator.run(buildParameters, baseImage.toString());
|
||||
@@ -78,6 +104,23 @@ async function runMain() {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
}
|
||||
async function runColdBuild(buildParameters, baseImage, workspace, actionFolder) {
|
||||
if (buildParameters.providerStrategy === 'local') {
|
||||
core.info('Building locally');
|
||||
await platform_setup_1.default.setup(buildParameters, actionFolder);
|
||||
return process.platform === 'darwin'
|
||||
? await mac_builder_1.default.run(actionFolder)
|
||||
: await model_1.Docker.run(baseImage.toString(), {
|
||||
workspace,
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await model_1.Orchestrator.run(buildParameters, baseImage.toString());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
runMain();
|
||||
|
||||
|
||||
@@ -375,6 +418,13 @@ class BuildParameters {
|
||||
cacheUnityInstallationOnMac: input_1.default.cacheUnityInstallationOnMac,
|
||||
unityHubVersionOnMac: input_1.default.unityHubVersionOnMac,
|
||||
dockerWorkspacePath: input_1.default.dockerWorkspacePath,
|
||||
hotRunnerEnabled: input_1.default.hotRunnerEnabled,
|
||||
hotRunnerTransport: input_1.default.hotRunnerTransport,
|
||||
hotRunnerHost: input_1.default.hotRunnerHost,
|
||||
hotRunnerPort: input_1.default.hotRunnerPort,
|
||||
hotRunnerHealthInterval: input_1.default.hotRunnerHealthInterval,
|
||||
hotRunnerMaxIdle: input_1.default.hotRunnerMaxIdle,
|
||||
hotRunnerFallbackToCold: input_1.default.hotRunnerFallbackToCold,
|
||||
};
|
||||
}
|
||||
static parseBuildFile(filename, platform, androidExportType) {
|
||||
@@ -1826,6 +1876,29 @@ class Input {
|
||||
static get skipActivation() {
|
||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||
}
|
||||
static get hotRunnerEnabled() {
|
||||
const input = Input.getInput('hotRunnerEnabled') ?? false;
|
||||
return input === 'true';
|
||||
}
|
||||
static get hotRunnerTransport() {
|
||||
return (Input.getInput('hotRunnerTransport') ?? 'websocket');
|
||||
}
|
||||
static get hotRunnerHost() {
|
||||
return Input.getInput('hotRunnerHost') ?? 'localhost';
|
||||
}
|
||||
static get hotRunnerPort() {
|
||||
return Number.parseInt(Input.getInput('hotRunnerPort') ?? '9090', 10);
|
||||
}
|
||||
static get hotRunnerHealthInterval() {
|
||||
return Number.parseInt(Input.getInput('hotRunnerHealthInterval') ?? '30', 10);
|
||||
}
|
||||
static get hotRunnerMaxIdle() {
|
||||
return Number.parseInt(Input.getInput('hotRunnerMaxIdle') ?? '3600', 10);
|
||||
}
|
||||
static get hotRunnerFallbackToCold() {
|
||||
const input = Input.getInput('hotRunnerFallbackToCold') ?? 'true';
|
||||
return input === 'true';
|
||||
}
|
||||
static ToEnvVarFormat(input) {
|
||||
if (input.toUpperCase() === input) {
|
||||
return input;
|
||||
@@ -9632,6 +9705,611 @@ class ContainerHookService {
|
||||
exports.ContainerHookService = ContainerHookService;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 62984:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.HotRunnerDispatcher = void 0;
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
class HotRunnerDispatcher {
|
||||
constructor(transports) {
|
||||
this.transports = transports;
|
||||
}
|
||||
/**
|
||||
* Dispatch a job to an available hot runner matching the request's build target.
|
||||
* If no runner is immediately available, waits up to the request timeout.
|
||||
* Returns the job result, or throws if no runner becomes available in time.
|
||||
*/
|
||||
async dispatchJob(request, registry, unityVersion, onOutput) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Dispatching job ${request.jobId} (target: ${request.buildTarget})`);
|
||||
// Find or wait for an available runner
|
||||
let runner = registry.findAvailableRunner({
|
||||
unityVersion,
|
||||
platform: request.buildTarget,
|
||||
});
|
||||
if (!runner) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] No idle runner available for ${unityVersion}/${request.buildTarget}, waiting...`);
|
||||
runner = await this.waitForRunner({ unityVersion, platform: request.buildTarget }, request.timeout, registry);
|
||||
}
|
||||
// Mark runner as busy
|
||||
registry.updateRunner(runner.id, {
|
||||
state: 'busy',
|
||||
currentJob: request.jobId,
|
||||
});
|
||||
const transport = this.transports.get(runner.id);
|
||||
if (!transport) {
|
||||
registry.updateRunner(runner.id, { state: 'idle', currentJob: undefined });
|
||||
throw new Error(`[HotRunner] No transport available for runner ${runner.id}`);
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Sending job ${request.jobId} to runner ${runner.id}`);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await this.executeWithTimeout(transport, request);
|
||||
const duration = Date.now() - startTime;
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Job ${request.jobId} completed on runner ${runner.id} in ${duration}ms (exit: ${result.exitCode})`);
|
||||
if (onOutput && result.output) {
|
||||
onOutput(result.output);
|
||||
}
|
||||
// Mark runner as idle and increment job count
|
||||
const currentStatus = registry.getRunner(runner.id);
|
||||
registry.updateRunner(runner.id, {
|
||||
state: 'idle',
|
||||
currentJob: undefined,
|
||||
lastJobCompleted: request.jobId,
|
||||
jobsCompleted: (currentStatus?.jobsCompleted ?? 0) + 1,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Job ${request.jobId} failed on runner ${runner.id}: ${error.message}`);
|
||||
// Mark runner as idle despite failure -- the health monitor will recycle if needed
|
||||
registry.updateRunner(runner.id, {
|
||||
state: 'idle',
|
||||
currentJob: undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wait for an available runner matching the requirements.
|
||||
* Polls the registry at a fixed interval until one becomes available or timeout expires.
|
||||
*/
|
||||
async waitForRunner(requirements, timeoutMs, registry) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const runner = registry.findAvailableRunner(requirements);
|
||||
if (runner) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Runner ${runner.id} became available`);
|
||||
return runner;
|
||||
}
|
||||
await this.sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
|
||||
}
|
||||
throw new Error(`[HotRunner] Timed out waiting for available runner (${requirements.unityVersion}/${requirements.platform}) after ${timeoutMs}ms`);
|
||||
}
|
||||
/**
|
||||
* Execute a job on a transport with a timeout guard.
|
||||
*/
|
||||
async executeWithTimeout(transport, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`[HotRunner] Job ${request.jobId} timed out after ${request.timeout}ms`));
|
||||
}, request.timeout);
|
||||
transport
|
||||
.sendJob(request)
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
exports.HotRunnerDispatcher = HotRunnerDispatcher;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 9991:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.HotRunnerHealthMonitor = void 0;
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
class HotRunnerHealthMonitor {
|
||||
constructor() {
|
||||
this.transports = new Map();
|
||||
}
|
||||
/**
|
||||
* Start periodic health monitoring for all registered runners.
|
||||
*/
|
||||
startMonitoring(registry, interval, transports) {
|
||||
if (this.intervalHandle) {
|
||||
this.stopMonitoring();
|
||||
}
|
||||
this.registry = registry;
|
||||
this.transports = transports;
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Starting health monitoring (interval: ${interval}s)`);
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.runHealthChecks().catch((error) => {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Health check cycle failed: ${error.message}`);
|
||||
});
|
||||
}, interval * 1000);
|
||||
}
|
||||
/**
|
||||
* Stop periodic health monitoring.
|
||||
*/
|
||||
stopMonitoring() {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = undefined;
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Health monitoring stopped`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Check health of a specific runner by ID. Returns true if healthy.
|
||||
*/
|
||||
async checkHealth(runnerId) {
|
||||
if (!this.registry) {
|
||||
return false;
|
||||
}
|
||||
const transport = this.transports.get(runnerId);
|
||||
if (!transport) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] No transport for runner ${runnerId}`);
|
||||
this.registry.updateRunner(runnerId, {
|
||||
state: 'unhealthy',
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const healthy = await transport.healthCheck();
|
||||
if (healthy) {
|
||||
const status = await transport.getStatus();
|
||||
this.registry.updateRunner(runnerId, {
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
memoryUsageMB: status.memoryUsageMB,
|
||||
uptime: status.uptime,
|
||||
libraryHash: status.libraryHash,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Runner ${runnerId} health check returned false`);
|
||||
this.registry.updateRunner(runnerId, {
|
||||
state: 'unhealthy',
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Runner ${runnerId} health check failed: ${error.message}`);
|
||||
this.registry.updateRunner(runnerId, {
|
||||
state: 'unhealthy',
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Mark an unhealthy runner for cleanup and disconnect its transport.
|
||||
*/
|
||||
async recycleUnhealthyRunner(runnerId) {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Recycling unhealthy runner ${runnerId}`);
|
||||
this.registry.updateRunner(runnerId, { state: 'stopping' });
|
||||
const transport = this.transports.get(runnerId);
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.disconnect();
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Error disconnecting runner ${runnerId}: ${error.message}`);
|
||||
}
|
||||
this.transports.delete(runnerId);
|
||||
}
|
||||
this.registry.unregisterRunner(runnerId);
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Runner ${runnerId} recycled and removed`);
|
||||
}
|
||||
/**
|
||||
* Recycle a runner that has been idle longer than the maximum idle time.
|
||||
*/
|
||||
async recycleIdleRunner(runnerId, maxIdleTime) {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
}
|
||||
const runner = this.registry.getRunner(runnerId);
|
||||
if (!runner || runner.state !== 'idle') {
|
||||
return;
|
||||
}
|
||||
const lastCheckTime = new Date(runner.lastHealthCheck).getTime();
|
||||
const now = Date.now();
|
||||
const idleSeconds = (now - lastCheckTime) / 1000;
|
||||
if (idleSeconds >= maxIdleTime) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Runner ${runnerId} idle for ${Math.floor(idleSeconds)}s (max: ${maxIdleTime}s), recycling`);
|
||||
await this.recycleUnhealthyRunner(runnerId);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Run health checks and idle-recycle checks for all registered runners.
|
||||
*/
|
||||
async runHealthChecks() {
|
||||
if (!this.registry) {
|
||||
return;
|
||||
}
|
||||
const runners = this.registry.listRunners();
|
||||
for (const runner of runners) {
|
||||
if (runner.state === 'stopping') {
|
||||
continue;
|
||||
}
|
||||
const healthy = await this.checkHealth(runner.id);
|
||||
if (!healthy && runner.state !== 'starting') {
|
||||
await this.recycleUnhealthyRunner(runner.id);
|
||||
continue;
|
||||
}
|
||||
// Check for idle timeout
|
||||
const config = this.registry.getConfig(runner.id);
|
||||
if (config && runner.state === 'idle') {
|
||||
await this.recycleIdleRunner(runner.id, config.maxIdleTime);
|
||||
}
|
||||
// Check for max jobs before recycle
|
||||
if (config && config.maxJobsBeforeRecycle > 0 && runner.jobsCompleted >= config.maxJobsBeforeRecycle) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Runner ${runner.id} reached max jobs (${runner.jobsCompleted}/${config.maxJobsBeforeRecycle}), recycling`);
|
||||
await this.recycleUnhealthyRunner(runner.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Whether health monitoring is currently active.
|
||||
*/
|
||||
get isMonitoring() {
|
||||
return this.intervalHandle !== undefined;
|
||||
}
|
||||
}
|
||||
exports.HotRunnerHealthMonitor = HotRunnerHealthMonitor;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 12722:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.HotRunnerRegistry = void 0;
|
||||
const node_fs_1 = __importDefault(__nccwpck_require__(87561));
|
||||
const node_path_1 = __importDefault(__nccwpck_require__(49411));
|
||||
const nanoid_1 = __nccwpck_require__(17592);
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
const generateId = (0, nanoid_1.customAlphabet)('abcdefghijklmnopqrstuvwxyz0123456789', 12);
|
||||
const PERSISTENCE_FILENAME = 'hot-runners.json';
|
||||
class HotRunnerRegistry {
|
||||
constructor(persistenceDirectory) {
|
||||
this.runners = new Map();
|
||||
this.configs = new Map();
|
||||
this.persistencePath = persistenceDirectory ? node_path_1.default.join(persistenceDirectory, PERSISTENCE_FILENAME) : '';
|
||||
}
|
||||
/**
|
||||
* Register a new hot runner. Returns the generated runner ID.
|
||||
*/
|
||||
registerRunner(config) {
|
||||
const id = `hr-${generateId()}`;
|
||||
const status = {
|
||||
id,
|
||||
state: 'starting',
|
||||
unityVersion: config.unityVersion ?? 'unknown',
|
||||
platform: config.platform ?? 'unknown',
|
||||
uptime: 0,
|
||||
jobsCompleted: 0,
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
memoryUsageMB: 0,
|
||||
};
|
||||
this.runners.set(id, status);
|
||||
this.configs.set(id, config);
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Registered runner ${id} (${status.unityVersion}/${status.platform})`);
|
||||
this.persist();
|
||||
return id;
|
||||
}
|
||||
/**
|
||||
* Remove a runner from the registry.
|
||||
*/
|
||||
unregisterRunner(id) {
|
||||
const existed = this.runners.delete(id);
|
||||
this.configs.delete(id);
|
||||
if (existed) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Unregistered runner ${id}`);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get a runner's current status by ID.
|
||||
*/
|
||||
getRunner(id) {
|
||||
return this.runners.get(id);
|
||||
}
|
||||
/**
|
||||
* Get a runner's config by ID.
|
||||
*/
|
||||
getConfig(id) {
|
||||
return this.configs.get(id);
|
||||
}
|
||||
/**
|
||||
* List all runners, optionally filtered by platform, state, or Unity version.
|
||||
*/
|
||||
listRunners(filter) {
|
||||
let results = [...this.runners.values()];
|
||||
if (filter?.platform) {
|
||||
results = results.filter((runner) => runner.platform === filter.platform);
|
||||
}
|
||||
if (filter?.state) {
|
||||
results = results.filter((runner) => runner.state === filter.state);
|
||||
}
|
||||
if (filter?.unityVersion) {
|
||||
results = results.filter((runner) => runner.unityVersion === filter.unityVersion);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/**
|
||||
* Find an idle runner matching the given Unity version and platform requirements.
|
||||
*/
|
||||
findAvailableRunner(requirements) {
|
||||
return this.listRunners({
|
||||
state: 'idle',
|
||||
unityVersion: requirements.unityVersion,
|
||||
platform: requirements.platform,
|
||||
})[0];
|
||||
}
|
||||
/**
|
||||
* Update a runner's status fields. Merges partial updates into existing status.
|
||||
*/
|
||||
updateRunner(id, update) {
|
||||
const existing = this.runners.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
this.runners.set(id, { ...existing, ...update, id });
|
||||
this.persist();
|
||||
}
|
||||
/**
|
||||
* Get the total number of registered runners.
|
||||
*/
|
||||
get size() {
|
||||
return this.runners.size;
|
||||
}
|
||||
/**
|
||||
* Persist current registry state to disk for crash recovery.
|
||||
*/
|
||||
persist() {
|
||||
if (!this.persistencePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = {
|
||||
runners: Object.fromEntries(this.runners),
|
||||
configs: Object.fromEntries(this.configs),
|
||||
};
|
||||
const directory = node_path_1.default.dirname(this.persistencePath);
|
||||
if (!node_fs_1.default.existsSync(directory)) {
|
||||
node_fs_1.default.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
node_fs_1.default.writeFileSync(this.persistencePath, JSON.stringify(data, undefined, 2));
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Failed to persist registry: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load registry state from disk. Returns the number of runners restored.
|
||||
*/
|
||||
loadFromDisk() {
|
||||
if (!this.persistencePath || !node_fs_1.default.existsSync(this.persistencePath)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const raw = node_fs_1.default.readFileSync(this.persistencePath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (data.runners) {
|
||||
for (const [id, status] of Object.entries(data.runners)) {
|
||||
this.runners.set(id, status);
|
||||
}
|
||||
}
|
||||
if (data.configs) {
|
||||
for (const [id, config] of Object.entries(data.configs)) {
|
||||
this.configs.set(id, config);
|
||||
}
|
||||
}
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Restored ${this.runners.size} runner(s) from disk`);
|
||||
return this.runners.size;
|
||||
}
|
||||
catch (error) {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Failed to load registry from disk: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.HotRunnerRegistry = HotRunnerRegistry;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 42517:
|
||||
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.HotRunnerService = void 0;
|
||||
const orchestrator_logger_1 = __importDefault(__nccwpck_require__(32549));
|
||||
const hot_runner_registry_1 = __nccwpck_require__(12722);
|
||||
const hot_runner_health_monitor_1 = __nccwpck_require__(9991);
|
||||
const hot_runner_dispatcher_1 = __nccwpck_require__(62984);
|
||||
class HotRunnerService {
|
||||
constructor(persistenceDirectory) {
|
||||
this.transports = new Map();
|
||||
this.registry = new hot_runner_registry_1.HotRunnerRegistry(persistenceDirectory);
|
||||
this.healthMonitor = new hot_runner_health_monitor_1.HotRunnerHealthMonitor();
|
||||
this.dispatcher = new hot_runner_dispatcher_1.HotRunnerDispatcher(this.transports);
|
||||
}
|
||||
/**
|
||||
* Initialize the hot runner service: load persisted state, start health monitoring.
|
||||
*/
|
||||
async initialize(config) {
|
||||
this.config = config;
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Initializing service (transport: ${config.transport}, ${config.host}:${config.port})`);
|
||||
// Attempt to restore previously registered runners from disk
|
||||
const restored = this.registry.loadFromDisk();
|
||||
if (restored > 0) {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Restored ${restored} runner(s) from persistence`);
|
||||
}
|
||||
// Start health monitoring
|
||||
this.healthMonitor.startMonitoring(this.registry, config.healthCheckInterval, this.transports);
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Service initialized`);
|
||||
}
|
||||
/**
|
||||
* Register a runner with a transport implementation.
|
||||
* Returns the runner ID.
|
||||
*/
|
||||
registerRunner(config, transport) {
|
||||
const id = this.registry.registerRunner(config);
|
||||
this.transports.set(id, transport);
|
||||
return id;
|
||||
}
|
||||
/**
|
||||
* Submit a build job to an available hot runner.
|
||||
* Converts BuildParameters to a HotRunnerJobRequest and dispatches.
|
||||
*/
|
||||
async submitBuild(params, onOutput) {
|
||||
const request = {
|
||||
jobId: params.buildGuid || `build-${Date.now()}`,
|
||||
buildMethod: params.buildMethod || undefined,
|
||||
buildTarget: params.targetPlatform,
|
||||
buildPath: params.buildPath,
|
||||
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
|
||||
timeout: 30 * 60 * 1000, // 30 minutes default
|
||||
};
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Submitting build: ${request.jobId} (target: ${request.buildTarget})`);
|
||||
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
|
||||
}
|
||||
/**
|
||||
* Submit a test job to an available hot runner.
|
||||
* Converts BuildParameters and optional suite config to a test-mode HotRunnerJobRequest.
|
||||
*/
|
||||
async submitTest(params, suiteConfig, onOutput) {
|
||||
const request = {
|
||||
jobId: params.buildGuid || `test-${Date.now()}`,
|
||||
buildTarget: params.targetPlatform,
|
||||
customParameters: params.customParameters ? this.parseCustomParameters(params.customParameters) : undefined,
|
||||
timeout: 30 * 60 * 1000,
|
||||
testMode: suiteConfig?.testMode ?? 'editmode',
|
||||
testSuitePath: suiteConfig?.testSuitePath,
|
||||
};
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Submitting test: ${request.jobId} (mode: ${request.testMode})`);
|
||||
return this.dispatcher.dispatchJob(request, this.registry, params.editorVersion, onOutput);
|
||||
}
|
||||
/**
|
||||
* Shut down the service: stop health monitoring, disconnect all transports,
|
||||
* and unregister all runners.
|
||||
*/
|
||||
async shutdown() {
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Shutting down service`);
|
||||
this.healthMonitor.stopMonitoring();
|
||||
const disconnectPromises = [];
|
||||
for (const [id, transport] of this.transports.entries()) {
|
||||
disconnectPromises.push(transport.disconnect().catch((error) => {
|
||||
orchestrator_logger_1.default.logWarning(`[HotRunner] Error disconnecting runner ${id}: ${error.message}`);
|
||||
}));
|
||||
}
|
||||
await Promise.all(disconnectPromises);
|
||||
this.transports.clear();
|
||||
orchestrator_logger_1.default.log(`[HotRunner] Service shut down`);
|
||||
}
|
||||
/**
|
||||
* Get the status of all registered runners.
|
||||
*/
|
||||
getStatus() {
|
||||
return this.registry.listRunners();
|
||||
}
|
||||
/**
|
||||
* Get the underlying registry (for testing or advanced use).
|
||||
*/
|
||||
getRegistry() {
|
||||
return this.registry;
|
||||
}
|
||||
/**
|
||||
* Parse a space-separated custom parameters string into a key-value map.
|
||||
* Handles `-key value` and `-key=value` formats.
|
||||
*/
|
||||
parseCustomParameters(raw) {
|
||||
const result = {};
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part.startsWith('-')) {
|
||||
const key = part.replace(/^-+/, '');
|
||||
if (key.includes('=')) {
|
||||
const [k, ...v] = key.split('=');
|
||||
result[k] = v.join('=');
|
||||
}
|
||||
else if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) {
|
||||
result[key] = parts[i + 1];
|
||||
i++;
|
||||
}
|
||||
else {
|
||||
result[key] = 'true';
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
exports.HotRunnerService = HotRunnerService;
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 74283:
|
||||
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
|
||||
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.HotRunnerDispatcher = exports.HotRunnerHealthMonitor = exports.HotRunnerRegistry = exports.HotRunnerService = void 0;
|
||||
var hot_runner_service_1 = __nccwpck_require__(42517);
|
||||
Object.defineProperty(exports, "HotRunnerService", ({ enumerable: true, get: function () { return hot_runner_service_1.HotRunnerService; } }));
|
||||
var hot_runner_registry_1 = __nccwpck_require__(12722);
|
||||
Object.defineProperty(exports, "HotRunnerRegistry", ({ enumerable: true, get: function () { return hot_runner_registry_1.HotRunnerRegistry; } }));
|
||||
var hot_runner_health_monitor_1 = __nccwpck_require__(9991);
|
||||
Object.defineProperty(exports, "HotRunnerHealthMonitor", ({ enumerable: true, get: function () { return hot_runner_health_monitor_1.HotRunnerHealthMonitor; } }));
|
||||
var hot_runner_dispatcher_1 = __nccwpck_require__(62984);
|
||||
Object.defineProperty(exports, "HotRunnerDispatcher", ({ enumerable: true, get: function () { return hot_runner_dispatcher_1.HotRunnerDispatcher; } }));
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 23451:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user