Files
NetMesh/electron/plugins/quotaManager.cjs

216 lines
6.9 KiB
JavaScript
Raw Normal View History

"use strict";
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const DEFAULT_QUOTAS = Object.freeze({
messageBurst: 512,
messagesPerSecond: 256,
concurrentCapabilities: 32,
capabilityBurst: 120,
capabilitiesPerMinute: 600,
logWritesPerMinute: 240,
bytesPerMinute: 32 * 1024 * 1024,
memoryBytes: 256 * 1024 * 1024,
cpuPercent: 80,
sustainedCpuSamples: 3,
sampleIntervalMs: 5_000,
});
function quotaError(message) {
return new PluginRpcError(RPC_ERRORS.resourceExhausted, message, {
pluginCode: "resource_exhausted",
});
}
class TokenBucket {
constructor(capacity, refillPerMs, clock) {
this.capacity = capacity;
this.refillPerMs = refillPerMs;
this.clock = clock;
this.tokens = capacity;
this.updatedAt = clock();
}
consume(amount = 1) {
const now = this.clock();
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.updatedAt) * this.refillPerMs));
this.updatedAt = now;
if (this.tokens < amount) return false;
this.tokens -= amount;
return true;
}
}
class PluginQuotaManager {
constructor(options = {}) {
this.clock = options.clock ?? (() => Date.now());
this.quotas = Object.freeze({ ...DEFAULT_QUOTAS, ...(options.quotas ?? {}) });
this.getProcessMetrics = options.getProcessMetrics ?? null;
this.onViolation = options.onViolation ?? (() => {});
this.setInterval = options.setInterval ?? setInterval;
this.clearInterval = options.clearInterval ?? clearInterval;
this.messageBuckets = new Map();
this.capabilityBuckets = new Map();
this.logBuckets = new Map();
this.concurrent = new Map();
this.byteWindows = new Map();
this.monitors = new Map();
}
setViolationHandler(handler) {
if (typeof handler !== "function") throw new TypeError("Plugin quota violation handler must be a function");
this.onViolation = handler;
}
#bucket(collection, key, capacity, refillPerMs) {
let bucket = collection.get(key);
if (!bucket) {
bucket = new TokenBucket(capacity, refillPerMs, this.clock);
collection.set(key, bucket);
}
return bucket;
}
guardMessage(identity) {
const bucket = this.#bucket(
this.messageBuckets,
identity.runtimeId,
this.quotas.messageBurst,
this.quotas.messagesPerSecond / 1_000,
);
if (!bucket.consume()) throw quotaError("Plugin runtime message rate exceeded");
}
createMiddleware() {
return async (context, next) => {
const runtimeId = context.runtimeId;
const current = this.concurrent.get(runtimeId) ?? 0;
if (current >= this.quotas.concurrentCapabilities) {
throw quotaError("Plugin concurrent capability limit exceeded");
}
const capabilityBucket = this.#bucket(
this.capabilityBuckets,
runtimeId,
this.quotas.capabilityBurst,
this.quotas.capabilitiesPerMinute / 60_000,
);
if (!capabilityBucket.consume()) throw quotaError("Plugin capability request rate exceeded");
if (context.method === "log.write") {
const logBucket = this.#bucket(
this.logBuckets,
runtimeId,
this.quotas.logWritesPerMinute,
this.quotas.logWritesPerMinute / 60_000,
);
if (!logBucket.consume()) throw quotaError("Plugin log rate exceeded");
}
this.concurrent.set(runtimeId, current + 1);
try { return await next(); }
finally {
const remaining = (this.concurrent.get(runtimeId) ?? 1) - 1;
if (remaining <= 0) this.concurrent.delete(runtimeId);
else this.concurrent.set(runtimeId, remaining);
}
};
}
chargeBytes(runtimeId, category, byteLength) {
if (!Number.isSafeInteger(byteLength) || byteLength < 0) throw new TypeError("Plugin quota bytes are invalid");
const key = `${runtimeId}\0${category}`;
const now = this.clock();
let window = this.byteWindows.get(key);
if (!window || now - window.startedAt >= 60_000) {
window = { startedAt: now, bytes: 0 };
this.byteWindows.set(key, window);
}
window.bytes += byteLength;
if (window.bytes > this.quotas.bytesPerMinute) {
throw quotaError(`Plugin ${category} byte quota exceeded`);
}
}
trackRuntime(identity, runtime) {
return this.trackProcess(identity.runtimeId, identity, runtime);
}
trackProcess(resourceId, identity, processHandle) {
this.releaseProcess(resourceId);
if (!this.getProcessMetrics || typeof processHandle.getProcessId !== "function") {
return Object.freeze({ dispose() {} });
}
let disposed = false;
let sampling = false;
let sustainedCpu = 0;
const sample = async () => {
if (disposed || sampling) return;
sampling = true;
try {
const pid = processHandle.getProcessId();
if (!Number.isSafeInteger(pid) || pid <= 0) return;
const metrics = await this.getProcessMetrics(pid);
if (!metrics) return;
if (metrics.memoryBytes > this.quotas.memoryBytes) {
disposed = true;
this.clearInterval(timer);
await this.onViolation(identity, quotaError("Plugin memory quota exceeded"));
return;
}
sustainedCpu = metrics.cpuPercent > this.quotas.cpuPercent ? sustainedCpu + 1 : 0;
if (sustainedCpu >= this.quotas.sustainedCpuSamples) {
disposed = true;
this.clearInterval(timer);
await this.onViolation(identity, quotaError("Plugin sustained CPU quota exceeded"));
}
} catch {
// Metrics are advisory. Capability and transport quotas remain fail-closed.
} finally {
sampling = false;
}
};
const timer = this.setInterval(sample, this.quotas.sampleIntervalMs);
timer.unref?.();
const monitor = Object.freeze({
dispose: () => {
if (disposed) return;
disposed = true;
this.clearInterval(timer);
},
});
this.monitors.set(resourceId, monitor);
void sample();
return monitor;
}
releaseProcess(resourceId) {
this.monitors.get(resourceId)?.dispose();
this.monitors.delete(resourceId);
}
releaseRuntime(runtimeId) {
for (const resourceId of [...this.monitors.keys()]) {
if (resourceId === runtimeId || resourceId.startsWith(`${runtimeId}\0`)) {
this.releaseProcess(resourceId);
}
}
this.messageBuckets.delete(runtimeId);
this.capabilityBuckets.delete(runtimeId);
this.logBuckets.delete(runtimeId);
this.concurrent.delete(runtimeId);
for (const key of [...this.byteWindows.keys()]) {
if (key.startsWith(`${runtimeId}\0`)) this.byteWindows.delete(key);
}
}
shutdown() {
for (const monitor of this.monitors.values()) monitor.dispose();
this.monitors.clear();
this.messageBuckets.clear();
this.capabilityBuckets.clear();
this.logBuckets.clear();
this.concurrent.clear();
this.byteWindows.clear();
}
}
module.exports = { DEFAULT_QUOTAS, PluginQuotaManager, TokenBucket, quotaError };