All files / server/services/sandbox runtime-text-limiter.ts

100% Statements 34/34
100% Branches 17/17
100% Functions 5/5
100% Lines 34/34

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      31x   31x   31x     31x                                                     23x 23x 23x     23x 23x 23x 23x 23x 23x 23x         202000x 202000x 202000x 650x 650x   201350x 201350x 201350x 2x 2x   201348x 1x   201348x       1x       202000x 202000x 202000x       3x 3x 3x      
import { Logger } from "@shared/logger";
 
/** Sustained runtime text lines per second that reach the client. */
export const RUNTIME_TEXT_LINES_PER_SECOND = 50;
/** Lines a run may emit at once before the sustained rate applies. */
export const RUNTIME_TEXT_LINE_BURST = 200;
/** A run that keeps dropping lines reports the count at most this often. */
export const RUNTIME_TEXT_DROP_LOG_INTERVAL_MS = 10_000;
/** Sent to the client once per run, when the first line is dropped. */
export const RUNTIME_TEXT_LIMIT_NOTICE =
  `Runtime output rate limit exceeded (${RUNTIME_TEXT_LINES_PER_SECOND} lines/s); further lines are dropped`;
 
export type RuntimeTextDecision = "forward" | "notice" | "drop";
 
interface RuntimeTextLimiterOptions {
  linesPerSecond?: number;
  burst?: number;
  logIntervalMs?: number;
  now?: () => number;
  logger?: Pick<Logger, "warn">;
}
 
/**
 * Budget of one run for runtime text lines: stdout/stderr output that is neither
 * a simulator protocol marker nor serial output. Sketch code controls these
 * lines and can write them far faster than any client reads them, so a token
 * bucket admits a burst and a sustained rate; every further line is dropped and
 * counted. The client is told once, the log only aggregated.
 */
export class RuntimeTextLimiter {
  private readonly linesPerSecond: number;
  private readonly burst: number;
  private readonly logIntervalMs: number;
  private readonly now: () => number;
  private readonly logger: Pick<Logger, "warn">;
  private tokens: number;
  private refilledAt: number;
  private dropped = 0;
  private droppedSinceLog = 0;
  private loggedAt = 0;
 
  constructor(options: RuntimeTextLimiterOptions = {}) {
    this.linesPerSecond = options.linesPerSecond ?? RUNTIME_TEXT_LINES_PER_SECOND;
    this.burst = options.burst ?? RUNTIME_TEXT_LINE_BURST;
    this.logIntervalMs = options.logIntervalMs ?? RUNTIME_TEXT_DROP_LOG_INTERVAL_MS;
    this.now = options.now ?? Date.now;
    this.logger = options.logger ?? new Logger("RuntimeTextLimiter");
    this.tokens = this.burst;
    this.refilledAt = this.now();
  }
 
  /** Decides one line: forward it, replace it by the one-time notice, or drop it. */
  admit(): RuntimeTextDecision {
    const now = this.now();
    this.refill(now);
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return "forward";
    }
    this.dropped += 1;
    this.droppedSinceLog += 1;
    if (this.dropped === 1) {
      this.logDrops(now, "Runtime output rate limit exceeded; dropping excess lines of this run");
      return "notice";
    }
    if (now - this.loggedAt >= this.logIntervalMs) {
      this.logDrops(now, "Runtime output still exceeds its rate limit");
    }
    return "drop";
  }
 
  get droppedLines(): number {
    return this.dropped;
  }
 
  private refill(now: number): void {
    const elapsedMs = Math.max(0, now - this.refilledAt);
    this.refilledAt = now;
    this.tokens = Math.min(this.burst, this.tokens + (elapsedMs * this.linesPerSecond) / 1_000);
  }
 
  private logDrops(now: number, message: string): void {
    this.logger.warn(`${message} (${this.droppedSinceLog} dropped since last report, ${this.dropped} total)`);
    this.loggedAt = now;
    this.droppedSinceLog = 0;
  }
}