All files / server/services/sandbox docker-compile-semaphore.ts

95.52% Statements 64/67
90.24% Branches 37/41
94.11% Functions 16/17
96.55% Lines 56/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142                                        16x 16x 16x   16x             14x                             51x 51x 1x 1x   50x   50x 2x 2x 2x 2x 2x 2x 2x   50x 50x       50x   51x 56x 46x 46x 46x 56x 56x 56x     51x 21x 51x 51x         46x 46x       46x 46x 33x 32x 32x 32x 5x 5x     32x           53x 17x 24x 17x 17x         40x       48x           33x             16x   4x 4x   16x    
/**
 * SandboxStartSemaphore
 *
 * A lightweight counting semaphore used to limit Docker sandbox startup
 * operations. It is acquired before docker run and released at RUNTIME_START.
 * Waiters are served oldest first, except that while other subjects wait one
 * subject holds at most half of the slots (work-conserving, no starvation).
 *
 * This prevents CPU starvation that occurs when many g++ processes compete for
 * resources on the host machine.  The semaphore is acquired before spawning a
 * Docker container and released when the startup phase transitions to runtime
 * (i.e. when [[RUNTIME_START]] is detected in stdout) or when the container
 * exits with an error.
 *
 * Environment variables: SANDBOX_START_MAX_CONCURRENT (default 8) and
 * SANDBOX_START_SLOT_TIMEOUT_MS (default 30000 ms)
 */
import { config } from "../../config";
 
export class SandboxStartSemaphore {
  private readonly queue: Array<{ attempt: () => boolean; timer: NodeJS.Timeout; subject?: string }> = [];
  private readonly activeBySubject = new Map<string, number>();
  private _active = 0;
 
  constructor(private readonly max: number) {}
 
  /**
   * Slots one subject may hold while other subjects wait. Work-conserving: with
   * no one else waiting, a subject may use every free slot.
   */
  private get subjectShare(): number {
    return Math.max(1, Math.ceil(this.max / 2));
  }
 
  /**
   * Acquire one sandbox-start slot.
   *
   * @param onQueued  Optional callback invoked exactly once when this caller is
   *                  placed in the queue (i.e. no slot is immediately available).
   * @param signal    Optional cancellation: an aborted waiter leaves the queue
   *                  and never takes a slot.
   * @param subject   Optional owner; a released slot goes to the oldest waiter whose
   *                  subject holds less than half of the slots before any other.
   * @returns         A release function.  Must be called exactly once.
   */
  acquire(onQueued?: () => void, timeoutMs = 60_000, signal?: AbortSignal, subject?: string): Promise<() => void> {
    return new Promise<() => void>((resolve, reject) => {
      if (signal?.aborted) {
        reject(new Error("Sandbox start slot acquire cancelled"));
        return;
      }
      let settled = false;
      let attempt: () => boolean;
      const leaveQueue = (error: Error) => {
        Iif (settled) return;
        const index = this.queue.findIndex((entry) => entry.attempt === attempt);
        Eif (index !== -1) this.queue.splice(index, 1);
        settled = true;
        clearTimeout(timer);
        signal?.removeEventListener("abort", onAbort);
        reject(error);
      };
      const onAbort = () => leaveQueue(new Error("Sandbox start slot acquire cancelled"));
      const timer = setTimeout(
        () => leaveQueue(new Error(`Sandbox start slot timeout after ${timeoutMs}ms`)),
        timeoutMs,
      );
      signal?.addEventListener("abort", onAbort, { once: true });
 
      attempt = () => {
        if (settled || this._active >= this.max) return false;
        settled = true;
        clearTimeout(timer);
        signal?.removeEventListener("abort", onAbort);
        this.take(subject);
        resolve(this._makeRelease(subject));
        return true;
      };
 
      if (this.queue.length === 0 && attempt()) return;
      onQueued?.();
      this.queue.push({ attempt, timer, subject });
      this.grantWaiting();
    });
  }
 
  private take(subject: string | undefined): void {
    this._active++;
    if (subject !== undefined) this.activeBySubject.set(subject, (this.activeBySubject.get(subject) ?? 0) + 1);
  }
 
  private _makeRelease(subject: string | undefined): () => void {
    let released = false;
    return () => {
      if (released) return; // idempotent
      released = true;
      this._active--;
      if (subject !== undefined) {
        const remaining = (this.activeBySubject.get(subject) ?? 1) - 1;
        if (remaining > 0) this.activeBySubject.set(subject, remaining);
        else Ethis.activeBySubject.delete(subject);
      }
      this.grantWaiting();
    };
  }
 
  /** Hands free slots to waiters: oldest within its subject's share first, otherwise oldest overall. */
  private grantWaiting(): void {
    while (this._active < this.max && this.queue.length > 0) {
      const withinShare = this.queue.findIndex(({ subject }) =>
        subject === undefined || (this.activeBySubject.get(subject) ?? 0) < this.subjectShare);
      const [next] = this.queue.splice(withinShare === -1 ? 0 : withinShare, 1);
      next?.attempt();
    }
  }
 
  get activeCount(): number {
    return this._active;
  }
 
  get queueLength(): number {
    return this.queue.length;
  }
}
 
// ─── Singleton factory ────────────────────────────────────────────────────────
 
let _instance: SandboxStartSemaphore | null = null;
 
/**
 * Returns (or lazily creates) the global sandbox-start semaphore.
 * Passing `maxOverride` replaces the configured value and resets the singleton.
 */
export function getSandboxStartSemaphore(maxOverride?: number): SandboxStartSemaphore {
  if (maxOverride !== undefined || _instance === null) {
    const max =
      maxOverride ?? config.capacity.sandboxStartMaxConcurrent;
    _instance = new SandboxStartSemaphore(max);
  }
  return _instance;
}