make extension activation transactional (T-377)

Three lifecycle gaps, benign among curated builtins but hazardous the
day Tier-6 Lua extensions land: a throw mid-contribution left earlier
contributions mounted while the extension recorded as failed (a retry
then double-applied them); deactivate ignored active dependents; and
the panel/command registries silently clobbered on id collision.

Activation now tracks what it mounted and unwinds it all on failure
(including the extension's own deactivate when its activate had
succeeded); deactivate refuses with a logged warning while active
dependents exist — disable the dependents first; duplicate
contribution/command ids throw, which the transactional path turns
into a clean failed activation with first-wins semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:35:24 +02:00
co-authored by Claude Fable 5
parent c31f5bfb14
commit bc3c47ee81
6 changed files with 146 additions and 0 deletions
@@ -315,6 +315,86 @@ void main() {
expect(ctx.project, same(f.services.project));
expect(ctx.ipc, same(f.services.ipc));
});
// T-377: activation is transactional, deactivation respects dependents,
// and duplicate contribution ids are rejected, not silently clobbered.
group('lifecycle hardening (T-377)', () {
test('a throw mid-contribution unwinds everything already mounted', () async {
// The tab mounts first, then the duplicate command id throws.
f.services.commands.register(CommandContribution(id: 'taken', command: 'taken.cmd', run: (_) async => IpcResponse.ok(id: '')));
f.services.extensions.register(
_Ext(
id: 'half-mounts',
contributions: [
TabContribution(id: 'half.view', slot: Slots.workspace, title: 'T', build: (_) => const SizedBox.shrink()),
CommandContribution(id: 'half.cmd', command: 'taken.cmd', run: (_) async => IpcResponse.ok(id: '')),
],
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.isActivated('half-mounts'), isFalse);
expect(f.services.extensions.didFail('half-mounts'), isTrue);
expect(f.services.panels.hasContribution('half.view'), isFalse, reason: 'the mounted tab must be unwound');
});
test('a failed activation can retry cleanly without double-applying', () async {
var attempts = 0;
f.services.extensions.register(
_Ext(
id: 'flaky',
contributions: [TabContribution(id: 'flaky.view', slot: Slots.workspace, title: 'T', build: (_) => const SizedBox.shrink())],
onActivate: (_) async {
attempts++;
if (attempts == 1) throw StateError('first attempt fails');
},
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.didFail('flaky'), isTrue);
await f.services.extensions.activate('flaky');
expect(f.services.extensions.isActivated('flaky'), isTrue);
expect(f.services.extensions.didFail('flaky'), isFalse);
expect(f.services.panels.tabsFor(Slots.workspace).where((t) => t.id == 'flaky.view'), hasLength(1), reason: 'exactly one mount after the retry');
});
test('deactivate refuses while an active extension depends on it', () async {
f.services.extensions
..register(_Ext(id: 'base'))
..register(_Ext(id: 'leaf', dependsOn: const ['base']));
await f.services.extensions.activateAll();
await f.services.extensions.deactivate('base');
expect(f.services.extensions.isActivated('base'), isTrue, reason: 'refused: leaf still depends on base');
await f.services.extensions.deactivate('leaf');
await f.services.extensions.deactivate('base');
expect(f.services.extensions.isActivated('base'), isFalse, reason: 'allowed once the dependent is gone');
});
test('a duplicate contribution id fails the second activation', () async {
f.services.extensions
..register(
_Ext(
id: 'first',
contributions: [TabContribution(id: 'shared.view', slot: Slots.workspace, title: 'A', build: (_) => const SizedBox.shrink())],
),
)
..register(
_Ext(
id: 'second',
contributions: [TabContribution(id: 'shared.view', slot: Slots.workspace, title: 'B', build: (_) => const SizedBox.shrink())],
),
);
await f.services.extensions.activateAll();
expect(f.services.extensions.isActivated('first'), isTrue);
expect(f.services.extensions.isActivated('second'), isFalse);
expect(f.services.extensions.didFail('second'), isTrue);
expect(f.services.panels.tabsFor(Slots.workspace).where((t) => t.id == 'shared.view'), hasLength(1), reason: 'first-wins, no clobber');
});
});
});
}