diff options
-rw-r--r-- | src/server/ActionUtilities.ts | 24 | ||||
-rw-r--r-- | src/server/ApiManagers/SessionManager.ts | 22 | ||||
-rw-r--r-- | src/server/DashSession.ts | 53 | ||||
-rw-r--r-- | src/server/Session/session.ts | 79 |
4 files changed, 137 insertions, 41 deletions
diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts index f0bfbc525..60f66c878 100644 --- a/src/server/ActionUtilities.ts +++ b/src/server/ActionUtilities.ts @@ -119,16 +119,24 @@ export namespace Email { } }); + export interface DispatchOptions<T extends string | string[]> { + to: T; + subject: string; + content: string; + attachments?: Mail.Attachment | Mail.Attachment[]; + } + export interface DispatchFailure { recipient: string; error: Error; } - export async function dispatchAll(recipients: string[], subject: string, content: string) { + export async function dispatchAll({ to, subject, content, attachments }: DispatchOptions<string[]>) { const failures: DispatchFailure[] = []; - await Promise.all(recipients.map(async (recipient: string) => { + await Promise.all(to.map(async recipient => { let error: Error | null; - if ((error = await Email.dispatch(recipient, subject, content)) !== null) { + const resolved = attachments ? "length" in attachments ? attachments : [attachments] : undefined; + if ((error = await Email.dispatch({ to: recipient, subject, content, attachments: resolved })) !== null) { failures.push({ recipient, error @@ -138,17 +146,15 @@ export namespace Email { return failures.length ? failures : undefined; } - export async function dispatch(recipient: string, subject: string, content: string, attachments?: Mail.Attachment[]): Promise<Error | null> { + export async function dispatch({ to, subject, content, attachments }: DispatchOptions<string>): Promise<Error | null> { const mailOptions = { - to: recipient, + to, from: 'brownptcdash@gmail.com', subject, - text: `Hello ${recipient.split("@")[0]},\n\n${content}`, + text: `Hello ${to.split("@")[0]},\n\n${content}`, attachments } as MailOptions; - return new Promise<Error | null>(resolve => { - smtpTransport.sendMail(mailOptions, resolve); - }); + return new Promise<Error | null>(resolve => smtpTransport.sendMail(mailOptions, resolve)); } }
\ No newline at end of file diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts index 0290b578c..6782643bc 100644 --- a/src/server/ApiManagers/SessionManager.ts +++ b/src/server/ApiManagers/SessionManager.ts @@ -19,7 +19,7 @@ export default class SessionManager extends ApiManager { if (password !== process.env.session_key) { return _permission_denied(res, permissionError); } - handler(core); + return handler(core); }; } @@ -28,11 +28,15 @@ export default class SessionManager extends ApiManager { register({ method: Method.GET, subscription: this.secureSubscriber("debug", "mode", "recipient"), - secureHandler: this.authorizedAction(({ req, res }) => { + secureHandler: this.authorizedAction(async ({ req, res }) => { const { mode, recipient } = req.params; if (["passive", "active"].includes(mode)) { - sessionAgent.serverWorker.sendMonitorAction("debug", { mode, recipient }); - res.send(`Your request was successful: the server is ${mode === "active" ? "creating and compressing a new" : "retrieving and compressing the most recent"} back up. It will be sent to ${recipient}.`); + const response = await sessionAgent.serverWorker.sendMonitorAction("debug", { mode, recipient }, true); + if (response instanceof Error) { + res.send(response); + } else { + res.send(`Your request was successful: the server ${mode === "active" ? "created and compressed a new" : "retrieved and compressed the most recent"} back up. It was sent to ${recipient}.`); + } } else { res.send(`Your request failed. '${mode}' is not a valid mode: please choose either 'active' or 'passive'`); } @@ -42,9 +46,13 @@ export default class SessionManager extends ApiManager { register({ method: Method.GET, subscription: this.secureSubscriber("backup"), - secureHandler: this.authorizedAction(({ res }) => { - sessionAgent.serverWorker.sendMonitorAction("backup"); - res.send(`Your request was successful: the server is creating a new back up.`); + secureHandler: this.authorizedAction(async ({ res }) => { + const response = await sessionAgent.serverWorker.sendMonitorAction("backup"); + if (response instanceof Error) { + res.send(response); + } else { + res.send("Your request was successful: the server successfully created a new back up."); + } }) }); diff --git a/src/server/DashSession.ts b/src/server/DashSession.ts index 56610874e..b8686a5e9 100644 --- a/src/server/DashSession.ts +++ b/src/server/DashSession.ts @@ -20,6 +20,13 @@ export class DashSessionAgent extends Session.AppliedSessionAgent { private readonly notificationRecipients = ["samuel_wilkins@brown.edu"]; private readonly signature = "-Dash Server Session Manager"; private readonly releaseDesktop = pathFromRoot("../../Desktop"); + private _instructions: string | undefined; + private get instructions() { + if (!this._instructions) { + this._instructions = readFileSync(resolve(__dirname, "./remote_debug_instructions.txt"), { encoding: "utf8" }); + } + return this._instructions; + } protected async launchMonitor() { const monitor = Session.Monitor.Create(this.notifiers); @@ -43,7 +50,11 @@ export class DashSessionAgent extends Session.AppliedSessionAgent { // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone // to kill the server via the /kill/:key route const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${this.signature}`; - const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Release Session Admin Authentication Key", content); + const failures = await Email.dispatchAll({ + to: this.notificationRecipients, + subject: "Dash Release Session Admin Authentication Key", + content + }); if (failures) { failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`))); return false; @@ -59,7 +70,11 @@ export class DashSessionAgent extends Session.AppliedSessionAgent { "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.", ].join("\n\n"); const content = `${body}\n\n${this.signature}`; - const failures = await Email.dispatchAll(this.notificationRecipients, "Dash Web Server Crash", content); + const failures = await Email.dispatchAll({ + to: this.notificationRecipients, + subject: "Dash Web Server Crash", + content + }); if (failures) { failures.map(({ recipient, error: { message } }) => this.sessionMonitor.mainLog(red(`dispatch failure @ ${recipient} (${yellow(message)})`))); return false; @@ -95,23 +110,30 @@ export class DashSessionAgent extends Session.AppliedSessionAgent { private backup = async () => this.sessionMonitor.exec("backup.bat", { cwd: this.releaseDesktop }); - private async dispatchZippedDebugBackup(mode: string, recipient: string) { + private async dispatchZippedDebugBackup(mode: string, to: string) { const { mainLog } = this.sessionMonitor; try { + // if desired, complete an immediate backup to send if (mode === "active") { await this.backup(); + mainLog("backup complete"); } - mainLog("backup complete"); + + // ensure the directory for compressed backups exists const backupsDirectory = `${this.releaseDesktop}/backups`; const compressedDirectory = `${this.releaseDesktop}/compressed`; if (!existsSync(compressedDirectory)) { mkdirSync(compressedDirectory); } + + // sort all backups by their modified time, and choose the most recent one const target = readdirSync(backupsDirectory).map(filename => ({ modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs, filename })).sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename; mainLog(`targeting ${target}...`); + + // create a zip file and to it, write the contents of the backup directory const zipName = `${target}.zip`; const zipPath = `${compressedDirectory}/${zipName}`; const output = createWriteStream(zipPath); @@ -120,15 +142,20 @@ export class DashSessionAgent extends Session.AppliedSessionAgent { zip.directory(`${backupsDirectory}/${target}/Dash`, false); await zip.finalize(); mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`); - let instructions = readFileSync(resolve(__dirname, "./remote_debug_instructions.txt"), { encoding: "utf8" }); - instructions = instructions.replace(/__zipname__/, zipName).replace(/__target__/, target).replace(/__signature__/, this.signature); - const error = await Email.dispatch(recipient, `Compressed backup of ${target}...`, instructions, [ - { - filename: zipName, - path: zipPath - } - ]); - mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(recipient)}`); + + // dispatch the email to the recipient, containing the finalized zip file + const error = await Email.dispatch({ + to, + subject: `Remote debug: compressed backup of ${target}...`, + content: this.instructions // prepare the body of the email with instructions on restoring the local database + .replace(/__zipname__/, zipName) + .replace(/__target__/, target) + .replace(/__signature__/, this.signature), + attachments: [{ filename: zipName, path: zipPath }] + }); + + // indicate success or failure + mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(to)}`); error && mainLog(red(error.message)); } catch (error) { mainLog(red("unable to dispatch zipped backup...")); diff --git a/src/server/Session/session.ts b/src/server/Session/session.ts index d46e6b6e7..7b194598b 100644 --- a/src/server/Session/session.ts +++ b/src/server/Session/session.ts @@ -133,6 +133,56 @@ export namespace Session { export type ExitHandler = (reason: Error | boolean) => void | Promise<void>; + namespace IPC { + + export const suffix = isMaster ? Utils.GenerateGuid() : process.env.ipc_suffix; + const ipc_id = `ipc_id_${suffix}`; + const response_expected = `response_expected_${suffix}`; + const is_response = `is_response_${suffix}`; + + export async function dispatchMessage(target: NodeJS.EventEmitter & { send?: Function }, message: any, expectResponse = false): Promise<Error | undefined> { + if (!target.send) { + return new Error("Cannot dispatch when send is undefined."); + } + message[response_expected] = expectResponse; + if (expectResponse) { + return new Promise(resolve => { + const messageId = Utils.GenerateGuid(); + message[ipc_id] = messageId; + const responseHandler: (args: any) => void = response => { + const { error } = response; + if (response[is_response] && response[ipc_id] === messageId) { + target.removeListener("message", responseHandler); + resolve(error); + } + }; + target.addListener("message", responseHandler); + target.send!(message); + }); + } else { + target.send(message); + } + } + + export function addMessagesHandler(target: NodeJS.EventEmitter & { send?: Function }, handler: (message: any) => void | Promise<void>): void { + target.addListener("message", async incoming => { + let error: Error | undefined; + try { + await handler(incoming); + } catch (e) { + error = e; + } + if (incoming[response_expected] && target.send) { + const response: any = { error }; + response[ipc_id] = incoming[ipc_id]; + response[is_response] = true; + target.send(response); + } + }); + } + + } + export namespace Monitor { export interface NotifierHooks { @@ -166,7 +216,7 @@ export namespace Session { public static Create(notifiers?: Monitor.NotifierHooks) { if (isWorker) { - process.send?.({ + IPC.dispatchMessage(process, { action: { message: "kill", args: { @@ -418,15 +468,15 @@ export namespace Session { repl.registerCommand("exit", [/clean|force/], args => this.killSession("manual exit requested by repl", args[0] === "clean", 0)); repl.registerCommand("restart", [/clean|force/], args => this.killActiveWorker(args[0] === "clean")); repl.registerCommand("set", [letters, "port", number, boolean], args => this.setPort(args[0], Number(args[2]), args[3] === "true")); - repl.registerCommand("set", [/polling/, number, boolean], args => { - const newPollingIntervalSeconds = Math.floor(Number(args[2])); + repl.registerCommand("set", [/polling/, number, boolean], async args => { + const newPollingIntervalSeconds = Math.floor(Number(args[1])); if (newPollingIntervalSeconds < 0) { this.mainLog(red("the polling interval must be a non-negative integer")); } else { if (newPollingIntervalSeconds !== this.config.polling.intervalSeconds) { this.config.polling.intervalSeconds = newPollingIntervalSeconds; - if (args[3] === "true") { - this.activeWorker?.send({ newPollingIntervalSeconds }); + if (args[2] === "true") { + return IPC.dispatchMessage(this.activeWorker!, { newPollingIntervalSeconds }, true); } } } @@ -442,7 +492,7 @@ export namespace Session { private killActiveWorker = (graceful = true, isSessionEnd = false): void => { if (this.activeWorker && !this.activeWorker.isDead()) { if (graceful) { - this.activeWorker.send({ manualExit: { isSessionEnd } }); + IPC.dispatchMessage(this.activeWorker, { manualExit: { isSessionEnd } }); } else { this.activeWorker.process.kill(); } @@ -487,11 +537,12 @@ export namespace Session { serverPort: ports.server, socketPort: ports.socket, pollingIntervalSeconds: intervalSeconds, - session_key: this.key + session_key: this.key, + ipc_suffix: IPC.suffix }); this.mainLog(cyan(`spawned new server worker with process id ${this.activeWorker.process.pid}`)); // an IPC message handler that executes actions on the master thread when prompted by the active worker - this.activeWorker.on("message", async ({ lifecycle, action }) => { + IPC.addMessagesHandler(this.activeWorker, async ({ lifecycle, action }) => { if (action) { const { message, args } = action as Monitor.Action; console.log(this.timestamp(), `${this.config.identifiers.worker.text} action requested (${cyan(message)})`); @@ -547,7 +598,7 @@ export namespace Session { console.error(red("cannot create a worker on the monitor process.")); process.exit(1); } else if (++ServerWorker.count > 1) { - process.send?.({ + IPC.dispatchMessage(process, { action: { message: "kill", args: { reason: "cannot create more than one worker on a given worker process.", @@ -579,7 +630,7 @@ export namespace Session { * A convenience wrapper to tell the session monitor (parent process) * to carry out the action with the specified message and arguments. */ - public sendMonitorAction = (message: string, args?: any) => process.send!({ action: { message, args } }); + public sendMonitorAction = (message: string, args?: any, expectResponse = false) => IPC.dispatchMessage(process, { action: { message, args } }, expectResponse); private constructor(work: Function) { this.lifecycleNotification(green(`initializing process... ${white(`[${process.execPath} ${process.execArgv.join(" ")}]`)}`)); @@ -601,8 +652,11 @@ export namespace Session { */ private configureProcess = () => { // updates the local values of variables to the those sent from master - process.on("message", async ({ newPollingIntervalSeconds, manualExit }) => { + IPC.addMessagesHandler(process, async ({ newPollingIntervalSeconds, manualExit }) => { if (newPollingIntervalSeconds !== undefined) { + await new Promise<void>(resolve => { + setTimeout(resolve, 1000 * 10); + }); this.pollingIntervalSeconds = newPollingIntervalSeconds; } if (manualExit !== undefined) { @@ -629,7 +683,7 @@ export namespace Session { /** * Notify master thread (which will log update in the console) of initialization via IPC. */ - public lifecycleNotification = (event: string) => process.send?.({ lifecycle: event }); + public lifecycleNotification = (event: string) => IPC.dispatchMessage(process, { lifecycle: event }); /** * Called whenever the process has a reason to terminate, either through an uncaught exception @@ -643,6 +697,7 @@ export namespace Session { // notify master thread (which will log update in the console) of crash event via IPC this.lifecycleNotification(red(`crash event detected @ ${new Date().toUTCString()}`)); this.lifecycleNotification(red(error.message)); + console.log("GAH!", error); process.exit(1); } |