Skip to content

Commit d0b65d4

Browse files
author
DavidQ
committed
Add plugin resource limits.
PR Details: - Prevents resource abuse
1 parent 62bd7a5 commit d0b65d4

7 files changed

Lines changed: 214 additions & 26 deletions

File tree

docs/dev/CODEX_COMMANDS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ MODEL: GPT-5.4
22
REASONING: medium
33

44
COMMAND:
5-
Implement plugin performance monitoring:
6-
- Track execution metrics
7-
- Provide diagnostics
5+
Implement plugin resource limits:
6+
- Enforce CPU/memory caps
7+
- Maintain stability
88
- Update roadmap status only

docs/dev/COMMIT_COMMENT.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Add plugin performance monitoring.
1+
Add plugin resource limits.
22

33
PR Details:
4-
- Enables tracking of plugin performance
4+
- Prevents resource abuse
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
[ ] Metrics captured
2-
[ ] Reporting works
3-
[ ] No regression
1+
[ ] Limits enforced
2+
[ ] System stable
3+
[ ] No degradation
44
[ ] Roadmap updated

docs/dev/roadmaps/MASTER_ROADMAP_HIGH_LEVEL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@
821821
- [x] validate plugin/extension patterns
822822
- [x] validate adding new systems is clean
823823
- [.] validate external integration points
824-
- [.] ensure future phases can build cleanly
824+
- [x] ensure future phases can build cleanly
825825

826826
### Track H — Final Stability Gate
827827
- [ ] full-repo validation sweep

docs/pr/BUILD_PR.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
# BUILD_PR_LEVEL_20_6_OVERLAY_PLUGIN_PERFORMANCE_MONITORING
1+
# BUILD_PR_LEVEL_20_7_OVERLAY_PLUGIN_RESOURCE_LIMITS
22

33
## Purpose
4-
Introduce performance monitoring for overlay plugins.
4+
Enforce resource limits on overlay plugins.
55

66
## Roadmap Improvement
7-
Provides visibility into plugin performance and impact.
7+
Prevents plugins from degrading system performance.
88

99
## Scope
10-
- Track plugin execution time
11-
- Identify slow or heavy plugins
12-
- Provide basic diagnostics
10+
- Define CPU/memory limits
11+
- Enforce limits
12+
- Validate behavior under limits
1313

1414
## Test Steps
15-
1. Run plugins
16-
2. Monitor performance metrics
17-
3. Validate reporting
15+
1. Run heavy plugin
16+
2. Verify limits enforced
17+
3. Confirm system stability
1818

1919
## Expected
20-
- Performance metrics available
21-
- No performance regression
20+
- Limits enforced
21+
- No system degradation

samples/phase-19/shared/overlay/createPhase19OverlayPluginRegistry.js

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,32 @@ const PLUGIN_STATES = Object.freeze({
1414
FAILED: 'failed',
1515
});
1616

17+
const DEFAULT_RESOURCE_LIMITS = Object.freeze({
18+
maxHookDurationMs: 24,
19+
maxHeapUsedBytes: 512 * 1024 * 1024,
20+
maxHeapDeltaBytes: 8 * 1024 * 1024,
21+
});
22+
1723
function normalizePluginId(pluginId) {
1824
return String(pluginId || '').trim();
1925
}
2026

