File size: 2,276 Bytes
f0634fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// src/budget.ts
//
// Parse budget: a hard cap on wall-clock time and on the number of syntax
// nodes a single parse may create. The parser calls `budget.tick()` every
// time it creates a node; long scan loops (word runs, strings, heredoc
// bodies) call `budget.progress()` periodically so a pathological single
// token still hits the deadline without inflating the node count. When
// either limit is exceeded the methods throw `Aborted`, which the `parse`
// entry point catches and reports as `{ ok: false, reason: 'aborted' }`.

export const DEFAULT_TIMEOUT_MS = 50;
export const DEFAULT_MAX_NODES = 50_000;

/** Internal control-flow error. Never escapes the `parse` entry point. */
export class Aborted extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'Aborted';
  }
}

export interface BudgetOptions {
  /** Wall-clock limit in milliseconds. `Infinity` disables the time check. */
  timeoutMs?: number;
  /** Maximum number of nodes the parse may create. */
  maxNodes?: number;
}

export class ParseBudget {
  private readonly deadline: number;
  private readonly maxNodes: number;
  private nodeCount = 0;

  constructor(options: BudgetOptions = {}) {
    const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    this.deadline = Date.now() + timeoutMs;
    this.maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
  }

  /** Number of nodes created so far. */
  get nodesUsed(): number {
    return this.nodeCount;
  }

  /**
   * Account for one created node and re-check the deadline. Throws `Aborted`
   * when the node cap is exceeded or the deadline has been reached.
   */
  tick(): void {
    this.nodeCount++;
    if (this.nodeCount > this.maxNodes) {
      throw new Aborted(`parse aborted: node budget exceeded (${this.nodeCount} > ${this.maxNodes})`);
    }
    if (Date.now() >= this.deadline) {
      throw new Aborted(`parse aborted: timeout`);
    }
  }

  /**
   * Re-check the deadline WITHOUT counting a node. For long scan loops that
   * run many iterations per produced node (character-level scanning), called
   * at intervals so the deadline is still enforced promptly.
   */
  progress(): void {
    if (Date.now() >= this.deadline) {
      throw new Aborted(`parse aborted: timeout`);
    }
  }
}