# Troubleshooting

This page is the index of "I saw X, what went wrong?" entries. Each one is a symptom, the cause behind it, and the fix. Most are referenced individually from concept and guide pages; this page is where you go when you do not know which page to look on.

If you have not yet started using the library, the [Getting Started](https://plugin-kit.saad-ardati.dev/getting-started/) page and the [concepts](https://plugin-kit.saad-ardati.dev/concepts/plugins/) are the better entry points. This page assumes you have already hit something confusing.

For "why does it work this way" questions about design choices, see the [FAQ](https://plugin-kit.saad-ardati.dev/faq/).

## Plugin lifecycle issues

### I get `LateInitializationError` reading `service.pluginId` or `service.serviceId`

**Cause.** Both fields on `PluginService` are declared `late`. They are stamped by the `ServiceRegistry` at resolution time, not at construction. If your test code constructs a service directly with `MyService()` and then reads `service.pluginId` before any `resolve<T>` call, you get `LateInitializationError`.

**Fix.** Construct the service through a `ServiceRegistry` so resolution actually runs. The simplest path is to register the service through its owning plugin and resolve from a test context. See the [Testing guide](https://plugin-kit.saad-ardati.dev/guides/testing/): the "does this service compute correctly?" section covers pure-logic tests that do not need the stamped identity; the "does it interact with the bus and registry?" section uses `SessionPluginContext.stub()` plus a registered service so `pluginId` and `serviceId` are populated.

If your assertions never read `pluginId` or `serviceId`, the pure-logic path is enough. The moment they touch identity, resolve through a registry instead.

### I get a `StateError` on `runtime.init` or `runtime.createSession` mentioning a missing factory

**Cause.** You declared a custom global or session context type but did not pass the corresponding factory. The runtime cannot construct an arbitrary subtype of `GlobalPluginContext` or `SessionPluginContext` on its own, so when the type parameter is anything other than the default base, the factory is mandatory.

The exact errors look like:

```
StateError: globalContextFactory is required when using a custom global context type (MyGlobalContext). The default factory creates a GlobalPluginContext, which cannot be assigned to MyGlobalContext.
```

```
StateError: contextFactory is required when using a custom session context type (MySessionContext). ...
```

**Fix.** Pass the factory:

```dart
extractRegion(customContextSource, 'custom-context-runtime-init')
```

See [Custom Context](https://plugin-kit.saad-ardati.dev/concepts/custom-context/) for the full pattern, including how to keep your plugins typed against the custom context.

### Two back-to-back plugin toggles throw `StateError`

**Cause.** `updateSessionSettings` rejects concurrent reconciliations. If a second toggle starts while the first one is still reconciling, the runtime throws `StateError` from `PluginRuntime._enterReconcile(...)`.

**Fix.** Serialize the toggles so only one reconciliation runs at a time. The standard pattern is a tail-chained `Future`:

```dart
extractRegion(flutterIntegrationSource, 'flutter-toggle-pending-serialize')
```

The `code_editor` example uses exactly this. If you expose toggles to users through any UI surface, do this or an equivalent serialization.

### `PluginLifecycleException` was thrown; what do I do with it?

**Cause.** One or more plugins threw during a lifecycle phase. The runtime collects errors across plugins (so plugin A's failure does not abort plugin B's attach) and throws an aggregate at the end of the phase.

**Fix.** Read `phase` and `failures`. The phase is one of:

- `attachGlobal`
- `detachGlobal`
- `attachSession`
- `detachSession`
- `updateGlobalSettings`
- `updateSessionSettings`

The failures field is a list of `(PluginId, Object error, StackTrace stackTrace)` records. Iterate and decide whether to log, reroute, or rethrow per plugin:

```dart
extractRegion(loggingSource, 'logging-try-catch-plugin-init')
```

The aggregation is intentional. Fail-fast on the first exception would hide downstream problems, so you fix one and immediately rediscover the next one. Plugin Kit surfaces the whole batch. See the [Logging guide](https://plugin-kit.saad-ardati.dev/guides/logging/) for the production pattern.

### Why didn't `onPluginSettingsChanged` fire when I called `updateSettingsSnapshot`?

**Cause.** `updateSettingsSnapshot` deliberately does not reconcile. It updates the runtime's stored settings value and emits on `settingsStream`, but it does not run plugin lifecycle, does not call `onPluginSettingsChanged`, and does not push new config into already-resolved services. It is for the cases where you only want to publish a new snapshot to listeners (a debug panel, an analytics ping) without any actual lifecycle work.

**Fix.** Use `updateSettings` for the all-scopes path or `updateGlobalSettings` / `updateSessionSettings` for the lower-level per-scope paths when you want the runtime to converge on the new settings. Those run the full pipeline: detach plugins that lost enablement, attach newly enabled ones, fire `onPluginSettingsChanged` on plugins whose configuration changed, re-inject settings into resolved services.

If you wanted reconciliation and got silence, you almost certainly called the snapshot variant. See [Settings](https://plugin-kit.saad-ardati.dev/guides/settings/) and the [Settings & Configuration reference](https://plugin-kit.saad-ardati.dev/reference/settings-and-configuration/).

### My plugin compiled but never attached

The most common cause is settings enablement. `RuntimeSettings` controls plugin enablement, but plugins without a `RuntimeSettings.plugins` entry are enabled by default unless they carry `FeatureFlag.experimental`. Plugins with `FeatureFlag.experimental` are disabled by default and require explicit opt-in. Plugins with `FeatureFlag.locked` are always enabled and cannot be turned off.

Build a `RuntimeSettings` with explicit entries for any stable plugin you want disabled and any experimental plugin you want enabled, then pass it to `runtime.init`.

The next most common cause is a dependency that is not enabled. The runtime auto-disables a plugin whose declared `dependencies` are missing, and logs an info entry. Look for an `INFO` entry mentioning your plugin id and the unmet dependency. Locked plugins are not auto-disabled, those dependency failures log at `SEVERE`.

Once the plugin is enabled and its dependencies are satisfied, check the lifecycle: did your `attach` handler actually run, and did you pass `context` to the `Plugin` helpers? See the first entry on this page.

### `runtime.init` throws `StateError` saying a plugin or service id is unknown

**Cause.** A `RuntimeSettings` you passed (often loaded from cached user storage) references a plugin id or service id that this runtime does not know about. Common when an app upgrade renames or removes a plugin: the cached settings written by the prior version still reference the old id, so the strict default surfaces the drift loudly.

The runtime validates three things at every `init` / `createSession` / `updateSettings` call:

- Plugin ids used in `RuntimeSettings.plugins` keys.
- Plugin ids used in the plugin half of `RuntimeSettings.services` pin keys.
- Service ids in plugin-scoped pins, checked after register-all (so a renamed slot on a still-existing plugin is caught too).

`Pin.wildcard(...)` pins are exempt from the service-id pass by design (they target whoever wins).

**Fix.** Pick the response that matches your environment:

```dart
runtime.init(
  // The default. Use during development and CI so typos and renamed
  // ids surface immediately.
  unknownReferencePolicy: UnknownReferencePolicy.throwError,
);

runtime.init(
  // Recommended for production load paths that read cached settings
  // across app upgrades. Unknown entries are dropped and one severe
  // log entry per pass lists them; known entries still apply.
  unknownReferencePolicy: UnknownReferencePolicy.logAndSkip,
);

runtime.init(
  // Silent drop. Use only when another channel (a settings UI, a
  // drift telemetry hook) already informs the user about the drop.
  unknownReferencePolicy: UnknownReferencePolicy.ignore,
);
```

The policy is set once on `init` and is read by every subsequent `createSession` / `updateSessionSettings` / `updateGlobalSettings` call on the same runtime. See [`PluginRuntime` in the reference](https://plugin-kit.saad-ardati.dev/reference/plugins-and-lifecycle/#pluginruntimeg-s) for the full constructor signature and the rest of the runtime-level parameters.

## Service resolution issues

### `registerFactory` for a `StatefulPluginService` throws on registration

**Cause.** Factories construct a fresh instance on every `resolve<T>` call. The runtime tracks `StatefulPluginService` instances so it can call `attach` and `detach` on them at the right lifecycle moments. Factories produce orphan instances the runtime never sees again, which would leak subscriptions and skip cleanup. The registry refuses the registration outright:

```
ArgumentError: StatefulPluginService "<id>" must be registered as a singleton or lazy singleton, not a factory. They require proper lifecycle management which factories do not provide.
```

**Fix.** Use `registerSingleton` (eager) or `registerLazySingleton` (constructed on first resolve) for any service that extends `StatefulPluginService`. Factory registration is fine for pure `PluginService` subclasses with no lifecycle hooks.

See the "Stateful services cannot be factories" Aside in [Service Registry](https://plugin-kit.saad-ardati.dev/concepts/service-registry/).

### My service registers but won't resolve, or resolves to the wrong one

**Cause.** Higher priority wins. Default is `Priority.normal` (500); another plugin at `Priority.elevated` will beat you.

**Fix.** Register at `Priority.elevated` (or `Priority.above(other)`) to win, `Priority.low` to be a fallback. `registry.getRegistrations(serviceId)` lists everything competing for the slot; the dialog's [Service Registry inspector](https://plugin-kit.saad-ardati.dev/reference/dialog-api/) shows it live.

## Event bus and request/response

### I throw inside an event handler and the bus seems to swallow it

**Cause.** It does not. The bus deliberately propagates handler exceptions to the caller of `emit`, `request`, or `requestSync`. If you are not seeing the exception, the most likely culprit is the call site: an unawaited `emit`, a `Future` whose error you never await, or a top-level `runZonedGuarded` that is eating it.

**Fix.** Make sure you are actually awaiting the bus call:

```dart
extractRegion(eventBusSource, 'event-bus-unawaited-vs-awaited')
```

The bus does not aggregate errors the way `PluginLifecycleException` does; that aggregation is for plugin lifecycle only. Inside `emit`, the first throwing handler interrupts the cascade and the caller gets the exception. This is usually the right tradeoff: silent failure in an event pipeline is far harder to debug a month later than a loud, unambiguous exception at the call site. See the note in the [Logging guide](https://plugin-kit.saad-ardati.dev/guides/logging/) and the explicit semantics in [Event Bus](https://plugin-kit.saad-ardati.dev/concepts/event-bus/).

### `request<Req, Res>` throws when no handler answers; I want null

**Cause.** `request` is the strict variant. It throws if no handlers are registered for the `(Request, Response)` type pair, and it throws if every registered handler returned `null` while `Response` is non-nullable. The thrown text starts with `AllConcededException: <Request> -> <Response> ... every registered handler conceded with null but Response is non-nullable.`

**Fix.** If "no handler" is a valid state in your domain, use `maybeRequest` (returns `Future<Response?>`) or `maybeRequestSync` (synchronous, also returns nullable). Or declare the response type as nullable so the null cascade is permitted:

```dart
extractRegion(eventBusSource, 'event-bus-maybe-request')
```

Pick one based on what reads better. `maybeRequest` is usually clearer at the call site. See [Event Bus & Events reference](https://plugin-kit.saad-ardati.dev/reference/event-bus-and-events/).

## Settings and reconciliation

### `copyWith(priority: null)` does not clear the priority on `ServiceSettings`

**Cause.** The implementation uses the standard Dart `??` fallback:

```dart
extractRegion(settingsSource, 'settings-copy-with')
```

That pattern cannot tell `null` (clear it) from "argument not passed" (keep current). Both look the same to the function body.

**Fix.** When you actually want to clear the priority, construct the `ServiceSettings` directly rather than going through `copyWith`:

```dart
extractRegion(runtimeSettingsSource, 'service-settings-clear-priority')
```

This is the same quirk every `copyWith` pattern in Dart has. The library could add a sentinel object, but it does not, because the rebuild approach is unambiguous.

## Tests, goldens, and CI

### Goldens render with block-rectangles instead of text

**Cause.** Flutter's test environment ships with the Ahem font as the default fallback. Ahem renders every glyph as a solid rectangle. Useful for layout regression, useless for goldens that should look like actual UI in screenshots and docs.

**Fix.** Call `loadAppFonts()` from the `golden_toolkit` package once in `setUpAll`:

```dart
import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
  setUpAll(() async {
    await loadAppFonts();
  });

  // tests ...
}
```

`loadAppFonts` registers every font declared in your app's pubspec, plus the Roboto bundled with `golden_toolkit`, plus any platform fonts it can find. After it runs, goldens render with real glyphs.

If your widgets use `fontFamily: 'monospace'` (a system alias on most platforms, but undefined in tests), Ahem also wins. The dialog demo solves this by aliasing `monospace` to a bundled `RobotoMono-Regular.ttf` in its pubspec:

```yaml
flutter:
  fonts:
    - family: monospace
      fonts:
        - asset: assets/fonts/RobotoMono-Regular.ttf
```

Now the test asset bundle resolves the alias to a real font, and `loadAppFonts` picks it up. The full setup lives in `example/plugin_kit_dialog_demo/test/golden_test.dart`.

## Related reading

[FAQ](https://plugin-kit.saad-ardati.dev/faq/)
  [Plugins](https://plugin-kit.saad-ardati.dev/concepts/plugins/)
  [Service Registry](https://plugin-kit.saad-ardati.dev/concepts/service-registry/)
  [Logging](https://plugin-kit.saad-ardati.dev/guides/logging/)