Skip to content

Commit 163d4b8

Browse files
author
DavidQ
committed
Add plugin error handling and recovery.
PR Details: - Improves resilience - Prevents crashes from plugin failures
1 parent 242b31f commit 163d4b8

7 files changed

Lines changed: 250 additions & 38 deletions

File tree

docs/dev/CODEX_COMMANDS.md

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

44
COMMAND:
5-
Implement plugin isolation:
6-
- Enforce boundaries
7-
- Prevent interference
8-
- Handle failures safely
5+
Implement plugin error handling:
6+
- Detect and isolate failures
7+
- Recover safely
98
- Update roadmap status only

docs/dev/COMMIT_COMMENT.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
Add plugin isolation and sandboxing.
1+
Add plugin error handling and recovery.
22

33
PR Details:
4-
- Prevents cross-plugin interference
5-
- Improves system stability
4+
- Improves resilience
5+
- Prevents crashes from plugin failures
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
[ ] Plugins isolated
2-
[ ] Failures contained
3-
[ ] No interference
1+
[ ] Errors detected
2+
[ ] Recovery works
3+
[ ] System stable
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
- [.] validate adding new systems is clean
823823
- [.] validate external integration points
824-
- [ ] ensure future phases can build cleanly
824+
- [.] 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_4_OVERLAY_PLUGIN_ISOLATION_AND_SANDBOXING
1+
# BUILD_PR_LEVEL_20_5_OVERLAY_PLUGIN_ERROR_HANDLING_AND_RECOVERY
22

33
## Purpose
4-
Ensure overlay plugins operate in isolation without impacting core systems or other plugins.
4+
Add robust error handling and recovery for overlay plugins.
55

66
## Roadmap Improvement
7-
Enhances stability and safety of the plugin system.
7+
Improves reliability and resilience of plugin system.
88

99
## Scope
10-
- Define isolation boundaries
11-
- Prevent cross-plugin interference
12-
- Ensure safe failure handling
10+
- Detect plugin errors
11+
- Recover without crashing system
12+
- Isolate faulty plugin
1313

1414
## Test Steps
15-
1. Run multiple plugins
16-
2. Simulate plugin failure
17-
3. Verify isolation maintained
15+
1. Trigger plugin error
16+
2. Verify recovery
17+
3. Confirm system stability
1818

1919
## Expected
20-
- No cross-plugin impact
21-
- Safe failure containment
20+
- Errors handled gracefully
21+
- System remains stable

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

Lines changed: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,26 @@ const PLUGIN_STATES = Object.freeze({
1111
INITIALIZED: 'initialized',
1212
ACTIVE: 'active',
1313
INACTIVE: 'inactive',
14+
FAILED: 'failed',
1415
});
1516

1617
function normalizePluginId(pluginId) {
1718
return String(pluginId || '').trim();
1819
}
1920

