addons/isl-server/src/utils.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {CommitInfo, SmartlogCommits} from 'isl/src/types';
b69ab319import type {EjecaChildProcess, EjecaError, EjecaReturn} from 'shared/ejeca';
b69ab3110
b69ab3111import os from 'node:os';
b69ab3112import {truncate} from 'shared/utils';
b69ab3113
b69ab3114export function sleep(timeMs: number): Promise<void> {
b69ab3115 return new Promise(res => setTimeout(res, timeMs));
b69ab3116}
b69ab3117
b69ab3118export function firstOfIterable<T>(iterable: IterableIterator<T>): T | undefined {
b69ab3119 return iterable.next().value;
b69ab3120}
b69ab3121
b69ab3122/**
b69ab3123 * Limits async function execution parallelism to only one at a time.
b69ab3124 * Hence, if a call is already running, it will wait for it to finish,
b69ab3125 * then start the next async execution, but if called again while not finished,
b69ab3126 * it will return the scheduled execution promise.
b69ab3127 *
b69ab3128 * Sample Usage:
b69ab3129 * ```
b69ab3130 * let i = 1;
b69ab3131 * const oneExecAtATime = serializeAsyncCall(() => {
b69ab3132 * return new Promise((resolve, reject) => {
b69ab3133 * setTimeout(200, () => resolve(i++));
b69ab3134 * });
b69ab3135 * });
b69ab3136 *
b69ab3137 * const result1Promise = oneExecAtATime(); // Start an async, and resolve to 1 in 200 ms.
b69ab3138 * const result2Promise = oneExecAtATime(); // Schedule the next async, and resolve to 2 in 400 ms.
b69ab3139 * const result3Promise = oneExecAtATime(); // Reuse scheduled promise and resolve to 2 in 400 ms.
b69ab3140 * ```
b69ab3141 */
b69ab3142export function serializeAsyncCall<T>(asyncFun: () => Promise<T>): () => Promise<T> {
b69ab3143 let scheduledCall: Promise<T> | undefined = undefined;
b69ab3144 let pendingCall: Promise<undefined> | undefined = undefined;
b69ab3145 const startAsyncCall = () => {
b69ab3146 const resultPromise = asyncFun();
b69ab3147 pendingCall = resultPromise.then(
b69ab3148 () => (pendingCall = undefined),
b69ab3149 () => (pendingCall = undefined),
b69ab3150 );
b69ab3151 return resultPromise;
b69ab3152 };
b69ab3153 const callNext = () => {
b69ab3154 scheduledCall = undefined;
b69ab3155 return startAsyncCall();
b69ab3156 };
b69ab3157 const scheduleNextCall = () => {
b69ab3158 if (scheduledCall == null) {
b69ab3159 if (pendingCall == null) {
b69ab3160 throw new Error('pendingCall must not be null!');
b69ab3161 }
b69ab3162 scheduledCall = pendingCall.then(callNext, callNext);
b69ab3163 }
b69ab3164 return scheduledCall;
b69ab3165 };
b69ab3166 return () => {
b69ab3167 if (pendingCall == null) {
b69ab3168 return startAsyncCall();
b69ab3169 } else {
b69ab3170 return scheduleNextCall();
b69ab3171 }
b69ab3172 };
b69ab3173}
b69ab3174
b69ab3175/**
b69ab3176 * Kill `child` on `AbortSignal`.
b69ab3177 *
b69ab3178 * This is slightly more robust than execa 6.0 and nodejs' `signal` support:
b69ab3179 * if a process was stopped (by `SIGTSTP` or `SIGSTOP`), it can still be killed.
b69ab3180 */
b69ab3181export function handleAbortSignalOnProcess(child: EjecaChildProcess, signal: AbortSignal) {
b69ab3182 signal.addEventListener('abort', () => {
b69ab3183 if (os.platform() == 'win32') {
b69ab3184 // Signals are ignored on Windows.
b69ab3185 // execa's default forceKillAfterTimeout behavior does not
b69ab3186 // make sense for Windows. Disable it explicitly.
b69ab3187 child.kill('SIGKILL', {forceKillAfterTimeout: false});
b69ab3188 } else {
b69ab3189 // If the process is stopped (ex. Ctrl+Z, kill -STOP), make it
b69ab3190 // continue first so it can respond to signals including SIGKILL.
b69ab3191 child.kill('SIGCONT');
b69ab3192 // A good citizen process should exit soon after receiving SIGTERM.
b69ab3193 // In case it doesn't, send SIGKILL after 5 seconds.
b69ab3194 child.kill('SIGTERM', {forceKillAfterTimeout: 5000});
b69ab3195 }
b69ab3196 });
b69ab3197}
b69ab3198
b69ab3199/**
b69ab31100 * Given a list of commits and a starting commit,
b69ab31101 * traverse up the chain of `parents` until we find a public commit
b69ab31102 */
b69ab31103export function findPublicAncestor(
b69ab31104 allCommits: SmartlogCommits | undefined,
b69ab31105 from: CommitInfo,
b69ab31106): CommitInfo | undefined {
b69ab31107 let publicCommit: CommitInfo | undefined;
b69ab31108 if (allCommits != null) {
b69ab31109 const map = new Map(allCommits.map(commit => [commit.hash, commit]));
b69ab31110
b69ab31111 let current: CommitInfo | undefined = from;
b69ab31112 while (current != null) {
b69ab31113 if (current.phase === 'public') {
b69ab31114 publicCommit = current;
b69ab31115 break;
b69ab31116 }
b69ab31117 if (current.parents[0] == null) {
b69ab31118 break;
b69ab31119 }
b69ab31120 current = map.get(current.parents[0]);
b69ab31121 }
b69ab31122 }
b69ab31123
b69ab31124 return publicCommit;
b69ab31125}
b69ab31126
b69ab31127/**
b69ab31128 * Run a command that is expected to produce JSON output.
b69ab31129 * Return a JSON object. On error, the JSON object has property "error".
b69ab31130 */
b69ab31131export function parseExecJson<T>(
b69ab31132 exec: Promise<EjecaReturn>,
b69ab31133 reply: (parsed?: T, error?: string) => void,
b69ab31134) {
b69ab31135 exec
b69ab31136 .then(result => {
b69ab31137 const stdout = result.stdout;
b69ab31138 try {
b69ab31139 const parsed = JSON.parse(stdout);
b69ab31140 if (parsed.error != null) {
b69ab31141 reply(undefined, parsed.error);
b69ab31142 } else {
b69ab31143 reply(parsed as T);
b69ab31144 }
b69ab31145 } catch (err) {
b69ab31146 const msg = `Cannot parse ${truncate(
b69ab31147 result.escapedCommand,
b69ab31148 )} output. (error: ${err}, stdout: ${stdout})`;
b69ab31149 reply(undefined, msg);
b69ab31150 }
b69ab31151 })
b69ab31152 .catch(err => {
b69ab31153 // Try extracting error from stdout '{error: message}'.
b69ab31154 try {
b69ab31155 const parsed = JSON.parse(err.stdout);
b69ab31156 if (parsed.error != null) {
b69ab31157 reply(undefined, parsed.error);
b69ab31158 return;
b69ab31159 }
b69ab31160 } catch {}
b69ab31161 // Fallback to general error.
b69ab31162 const msg = `Cannot run ${truncate(err.escapedCommand)}. (error: ${err})`;
b69ab31163 reply(undefined, msg);
b69ab31164 });
b69ab31165}
b69ab31166
b69ab31167export type EjecaSpawnError = Error & {code: string; path: string};
b69ab31168
b69ab31169/**
b69ab31170 * True if an Ejeca spawned process exits non-zero or is killed.
b69ab31171 * @see {EjecaSpawnError} for when a process fails to spawn in the first place (e.g. ENOENT).
b69ab31172 */
b69ab31173export function isEjecaError(s: unknown): s is EjecaError {
b69ab31174 return s != null && typeof s === 'object' && 'exitCode' in s;
b69ab31175}
b69ab31176
b69ab31177/** True when Ejeca fails to spawn a process, e.g. ENOENT.
b69ab31178 * (as opposed to the command spawning, then exiting non-zero) */
b69ab31179export function isEjecaSpawnError(s: unknown): s is EjecaSpawnError {
b69ab31180 return s != null && typeof s === 'object' && 'code' in s;
b69ab31181}
b69ab31182
b69ab31183export function fromEntries<V>(entries: Array<[string, V]>): {
b69ab31184 [key: string]: V;
b69ab31185} {
b69ab31186 // Object.fromEntries() is available in Node v12 and later.
b69ab31187 if (typeof Object.fromEntries === 'function') {
b69ab31188 return Object.fromEntries(entries);
b69ab31189 }
b69ab31190
b69ab31191 const obj: {
b69ab31192 [key: string]: V;
b69ab31193 } = {};
b69ab31194 for (const [key, value] of entries) {
b69ab31195 obj[key] = value;
b69ab31196 }
b69ab31197 return obj;
b69ab31198}