Public: anyone can read this; the crew can change it.
The forge reports npm registry bytes per hour, so the steward can see the bun cache working #7694
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7546-the-forge-reports-npm-registry-bytes-per → main 7 commits fork
16 files
changed +429
−38
docs/MCP.md +1 −1
| @@ -61,7 +61,7 @@ for a tunnel and a forge that sleeps. | ||
| 61 | 61 | | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes | |
| 62 | 62 | | `boards` | `{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step` | | |
| 63 | 63 | | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes | |
| 64 | | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | | |
| 64 | | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | | |
| 65 | 65 | | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes | |
| 66 | 66 | | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes | |
| 67 | 67 | | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes | |
docs/METRICS.md +20
| @@ -11,6 +11,8 @@ not an admin, 200 for an admin's `ro` token. It is never cached | ||
| 11 | 11 | | `forge_egress_connections` | gauge | `repo`, `destination` | Open egress connections. | |
| 12 | 12 | | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. | |
| 13 | 13 | | `forge_egress_bytes_out` | gauge | `repo`, `destination` | Bytes sent to the destination over open connections. | |
| 14 | | `forge_egress_received_bytes_total` | counter | `destination` | Bytes received from the destination since the process started, every app and check together. | | |
| 15 | | `forge_egress_sent_bytes_total` | counter | `destination` | Bytes sent to the destination since the process started. | | |
| 14 | 16 | | `forge_http_request_duration_seconds` | histogram | `pattern` (plus `le`) | Seconds taken to answer a request, by the route pattern matched (never the raw path), since the process started. Buckets are `route_timing`'s edges, 1 ms to 2.5 s, in seconds. | |
| 15 | 17 | | `forge_check_duration_seconds` | histogram | (plus `le`) | Seconds a repository check took from start to finish, for checks that ended since the process started. Buckets 5 s to 30 min. | |
| 16 | 18 | | `forge_check_queue_depth` | gauge | none | Checks queued and not yet started. | |
| @@ -65,3 +67,21 @@ scrape_configs: | ||
| 65 | 67 | `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` |
| 66 | 68 | per `repo` and `destination` as Prometheus text, so a leaked upstream socket |
| 67 | 69 | shows as a gauge that never falls (#3719). |
| 70 | ||
| 71 | Beside them, `forge_egress_received_bytes_total` and `forge_egress_sent_bytes_total` | |
| 72 | count every byte since the process started, keyed by `destination` alone (never | |
| 73 | by run, so the series stay one per host). They show the bun cache working | |
| 74 | (#7546): on a warm cache, | |
| 75 | `rate(forge_egress_received_bytes_total{destination="registry.npmjs.org:443"}[1h])` | |
| 76 | stays near zero, and a cold one is hundreds of megabytes an hour (#7541). | |
| 77 | Without Prometheus, the same hour is read four ways: | |
| 78 | ||
| 79 | - MCP `capacity` (forge admin) answers `egress.npmBytesLastHour` and the ten | |
| 80 | busiest `egress.destinations` with their totals and last hour. | |
| 81 | - `/settings/capacity` prints it in the Egress connections card. | |
| 82 | - The terminal's Egress connections screen (About, `c`, site admin) prints the | |
| 83 | same line above the open connections. | |
| 84 | - Each check's install step in a cell logs one line, `install fetched 0 B from | |
| 85 | the registry` on a warm cache: what the cell's egress proxy received. A check | |
| 86 | outside a cell logs none: its repository's bun cache is shared, so no figure | |
| 87 | there would be its own. |
src/actions/capacity.ts +21 −1
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | type CapacityChange, |
| 23 | 23 | type CapacityKey, |
| 24 | 24 | } from "../core/capacity.ts"; |
| 25 | import { egressTotals, npmBytesLastHour, type EgressTotal } from "../apps/egress.ts"; | |
| 25 | 26 | import { passkeyIds } from "../core/passkeys.ts"; |
| 26 | 27 | import type { User } from "../core/users.ts"; |
| 27 | 28 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -49,6 +50,24 @@ export interface CapacityRow { | ||
| 49 | 50 | readonly changed?: { readonly at: string; readonly by: string }; |
| 50 | 51 | } |
| 51 | 52 | |
| 53 | /** | |
| 54 | * What the egress proxies received over the last hour (#7546), the npm | |
| 55 | * registry first: a warm bun cache keeps `npmBytesLastHour` near zero, so a | |
| 56 | * figure in the hundreds of megabytes is the cache gone cold again (#7541). | |
| 57 | */ | |
| 58 | export interface EgressHour { | |
| 59 | readonly npmBytesLastHour: number; | |
| 60 | /** The ten destinations that sent most this hour, with their totals since boot. */ | |
| 61 | readonly destinations: readonly EgressTotal[]; | |
| 62 | } | |
| 63 | ||
| 64 | export function egressHour(now = Date.now()): EgressHour { | |
| 65 | return { | |
| 66 | npmBytesLastHour: npmBytesLastHour(now), | |
| 67 | destinations: egressTotals(now).slice(0, 10), | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 52 | 71 | function admin(actor: User | undefined): Result<User, "not_found"> { |
| 53 | 72 | return actor?.isAdmin === true && actor.kind !== "agent" |
| 54 | 73 | ? ok(actor) |
| @@ -70,7 +89,7 @@ export function changedOf(db: Database, key: CapacityKey): { changed?: { at: str | ||
| 70 | 89 | export function listCapacity( |
| 71 | 90 | app: App, |
| 72 | 91 | actor: User | undefined, |
| 73 | ): Result<{ settings: CapacityRow[]; changes: CapacityChange[] }, "not_found"> { | |
| 92 | ): Result<{ settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour }, "not_found"> { | |
| 74 | 93 | const who = admin(actor); |
| 75 | 94 | if (!who.ok) return who; |
| 76 | 95 | return ok({ |
| @@ -85,6 +104,7 @@ export function listCapacity( | ||
| 85 | 104 | ...changedOf(app.db, key), |
| 86 | 105 | })), |
| 87 | 106 | changes: capacityChanges(app.db), |
| 107 | egress: egressHour(), | |
| 88 | 108 | }); |
| 89 | 109 | } |
| 90 | 110 |
src/apps/egress.ts +123
| @@ -109,6 +109,121 @@ export function egressSnapshot(): EgressRow[] { | ||
| 109 | 109 | return rows.sort((a, b) => b.open - a.open || a.label.localeCompare(b.label)); |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | /** | |
| 113 | * Bytes moved since the forge started, per destination alone (#7546): the | |
| 114 | * counters `/metrics` serves, and a minute-by-minute ring of the last hour, | |
| 115 | * so a cold bun cache shows as registry bytes without a packet capture. | |
| 116 | * Keyed by `host:port` and never by run label, so it holds one entry per | |
| 117 | * host the apps reach, not one per run; past `MOVED_MAX` destinations the | |
| 118 | * rest are counted under `other`, save the registry, which always keeps its | |
| 119 | * own entry so the headline figure is never lost there. | |
| 120 | */ | |
| 121 | interface Moved { | |
| 122 | bytesIn: number; | |
| 123 | bytesOut: number; | |
| 124 | /** Minute `m` (ms since epoch / 60 000) lands in slot `m % 60`. */ | |
| 125 | readonly minute: number[]; | |
| 126 | readonly minuteIn: number[]; | |
| 127 | } | |
| 128 | ||
| 129 | const MOVED_MAX = 256; | |
| 130 | const HOUR_MINUTES = 60; | |
| 131 | const moved = new Map<string, Moved>(); | |
| 132 | /** label -> bytes in, for the labels someone is metering now (`meterEgress`). */ | |
| 133 | const meters = new Map<string, { bytesIn: number; holders: number }>(); | |
| 134 | ||
| 135 | /** Count `bytesIn` received from and `bytesOut` sent to `destination`. */ | |
| 136 | export function recordEgress( | |
| 137 | label: string, | |
| 138 | destination: string, | |
| 139 | bytesIn: number, | |
| 140 | bytesOut: number, | |
| 141 | now = Date.now(), | |
| 142 | ): void { | |
| 143 | const key = | |
| 144 | destination === NPM_REGISTRY || moved.has(destination) || moved.size < MOVED_MAX | |
| 145 | ? destination | |
| 146 | : "other"; | |
| 147 | let m = moved.get(key); | |
| 148 | if (m === undefined) | |
| 149 | moved.set(key, (m = { bytesIn: 0, bytesOut: 0, minute: Array(HOUR_MINUTES).fill(-1), minuteIn: Array(HOUR_MINUTES).fill(0) })); | |
| 150 | m.bytesIn += bytesIn; | |
| 151 | m.bytesOut += bytesOut; | |
| 152 | const minute = Math.floor(now / 60_000); | |
| 153 | const slot = minute % HOUR_MINUTES; | |
| 154 | if (m.minute[slot] !== minute) { | |
| 155 | m.minute[slot] = minute; | |
| 156 | m.minuteIn[slot] = 0; | |
| 157 | } | |
| 158 | m.minuteIn[slot] = (m.minuteIn[slot] ?? 0) + bytesIn; | |
| 159 | const meter = meters.get(label); | |
| 160 | if (meter !== undefined) meter.bytesIn += bytesIn; | |
| 161 | } | |
| 162 | ||
| 163 | export interface EgressTotal { | |
| 164 | readonly destination: string; | |
| 165 | /** Bytes received from it since the forge started. */ | |
| 166 | readonly bytesIn: number; | |
| 167 | /** Bytes sent to it since the forge started. */ | |
| 168 | readonly bytesOut: number; | |
| 169 | /** Bytes received from it in the 60 minutes up to `now`, this minute included. */ | |
| 170 | readonly bytesInLastHour: number; | |
| 171 | } | |
| 172 | ||
| 173 | /** Bytes `m` received in the 60 minutes up to `minute`, that minute included. */ | |
| 174 | function lastHour(m: Moved, minute: number): number { | |
| 175 | let hour = 0; | |
| 176 | for (let i = 0; i < HOUR_MINUTES; i++) { | |
| 177 | const at = m.minute[i] ?? -1; | |
| 178 | if (at > minute - HOUR_MINUTES && at <= minute) hour += m.minuteIn[i] ?? 0; | |
| 179 | } | |
| 180 | return hour; | |
| 181 | } | |
| 182 | ||
| 183 | /** Every destination reached since the forge started, most received this hour first. */ | |
| 184 | export function egressTotals(now = Date.now()): EgressTotal[] { | |
| 185 | const minute = Math.floor(now / 60_000); | |
| 186 | const rows: EgressTotal[] = []; | |
| 187 | for (const [destination, m] of moved) | |
| 188 | rows.push({ destination, bytesIn: m.bytesIn, bytesOut: m.bytesOut, bytesInLastHour: lastHour(m, minute) }); | |
| 189 | return rows.sort((a, b) => b.bytesInLastHour - a.bytesInLastHour || a.destination.localeCompare(b.destination)); | |
| 190 | } | |
| 191 | ||
| 192 | /** The package registry every install step reaches (`apps/contain.ts` STEP_HOSTS). */ | |
| 193 | export const NPM_REGISTRY = "registry.npmjs.org:443"; | |
| 194 | ||
| 195 | /** Bytes received from the npm registry over the last hour, across every app and check. */ | |
| 196 | export function npmBytesLastHour(now = Date.now()): number { | |
| 197 | const m = moved.get(NPM_REGISTRY); | |
| 198 | return m === undefined ? 0 : lastHour(m, Math.floor(now / 60_000)); | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Count what `label`'s proxy receives from now until `stop()`, which answers | |
| 203 | * the bytes in. Only labels being metered are kept, and each goes with its | |
| 204 | * last holder, so a run label never outlives the step that asked. | |
| 205 | */ | |
| 206 | export function meterEgress(label: string): { stop(): number } { | |
| 207 | const meter = meters.get(label) ?? { bytesIn: 0, holders: 0 }; | |
| 208 | meters.set(label, meter); | |
| 209 | meter.holders++; | |
| 210 | const start = meter.bytesIn; | |
| 211 | let stopped = false; | |
| 212 | return { | |
| 213 | stop() { | |
| 214 | const bytes = meter.bytesIn - start; | |
| 215 | if (!stopped && --meter.holders === 0) meters.delete(label); | |
| 216 | stopped = true; | |
| 217 | return bytes; | |
| 218 | }, | |
| 219 | }; | |
| 220 | } | |
| 221 | ||
| 222 | /** For tests: forget every cumulative count. */ | |
| 223 | export function resetEgressTotals(): void { | |
| 224 | moved.clear(); | |
| 225 | } | |
| 226 | ||
| 112 | 227 | /** The ports a declared host may be reached on when its entry names none. */ |
| 113 | 228 | export const WEB_PORTS: readonly number[] = [80, 443]; |
| 114 | 229 | |
| @@ -230,6 +345,9 @@ interface Conn { | ||
| 230 | 345 | done: boolean; |
| 231 | 346 | /** The destination's counts once dialled; the data path adds to it directly. */ |
| 232 | 347 | tally?: Tally; |
| 348 | /** Whose proxy and where to, once dialled, for the cumulative counts. */ | |
| 349 | label: string; | |
| 350 | destination?: string; | |
| 233 | 351 | /** When bytes last moved either way (ms). */ |
| 234 | 352 | last: number; |
| 235 | 353 | /** Frees the destination's slot and counts it closed; set once dialled. */ |
| @@ -394,6 +512,7 @@ async function open( | ||
| 394 | 512 | count.open++; |
| 395 | 513 | client.data.tally = count; |
| 396 | 514 | const label = policy.label ?? ""; |
| 515 | client.data.destination = destination; | |
| 397 | 516 | client.data.release = () => untally(label, destination); |
| 398 | 517 | const ended = () => { |
| 399 | 518 | if (!client.data.done) { |
| @@ -410,6 +529,7 @@ async function open( | ||
| 410 | 529 | if (client.data.done) return; |
| 411 | 530 | client.data.last = Date.now(); |
| 412 | 531 | count.bytesIn += chunk.length; |
| 532 | recordEgress(label, destination, chunk.length, 0); | |
| 413 | 533 | send(client, client.data.toClient, chunk); |
| 414 | 534 | if (queued(client.data.toClient) > (policy.queueMax ?? QUEUE_MAX)) { |
| 415 | 535 | drop(client); |
| @@ -443,6 +563,7 @@ async function open( | ||
| 443 | 563 | const first = concat(prefix, client.data.head.subarray(end)); |
| 444 | 564 | if (first.length > 0) { |
| 445 | 565 | count.bytesOut += first.length; |
| 566 | recordEgress(label, destination, 0, first.length); | |
| 446 | 567 | send(peer, client.data.toPeer, first); |
| 447 | 568 | } |
| 448 | 569 | } catch { |
| @@ -471,6 +592,7 @@ export function startEgress( | ||
| 471 | 592 | toPeer: [], |
| 472 | 593 | toClient: [], |
| 473 | 594 | done: false, |
| 595 | label: policy.label ?? "", | |
| 474 | 596 | last: Date.now(), |
| 475 | 597 | release: undefined, |
| 476 | 598 | }; |
| @@ -481,6 +603,7 @@ export function startEgress( | ||
| 481 | 603 | if (conn.peer !== undefined) { |
| 482 | 604 | if (conn.done) return; |
| 483 | 605 | if (conn.tally !== undefined) conn.tally.bytesOut += chunk.length; |
| 606 | if (conn.destination !== undefined) recordEgress(conn.label, conn.destination, 0, chunk.length); | |
| 484 | 607 | send(conn.peer, conn.toPeer, chunk); |
| 485 | 608 | return; |
| 486 | 609 | } |
src/apps/egress_metrics.ts +15 −3
| @@ -1,16 +1,19 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The egress tally as Prometheus text (#3719): one line per app and |
| 3 | 3 | * destination for open connections and the bytes they have moved, so a leaked |
| 4 | * upstream socket shows as a gauge that never falls. | |
| 4 | * upstream socket shows as a gauge that never falls. Beside them, counters of | |
| 5 | * every byte moved since the forge started, per destination alone (#7546): | |
| 6 | * `rate(forge_egress_received_bytes_total{destination="registry.npmjs.org:443"}[1h])` | |
| 7 | * is the bun cache working, or not. | |
| 5 | 8 | */ |
| 6 | import type { EgressRow } from "./egress.ts"; | |
| 9 | import type { EgressRow, EgressTotal } from "./egress.ts"; | |
| 7 | 10 | |
| 8 | 11 | /** A label value, escaped as the exposition format asks. */ |
| 9 | 12 | function label(value: string): string { |
| 10 | 13 | return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); |
| 11 | 14 | } |
| 12 | 15 | |
| 13 | export function egressMetrics(rows: readonly EgressRow[]): string { | |
| 16 | export function egressMetrics(rows: readonly EgressRow[], totals: readonly EgressTotal[] = []): string { | |
| 14 | 17 | const series: [string, string, (r: EgressRow) => number][] = [ |
| 15 | 18 | ["forge_egress_connections", "Open egress connections.", (r) => r.open], |
| 16 | 19 | ["forge_egress_bytes_in", "Bytes received from the destination over open connections.", (r) => r.bytesIn], |
| @@ -22,5 +25,14 @@ export function egressMetrics(rows: readonly EgressRow[]): string { | ||
| 22 | 25 | for (const r of rows) |
| 23 | 26 | lines.push(`${name}{repo="${label(r.label)}",destination="${label(r.destination)}"} ${value(r)}`); |
| 24 | 27 | } |
| 28 | const counters: [string, string, (t: EgressTotal) => number][] = [ | |
| 29 | ["forge_egress_received_bytes", "Bytes received from the destination since the forge started.", (t) => t.bytesIn], | |
| 30 | ["forge_egress_sent_bytes", "Bytes sent to the destination since the forge started.", (t) => t.bytesOut], | |
| 31 | ]; | |
| 32 | for (const [name, help, value] of counters) { | |
| 33 | // A counter's family is named without `_total`; its samples carry it. | |
| 34 | lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} counter`); | |
| 35 | for (const t of totals) lines.push(`${name}_total{destination="${label(t.destination)}"} ${value(t)}`); | |
| 36 | } | |
| 25 | 37 | return `${lines.join("\n")}\n`; |
| 26 | 38 | } |
src/checks/install.ts +10
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 20 | 20 | import { tmpdir } from "node:os"; |
| 21 | 21 | import { dirname, join } from "node:path"; |
| 22 | import { formatBytes } from "../core/storage.ts"; | |
| 22 | 23 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 23 | 24 | |
| 24 | 25 | /** What an install prints when the machine, not the code, failed it. */ |
| @@ -35,6 +36,15 @@ export const LOCK_STALE_MS = 15 * 60_000; | ||
| 35 | 36 | |
| 36 | 37 | const LOCK_POLL_MS = 50; |
| 37 | 38 | |
| 39 | /** | |
| 40 | * The one line an install step logs about the registry (#7546): `0 B` on a | |
| 41 | * warm cache. A cell's figure is what its egress proxy received from the | |
| 42 | * registry; uncontained, nothing is logged. | |
| 43 | */ | |
| 44 | export function installFetchedLine(bytes: number): string { | |
| 45 | return `install fetched ${formatBytes(Math.max(0, bytes))} from the registry`; | |
| 46 | } | |
| 47 | ||
| 38 | 48 | export type InstallOutcome = |
| 39 | 49 | | { readonly ok: true } |
| 40 | 50 | | { readonly ok: false; readonly error: string; readonly infrastructure: boolean }; |
src/checks/service.ts +43 −23
| @@ -95,7 +95,15 @@ import { summariseCheckFailure } from "./summary.ts"; | ||
| 95 | 95 | import type { Result } from "../kernel/result.ts"; |
| 96 | 96 | import { placementFromEnv, type Placement } from "./placement.ts"; |
| 97 | 97 | import { JOB_VANISHED, runJobStep } from "./job_step.ts"; |
| 98 | import { cellInstall, checkInstallCache, installWithRetry, prefillInstallCache, withLock } from "./install.ts"; | |
| 98 | import { | |
| 99 | cellInstall, | |
| 100 | checkInstallCache, | |
| 101 | installFetchedLine, | |
| 102 | installWithRetry, | |
| 103 | prefillInstallCache, | |
| 104 | withLock, | |
| 105 | } from "./install.ts"; | |
| 106 | import { meterEgress } from "../apps/egress.ts"; | |
| 99 | 107 | import { checkStage, type GateStage } from "./gate_reason.ts"; |
| 100 | 108 | import { proofsInFlight } from "./proof.ts"; |
| 101 | 109 | import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts"; |
| @@ -1071,29 +1079,41 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1071 | 1079 | cell: revealed, |
| 1072 | 1080 | }); |
| 1073 | 1081 | }; |
| 1074 | const installed = await installWithRetry( | |
| 1075 | async (log) => { | |
| 1076 | if (shared === undefined) { | |
| 1077 | if (warm !== undefined) { | |
| 1078 | const fill = await prefillInstallCache({ | |
| 1079 | cache: warm, | |
| 1080 | dir, | |
| 1081 | env, | |
| 1082 | log, | |
| 1083 | signal, | |
| 1084 | timeoutMs: manifest.timeout * 1000, | |
| 1085 | }); | |
| 1086 | if (!fill.ok) return fill; | |
| 1087 | filled = fill.value; | |
| 1082 | // What the cell's proxy received, for the one line that shows the cache | |
| 1083 | // working (#7546). Uncontained, nothing is logged: the shared cache | |
| 1084 | // is not metered, and its growth is wrong under concurrent checks. | |
| 1085 | // Stopped in `finally`, so a throw never leaves the label metered. | |
| 1086 | const meter = cell === undefined ? undefined : meterEgress(`check:${run.id}`); | |
| 1087 | let fetched: number | undefined; | |
| 1088 | let installed: Awaited<ReturnType<typeof installWithRetry>>; | |
| 1089 | try { | |
| 1090 | installed = await installWithRetry( | |
| 1091 | async (log) => { | |
| 1092 | if (shared === undefined) { | |
| 1093 | if (warm !== undefined) { | |
| 1094 | const fill = await prefillInstallCache({ | |
| 1095 | cache: warm, | |
| 1096 | dir, | |
| 1097 | env, | |
| 1098 | log, | |
| 1099 | signal, | |
| 1100 | timeoutMs: manifest.timeout * 1000, | |
| 1101 | }); | |
| 1102 | if (!fill.ok) return fill; | |
| 1103 | filled = fill.value; | |
| 1104 | } | |
| 1105 | return once(log); | |
| 1088 | 1106 | } |
| 1089 | return once(log); | |
| 1090 | } | |
| 1091 | mkdirSync(shared, { recursive: true }); | |
| 1092 | const held = await withLock(`${shared}.lock`, () => once(log), { signal }); | |
| 1093 | return held.ok ? held.value : held; | |
| 1094 | }, | |
| 1095 | { log: write, signal, retryMs: options.installRetryMs }, | |
| 1096 | ); | |
| 1107 | mkdirSync(shared, { recursive: true }); | |
| 1108 | const held = await withLock(`${shared}.lock`, () => once(log), { signal }); | |
| 1109 | return held.ok ? held.value : held; | |
| 1110 | }, | |
| 1111 | { log: write, signal, retryMs: options.installRetryMs }, | |
| 1112 | ); | |
| 1113 | } finally { | |
| 1114 | fetched = meter?.stop(); | |
| 1115 | } | |
| 1116 | if (fetched !== undefined) write(installFetchedLine(fetched)); | |
| 1097 | 1117 | if (!installed.ok) |
| 1098 | 1118 | return { |
| 1099 | 1119 | ok: false, |
src/http/api.ts +2 −2
| @@ -335,7 +335,7 @@ import { upstreamRoutes } from "./api/upstream.ts"; | ||
| 335 | 335 | import type { Router } from "./router.ts"; |
| 336 | 336 | import { clientAddress } from "./peer.ts"; |
| 337 | 337 | import { manifestFor } from "../actions/here.ts"; |
| 338 | import { egressSnapshot } from "../apps/egress.ts"; | |
| 338 | import { egressSnapshot, egressTotals } from "../apps/egress.ts"; | |
| 339 | 339 | import { egressMetrics } from "../apps/egress_metrics.ts"; |
| 340 | 340 | import { forgeMetrics } from "../metrics/forge.ts"; |
| 341 | 341 | import { appSources } from "../metrics/app_sources.ts"; |
| @@ -3105,7 +3105,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3105 | 3105 | router.add("GET", "/metrics", (ctx) => { |
| 3106 | 3106 | const denied = accountAdmin(ctx, "ro"); |
| 3107 | 3107 | if (denied) return denied; |
| 3108 | return new Response(egressMetrics(egressSnapshot()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), { | |
| 3108 | return new Response(egressMetrics(egressSnapshot(), egressTotals()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), { | |
| 3109 | 3109 | headers: { |
| 3110 | 3110 | "content-type": "text/plain; version=0.0.4; charset=utf-8", |
| 3111 | 3111 | "cache-control": "no-store", |
src/http/mcp/tools/capacity.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "capacity", |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 9 | 9 | description: |
| 10 | 10 | "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", |
| 11 | 11 | inputSchema: { |
src/tui/screens/egress.ts +9 −4
| @@ -10,7 +10,7 @@ import { | ||
| 10 | 10 | revokeAppEgress, |
| 11 | 11 | type EgressRow, |
| 12 | 12 | } from "../../actions/egress_grants.ts"; |
| 13 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 13 | import { egressSnapshot, npmBytesLastHour } from "../../apps/egress.ts"; | |
| 14 | 14 | import { bytes } from "../../system/format.ts"; |
| 15 | 15 | import type { Repo } from "../../core/repos.ts"; |
| 16 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -89,8 +89,9 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 89 | 89 | |
| 90 | 90 | /** |
| 91 | 91 | * The egress connections open right now, per app and destination: the |
| 92 | * Capacity page's card, for a site admin. Read again on every draw, so it | |
| 93 | * moves as connections do; anyone else is offered nothing. | |
| 92 | * Capacity page's card, for a site admin, under the bytes the npm registry | |
| 93 | * sent over the last hour (#7546). Read again on every draw, so it moves as | |
| 94 | * connections do; anyone else is offered nothing. | |
| 94 | 95 | */ |
| 95 | 96 | export function connectionsScreen(f: Session, ctx: Ctx): Screen { |
| 96 | 97 | const state = new ListState(); |
| @@ -108,7 +109,11 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 108 | 109 | const c = ctx.theme.c; |
| 109 | 110 | const rows = lines(); |
| 110 | 111 | state.clamp(rows.length); |
| 111 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 112 | const [npm, head, list] = split(area, "v", [len(1), len(1), fill(1)]); | |
| 113 | if (f.user?.isAdmin === true) { | |
| 114 | const sent = `The npm registry sent ${bytes(npmBytesLastHour())} over the last hour.`; | |
| 115 | s.text(npm!.x, npm!.y, truncate(sent, npm!.w), style(c.mutedFg, c.bg), npm!.x + npm!.w); | |
| 116 | } | |
| 112 | 117 | const note = |
| 113 | 118 | rows.length === 0 |
| 114 | 119 | ? "No app holds an egress connection open." |
src/web/pages/capacity.ts +6 −1
| @@ -76,7 +76,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 76 | 76 | listCapacity(ctx.app, ctx.auth.user), |
| 77 | 77 | ); |
| 78 | 78 | if (!listed.ok) return notFoundPage(ctx); |
| 79 | const { settings, changes } = listed.value; | |
| 79 | const { settings, changes, egress } = listed.value; | |
| 80 | 80 | const errs = timePhase(ctx.request, "errors", () => |
| 81 | 81 | ctx.app.signals.errors.recent(10), |
| 82 | 82 | ); |
| @@ -158,14 +158,19 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 158 | 158 | }, |
| 159 | 159 | ); |
| 160 | 160 | const open = egressSnapshot(); |
| 161 | // The bun cache at work (#7546): a warm cache keeps this near zero. | |
| 162 | const npm = html`The npm registry sent | |
| 163 | <span class="mono">${bytes(egress.npmBytesLastHour)}</span> over the last hour.`; | |
| 161 | 164 | const connections = card( |
| 162 | 165 | open.length === 0 |
| 163 | 166 | ? html`<div class="card-body"> |
| 167 | <p class="muted small" id="egress-npm">${npm}</p> | |
| 164 | 168 | <p class="muted small" id="egress-open"> |
| 165 | 169 | No app holds an egress connection open. |
| 166 | 170 | </p> |
| 167 | 171 | </div>` |
| 168 | 172 | : html`<ul class="list" id="egress-open"> |
| 173 | ${row({ id: "egress-npm", lead: icon("activity"), title: npm })} | |
| 169 | 174 | ${open.map((r) => |
| 170 | 175 | row({ |
| 171 | 176 | lead: icon("activity"), |
test/capacity.test.ts +20 −1
| @@ -18,7 +18,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 18 | 18 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { enableApp } from "../src/actions/apps.ts"; |
| 21 | import { startEgress } from "../src/apps/egress.ts"; | |
| 21 | import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 22 | 22 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 23 | 23 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 24 | 24 | import { |
| @@ -142,6 +142,25 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 142 | 142 | await rm(dataDir, { recursive: true, force: true }); |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | test("MCP capacity and the page answer the npm registry's bytes over the last hour (#7546)", async () => { | |
| 146 | resetEgressTotals(); | |
| 147 | recordEgress("check:r1", NPM_REGISTRY, 3_000_000, 4_000); | |
| 148 | recordEgress("check:r1", "example.com:443", 10, 0); | |
| 149 | const read = await mcp(aliceRo, {}); | |
| 150 | expect(read.error).toBe(false); | |
| 151 | const egress = (JSON.parse(read.text) as { egress: { npmBytesLastHour: number; destinations: { destination: string }[] } }).egress; | |
| 152 | expect(egress.npmBytesLastHour).toBe(3_000_000); | |
| 153 | // Other files' proxies share this process's tally; only the order of ours is asserted. | |
| 154 | const named = egress.destinations.map((d) => d.destination); | |
| 155 | expect(named[0]).toBe(NPM_REGISTRY); | |
| 156 | expect(named).toContain("example.com:443"); | |
| 157 | const cookie = await login("alice"); | |
| 158 | const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } })).text(); | |
| 159 | // 3,000,000 B / 1024² = 2.86 MiB. | |
| 160 | expect(page).toMatch(/id="egress-npm"[\s\S]*?2\.9 MiB/); | |
| 161 | resetEgressTotals(); | |
| 162 | }); | |
| 163 | ||
| 145 | 164 | test("a member, an agent and nobody are told it is not there, at every door", async () => { |
| 146 | 165 | expect(listCapacity(app, bob).ok).toBe(false); |
| 147 | 166 | expect(listCapacity(app, agent).ok).toBe(false); |
test/check-install.test.ts +5
| @@ -27,6 +27,7 @@ import { INFRA_SUMMARY, listRuns, type Run } from "../src/core/runs.ts"; | ||
| 27 | 27 | import { createUser } from "../src/core/users.ts"; |
| 28 | 28 | import type { Repo } from "../src/core/repos.ts"; |
| 29 | 29 | import { serve, type Servers } from "../src/http/server.ts"; |
| 30 | import { readRunLog } from "../src/runs/log.ts"; | |
| 30 | 31 | import { err, ok, type Result } from "../src/kernel/result.ts"; |
| 31 | 32 | |
| 32 | 33 | const never = new AbortController().signal; |
| @@ -220,6 +221,10 @@ describe("a check whose install fails on the machine", () => { | ||
| 220 | 221 | expect(runs[0]!.state).toBe("ok"); |
| 221 | 222 | // Uncontained, the repository's own cache is the one bun is pointed at. |
| 222 | 223 | expect(existsSync(checkInstallCache(dataDir, flaky.id))).toBe(true); |
| 224 | // Uncontained, no install line: its shared cache is not metered (#7546). | |
| 225 | expect( | |
| 226 | readRunLog(dataDir, runs[0]!.id).lines.some((l) => l.startsWith("install fetched")), | |
| 227 | ).toBe(false); | |
| 223 | 228 | }); |
| 224 | 229 | |
| 225 | 230 | test("failing twice is infrastructure: queued again, not red; a second time it fails saying so", async () => { |
test/egress-admin.test.ts +7 −1
| @@ -13,7 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { listEgressGrants } from "../src/core/egress_grants.ts"; |
| 14 | 14 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | import { startEgress } from "../src/apps/egress.ts"; | |
| 16 | import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 17 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 18 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -126,6 +126,8 @@ describe("egress grants in the terminal", () => { | ||
| 126 | 126 | }); |
| 127 | 127 | client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`); |
| 128 | 128 | while (!reply.includes("200")) await Bun.sleep(10); |
| 129 | resetEgressTotals(); | |
| 130 | recordEgress("check:r", NPM_REGISTRY, 3_000_000, 0); | |
| 129 | 131 | const admin = boot(ada); |
| 130 | 132 | const other = boot(bob); |
| 131 | 133 | try { |
| @@ -133,11 +135,15 @@ describe("egress grants in the terminal", () => { | ||
| 133 | 135 | await admin.press("c"); |
| 134 | 136 | await settleUntil(admin.tui, "app:cam/main"); |
| 135 | 137 | expect(admin.text()).toContain(`127.0.0.1:${camera.port} · 1 open`); |
| 138 | // The registry's hour, as the Capacity page and MCP `capacity` read it (#7546). | |
| 139 | expect(admin.text()).toContain("The npm registry sent 2.9 MiB over the last hour."); | |
| 136 | 140 | await other.openAbout(); |
| 137 | 141 | await other.press("c"); |
| 138 | 142 | expect(other.text()).not.toContain("Egress connections"); |
| 139 | 143 | expect(other.text()).not.toContain("app:cam/main"); |
| 144 | expect(other.text()).not.toContain("npm registry"); | |
| 140 | 145 | } finally { |
| 146 | resetEgressTotals(); | |
| 141 | 147 | admin.tui.stop(); |
| 142 | 148 | other.tui.stop(); |
| 143 | 149 | client.terminate(); |
test/egress-metrics.test.ts +10
| @@ -13,3 +13,13 @@ test("every destination gets its open count and bytes, labels escaped", () => { | ||
| 13 | 13 | test("no connections leaves only the headers", () => { |
| 14 | 14 | expect(egressMetrics([])).not.toContain("{"); |
| 15 | 15 | }); |
| 16 | ||
| 17 | test("cumulative bytes are counters keyed by destination alone (#7546)", () => { | |
| 18 | const text = egressMetrics([], [ | |
| 19 | { destination: "registry.npmjs.org:443", bytesIn: 5_000, bytesOut: 70, bytesInLastHour: 5_000 }, | |
| 20 | ]); | |
| 21 | expect(text).toContain("# TYPE forge_egress_received_bytes counter"); | |
| 22 | expect(text).toContain('forge_egress_received_bytes_total{destination="registry.npmjs.org:443"} 5000'); | |
| 23 | expect(text).toContain('forge_egress_sent_bytes_total{destination="registry.npmjs.org:443"} 70'); | |
| 24 | expect(text).not.toContain("repo="); | |
| 25 | }); |
test/egress-totals.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * The egress tally keeps every byte per destination, not only what open | |
| 3 | * connections moved (#7546): counters that survive a closed connection, the | |
| 4 | * last hour minute by minute, a meter an install step reads, and a bound on | |
| 5 | * how many destinations it holds. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 9 | import { | |
| 10 | egressSnapshot, | |
| 11 | egressTotals, | |
| 12 | meterEgress, | |
| 13 | NPM_REGISTRY, | |
| 14 | npmBytesLastHour, | |
| 15 | recordEgress, | |
| 16 | resetEgressTotals, | |
| 17 | startEgress, | |
| 18 | } from "../src/apps/egress.ts"; | |
| 19 | import { installFetchedLine } from "../src/checks/install.ts"; | |
| 20 | ||
| 21 | // The tally is the process's own, and `bun test` runs many files in one | |
| 22 | // process: a proxy test in a file before this one leaves its destinations | |
| 23 | // behind, so each test starts from nothing, not only ends there. | |
| 24 | beforeEach(resetEgressTotals); | |
| 25 | afterEach(resetEgressTotals); | |
| 26 | ||
| 27 | const T = Date.UTC(2026, 9, 27, 12, 0, 0); | |
| 28 | const MIN = 60_000; | |
| 29 | ||
| 30 | test("bytes add up per destination across labels", () => { | |
| 31 | recordEgress("check:a", NPM_REGISTRY, 1_000, 10, T); | |
| 32 | recordEgress("check:b", NPM_REGISTRY, 2_500, 5, T); | |
| 33 | expect(egressTotals(T)).toEqual([ | |
| 34 | { destination: NPM_REGISTRY, bytesIn: 3_500, bytesOut: 15, bytesInLastHour: 3_500 }, | |
| 35 | ]); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the last hour counts the 60 minutes up to now and drops older ones", () => { | |
| 39 | recordEgress("x", NPM_REGISTRY, 100, 0, T); // minute 0 | |
| 40 | recordEgress("x", NPM_REGISTRY, 20, 0, T + 30 * MIN); // minute 30 | |
| 41 | recordEgress("x", NPM_REGISTRY, 3, 0, T + 59 * MIN); // minute 59 | |
| 42 | expect(npmBytesLastHour(T + 59 * MIN)).toBe(123); | |
| 43 | // At minute 60, minute 0 has left the window: 20 + 3. | |
| 44 | expect(npmBytesLastHour(T + 60 * MIN)).toBe(23); | |
| 45 | // Minute 60 reuses minute 0's slot and starts it from nothing. | |
| 46 | recordEgress("x", NPM_REGISTRY, 7, 0, T + 60 * MIN); | |
| 47 | expect(npmBytesLastHour(T + 60 * MIN)).toBe(30); | |
| 48 | expect(npmBytesLastHour(T + 200 * MIN)).toBe(0); | |
| 49 | // The counter never falls: 100 + 20 + 3 + 7. | |
| 50 | expect(egressTotals(T + 200 * MIN)[0]?.bytesIn).toBe(130); | |
| 51 | }); | |
| 52 | ||
| 53 | test("no traffic to the registry reads 0", () => { | |
| 54 | recordEgress("x", "example.com:443", 9, 0, T); | |
| 55 | expect(npmBytesLastHour(T)).toBe(0); | |
| 56 | }); | |
| 57 | ||
| 58 | test("destinations are bounded: past 256 the rest count as other", () => { | |
| 59 | for (let i = 0; i < 300; i++) recordEgress("x", `h${i}.example:443`, 1, 0, T); | |
| 60 | const totals = egressTotals(T); | |
| 61 | expect(totals.length).toBe(257); | |
| 62 | expect(totals.find((t) => t.destination === "other")?.bytesIn).toBe(44); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the registry keeps its own entry however many destinations came first", () => { | |
| 66 | for (let i = 0; i < 300; i++) recordEgress("x", `h${i}.example:443`, 1, 0, T); | |
| 67 | recordEgress("check:a", NPM_REGISTRY, 5_000, 0, T); | |
| 68 | expect(npmBytesLastHour(T)).toBe(5_000); | |
| 69 | expect(egressTotals(T).find((t) => t.destination === "other")?.bytesIn).toBe(44); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a meter counts its label from start to stop, and forgets the label after", () => { | |
| 73 | recordEgress("check:r", NPM_REGISTRY, 50, 0, T); | |
| 74 | const meter = meterEgress("check:r"); | |
| 75 | recordEgress("check:r", NPM_REGISTRY, 400, 0, T); | |
| 76 | recordEgress("check:other", NPM_REGISTRY, 9_999, 0, T); | |
| 77 | expect(meter.stop()).toBe(400); | |
| 78 | // A second meter on the same label starts from nothing. | |
| 79 | const again = meterEgress("check:r"); | |
| 80 | expect(again.stop()).toBe(0); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the install line reads 0 B on a warm cache", () => { | |
| 84 | expect(installFetchedLine(0)).toBe("install fetched 0 B from the registry"); | |
| 85 | expect(installFetchedLine(12_345_678)).toBe("install fetched 12.3 MB from the registry"); | |
| 86 | expect(installFetchedLine(-4096)).toBe("install fetched 0 B from the registry"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("bytes through the proxy stay counted after the connection closes", async () => { | |
| 90 | const upstream = Bun.listen<undefined>({ | |
| 91 | hostname: "127.0.0.1", | |
| 92 | port: 0, | |
| 93 | socket: { | |
| 94 | data(s) { | |
| 95 | s.end("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"); | |
| 96 | }, | |
| 97 | }, | |
| 98 | }); | |
| 99 | const port = 20_000 + Math.floor(Math.random() * 20_000); | |
| 100 | const label = `check:totals-${port}`; | |
| 101 | const egress = startEgress({ tcp: port }, { hosts: [], origins: [{ host: "127.0.0.1", port: upstream.port }], label }); | |
| 102 | const meter = meterEgress(label); | |
| 103 | try { | |
| 104 | // A raw socket: fetch goes straight to a loopback host, past any proxy. | |
| 105 | const answer = await new Promise<string>((resolve) => { | |
| 106 | let got = ""; | |
| 107 | void Bun.connect<undefined>({ | |
| 108 | hostname: "127.0.0.1", | |
| 109 | port, | |
| 110 | socket: { | |
| 111 | open(s) { | |
| 112 | s.write(`GET http://127.0.0.1:${upstream.port}/ HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`); | |
| 113 | }, | |
| 114 | data(_s, chunk) { | |
| 115 | got += new TextDecoder().decode(chunk); | |
| 116 | }, | |
| 117 | close() { | |
| 118 | resolve(got); | |
| 119 | }, | |
| 120 | }, | |
| 121 | }); | |
| 122 | }); | |
| 123 | expect(answer).toEndWith("hello"); | |
| 124 | const destination = `127.0.0.1:${upstream.port}`; | |
| 125 | const end = Date.now() + 2_000; | |
| 126 | while (Date.now() < end && egressSnapshot().some((r) => r.label === label)) await Bun.sleep(20); | |
| 127 | expect(egressSnapshot().some((r) => r.label === label)).toBe(false); | |
| 128 | const total = egressTotals().find((t) => t.destination === destination); | |
| 129 | expect(total?.bytesIn).toBeGreaterThan(5); | |
| 130 | expect(total?.bytesOut).toBeGreaterThan(0); | |
| 131 | expect(meter.stop()).toBe(total?.bytesIn ?? -1); | |
| 132 | } finally { | |
| 133 | egress.close(); | |
| 134 | upstream.stop(true); | |
| 135 | } | |
| 136 | }); |
new above
offline