21+
function toErrorDetails(error) {
22+
if (error instanceof Error) {
23+
return {
24+
name: String(error.name || 'Error'),
25+
message: String(error.message || 'Unknown plugin error'),
26+
};
27+
}
28+
return {
29+
name: 'Error',
30+
message: String(error || 'Unknown plugin error'),
31+
};
32+
}
33+
2034
function deepFreezeValue(value, seen = new Set()) {
2135
if (!value || typeof value !== 'object') {
2236
return value;
@@ -179,10 +193,63 @@ export default function createPhase19OverlayPluginRegistry({
179193
return !currentOwner || currentOwner === normalizedPluginId;
180194
}
181195

196+
function getRecoveryTargetState(record) {
197+
if (!record) {
198+
return PLUGIN_STATES.REGISTERED;
199+
}
200+
if (record.stateBeforeFailure === PLUGIN_STATES.ACTIVE) {
201+
return PLUGIN_STATES.INACTIVE;
202+
}
203+
if (
204+
record.stateBeforeFailure === PLUGIN_STATES.INITIALIZED
205+
|| record.stateBeforeFailure === PLUGIN_STATES.INACTIVE
206+
|| record.stateBeforeFailure === PLUGIN_STATES.REGISTERED
207+
) {
208+
return record.stateBeforeFailure;
209+
}
210+
return PLUGIN_STATES.REGISTERED;
211+
}
212+
213+
function isolatePluginFailure(record, phase, error, context = {}, options = {}) {
214+
if (!record) {
215+
return false;
216+
}
217+
const shouldUnregisterExtension = options.unregisterExtension !== false;
218+
const shouldQuarantine = options.quarantine !== false;
219+
const { name, message } = toErrorDetails(error);
220+
const failureSnapshot = Object.freeze({
221+
phase: String(phase || 'unknown'),
222+
name,
223+
message,
224+
pluginId: record.plugin.id,
225+
extensionId: record.extension.id,
226+
timestampIso: new Date().toISOString(),
227+
contextReason: String(context?.reason || ''),
228+
});
229+
if (shouldUnregisterExtension) {
230+
try {
231+
expansionFramework.unregisterExtension(record.extension.id);
232+
} catch {
233+
// Failure isolation must never throw.
234+
}
235+
}
236+
record.failureCount = (Number(record.failureCount) || 0) + 1;
237+
record.lastFailure = failureSnapshot;
238+
record.failureHistory.push(failureSnapshot);
239+
if (record.failureHistory.length > 10) {
240+
record.failureHistory.shift();
241+
}
242+
if (shouldQuarantine) {
243+
record.stateBeforeFailure = record.state;
244+
record.state = PLUGIN_STATES.FAILED;
245+
}
246+
return true;
247+
}
248+
182249
function runLifecycleHook(record, phase, context = {}) {
183250
const hook = record?.plugin?.[phase];
184251
if (typeof hook !== 'function') {
185-
return true;
252+
return { ok: true };
186253
}
187254
try {
188255
const previousHookPluginId = activeHookPluginId;
@@ -197,12 +264,12 @@ export default function createPhase19OverlayPluginRegistry({
197264
});
198265
try {
199266
hook(lifecycleContext);
200-
return true;
267+
return { ok: true };
201268
} finally {
202269
activeHookPluginId = previousHookPluginId;
203270
}
204-
} catch {
205-
return false;
271+
} catch (error) {
272+
return { ok: false, error: toErrorDetails(error) };
206273
}
207274
}
208275

@@ -221,7 +288,12 @@ export default function createPhase19OverlayPluginRegistry({
221288
if (record.state !== PLUGIN_STATES.REGISTERED) {
222289
return false;
223290
}
224-
if (!runLifecycleHook(record, 'init', context)) {
291+
const initResult = runLifecycleHook(record, 'init', context);
292+
if (!initResult.ok) {
293+
isolatePluginFailure(record, 'init', initResult.error, context, {
294+
unregisterExtension: true,
295+
quarantine: true,
296+
});
225297
return false;
226298
}
227299
record.state = PLUGIN_STATES.INITIALIZED;
@@ -241,6 +313,9 @@ export default function createPhase19OverlayPluginRegistry({
241313
if (record.state === PLUGIN_STATES.ACTIVE) {
242314
return false;
243315
}
316+
if (record.state === PLUGIN_STATES.FAILED) {
317+
return false;
318+
}
244319
if (record.state === PLUGIN_STATES.REGISTERED) {
245320
if (!initPlugin(pluginId, context)) {
246321
return false;
@@ -255,11 +330,19 @@ export default function createPhase19OverlayPluginRegistry({
255330

256331
const registered = expansionFramework.registerExtension(record.extension);
257332
if (!registered || !registered.id) {
333+
isolatePluginFailure(record, 'activate-register', new Error('extension registration failed'), context, {
334+
unregisterExtension: true,
335+
quarantine: true,
336+
});
258337
return false;
259338
}
260339

261-
if (!runLifecycleHook(record, 'activate', context)) {
262-
expansionFramework.unregisterExtension(record.extension.id);
340+
const activateResult = runLifecycleHook(record, 'activate', context);
341+
if (!activateResult.ok) {
342+
isolatePluginFailure(record, 'activate', activateResult.error, context, {
343+
unregisterExtension: true,
344+
quarantine: true,
345+
});
263346
return false;
264347
}
265348

@@ -280,7 +363,12 @@ export default function createPhase19OverlayPluginRegistry({
280363
if (record.state !== PLUGIN_STATES.ACTIVE) {
281364
return false;
282365
}
283-
if (!runLifecycleHook(record, 'deactivate', context)) {
366+
const deactivateResult = runLifecycleHook(record, 'deactivate', context);
367+
if (!deactivateResult.ok) {
368+
isolatePluginFailure(record, 'deactivate', deactivateResult.error, context, {
369+
unregisterExtension: true,
370+
quarantine: true,
371+
});
284372
return false;
285373
}
286374

@@ -305,7 +393,12 @@ export default function createPhase19OverlayPluginRegistry({
305393
return false;
306394
}
307395
}
308-
if (!runLifecycleHook(record, 'destroy', context)) {
396+
const destroyResult = runLifecycleHook(record, 'destroy', context);
397+
if (!destroyResult.ok) {
398+
isolatePluginFailure(record, 'destroy', destroyResult.error, context, {
399+
unregisterExtension: true,
400+
quarantine: true,
401+
});
309402
return false;
310403
}
311404

@@ -351,14 +444,16 @@ export default function createPhase19OverlayPluginRegistry({
351444
plugin: normalizedPlugin,
352445
extension: resolvedExtension,
353446
state: PLUGIN_STATES.REGISTERED,
447+
stateBeforeFailure: '',
448+
failureCount: 0,
449+
lastFailure: null,
450+
failureHistory: [],
354451
};
355452
pluginRecordMap.set(pluginId, record);
356453
extensionOwnerMap.set(resolvedExtension.id, pluginId);
357454

358455
if (autoActivate) {
359456
if (!activatePlugin(pluginId, context)) {
360-
extensionOwnerMap.delete(resolvedExtension.id);
361-
pluginRecordMap.delete(pluginId);
362457
throw new Error(`Overlay plugin "${pluginId}" failed lifecycle activation.`);
363458
}
364459
}
@@ -373,6 +468,34 @@ export default function createPhase19OverlayPluginRegistry({
373468
return destroyPlugin(pluginId, context);
374469
}
375470

471+
function recoverPlugin(pluginId, options = {}) {
472+
if (!canMutate(pluginId)) {
473+
return false;
474+
}
475+
const context = options?.context || {};
476+
const activate = options?.activate === true;
477+
const record = getPluginRecord(pluginId);
478+
if (!record || record.state !== PLUGIN_STATES.FAILED) {
479+
return false;
480+
}
481+
return withPluginTransition(record.plugin.id, () => {
482+
const recoveryState = getRecoveryTargetState(record);
483+
try {
484+
expansionFramework.unregisterExtension(record.extension.id);
485+
} catch {
486+
// Recovery should continue even if extension was already removed.
487+
}
488+
record.state = recoveryState;
489+
record.stateBeforeFailure = '';
490+
record.lastFailure = null;
491+
record.failureHistory = [];
492+
if (!activate) {
493+
return true;
494+
}
495+
return activatePlugin(pluginId, { ...context, reason: context?.reason || 'recover-activate' });
496+
});
497+
}
498+
376499
function getPlugin(pluginId) {
377500
const record = getPluginRecord(pluginId);
378501
return record ? record.plugin : null;
@@ -388,6 +511,41 @@ export default function createPhase19OverlayPluginRegistry({
388511
return record?.extension?.id || '';
389512
}
390513

514+
function getPluginFailure(pluginId) {
515+
const record = getPluginRecord(pluginId);
516+
if (!record || !record.lastFailure) {
517+
return null;
518+
}
519+
return record.lastFailure;
520+
}
521+
522+
function listPluginFailures() {
523+
const failures = [];
524+
for (const [pluginId, record] of pluginRecordMap.entries()) {
525+
if (!record.lastFailure) {
526+
continue;
527+
}
528+
failures.push({
529+
pluginId,
530+
extensionId: record.extension.id,
531+
state: record.state,
532+
failureCount: record.failureCount,
533+
lastFailure: record.lastFailure,
534+
});
535+
}
536+
return Object.freeze(failures);
537+
}
538+
539+
function clearPluginFailure(pluginId) {
540+
const record = getPluginRecord(pluginId);
541+
if (!record || !record.lastFailure) {
542+
return false;
543+
}
544+
record.lastFailure = null;
545+
record.failureHistory = [];
546+
return true;
547+
}
548+
391549
function listPlugins() {
392550
const entries = [];
393551
for (const [pluginId, record] of pluginRecordMap.entries()) {
@@ -396,6 +554,7 @@ export default function createPhase19OverlayPluginRegistry({
396554
version: record.plugin.version,
397555
extensionId: record.extension.id,
398556
state: record.state,
557+
failureCount: record.failureCount,
399558
});
400559
}
401560
return Object.freeze(entries);
@@ -407,10 +566,14 @@ export default function createPhase19OverlayPluginRegistry({
407566
activatePlugin,
408567
deactivatePlugin,
409568
destroyPlugin,
569+
recoverPlugin,
410570
unregisterPlugin,
411571
getPlugin,
412572
getPluginState,
413573
getPluginExtensionId,
574+
getPluginFailure,
575+
listPluginFailures,
576+
clearPluginFailure,
414577
listPlugins,
415578
states: PLUGIN_STATES,
416579
getFramework() {

0 commit comments

Comments
 (0)