homegames-common 1.4.2 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker-helper.js +398 -0
- package/game-loader.js +224 -0
- package/game-session-manager.js +694 -0
- package/game-session.js +600 -0
- package/index.js +96 -624
- package/package.json +5 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GameSessionManager — Orchestrates game sessions.
|
|
3
|
+
*
|
|
4
|
+
* Takes a game (by versionId, raw code, or local path) and gives you a port
|
|
5
|
+
* where a WebSocket game server is running.
|
|
6
|
+
*
|
|
7
|
+
* Uses Docker containers when available, falls back to fork() when not.
|
|
8
|
+
*
|
|
9
|
+
* Consumers:
|
|
10
|
+
* - homegames-core HomegamesDashboard (startSession when player picks a game)
|
|
11
|
+
* - Studio preview (via Homenames API)
|
|
12
|
+
* - homedome (could use for test-running a game, though it mainly uses validateGame)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { fork } = require('child_process');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const { isDockerAvailable, isImageBuilt, ensureImage, runGameContainer, stopContainer, isContainerRunning } = require('./docker-helper');
|
|
22
|
+
const { squishMap, DEFAULT_SQUISH_VERSION, fetchGameFromForgejo, loadGameClassFromPath, detectSquishVersion, parseSquishVersion } = require('./game-loader');
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Port pool
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const DEFAULT_PORT_MIN = 7002;
|
|
28
|
+
const DEFAULT_PORT_MAX = 7099;
|
|
29
|
+
|
|
30
|
+
class PortPool {
|
|
31
|
+
constructor(min, max) {
|
|
32
|
+
this.ports = {};
|
|
33
|
+
for (let i = min; i <= max; i++) {
|
|
34
|
+
this.ports[i] = false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
acquire() {
|
|
39
|
+
for (const p in this.ports) {
|
|
40
|
+
if (!this.ports[p]) {
|
|
41
|
+
this.ports[p] = true;
|
|
42
|
+
return Number(p);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
release(port) {
|
|
49
|
+
if (this.ports[port] !== undefined) {
|
|
50
|
+
this.ports[port] = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Session tracking
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
let sessionIdCounter = 0;
|
|
59
|
+
|
|
60
|
+
class GameSessionManager {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {number} [opts.portMin] — start of port range (default 7002)
|
|
64
|
+
* @param {number} [opts.portMax] — end of port range (default 7099)
|
|
65
|
+
* @param {string} [opts.dockerImageDir] — path to dir containing the Dockerfile for homegames-runner
|
|
66
|
+
* @param {string} [opts.childServerPath] — path to child_game_server.js (for fork fallback)
|
|
67
|
+
* @param {string} [opts.dockerImageName] — Docker image name (default 'homegames-runner')
|
|
68
|
+
* @param {number} [opts.gracePeriodMs] — ms to wait with no players before killing (default 30000)
|
|
69
|
+
* @param {number} [opts.lifecycleCheckMs] — ms between lifecycle checks (default 10000)
|
|
70
|
+
* @param {object} [opts.forgejo] — { url, token } for Forgejo access
|
|
71
|
+
* @param {string} [opts.saveDataRoot] — root directory for game save data
|
|
72
|
+
* @param {string} [opts.username] — homegames username
|
|
73
|
+
* @param {string} [opts.certPath] — path to TLS certs
|
|
74
|
+
* @param {function} [opts.log] — logging function ({ info, error })
|
|
75
|
+
* @param {number} [opts.bezelX] — bezel size X for init message (default 0)
|
|
76
|
+
* @param {number} [opts.bezelY] — bezel size Y for init message (default 0)
|
|
77
|
+
*/
|
|
78
|
+
constructor(opts = {}) {
|
|
79
|
+
this.portPool = new PortPool(
|
|
80
|
+
opts.portMin || DEFAULT_PORT_MIN,
|
|
81
|
+
opts.portMax || DEFAULT_PORT_MAX
|
|
82
|
+
);
|
|
83
|
+
this.dockerImageDir = opts.dockerImageDir || null;
|
|
84
|
+
this.dockerImageName = opts.dockerImageName || 'homegames-runner';
|
|
85
|
+
this.childServerPath = opts.childServerPath || null;
|
|
86
|
+
this.gracePeriodMs = opts.gracePeriodMs || 30000;
|
|
87
|
+
this.lifecycleCheckMs = opts.lifecycleCheckMs || 10000;
|
|
88
|
+
this.forgejo = opts.forgejo || {};
|
|
89
|
+
this.saveDataRoot = opts.saveDataRoot || path.join(os.tmpdir(), 'hg-save-data');
|
|
90
|
+
this.assetCachePath = opts.assetCachePath || null;
|
|
91
|
+
this.maxSessions = opts.maxSessions || 50;
|
|
92
|
+
this.username = opts.username || null;
|
|
93
|
+
this.certPath = opts.certPath || null;
|
|
94
|
+
this.log = opts.log || { info: console.log, error: console.error };
|
|
95
|
+
|
|
96
|
+
this.sessions = {};
|
|
97
|
+
this._dockerChecked = false;
|
|
98
|
+
this._dockerOk = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Determine whether Docker is available and the image is ready.
|
|
103
|
+
* Caches the result after first call.
|
|
104
|
+
*/
|
|
105
|
+
async _ensureDockerReady() {
|
|
106
|
+
if (this._dockerChecked) return this._dockerOk;
|
|
107
|
+
this._dockerChecked = true;
|
|
108
|
+
|
|
109
|
+
if (!(await isDockerAvailable())) {
|
|
110
|
+
this.log.info('Docker not available — sessions will use fork()');
|
|
111
|
+
this._dockerOk = false;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.dockerImageDir) {
|
|
116
|
+
try {
|
|
117
|
+
await ensureImage(this.dockerImageDir, this.dockerImageName);
|
|
118
|
+
this._dockerOk = true;
|
|
119
|
+
this.log.info('Docker available and homegames-runner image ready');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
this.log.error('Failed to build Docker image: ' + err.message);
|
|
122
|
+
this._dockerOk = false;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// No Dockerfile dir specified — check if image already exists
|
|
126
|
+
this._dockerOk = await isImageBuilt(this.dockerImageName);
|
|
127
|
+
if (this._dockerOk) {
|
|
128
|
+
this.log.info('Docker available, existing homegames-runner image found');
|
|
129
|
+
} else {
|
|
130
|
+
this.log.info('Docker available but no homegames-runner image. Specify dockerImageDir to build it. Using fork().');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return this._dockerOk;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start a game session.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} input — one of:
|
|
141
|
+
* { versionId } — fetch code from Forgejo and run it
|
|
142
|
+
* { code } — run raw code (Studio preview)
|
|
143
|
+
* { gamePath } — run from local file path
|
|
144
|
+
* @param {object} [opts]
|
|
145
|
+
* { onReady } — callback when session is ready and listening
|
|
146
|
+
* { env } — extra env vars for child process / container
|
|
147
|
+
* { movePlayer } — function({ playerId, port }) for Dashboard integration
|
|
148
|
+
* { playerId } — initial player to notify on ready
|
|
149
|
+
*
|
|
150
|
+
* @returns {Promise<{ sessionId, port, type: 'docker'|'fork' }>}
|
|
151
|
+
*/
|
|
152
|
+
async startSession(input, opts = {}) {
|
|
153
|
+
// Cap concurrent sessions
|
|
154
|
+
const maxSessions = this.maxSessions || 50;
|
|
155
|
+
if (Object.keys(this.sessions).length >= maxSessions) {
|
|
156
|
+
throw new Error('Maximum concurrent sessions reached');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const port = this.portPool.acquire();
|
|
160
|
+
if (!port) {
|
|
161
|
+
throw new Error('No available ports for new game session');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sessionId = ++sessionIdCounter;
|
|
165
|
+
const useDocker = await this._ensureDockerReady();
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
if (useDocker) {
|
|
169
|
+
return await this._startDockerSession(sessionId, port, input, opts);
|
|
170
|
+
} else {
|
|
171
|
+
return await this._startForkSession(sessionId, port, input, opts);
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.portPool.release(port);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// -----------------------------------------------------------------------
|
|
180
|
+
// Docker path
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
async _startDockerSession(sessionId, port, input, opts) {
|
|
183
|
+
let codePath;
|
|
184
|
+
let squishVersion;
|
|
185
|
+
let cleanupFn = null;
|
|
186
|
+
let gameEntryRelative = null;
|
|
187
|
+
|
|
188
|
+
if (input.code) {
|
|
189
|
+
// Raw code from Studio — write to temp dir
|
|
190
|
+
codePath = path.join(os.tmpdir(), `hg-session-${sessionId}`);
|
|
191
|
+
fs.mkdirSync(codePath, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(codePath, 'index.js'), input.code);
|
|
193
|
+
squishVersion = detectSquishVersion(input.code);
|
|
194
|
+
cleanupFn = () => {
|
|
195
|
+
try { fs.rmSync(codePath, { recursive: true, force: true }); } catch (e) {}
|
|
196
|
+
};
|
|
197
|
+
} else if (input.gamePath) {
|
|
198
|
+
// Local file path — mount a broad enough ancestor so that
|
|
199
|
+
// relative requires (e.g. ../../common/util) resolve correctly.
|
|
200
|
+
// We walk up from the game file to find the project root
|
|
201
|
+
// (directory containing node_modules or package.json).
|
|
202
|
+
const resolved = path.resolve(input.gamePath);
|
|
203
|
+
codePath = this._findProjectRoot(resolved) || path.dirname(resolved);
|
|
204
|
+
// Tell the container where the entry point is relative to the mount
|
|
205
|
+
gameEntryRelative = path.relative(codePath, resolved);
|
|
206
|
+
// Parse squish version from source via AST (no require) — safe for
|
|
207
|
+
// temp files / paths where squish packages aren't installed locally.
|
|
208
|
+
try {
|
|
209
|
+
squishVersion = parseSquishVersion(resolved);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
squishVersion = DEFAULT_SQUISH_VERSION;
|
|
212
|
+
}
|
|
213
|
+
} else if (input.versionId) {
|
|
214
|
+
// Fetch from Forgejo
|
|
215
|
+
const result = await fetchGameFromForgejo({
|
|
216
|
+
forgejoUrl: this.forgejo.url,
|
|
217
|
+
forgejoToken: this.forgejo.token,
|
|
218
|
+
owner: input.owner,
|
|
219
|
+
repo: input.repo,
|
|
220
|
+
ref: input.ref,
|
|
221
|
+
});
|
|
222
|
+
codePath = path.dirname(result.entryPath);
|
|
223
|
+
squishVersion = result.squishVersion;
|
|
224
|
+
cleanupFn = result.cleanup;
|
|
225
|
+
} else {
|
|
226
|
+
throw new Error('startSession requires one of: code, gamePath, or versionId');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Save data directory for this game
|
|
230
|
+
const saveDataPath = path.join(this.saveDataRoot, `session-${sessionId}`);
|
|
231
|
+
|
|
232
|
+
// Pass host config to the container so it can reach the local API,
|
|
233
|
+
// API_URL is used by Asset.js for downloading game assets.
|
|
234
|
+
// squish's Asset.js hardcodes https for downloads, so API_URL must
|
|
235
|
+
// use https://. Use the public API for asset downloads; Homenames
|
|
236
|
+
// access uses DOCKER_HOST_HOSTNAME separately.
|
|
237
|
+
const extraEnv = {};
|
|
238
|
+
extraEnv.API_URL = 'https://api.homegames.io';
|
|
239
|
+
// Homenames runs on the host — container reaches it via DOCKER_HOST_HOSTNAME
|
|
240
|
+
// which is set in docker-helper.js. HTTPS_ENABLED controls whether
|
|
241
|
+
// HomenamesHelper uses https — must be false for local dev.
|
|
242
|
+
extraEnv.HTTPS_ENABLED = 'false';
|
|
243
|
+
|
|
244
|
+
const { containerId } = await runGameContainer({
|
|
245
|
+
codePath,
|
|
246
|
+
port,
|
|
247
|
+
squishVersion,
|
|
248
|
+
saveDataPath,
|
|
249
|
+
assetCachePath: this.assetCachePath,
|
|
250
|
+
imageName: this.dockerImageName,
|
|
251
|
+
gameEntryRelative,
|
|
252
|
+
noFrame: input.noFrame || false,
|
|
253
|
+
extraEnv,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const session = {
|
|
257
|
+
id: sessionId,
|
|
258
|
+
port,
|
|
259
|
+
type: 'docker',
|
|
260
|
+
containerId,
|
|
261
|
+
squishVersion,
|
|
262
|
+
cleanup: cleanupFn,
|
|
263
|
+
_emptyTicks: 0,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
this.sessions[sessionId] = session;
|
|
267
|
+
this._startLifecycleMonitor(sessionId);
|
|
268
|
+
|
|
269
|
+
this.log.info(`Session ${sessionId} started via Docker on port ${port} (container ${containerId.slice(0, 12)})`);
|
|
270
|
+
|
|
271
|
+
if (opts.onReady) {
|
|
272
|
+
this.log.info(`Session ${sessionId} waiting for port ${port} to become reachable...`);
|
|
273
|
+
this._waitForPort(port, 15000).then(() => {
|
|
274
|
+
this.log.info(`Session ${sessionId} port ${port} is reachable, calling onReady`);
|
|
275
|
+
// Additional delay for Docker Desktop on macOS — the port mapping
|
|
276
|
+
// through the Linux VM can briefly refuse connections after the
|
|
277
|
+
// TCP check passes. The WebSocket upgrade needs the full stack ready.
|
|
278
|
+
return this._waitForWebSocket(port, 10000);
|
|
279
|
+
}).then(() => {
|
|
280
|
+
this.log.info(`Session ${sessionId} WebSocket confirmed, calling onReady`);
|
|
281
|
+
opts.onReady(session);
|
|
282
|
+
}).catch((err) => {
|
|
283
|
+
this.log.error(`Session ${sessionId} container never became ready: ${err.message}`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { sessionId, port, type: 'docker' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -----------------------------------------------------------------------
|
|
291
|
+
// Fork path (fallback when Docker is not available)
|
|
292
|
+
// -----------------------------------------------------------------------
|
|
293
|
+
async _startForkSession(sessionId, port, input, opts) {
|
|
294
|
+
if (!this.childServerPath) {
|
|
295
|
+
throw new Error('No childServerPath configured and Docker is not available');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let gamePath;
|
|
299
|
+
let squishVersion;
|
|
300
|
+
let cleanupFn = null;
|
|
301
|
+
|
|
302
|
+
if (input.code) {
|
|
303
|
+
const tmpDir = path.join(os.tmpdir(), `hg-session-${sessionId}`);
|
|
304
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
305
|
+
gamePath = path.join(tmpDir, 'index.js');
|
|
306
|
+
fs.writeFileSync(gamePath, input.code);
|
|
307
|
+
squishVersion = detectSquishVersion(input.code);
|
|
308
|
+
cleanupFn = () => {
|
|
309
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) {}
|
|
310
|
+
};
|
|
311
|
+
} else if (input.gamePath) {
|
|
312
|
+
gamePath = path.resolve(input.gamePath);
|
|
313
|
+
try {
|
|
314
|
+
squishVersion = parseSquishVersion(gamePath);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.log(err);
|
|
317
|
+
squishVersion = DEFAULT_SQUISH_VERSION;
|
|
318
|
+
}
|
|
319
|
+
} else if (input.versionId) {
|
|
320
|
+
const result = await fetchGameFromForgejo({
|
|
321
|
+
forgejoUrl: this.forgejo.url,
|
|
322
|
+
forgejoToken: this.forgejo.token,
|
|
323
|
+
owner: input.owner,
|
|
324
|
+
repo: input.repo,
|
|
325
|
+
ref: input.ref,
|
|
326
|
+
});
|
|
327
|
+
gamePath = result.entryPath;
|
|
328
|
+
squishVersion = result.squishVersion;
|
|
329
|
+
cleanupFn = result.cleanup;
|
|
330
|
+
} else {
|
|
331
|
+
throw new Error('startSession requires one of: code, gamePath, or versionId');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const squishPkg = squishMap[squishVersion] || squishMap[DEFAULT_SQUISH_VERSION];
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
// Allowlist environment variables for child processes.
|
|
338
|
+
// Only pass what the child needs — avoid leaking secrets
|
|
339
|
+
// (e.g. FORGEJO_ADMIN_TOKEN, auth tokens, etc.)
|
|
340
|
+
const ALLOWED_ENV_KEYS = [
|
|
341
|
+
'PATH', 'HOME', 'USER', 'LANG', 'TERM',
|
|
342
|
+
'NODE_ENV', 'NODE_PATH',
|
|
343
|
+
'SQUISH_PATH', 'API_URL', 'LOGGER_LOCATION',
|
|
344
|
+
'HTTPS_ENABLED', 'HOMENAMES_PORT', 'BEZEL_SIZE_X', 'BEZEL_SIZE_Y',
|
|
345
|
+
'DOCKER_HOST_HOSTNAME', 'GRACE_PERIOD_MS',
|
|
346
|
+
];
|
|
347
|
+
const env = {};
|
|
348
|
+
for (const key of ALLOWED_ENV_KEYS) {
|
|
349
|
+
if (process.env[key] !== undefined) {
|
|
350
|
+
env[key] = process.env[key];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Apply required overrides (these take precedence)
|
|
354
|
+
env.NODE_PATH = `${process.cwd()}${path.sep}node_modules`;
|
|
355
|
+
env.SQUISH_PATH = squishPkg;
|
|
356
|
+
// opts.env is filtered through the same allowlist — no arbitrary injection
|
|
357
|
+
if (opts.env) {
|
|
358
|
+
for (const key of ALLOWED_ENV_KEYS) {
|
|
359
|
+
if (opts.env[key] !== undefined) {
|
|
360
|
+
env[key] = opts.env[key];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const child = fork(this.childServerPath, [], {
|
|
366
|
+
env,
|
|
367
|
+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
368
|
+
execArgv: ['--max-old-space-size=64'],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
child.send(JSON.stringify({
|
|
372
|
+
key: input.gameKey || path.basename(gamePath, '.js'),
|
|
373
|
+
squishVersion,
|
|
374
|
+
gamePath,
|
|
375
|
+
port,
|
|
376
|
+
player: opts.playerId ? { id: opts.playerId } : undefined,
|
|
377
|
+
username: this.username,
|
|
378
|
+
certPath: this.certPath,
|
|
379
|
+
noFrame: input.noFrame || false,
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
const session = {
|
|
383
|
+
id: sessionId,
|
|
384
|
+
port,
|
|
385
|
+
type: 'fork',
|
|
386
|
+
child,
|
|
387
|
+
squishVersion,
|
|
388
|
+
cleanup: cleanupFn,
|
|
389
|
+
gameKey: input.gameKey || null,
|
|
390
|
+
gamePath,
|
|
391
|
+
_emptyTicks: 0,
|
|
392
|
+
requestCallbacks: {},
|
|
393
|
+
_requestIdCounter: 0,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
child.on('message', (thang) => {
|
|
397
|
+
const msg = JSON.parse(thang);
|
|
398
|
+
if (msg.success) {
|
|
399
|
+
this.sessions[sessionId] = session;
|
|
400
|
+
this._startLifecycleMonitor(sessionId);
|
|
401
|
+
this.log.info(`Session ${sessionId} started via fork() on port ${port}`);
|
|
402
|
+
resolve({ sessionId, port, type: 'fork' });
|
|
403
|
+
|
|
404
|
+
if (opts.onReady) opts.onReady(session);
|
|
405
|
+
} else if (msg.requestId && session.requestCallbacks[msg.requestId]) {
|
|
406
|
+
session.requestCallbacks[msg.requestId](msg.payload);
|
|
407
|
+
delete session.requestCallbacks[msg.requestId];
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
child.on('error', (err) => {
|
|
412
|
+
this.log.error(`Session ${sessionId} fork error: ${err.message}`);
|
|
413
|
+
if (this.sessions[sessionId]) {
|
|
414
|
+
this._cleanupSession(sessionId);
|
|
415
|
+
} else {
|
|
416
|
+
// Session never registered — release port directly
|
|
417
|
+
this.portPool.release(port);
|
|
418
|
+
if (cleanupFn) cleanupFn();
|
|
419
|
+
}
|
|
420
|
+
reject(err);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
child.on('close', () => {
|
|
424
|
+
this.log.info(`Session ${sessionId} fork closed`);
|
|
425
|
+
if (this.sessions[sessionId]) {
|
|
426
|
+
this._cleanupSession(sessionId);
|
|
427
|
+
} else {
|
|
428
|
+
// Session never registered — release port directly
|
|
429
|
+
this.portPool.release(port);
|
|
430
|
+
if (cleanupFn) cleanupFn();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// -----------------------------------------------------------------------
|
|
437
|
+
// Find the project root (nearest ancestor with node_modules or package.json)
|
|
438
|
+
// so we mount enough of the tree for relative requires to work.
|
|
439
|
+
// -----------------------------------------------------------------------
|
|
440
|
+
_findProjectRoot(filePath) {
|
|
441
|
+
let dir = path.dirname(filePath);
|
|
442
|
+
const root = path.parse(dir).root;
|
|
443
|
+
|
|
444
|
+
while (dir !== root) {
|
|
445
|
+
if (fs.existsSync(path.join(dir, 'node_modules')) || fs.existsSync(path.join(dir, 'package.json'))) {
|
|
446
|
+
return dir;
|
|
447
|
+
}
|
|
448
|
+
dir = path.dirname(dir);
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// -----------------------------------------------------------------------
|
|
454
|
+
// Wait for a port to become reachable (container startup)
|
|
455
|
+
// -----------------------------------------------------------------------
|
|
456
|
+
_waitForPort(port, timeoutMs = 15000) {
|
|
457
|
+
const net = require('net');
|
|
458
|
+
const start = Date.now();
|
|
459
|
+
const interval = 500;
|
|
460
|
+
|
|
461
|
+
return new Promise((resolve, reject) => {
|
|
462
|
+
const check = () => {
|
|
463
|
+
if (Date.now() - start > timeoutMs) {
|
|
464
|
+
return reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const socket = new net.Socket();
|
|
468
|
+
socket.setTimeout(1000);
|
|
469
|
+
socket.once('connect', () => {
|
|
470
|
+
socket.destroy();
|
|
471
|
+
resolve();
|
|
472
|
+
});
|
|
473
|
+
socket.once('error', () => {
|
|
474
|
+
socket.destroy();
|
|
475
|
+
setTimeout(check, interval);
|
|
476
|
+
});
|
|
477
|
+
socket.once('timeout', () => {
|
|
478
|
+
socket.destroy();
|
|
479
|
+
setTimeout(check, interval);
|
|
480
|
+
});
|
|
481
|
+
socket.connect(port, '127.0.0.1');
|
|
482
|
+
};
|
|
483
|
+
check();
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
_waitForWebSocket(port, timeoutMs = 10000) {
|
|
488
|
+
const WebSocket = require('ws');
|
|
489
|
+
const start = Date.now();
|
|
490
|
+
const interval = 500;
|
|
491
|
+
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const attempt = () => {
|
|
494
|
+
if (Date.now() - start > timeoutMs) {
|
|
495
|
+
return reject(new Error(`WebSocket on port ${port} not ready after ${timeoutMs}ms`));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
499
|
+
ws.once('open', () => {
|
|
500
|
+
ws.close();
|
|
501
|
+
resolve();
|
|
502
|
+
});
|
|
503
|
+
ws.once('error', () => {
|
|
504
|
+
ws.close();
|
|
505
|
+
setTimeout(attempt, interval);
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
attempt();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// -----------------------------------------------------------------------
|
|
513
|
+
// Lifecycle monitor — kill sessions with no players after grace period
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
_startLifecycleMonitor(sessionId) {
|
|
516
|
+
const session = this.sessions[sessionId];
|
|
517
|
+
if (!session) return;
|
|
518
|
+
|
|
519
|
+
session._lifecycleInterval = setInterval(async () => {
|
|
520
|
+
const s = this.sessions[sessionId];
|
|
521
|
+
if (!s) {
|
|
522
|
+
clearInterval(session._lifecycleInterval);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (s.type === 'docker') {
|
|
527
|
+
const running = await isContainerRunning(s.containerId);
|
|
528
|
+
this.log.info(`Session ${sessionId} lifecycle check: container running = ${running}`);
|
|
529
|
+
if (!running) {
|
|
530
|
+
this.log.info(`Session ${sessionId} container exited`);
|
|
531
|
+
this._cleanupSession(sessionId);
|
|
532
|
+
}
|
|
533
|
+
} else if (s.type === 'fork') {
|
|
534
|
+
// For fork sessions, send heartbeat. The child_game_server.js
|
|
535
|
+
// already has its own checkPulse logic.
|
|
536
|
+
try {
|
|
537
|
+
s.child.send(JSON.stringify({ type: 'heartbeat' }));
|
|
538
|
+
} catch (err) {
|
|
539
|
+
// Child already dead
|
|
540
|
+
this._cleanupSession(sessionId);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}, this.lifecycleCheckMs);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// -----------------------------------------------------------------------
|
|
547
|
+
// Stop a session
|
|
548
|
+
// -----------------------------------------------------------------------
|
|
549
|
+
async stopSession(sessionId) {
|
|
550
|
+
const session = this.sessions[sessionId];
|
|
551
|
+
if (!session) return;
|
|
552
|
+
|
|
553
|
+
if (session.type === 'docker') {
|
|
554
|
+
await stopContainer(session.containerId);
|
|
555
|
+
} else if (session.type === 'fork') {
|
|
556
|
+
try { session.child.kill(); } catch (e) {}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this._cleanupSession(sessionId);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
_cleanupSession(sessionId) {
|
|
563
|
+
const session = this.sessions[sessionId];
|
|
564
|
+
if (!session) return;
|
|
565
|
+
|
|
566
|
+
if (session._lifecycleInterval) {
|
|
567
|
+
clearInterval(session._lifecycleInterval);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Resolve any pending request callbacks so callers don't hang
|
|
571
|
+
if (session.requestCallbacks) {
|
|
572
|
+
for (const reqId in session.requestCallbacks) {
|
|
573
|
+
try { session.requestCallbacks[reqId](null); } catch (e) {}
|
|
574
|
+
}
|
|
575
|
+
session.requestCallbacks = {};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
this.portPool.release(session.port);
|
|
579
|
+
|
|
580
|
+
if (session.cleanup) {
|
|
581
|
+
session.cleanup();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
delete this.sessions[sessionId];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// -----------------------------------------------------------------------
|
|
588
|
+
// Query helpers
|
|
589
|
+
// -----------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
getSession(sessionId) {
|
|
592
|
+
return this.sessions[sessionId] || null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
findSessionByPort(port) {
|
|
596
|
+
for (const id in this.sessions) {
|
|
597
|
+
if (this.sessions[id].port === port) return this.sessions[id];
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
findSessionsByGame(gameKey) {
|
|
603
|
+
return Object.values(this.sessions).filter(s => s.gameKey === gameKey);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
listSessions() {
|
|
607
|
+
return Object.values(this.sessions).map(s => ({
|
|
608
|
+
id: s.id,
|
|
609
|
+
port: s.port,
|
|
610
|
+
type: s.type,
|
|
611
|
+
squishVersion: s.squishVersion,
|
|
612
|
+
gameKey: s.gameKey || null,
|
|
613
|
+
gameId: s.gameId || null,
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Send a message to a forked child session (no-op for Docker sessions).
|
|
619
|
+
*/
|
|
620
|
+
sendToSession(sessionId, msg) {
|
|
621
|
+
const session = this.sessions[sessionId];
|
|
622
|
+
if (session && session.type === 'fork' && session.child) {
|
|
623
|
+
session.child.send(JSON.stringify(msg));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Request data from a forked child session (e.g., getPlayers).
|
|
629
|
+
*/
|
|
630
|
+
requestFromSession(sessionId, apiName) {
|
|
631
|
+
return new Promise((resolve, reject) => {
|
|
632
|
+
const session = this.sessions[sessionId];
|
|
633
|
+
if (!session) {
|
|
634
|
+
resolve(null);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (session.type === 'docker') {
|
|
639
|
+
// Docker sessions: use HTTP API on the session's port
|
|
640
|
+
const apiPath = apiName === 'getPlayers' ? '/api/players' : `/api/${apiName}`;
|
|
641
|
+
const req = http.get(`http://localhost:${session.port}${apiPath}`, (res) => {
|
|
642
|
+
let buf = '';
|
|
643
|
+
res.on('data', (chunk) => { buf += chunk; });
|
|
644
|
+
res.on('end', () => {
|
|
645
|
+
try { resolve(JSON.parse(buf)); } catch (e) { resolve(null); }
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
req.on('error', () => resolve(null));
|
|
649
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (session.type !== 'fork') {
|
|
654
|
+
resolve(null);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const requestId = ++session._requestIdCounter;
|
|
659
|
+
session.requestCallbacks[requestId] = resolve;
|
|
660
|
+
session.child.send(JSON.stringify({ api: apiName, requestId }));
|
|
661
|
+
|
|
662
|
+
// Timeout after 5 seconds
|
|
663
|
+
setTimeout(() => {
|
|
664
|
+
if (session.requestCallbacks[requestId]) {
|
|
665
|
+
delete session.requestCallbacks[requestId];
|
|
666
|
+
resolve(null);
|
|
667
|
+
}
|
|
668
|
+
}, 5000);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Get a log stream for a session.
|
|
674
|
+
* For Docker sessions: streams container logs.
|
|
675
|
+
* For fork sessions: returns the child's stdout/stderr (if captured).
|
|
676
|
+
* Returns { type: 'docker'|'fork', stream?, child? }
|
|
677
|
+
*/
|
|
678
|
+
async getSessionLogStream(sessionId) {
|
|
679
|
+
const session = this.sessions[sessionId];
|
|
680
|
+
if (!session) return null;
|
|
681
|
+
|
|
682
|
+
if (session.type === 'docker') {
|
|
683
|
+
const { streamContainerLogs } = require('./docker-helper');
|
|
684
|
+
const { logStream, demuxDockerLogs } = await streamContainerLogs(session.containerId);
|
|
685
|
+
return { type: 'docker', logStream, demuxDockerLogs };
|
|
686
|
+
} else if (session.type === 'fork') {
|
|
687
|
+
return { type: 'fork', child: session.child };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
module.exports = GameSessionManager;
|