diff --git a/lib/process-manager.ts b/lib/process-manager.ts index 81c1cdcf7f..1ec2419b60 100644 --- a/lib/process-manager.ts +++ b/lib/process-manager.ts @@ -95,7 +95,7 @@ class RawSubprocessStream extends Streams.ObjectReadWriteStream { } interface ProcessWrapper { - load: number; + getLoad: () => number; process: ChildProcess | Worker; release: () => Promise; getProcess: () => ChildProcess; @@ -144,7 +144,7 @@ export class QueryProcessWrapper implements ProcessWrapper { this.pendingTasks.delete(taskId); resolve(JSON.parse(message.slice(nlLoc + 1))); - if (this.resolveRelease && !this.load) this.destroy(); + if (this.resolveRelease && !this.getLoad()) this.destroy(); }); } @@ -152,7 +152,7 @@ export class QueryProcessWrapper implements ProcessWrapper { return this.process; } - get load() { + getLoad() { return this.pendingTasks.size; } @@ -167,7 +167,7 @@ export class QueryProcessWrapper implements ProcessWrapper { release(): Promise { if (this.pendingRelease) return this.pendingRelease; - if (!this.load) { + if (!this.getLoad()) { this.destroy(); } else { this.pendingRelease = new Promise(resolve => { @@ -260,6 +260,10 @@ export class StreamProcessWrapper implements ProcessWrapper { }); } + getLoad() { + return this.activeStreams.size; + } + getProcess() { return this.process; } @@ -267,11 +271,7 @@ export class StreamProcessWrapper implements ProcessWrapper { deleteStream(taskId: number) { this.activeStreams.delete(taskId); // try to release - if (this.resolveRelease && !this.load) void this.destroy(); - } - - get load() { - return this.activeStreams.size; + if (this.resolveRelease && !this.getLoad()) void this.destroy(); } createStream(): SubprocessStream { @@ -284,7 +284,7 @@ export class StreamProcessWrapper implements ProcessWrapper { release(): Promise { if (this.pendingRelease) return this.pendingRelease; - if (!this.load) { + if (!this.getLoad()) { void this.destroy(); } else { this.pendingRelease = new Promise(resolve => { @@ -361,13 +361,16 @@ export class RawProcessWrapper implements ProcessWrapper, StreamWorker { this.stream = new RawSubprocessStream(this); } + getLoad() { + return this.load; + } getProcess() { return this.process.process ? this.process.process : this.process; } release(): Promise { if (this.pendingRelease) return this.pendingRelease; - if (!this.load) { + if (!this.getLoad()) { void this.destroy(); } else { this.pendingRelease = new Promise(resolve => { @@ -417,7 +420,7 @@ export abstract class ProcessManager } let lowestLoad = this.processes[0]; for (const process of this.processes) { - if (process.load < lowestLoad.load) { + if (process.getLoad() < lowestLoad.getLoad()) { lowestLoad = process; } } diff --git a/server/chat-commands/admin.ts b/server/chat-commands/admin.ts index e5f8add13b..b3106ea0b9 100644 --- a/server/chat-commands/admin.ts +++ b/server/chat-commands/admin.ts @@ -34,6 +34,26 @@ function bash(command: string, context: CommandContext, cwd?: string): Promise<[ }); } +function keysIncludingNonEnumerable(obj: object) { + const methods = new Set(); + let current = obj; + do { + const curProps = Object.getOwnPropertyNames(current); + for (const prop of curProps) { + methods.add(prop); + } + } while ((current = Object.getPrototypeOf(current))); + return [...methods]; +} + +function keysToCopy(obj: object) { + return keysIncludingNonEnumerable(obj).filter( + // `__` matches sucrase init methods + // FIXME: document what 'prop' is for + prop => !(prop.includes('__') || prop.toLowerCase().includes('prop') || ['valueOf', 'constructor'].includes(prop)) + ); +} + /** * @returns {boolean} Whether or not the rebase failed */ @@ -406,7 +426,10 @@ export const commands: ChatCommands = { await rebuild(this); const lock = Monitor.hotpatchLock; - const hotpatches = ['chat', 'formats', 'loginserver', 'punishments', 'dnsbl', 'modlog']; + const hotpatches = [ + 'chat', 'formats', 'loginserver', 'punishments', 'dnsbl', 'modlog', + 'processmanager', 'roomsp', 'usersp', + ]; try { Utils.clearRequireCache({exclude: ['/.lib-dist/process-manager']}); @@ -422,12 +445,13 @@ export const commands: ChatCommands = { await this.parse(`/hotpatch ${hotpatch}`); } } else if (target === 'chat' || target === 'commands') { - if (lock['chat']) { - return this.errorReply(`Hot-patching chat has been disabled by ${lock['chat'].by} (${lock['chat'].reason})`); - } if (lock['tournaments']) { return this.errorReply(`Hot-patching tournaments has been disabled by ${lock['tournaments'].by} (${lock['tournaments'].reason})`); } + if (lock['chat']) { + return this.errorReply(`Hot-patching chat has been disabled by ${lock['chat'].by} (${lock['chat'].reason})`); + } + this.sendReply("Hotpatching chat commands..."); const disabledCommands = Chat.allCommands().filter(c => c.disabled).map(c => `/${c.fullCmd}`); @@ -456,11 +480,100 @@ export const commands: ChatCommands = { this.sendReply("Reloading chat plugins..."); Chat.loadPlugins(oldPlugins); this.sendReply("DONE"); + } else if (target === 'processmanager') { + if (lock['processmanager']) { + return this.errorReply( + `Hot-patching formats has been disabled by ${lock['processmanager'].by} ` + + `(${lock['processmanager'].reason})` + ); + } + this.sendReply('Hotpatching processmanager prototypes...'); + + // keep references + const cache = {...require.cache}; + Utils.clearRequireCache(); + const newPM = require('../../lib/process-manager'); + require.cache = cache; + + const protos = [ + [ProcessManager.QueryProcessManager, newPM.QueryProcessManager], + [ProcessManager.StreamProcessManager, newPM.StreamProcessManager], + [ProcessManager.ProcessManager, newPM.ProcessManager], + [ProcessManager.RawProcessManager, newPM.RawProcessManager], + [ProcessManager.QueryProcessWrapper, newPM.QueryProcessWrapper], + [ProcessManager.StreamProcessWrapper, newPM.StreamProcessWrapper], + [ProcessManager.RawProcessManager, newPM.RawProcessWrapper], + ].map(part => part.map(constructor => constructor.prototype)); + + for (const [oldProto, newProto] of protos) { + const newKeys = keysToCopy(newProto); + const oldKeys = keysToCopy(oldProto); + for (const key of oldKeys) { + if (!newProto[key]) { + delete oldProto[key]; + } + } + for (const key of newKeys) { + oldProto[key] = newProto[key]; + } + } + this.sendReply('DONE'); + } else if (target === 'usersp' || target === 'roomsp') { + if (lock[target]) { + return this.errorReply(`Hot-patching ${target} has been disabled by ${lock[target].by} (${lock[target].reason})`); + } + let newProto: any, oldProto: any, message: string; + switch (target) { + case 'usersp': + newProto = require('../users').User.prototype; + oldProto = Users.User.prototype; + message = 'user prototypes'; + break; + case 'roomsp': + newProto = require('../rooms').BasicRoom.prototype; + oldProto = Rooms.BasicRoom.prototype; + message = 'rooms prototypes'; + break; + } + + this.sendReply(`Hotpatching ${message}...`); + const newKeys = keysToCopy(newProto); + const oldKeys = keysToCopy(oldProto); + + const counts = { + added: 0, + updated: 0, + deleted: 0, + }; + + for (const key of oldKeys) { + if (!newProto[key]) { + counts.deleted++; + delete oldProto[key]; + } + } + for (const key of newKeys) { + if (!oldProto[key]) { + counts.added++; + } else if ( + // compare source code + typeof oldProto[key] !== 'function' || oldProto[key].toString() !== newProto[key].toString() + ) { + counts.updated++; + } + + oldProto[key] = newProto[key]; + } + this.sendReply(`DONE`); + this.sendReply( + `Updated ${Chat.count(counts.updated, 'methods')}` + + (counts.added ? `, added ${Chat.count(counts.added, 'new methods')} to ${message}` : '') + + (counts.deleted ? `, and removed ${Chat.count(counts.deleted, 'methods')}.` : '.') + ); } else if (target === 'tournaments') { if (lock['tournaments']) { return this.errorReply(`Hot-patching tournaments has been disabled by ${lock['tournaments'].by} (${lock['tournaments'].reason})`); } - this.sendReply("Hotpatching tournaments..."); global.Tournaments = require('../tournaments').Tournaments; @@ -587,7 +700,10 @@ export const commands: ChatCommands = { if (!reason || !target.includes(separator)) return this.parse('/help nohotpatch'); const lock = Monitor.hotpatchLock; - const validDisable = ['chat', 'battles', 'formats', 'validator', 'tournaments', 'punishments', 'modlog', 'all']; + const validDisable = [ + 'roomsp', 'usersp', 'chat', 'battles', 'formats', 'validator', + 'tournaments', 'punishments', 'modlog', 'all', 'processmanager', + ]; if (!validDisable.includes(hotpatch)) { return this.errorReply(`Disabling hotpatching "${hotpatch}" is not supported.`); @@ -648,7 +764,7 @@ export const commands: ChatCommands = { for (const manager of ProcessManager.processManagers) { for (const [i, process] of manager.processes.entries()) { const pid = process.getProcess().pid; - buf += `${pid} - ${manager.basename} ${i} (load ${process.load}`; + buf += `${pid} - ${manager.basename} ${i} (load ${process.getLoad()}`; const info = processes.get(`${pid}`)!; if (info.cpu) buf += `, CPU: ${info.cpu}`; if (info.time) buf += `, time: ${info.time}`; @@ -657,7 +773,7 @@ export const commands: ChatCommands = { } for (const [i, process] of manager.releasingProcesses.entries()) { const pid = process.getProcess().pid; - buf += `${pid} - PENDING RELEASE ${manager.basename} ${i} (load ${process.load}`; + buf += `${pid} - PENDING RELEASE ${manager.basename} ${i} (load ${process.getLoad()}`; const info = processes.get(`${pid}`)!; if (info.cpu) buf += `, CPU: ${info.cpu}`; if (info.time) buf += `, time: ${info.time}`; diff --git a/test/server/room-battle.js b/test/server/room-battle.js index 9cd89a2880..c28f460265 100644 --- a/test/server/room-battle.js +++ b/test/server/room-battle.js @@ -40,10 +40,10 @@ describe('Simulator abstraction layer features', function () { const PM = require('../../.server-dist/room-battle').PM; assert.equal(PM.processes.length, 0); PM.spawn(1, true); - assert.equal(PM.processes[0].load, 0); + assert.equal(PM.processes[0].getLoad(), 0); const stream = PM.createStream(); - assert.equal(PM.processes[0].load, 1); + assert.equal(PM.processes[0].getLoad(), 1); stream.write( '>version a2393dfd2a2da5594148bf99eea514e72b136c2c\n' + '>start {"formatid":"gen8randombattle","seed":[9619,36790,28450,62465],"rated":"Rated battle"}\n' + @@ -61,10 +61,10 @@ describe('Simulator abstraction layer features', function () { assert((await stream.read()).startsWith('sideupdate\np2\n|request|')); assert((await stream.read()).includes('|move|')); stream.destroy(); - assert.equal(PM.processes[0].load, 0); + assert.equal(PM.processes[0].getLoad(), 0); const stream2 = PM.createStream(); - assert.equal(PM.processes[0].load, 1); + assert.equal(PM.processes[0].getLoad(), 1); stream2.write( '>version a2393dfd2a2da5594148bf99eea514e72b136c2c\n' + '>start {"formatid":"gen8randombattle","seed":[9619,36790,28450,62465],"rated":"Rated battle"}\n' + @@ -76,7 +76,7 @@ describe('Simulator abstraction layer features', function () { assert(await stream2.read()); stream2.writeEnd(); await stream2.readAll(); - assert.equal(PM.processes[0].load, 0); + assert.equal(PM.processes[0].getLoad(), 0); PM.unspawn(); }); });