27+
function normalizeLimit(value, fallback) {
28+
const numeric = Number(value);
29+
if (!Number.isFinite(numeric) || numeric <= 0) {
30+
return fallback;
31+
}
32+
return numeric;
33+
}
34+
35+
function normalizeResourceLimits(resourceLimits = {}) {
36+
return Object.freeze({
37+
maxHookDurationMs: normalizeLimit(resourceLimits?.maxHookDurationMs, DEFAULT_RESOURCE_LIMITS.maxHookDurationMs),
38+
maxHeapUsedBytes: normalizeLimit(resourceLimits?.maxHeapUsedBytes, DEFAULT_RESOURCE_LIMITS.maxHeapUsedBytes),
39+
maxHeapDeltaBytes: normalizeLimit(resourceLimits?.maxHeapDeltaBytes, DEFAULT_RESOURCE_LIMITS.maxHeapDeltaBytes),
40+
});
41+
}
42+
2143
function toErrorDetails(error) {
2244
if (error && typeof error === 'object' && typeof error.message === 'string') {
2345
return {
@@ -73,6 +95,31 @@ function getNowMs() {
7395
return Date.now();
7496
}
7597

98+
function readHeapUsageBytes() {
99+
try {
100+
if (typeof globalThis?.process?.memoryUsage === 'function') {
101+
const usage = globalThis.process.memoryUsage();
102+
const heapUsed = Number(usage?.heapUsed);
103+
if (Number.isFinite(heapUsed) && heapUsed >= 0) {
104+
return heapUsed;
105+
}
106+
}
107+
} catch {
108+
// Heap metrics are best-effort for diagnostics.
109+
}
110+
111+
try {
112+
const browserHeap = Number(globalThis?.performance?.memory?.usedJSHeapSize);
113+
if (Number.isFinite(browserHeap) && browserHeap >= 0) {
114+
return browserHeap;
115+
}
116+
} catch {
117+
// Ignore unavailable browser heap APIs.
118+
}
119+
120+
return 0;
121+
}
122+
76123
function createHookMetrics() {
77124
return {
78125
calls: 0,
@@ -114,6 +161,14 @@ function createPluginMetrics() {
114161
lastRegisteredAtIso: '',
115162
lastExtensionId: '',
116163
},
164+
resources: {
165+
cpuViolations: 0,
166+
memoryViolations: 0,
167+
lastHookDurationMs: 0,
168+
lastHeapUsedBytes: 0,
169+
lastHeapDeltaBytes: 0,
170+
lastViolationMessage: '',
171+
},
117172
lastState: '',
118173
lastStateChangedAtIso: '',
119174
};
@@ -148,6 +203,7 @@ function normalizePluginDefinition(plugin) {
148203
id,
149204
version: String(plugin.version || '').trim(),
150205
metadata: Object.freeze({ ...(plugin.metadata || {}) }),
206+
resourceLimits: normalizeResourceLimits(plugin.resourceLimits),
151207
init: typeof plugin.init === 'function' ? plugin.init : (typeof plugin.onInit === 'function' ? plugin.onInit : null),
152208
activate: typeof plugin.activate === 'function'
153209
? plugin.activate
@@ -322,10 +378,60 @@ export default function createPhase19OverlayPluginRegistry({
322378
return true;
323379
}
324380

381+
function evaluateResourceLimitViolations(record, phase, durationMs, heapUsedBytes, heapDeltaBytes) {
382+
const limits = record?.plugin?.resourceLimits || DEFAULT_RESOURCE_LIMITS;
383+
const duration = Math.max(0, Number(durationMs) || 0);
384+
const heapUsed = Math.max(0, Number(heapUsedBytes) || 0);
385+
const heapDelta = Math.max(0, Number(heapDeltaBytes) || 0);
386+
const cpuExceeded = duration > limits.maxHookDurationMs;
387+
const heapExceeded = heapUsed > limits.maxHeapUsedBytes;
388+
const heapDeltaExceeded = heapDelta > limits.maxHeapDeltaBytes;
389+
390+
if (record?.metrics?.resources) {
391+
record.metrics.resources.lastHookDurationMs = duration;
392+
record.metrics.resources.lastHeapUsedBytes = heapUsed;
393+
record.metrics.resources.lastHeapDeltaBytes = heapDelta;
394+
if (cpuExceeded) {
395+
record.metrics.resources.cpuViolations += 1;
396+
}
397+
if (heapExceeded || heapDeltaExceeded) {
398+
record.metrics.resources.memoryViolations += 1;
399+
}
400+
}
401+
402+
if (!cpuExceeded && !heapExceeded && !heapDeltaExceeded) {
403+
return { ok: true };
404+
}
405+
406+
const reasons = [];
407+
if (cpuExceeded) {
408+
reasons.push(`CPU ${duration.toFixed(3)}ms > ${limits.maxHookDurationMs}ms`);
409+
}
410+
if (heapExceeded) {
411+
reasons.push(`heapUsed ${heapUsed}B > ${limits.maxHeapUsedBytes}B`);
412+
}
413+
if (heapDeltaExceeded) {
414+
reasons.push(`heapDelta ${heapDelta}B > ${limits.maxHeapDeltaBytes}B`);
415+
}
416+
const message = `Resource limit exceeded (${reasons.join(', ')})`;
417+
if (record?.metrics?.resources) {
418+
record.metrics.resources.lastViolationMessage = message;
419+
}
420+
return {
421+
ok: false,
422+
phase: `${phase}-resource-limit`,
423+
error: {
424+
name: 'ResourceLimitError',
425+
message,
426+
},
427+
};
428+
}
429+
325430
function runLifecycleHook(record, phase, context = {}) {
326431
const bucket = record?.metrics?.hooks?.[phase] || null;
327432
const hook = record?.plugin?.[phase];
328433
const startedAtMs = getNowMs();
434+
const startedHeapBytes = readHeapUsageBytes();
329435
const startedAtIso = new Date().toISOString();
330436
if (bucket) {
331437
bucket.calls += 1;
@@ -334,14 +440,16 @@ export default function createPhase19OverlayPluginRegistry({
334440
if (typeof hook !== 'function') {
335441
const finishedAtMs = getNowMs();
336442
const durationMs = Math.max(0, finishedAtMs - startedAtMs);
443+
const finishedHeapBytes = readHeapUsageBytes();
444+
const heapDeltaBytes = Math.max(0, finishedHeapBytes - startedHeapBytes);
337445
if (bucket) {
338446
bucket.successes += 1;
339447
bucket.totalDurationMs += durationMs;
340448
bucket.lastDurationMs = durationMs;
341449
bucket.lastFinishedAtIso = new Date().toISOString();
342450
bucket.lastErrorMessage = '';
343451
}
344-
return { ok: true };
452+
return evaluateResourceLimitViolations(record, phase, durationMs, finishedHeapBytes, heapDeltaBytes);
345453
}
346454
try {
347455
const previousHookPluginId = activeHookPluginId;
@@ -358,14 +466,16 @@ export default function createPhase19OverlayPluginRegistry({
358466
hook(lifecycleContext);
359467
const finishedAtMs = getNowMs();
360468
const durationMs = Math.max(0, finishedAtMs - startedAtMs);
469+
const finishedHeapBytes = readHeapUsageBytes();
470+
const heapDeltaBytes = Math.max(0, finishedHeapBytes - startedHeapBytes);
361471
if (bucket) {
362472
bucket.successes += 1;
363473
bucket.totalDurationMs += durationMs;
364474
bucket.lastDurationMs = durationMs;
365475
bucket.lastFinishedAtIso = new Date().toISOString();
366476
bucket.lastErrorMessage = '';
367477
}
368-
return { ok: true };
478+
return evaluateResourceLimitViolations(record, phase, durationMs, finishedHeapBytes, heapDeltaBytes);
369479
} finally {
370480
activeHookPluginId = previousHookPluginId;
371481
}
@@ -404,7 +514,7 @@ export default function createPhase19OverlayPluginRegistry({
404514
}
405515
const initResult = runLifecycleHook(record, 'init', context);
406516
if (!initResult.ok) {
407-
isolatePluginFailure(record, 'init', initResult.error, context, {
517+
isolatePluginFailure(record, initResult.phase || 'init', initResult.error, context, {
408518
unregisterExtension: true,
409519
quarantine: true,
410520
});
@@ -473,7 +583,7 @@ export default function createPhase19OverlayPluginRegistry({
473583

474584
const activateResult = runLifecycleHook(record, 'activate', context);
475585
if (!activateResult.ok) {
476-
isolatePluginFailure(record, 'activate', activateResult.error, context, {
586+
isolatePluginFailure(record, activateResult.phase || 'activate', activateResult.error, context, {
477587
unregisterExtension: true,
478588
quarantine: true,
479589
});
@@ -510,7 +620,7 @@ export default function createPhase19OverlayPluginRegistry({
510620
}
511621
const deactivateResult = runLifecycleHook(record, 'deactivate', context);
512622
if (!deactivateResult.ok) {
513-
isolatePluginFailure(record, 'deactivate', deactivateResult.error, context, {
623+
isolatePluginFailure(record, deactivateResult.phase || 'deactivate', deactivateResult.error, context, {
514624
unregisterExtension: true,
515625
quarantine: true,
516626
});
@@ -548,7 +658,7 @@ export default function createPhase19OverlayPluginRegistry({
548658
}
549659
const destroyResult = runLifecycleHook(record, 'destroy', context);
550660
if (!destroyResult.ok) {
551-
isolatePluginFailure(record, 'destroy', destroyResult.error, context, {
661+
isolatePluginFailure(record, destroyResult.phase || 'destroy', destroyResult.error, context, {
552662
unregisterExtension: true,
553663
quarantine: true,
554664
});
@@ -728,6 +838,7 @@ export default function createPhase19OverlayPluginRegistry({
728838
version: record.plugin.version,
729839
extensionId: record.extension.id,
730840
state: record.state,
841+
resourceLimits: record.plugin.resourceLimits,
731842
failureCount: record.failureCount,
732843
lastFailure: record.lastFailure,
733844
metrics: snapshotPluginMetrics(record.metrics),
@@ -801,6 +912,8 @@ export default function createPhase19OverlayPluginRegistry({
801912
failureCount: record.failureCount,
802913
transitionAttempts: record.metrics?.transitions?.attempted || 0,
803914
transitionFailures: record.metrics?.transitions?.failed || 0,
915+
cpuViolations: record.metrics?.resources?.cpuViolations || 0,
916+
memoryViolations: record.metrics?.resources?.memoryViolations || 0,
804917
});
805918
}
806919
return Object.freeze(entries);

tests/runtime/Phase19OverlayPluginRegistry.test.mjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,86 @@ function assertPluginBoundaryIsolationAndInterferenceProtection() {
372372
);
373373
}
374374

375+
function assertPluginResourceLimitEnforcementAndDiagnostics() {
376+
const registry = createPhase19OverlayPluginRegistry();
377+
378+
registry.registerPlugin({
379+
id: 'phase19.resource.cpu',
380+
resourceLimits: {
381+
maxHookDurationMs: 1,
382+
maxHeapUsedBytes: 1024 * 1024 * 1024,
383+
maxHeapDeltaBytes: 1024 * 1024 * 1024,
384+
},
385+
createOverlayExtension() {
386+
return definePhase19OverlayExtension({
387+
id: 'phase19.resource.cpu.overlay',
388+
overlays: [{ id: 'runtime', label: 'Runtime' }],
389+
});
390+
},
391+
activate() {
392+
const start = Date.now();
393+
while (Date.now() - start < 6) {
394+
// Busy wait to force CPU cap exceedance.
395+
}
396+
},
397+
}, { autoActivate: false });
398+
399+
assert.equal(registry.activatePlugin('phase19.resource.cpu'), false, 'CPU cap exceedance should block activation safely.');
400+
assert.equal(registry.getPluginState('phase19.resource.cpu'), registry.states.FAILED, 'CPU cap exceedance should quarantine plugin.');
401+
assert.equal(
402+
String(registry.getPluginFailure('phase19.resource.cpu')?.phase || '').includes('resource-limit'),
403+
true,
404+
'CPU cap exceedance should be reported as resource-limit phase.'
405+
);
406+
const cpuMetrics = registry.getPluginMetrics('phase19.resource.cpu');
407+
assert.equal(cpuMetrics.resources.cpuViolations >= 1, true, 'CPU cap exceedance should increment CPU violation metrics.');
408+
assert.equal(
409+
registry.getFramework().getExtension('phase19.resource.cpu.overlay'),
410+
null,
411+
'CPU cap exceedance should isolate plugin by removing active extension registration.'
412+
);
413+
414+
registry.registerPlugin({
415+
id: 'phase19.resource.memory',
416+
resourceLimits: {
417+
maxHookDurationMs: 1000,
418+
maxHeapUsedBytes: 1,
419+
maxHeapDeltaBytes: 1024,
420+
},
421+
createOverlayExtension() {
422+
return definePhase19OverlayExtension({
423+
id: 'phase19.resource.memory.overlay',
424+
overlays: [{ id: 'runtime', label: 'Runtime' }],
425+
});
426+
},
427+
}, { autoActivate: false });
428+
429+
assert.equal(registry.initPlugin('phase19.resource.memory'), false, 'Memory cap exceedance should block initialization safely.');
430+
assert.equal(registry.getPluginState('phase19.resource.memory'), registry.states.FAILED, 'Memory cap exceedance should quarantine plugin.');
431+
const memoryMetrics = registry.getPluginMetrics('phase19.resource.memory');
432+
assert.equal(memoryMetrics.resources.memoryViolations >= 1, true, 'Memory cap exceedance should increment memory violation metrics.');
433+
434+
const diagnostics = registry.getPluginDiagnostics('phase19.resource.memory');
435+
assert.notEqual(diagnostics, null, 'Resource-limited plugin should expose diagnostics snapshot.');
436+
assert.equal(Boolean(diagnostics.resourceLimits), true, 'Diagnostics should include configured resource limits.');
437+
assert.equal(
438+
diagnostics.resourceLimits.maxHeapUsedBytes,
439+
1,
440+
'Diagnostics should expose memory cap configuration.'
441+
);
442+
assert.equal(
443+
registry.listPluginMetrics().some((entry) => entry.pluginId === 'phase19.resource.memory'),
444+
true,
445+
'Metrics listing should include resource-limited plugin.'
446+
);
447+
}
448+
375449
export function run() {
376450
assertPluginRegistrationAndRuntimeCompatibility();
377451
assertPluginUnregisterCleansFramework();
378452
assertPluginLifecycleTransitionsAndSafety();
379453
assertPluginLifecycleFailureIsolation();
380454
assertPluginFailureRecoveryFlow();
381455
assertPluginBoundaryIsolationAndInterferenceProtection();
456+
assertPluginResourceLimitEnforcementAndDiagnostics();
382457
}

0 commit comments

Comments
 (0)