injeca 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,7 @@ process.once('SIGINT', async () => {
71
71
  - `provide` returns a typed key. Use it in `dependsOn`. injeca infers the type of each dependency.
72
72
  - `start()` builds each service when its dependencies are running, then runs its `start` hooks. It resolves when everything is running.
73
73
  - `stop()` stops dependents before their providers. The registrations stay, so a later `start()` builds them again.
74
+ - injeca writes no lifecycle logs by default. It writes warnings with `console.warn`. See [Logging](#logging).
74
75
 
75
76
  > [!WARNING]
76
77
  >
@@ -163,16 +164,110 @@ provide(container, 'modules:camera', {
163
164
  onTimeout: (context) => {
164
165
  if (context.hook === 'start')
165
166
  return context.decide.requeue({ after: 1_000, maxAttempts: 3 })
166
- return context.decide.fail(new Error('The camera did not close.'))
167
+ return undefined // a stop timeout counts as handled
167
168
  },
168
169
  build: () => createCamera(),
169
170
  })
170
171
  ```
171
172
 
172
173
  - The `start` timeout is for all `start` handlers of one attempt. The `stop` timeout is for all `stop` handlers and effect disposers of one instance.
173
- - When a timeout passes, `observe` gets a `timeout` record. Then `onTimeout` of the service, or of the container, decides. Without it, the result is `fail` with a `HookTimeoutError` (`hook-timeout`).
174
+ - When a timeout passes, `observe` gets a `timeout` record. Then `onTimeout` of the service, or of the container, decides.
175
+ - Without `onTimeout`, a `start` timeout gives `fail` with a `HookTimeoutError` (`hook-timeout`).
176
+ - Without `onTimeout`, a `stop` timeout counts as `handled`. injeca writes a warning with `logger.warn`, and `stop()`, `dispose()`, and `withdraw()` resolve as usual. Only `decide.fail()` from `onTimeout` fails them.
177
+ - During a stop or a disposal of the container, the warning tells that a cleanup can be waiting for its own container. Then that cleanup must pass its signal. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
174
178
  - `onTimeout` runs synchronously. `context.decide` has the methods of the hook that timed out.
175
- - After a `stop` timeout, the remaining cleanups still run, but injeca does not wait for them. So a cleanup that awaits the disposal of its own container cannot block that disposal.
179
+ - After a `stop` timeout, the remaining cleanups still run, but injeca does not wait for them. So a cleanup that awaits the disposal of its own container without its signal cannot block that disposal.
180
+ - A cleanup or a `start` hook that injeca stopped waiting for can still fail later. injeca writes that failure with `logger.warn`, and a late `stop` decision gets a `decision` record with `ignored: true`. A rejection with the abort reason of the callback signal has no warning, because the callback only followed its abort.
181
+ - `stop()` and `dispose()` abort the signal of a `start` hook in progress and do not wait for it. The `stop` hooks run at once. A decision that the hook returns later is ignored.
182
+ - At a `start` timeout, the `signal` of the hook aborts with the `HookTimeoutError`. A `stop` handler sees the `stop` timeout on its `signal` in the same way.
183
+
184
+ ## Cancellation
185
+
186
+ Each call that can wait takes `{ signal }`, a standard `AbortSignal`. injeca has no built-in timeout for these calls. Use `timeout(container, ms)` to make a signal on the container clock:
187
+
188
+ ```ts
189
+ import { resolve, start, stop, timeout, withTimeout } from 'injeca'
190
+
191
+ await start(container, { signal: timeout(container, 10_000) })
192
+ const { llm } = await resolve(container, { llm: llmKey }, { signal: withTimeout(request.signal, 5_000, container) })
193
+ await stop(container, { signal: timeout(container, 5_000) })
194
+ ```
195
+
196
+ - `timeout(container, ms)` aborts after `ms` on the clock of the container, with a `DOMException` named `TimeoutError`. Tests with a manual clock control it. `AbortSignal.timeout()` does not use the container clock.
197
+ - `withTimeout(signal, ms, container)` aborts when `signal` aborts, or after `ms`. `signal` can be `undefined`.
198
+ - An aborted signal ends the call at once. A call removes its listener from the signal when it settles.
199
+ - A timeout reason is a reason with `name === 'TimeoutError'`, from `timeout()` or from `AbortSignal.timeout()`.
200
+
201
+ | Call | When the signal aborts |
202
+ | --- | --- |
203
+ | `resolve(container, deps, { signal })` | Rejects. A timeout reason gives `WaitTimeoutError` (`wait-timeout`) with the reason as `cause`. Another reason rejects as it is. The hidden resolver goes. |
204
+ | `waitFor(container, key, { status, signal })` | The same as `resolve`. |
205
+ | `start(container, { signal })` | Stops the wait only. The services keep converging. A timeout reason gives `StartTimeoutError` (`start-timeout`) with the explanations. Another reason rejects as it is. |
206
+ | `stop(container, { signal })` | Rejects with the reason. The container is stopped, and a later `start()` builds again. The services that did not stop stay in `stopping` until their cleanups settle. |
207
+ | `dispose(container, { signal })` | Rejects with the reason. The disposal continues in the background, and the container stays in `disposing`. A later `dispose(container)` waits for the same disposal and gets its result. |
208
+ | `registration.withdraw({ signal })` | Rejects with the reason. The withdrawal continues. The service gets a `wait-aborted` record when it did not finish. `invoke()` handles take the same option. |
209
+ | `registration.replace(option, { signal })` | Cancels a pending replacement, as a newer `replace()` does. The old instance stays, and `replace()` resolves `false`. After the swap, the abort has no effect. |
210
+ | `tx.commit({ signal })` | Before the publish, the transaction aborts and rolls back, and `commit()` rejects at once with the reason. After the publish, the abort has no effect. |
211
+
212
+ - For `stop`, `dispose`, and `withdraw`, each service that did not stop gets a `wait-aborted` record in `observe`, with the phase at the abort and the reason.
213
+ - For `start`, `stop`, `dispose`, and `withdraw`, an aborted signal still starts the action. Only the wait ends.
214
+
215
+ ### Calling dispose from a cleanup
216
+
217
+ A cleanup that awaits `stop()` or `dispose()` of its own container, or of an ancestor, waits for itself. A cleanup that awaits `withdraw()` of its own registration, or of a provider that it holds, waits for itself too. So does a cleanup that awaits `stop()` or `dispose()` of a container whose service it holds. Pass the `signal` of the cleanup, so that injeca knows the caller:
218
+
219
+ ```ts
220
+ provide(plugin, 'plugin:window', ({ on }) => {
221
+ on('stop', async ({ signal }) => {
222
+ await window.close()
223
+ await dispose(plugin, { signal }) // resolves at once, and the disposal continues
224
+ })
225
+ return createWindow()
226
+ })
227
+ ```
228
+
229
+ - injeca knows the instance of each signal that it gives to a build, a hook, and an invocation. A signal from `withTimeout(signal, ms, container)` keeps that origin. A signal from `AbortSignal.any()` does not.
230
+ - A call with such a signal while the teardown of its instance runs does not wait. The check uses the instance, so it also works during a rebuild or a replacement, when the old and the new instance live together. A signal of the new running instance still waits.
231
+ - The cleanup can also be outside the container. A service of the parent that holds an export of a plugin container, directly or through other services, stops before that export. So its `stop` hook can call `dispose(plugin, { signal })` or `stop(plugin, { signal })`, and the call does not wait. A plugin host that stops a plugin from its own cleanup needs this.
232
+ - A call from the synchronous part of a cleanup does not wait either.
233
+ - Such a call writes a warning with `logger.warn`, sends a `reentrant-call` record to `observe`, and resolves at once. A re-entrant `withdraw()` resolves `true`. The stop, the disposal, or the withdrawal continues. Other callers get a promise that settles when it ends.
234
+ - A call without a signal after an `await` is not found. It waits for itself until a configured `stop` timeout ends the cleanup. The warning of that timeout tells about this cause.
235
+
236
+ ```ts
237
+ provide(plugin, 'plugin:host', ({ on }) => {
238
+ on('stop', async ({ signal }) => {
239
+ await exportedTool.withdraw({ signal }) // waits for the export, or resolves at once when it is re-entrant
240
+ await dispose(plugin, { signal })
241
+ })
242
+ return createHost()
243
+ })
244
+ ```
245
+
246
+ ### Testing with a manual clock
247
+
248
+ `injeca/testing` has a manual clock, so that a test controls `timeout()`, `withTimeout()`, requeue delays, and policy timeouts without real time:
249
+
250
+ ```ts
251
+ import { createContainer, provide, start, stop } from 'injeca'
252
+ import { drained, manualClock } from 'injeca/testing'
253
+
254
+ const { clock, advance } = manualClock()
255
+ const container = createContainer({ clock, timeouts: { stop: 1_000 } })
256
+ provide(container, 'modules:tray', ({ on }) => {
257
+ on('stop', () => new Promise(() => {})) // never settles
258
+ return createTray()
259
+ })
260
+
261
+ await start(container)
262
+ const stopped = stop(container)
263
+ await drained(container) // the work queue stays drained
264
+ advance(1_000) // runs the stop timeout, synchronously
265
+ await stopped // resolves, with a warning
266
+ ```
267
+
268
+ - `advance(ms)` moves the time and runs each due timer in time order. `pending()` counts the timers that wait.
269
+ - `drained(container)` waits one real macrotask after each drain of the work queue. Do not use it while the fake timers of the test runner hold `setTimeout`.
270
+ - `createHookContext` and `checkDecision` test a hook handler alone.
176
271
 
177
272
  ## Changes at run time
178
273
 
@@ -200,6 +295,14 @@ on('dependency-change', ({ dependsOn, decide }) => {
200
295
  })
201
296
  ```
202
297
 
298
+ A rebuild of a running service has no gap. The old instance keeps serving while a new instance builds and starts against the new dependencies. Then one batch swaps them, as for `replace()`. The dependents of the service get `dependency-change` with the new instance. They do not lose it.
299
+
300
+ - This applies to a rebuild without a handler and to `decide.rebuild()`.
301
+ - The service stops first and builds again when a dependency that the old instance uses has no provider now. Then there is nothing to build against.
302
+ - An invocation stops first and runs again.
303
+ - If the new instance fails, the old instance stops and the service goes to `failed`.
304
+ - A `replace()` during the rebuild cancels the rebuild and replaces the service.
305
+
203
306
  ### Atomic replacement
204
307
 
205
308
  A withdrawal followed by a new `provide` leaves a gap: all dependents stop. `replace()` has no gap. A hot reload tool uses it.
@@ -212,9 +315,12 @@ await tts.replace(() => createTtsV2())
212
315
  ```
213
316
 
214
317
  - The swap is one batch. It occurs only after the new instance completes `start`. The new instance is `ready`.
318
+ - For a `perConsumer` registration, each running consumer gets a new instance first. The swap of all consumers is one batch, after each new instance completes `start`. If one fails, no consumer changes.
215
319
  - If the new `build` or `start` fails, `replace()` rejects and the old instance does not change. This is also the usual fix for an `unavailable` provider.
216
- - The old instance stops after each dependent releases it. A dependent with a `dependency-change` handler moves in place. Other dependents rebuild.
320
+ - The old instance stops after each dependent releases it. A dependent with a `dependency-change` handler moves in place. Other dependents rebuild without a gap, so a replacement deep in the graph does not stop the services above it.
321
+ - `graph(container).explain(key)` shows the new instance as `replacement` while the swap waits, with its phase and blockers.
217
322
  - A stale registration, or a call that a newer `replace()` or a withdrawal supersedes, resolves `false`. A service in `stopping` rejects with `ReplaceWhileStoppingError`.
323
+ - `replace(option, { signal })` cancels the pending replacement when the signal aborts, and resolves `false`. See [Cancellation](#cancellation).
218
324
 
219
325
  ### Transactions
220
326
 
@@ -236,6 +342,9 @@ await tx.commit()
236
342
  - A service whose dependencies are not available goes to `pending`. That is not a failure.
237
343
  - A declared service without `provide` fails the commit with `TransactionIncompleteError`, unless it is declared with `optional: true`.
238
344
  - `tx.replace(key, option)` replaces a registration at the commit. `tx.abort()` and the disposal of the container drop everything.
345
+ - `tx.declare` and `tx.provide` reserve the key until the commit or the abort. Another `provide` of the key in the same container fails with `DuplicateServiceError`. For an exclusive key, a provider in another container of the area goes to `conflict` until the abort.
346
+ - `withdraw()` on the result of `tx.provide` before the commit removes that provide and resolves `true`. A declared key without a provide then fails the commit.
347
+ - `tx.commit({ signal })` aborts the transaction when the signal aborts before the publish. See [Cancellation](#cancellation).
239
348
 
240
349
  ## Keys without a provider
241
350
 
@@ -268,6 +377,8 @@ const whiteboardKey = defineKey<Whiteboard>('dev.airi.whiteboard', { exclusive:
268
377
 
269
378
  If two containers in one area provide it, both go to `conflict`, in any order. They run no code, and their dependents stay `pending`. When one provider is removed, the other converges. `graph(container).explain()` lists the owner of each provider.
270
379
 
380
+ An [exported registration](#exported-registrations) counts in the area of the parent container. So two sibling plugins that export one exclusive key conflict, and so do a host registration and a plugin export of that key.
381
+
271
382
  ### Resolution conditions
272
383
 
273
384
  `where(key, condition)` accepts a provider only when the condition returns `true`. The condition reads the key, the owner, and the `metadata` of the provider.
@@ -290,6 +401,29 @@ injeca.provide('whiteboard:view', {
290
401
  - A rejected required dependency keeps the dependent `pending`. A rejected optional dependency is `undefined`.
291
402
  - The condition must be pure and synchronous. A thrown error counts as `false` and goes to the observe stream.
292
403
 
404
+ A condition also filters the candidates of `firstAvailable`. The selection is the first available candidate that passes:
405
+
406
+ ```ts
407
+ import type { ProviderInfo } from 'injeca'
408
+
409
+ import { firstAvailable, optional, where } from 'injeca'
410
+
411
+ const atLeastTwo = ({ metadata }: ProviderInfo) => String(metadata.version).startsWith('2.')
412
+
413
+ injeca.provide('voice:chat', {
414
+ dependsOn: {
415
+ // The condition applies to each candidate.
416
+ llm: where(firstAvailable(remoteLlmKey, localLlmKey), atLeastTwo),
417
+ // The condition applies to the remote candidate only.
418
+ backup: optional(firstAvailable(where(remoteLlmKey, atLeastTwo), localLlmKey)),
419
+ },
420
+ build: ({ dependsOn }) => createChat(dependsOn.llm, dependsOn.backup?.value),
421
+ })
422
+ ```
423
+
424
+ - `where`, `firstAvailable`, and `optional` combine in any order. A candidate with two conditions must pass both.
425
+ - `graph(container).explain()` names each candidate that a condition rejected.
426
+
293
427
  ## Status
294
428
 
295
429
  A running service is `ready`, `degraded`, or `unavailable`.
@@ -339,20 +473,69 @@ const plugin = createContainer({
339
473
  provide(plugin, 'agent-activity:reaction', { dependsOn: { llm: llmKey }, build: () => createReaction() })
340
474
  effect(plugin, () => () => console.info('plugin cleaned up'))
341
475
 
342
- await start(plugin, { timeoutMs: 10_000 })
476
+ await start(plugin, { signal: timeout(plugin, 10_000) })
343
477
  await dispose(plugin)
344
478
  ```
345
479
 
346
480
  - `stop()` stops. `dispose()` also withdraws the registrations and runs the container effects. A disposed container cannot be used again.
347
- - `start(container, { timeoutMs })` rejects with a `StartTimeoutError` that explains each service that is not running. injeca has no built-in timeout.
348
- - A service with a missing provider, or in a dependency cycle, stays `pending`. Use `timeoutMs` to find it.
481
+ - `dispose(parent)` disposes the child containers first, newest first, each one with its effects. Then the services of the parent stop, and then its effects run.
482
+ - `effect(container, setup)` after the disposal started runs the disposer at once.
483
+ - `start(container, { signal: timeout(container, ms) })` rejects with a `StartTimeoutError` that explains each service that is not running. injeca has no built-in timeout.
484
+ - A service with a missing provider, or in a dependency cycle, stays `pending`. Use a timeout signal to find it.
349
485
  - A provider in a child container shadows the provider of an ancestor only while it is `running`. Until then, and after it stops, dependents use the ancestor provider. So shadowing never leaves a gap.
350
- - `createContainer({ isolate: [key] })` stops a key from resolving from the ancestors.
486
+ - `createContainer({ isolate: [key] })` stops a key from resolving from the ancestors. It also hides the exports of the sibling containers. It does not hide the exports of its own child containers.
487
+ - A `stop` or `dispose` call from a cleanup of the same container, or of a descendant, does not wait for itself. The same is true for a cleanup of a service that holds a service of the container. It warns and resolves at once. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
488
+
489
+ ### Logging
490
+
491
+ `logging: true` writes the lifecycle trace: each `provide`, `invoke`, build, `start` and `stop` hook, and `running`. It is off by default.
492
+
493
+ ```ts
494
+ import { createContainer, createLoggLogger, createNoopLogger, injeca } from 'injeca'
495
+
496
+ const app = createContainer({ logging: true }) // writes PROVIDE, RUN, HOOK OnStart, RUNNING, and the other lines
497
+ const plugin = createContainer({ parent: app, logging: false }) // a child uses the value of its parent, unless it sets its own
498
+ const quiet = createContainer({ logger: createNoopLogger() }) // also no warnings, for tests
499
+
500
+ injeca.getContainer().logging = true // the global container
501
+ injeca.setLogger(createLoggLogger(logg)) // a logger from @guiiai/logg
502
+ ```
503
+
504
+ - `logger.warn` always writes, whatever `logging` is. injeca warns for a re-entrant call, a `stop` timeout, and a late failure of a callback that it stopped waiting for.
505
+ - The logger is `options.logger`, then the logger of the parent container, then `createDefaultLogger()`. It writes with `console`.
506
+
507
+ ### Exported registrations
508
+
509
+ A registration in a child container is visible only in that container and its descendants. `export: true` also makes it a candidate for consumers in the area of the parent container: the parent and each of its descendants. So a plugin can use a Kit that another plugin provides.
510
+
511
+ ```ts
512
+ const host = createContainer({ owner: { kind: 'host' } })
513
+ const pluginA = createContainer({ parent: host, owner: { kind: 'plugin', id: 'a' } })
514
+ const pluginB = createContainer({ parent: host, owner: { kind: 'plugin', id: 'b' } })
515
+
516
+ provide(pluginA, memoryKitKey, { export: true, build: () => createMemoryKit() })
517
+ provide(pluginB, 'b:session', { dependsOn: { memory: memoryKitKey }, build: ({ dependsOn }) => startSession(dependsOn.memory) })
518
+ ```
519
+
520
+ - The registration stays in its container. That container owns it, and `dispose(pluginA)` withdraws it. Its own dependencies resolve from that container. This is also true for a `perConsumer` export.
521
+ - An export goes exactly one level up. A container that wants to reach the area of its grandparent exports from its own level.
522
+ - A consumer looks in its own container, then in each ancestor. At each level, the registration of that container comes first, then the exports of its children in creation order.
523
+ - Shadowing still has no gap. An export takes over from a provider of a farther ancestor only while it runs.
524
+ - `perConsumer` and `export` combine. Each consumer instance lives in the consumer container.
525
+ - `tx.provide(key, { export: true, ... })` reserves an exclusive key in the parent area at once, and the commit publishes the service there in one batch.
526
+ - `replace()` keeps the export. On a root container, `export` has no effect.
527
+ - `graph(parent)`, `peek`, `watch`, `waitFor`, and `observe` see the export. `graph().services()` marks it with `exported: true`, and its `owner` is the owner of the exporting container.
351
528
 
352
529
  ### Per-consumer services
353
530
 
354
531
  `perConsumer: true` runs `build` once for each consumer container. The build context has `consumer`, the owner record of that container. The instance disposes with the consumer container.
355
532
 
533
+ The instance lives in the consumer container, but its `dependsOn` resolves from the container of the registration, as for a plain registration. So a key of the consumer container does not change the dependencies of the registration.
534
+
535
+ A dependent demands its consumer instance through a required, an optional, or a `firstAvailable` edge. This is also true when the key registers after the dependent.
536
+
537
+ A failed consumer instance builds again on the next demand from its container: a new `resolve`, `watch`, or `waitFor` call, or a dependent that starts to wait. Each demander gets one new build for each failure.
538
+
356
539
  ```ts
357
540
  const hostClient = injeca.provide('host:client', {
358
541
  perConsumer: true,
@@ -366,17 +549,33 @@ const hostClient = injeca.provide('host:client', {
366
549
  ## Reading state
367
550
 
368
551
  ```ts
369
- import { observe, waitFor, watch } from 'injeca'
552
+ import { observe, timeout, waitFor, watch } from 'injeca'
370
553
 
371
554
  const container = injeca.getContainer()
372
555
 
373
556
  const stopWatching = watch(container, server, ({ phase, status }) => {})
374
- await waitFor(container, server, { status: 'ready', timeoutMs: 5_000 })
557
+ await waitFor(container, server, { status: 'ready', signal: timeout(container, 5_000) })
375
558
  const stopObserving = observe(container, (record) => {})
376
559
  ```
377
560
 
378
561
  - `watch` sends the current state at once, then each change, in order.
379
- - `observe` sends each phase change, status change, decision, and hook timeout in the container and its children.
562
+ - `waitFor` rejects with a `ServiceFailedError` (`service-failed`) when the service failed, or waits for a required provider that failed. Its `cause` is the failure.
563
+ - For a `perConsumer` key, `watch` and `waitFor` create the consumer instance for their container, as `resolve` does. The demand ends with the subscription.
564
+ - `observe` sends each phase change, status change, decision, hook timeout, aborted wait, and re-entrant call in the container and its children.
565
+
566
+ `peek` reads a value now, without a wait, and never builds anything. `resolve` takes `signal` to stop a wait:
567
+
568
+ ```ts
569
+ import { peek, resolve, timeout, where } from 'injeca'
570
+
571
+ const result = peek(container, where(llmKey, ({ metadata }) => metadata.version === '2'))
572
+ if (result.kind === 'resolved')
573
+ result.value.chat()
574
+ else
575
+ console.info(result.kind) // 'missing', 'rejected', 'conflict', 'not-running', or 'unavailable'
576
+
577
+ const { llm } = await resolve(container, { llm: llmKey }, { signal: timeout(container, 5_000) }) // rejects with WaitTimeoutError
578
+ ```
380
579
 
381
580
  ### Graph queries
382
581
 
@@ -392,7 +591,7 @@ queries.dependencies(server) // the direct edges of a service
392
591
  queries.dependents(config) // the services that use a service now
393
592
  queries.order() // providers before their dependents
394
593
  queries.cycles() // each dependency cycle
395
- queries.explain(server) // why a service is not running
594
+ queries.explain(server) // why a service is not running, also for a key without a provider, with a pending replacement
396
595
  queries.impact({ kind: 'withdraw', key: llmKey }) // { stopped, rebuilt, notified }
397
596
  ```
398
597
 
@@ -442,9 +641,47 @@ const server = injeca.provide({
442
641
 
443
642
  Other differences:
444
643
 
445
- - `start()` waits for a missing provider. 0.2 threw an error. Use `start({ timeoutMs })` to fail after a time.
644
+ - `start()` waits for a missing provider. 0.2 threw an error. Use `start(container, { signal: timeout(container, ms) })` to fail after a time.
446
645
  - `resolve()` waits for a missing provider too. Before `start()`, it builds the services that it needs, with their `start` hooks.
447
- - `invoke()` returns a handle with `withdraw()`. Its callback runs again when a required dependency gets a new instance.
646
+ - `invoke()` returns a handle with `key` and `withdraw()`. Graph queries accept `key`. Its callback runs again when a required dependency gets a new instance.
647
+
648
+ ## Migration from 0.3 to 0.4
649
+
650
+ 0.4 removes the `timeoutMs` option of `resolve`, `waitFor`, and `start`. Pass a signal from `timeout(container, ms)`:
651
+
652
+ ```ts
653
+ // 0.3
654
+ await resolve(container, { llm: llmKey }, { timeoutMs: 5_000 })
655
+ await waitFor(container, server, { status: 'ready', timeoutMs: 5_000 })
656
+ await start(container, { timeoutMs: 10_000 })
657
+
658
+ // 0.4
659
+ await resolve(container, { llm: llmKey }, { signal: timeout(container, 5_000) })
660
+ await waitFor(container, server, { status: 'ready', signal: timeout(container, 5_000) })
661
+ await start(container, { signal: timeout(container, 10_000) })
662
+ ```
663
+
664
+ - The errors do not change. A timeout gives `WaitTimeoutError` or `StartTimeoutError`. The error message no longer has the milliseconds. The `cause` is the `TimeoutError` reason.
665
+ - A call with `timeoutMs` and `signal` becomes one signal: `withTimeout(signal, ms, container)`.
666
+ - A `TimeoutError` reason from your own signal, for example `AbortSignal.timeout()`, now also gives `WaitTimeoutError` or `StartTimeoutError`. Another reason rejects as it is, as before.
667
+ - `stop`, `dispose`, `withdraw`, `replace`, and `tx.commit` take `{ signal }` too. See [Cancellation](#cancellation).
668
+ - `timeouts: { start, stop }` and `onTimeout` keep their shape. A `stop` handler now sees the `stop` timeout on its `signal`.
669
+ - A `stop` timeout no longer fails `stop()`, `dispose()`, or `withdraw()`. Without `onTimeout`, it counts as `handled`, with a warning. In 0.3, the callers rejected with `HookTimeoutError`. To keep a failure, return `context.decide.fail(error)` from `onTimeout` for the `stop` hook.
670
+ - `observe` has two new record types, `wait-aborted` and `reentrant-call`. A `switch` on `record.type` with an exhaustive check needs a case for each. Their `call` can be `'stop'`, `'dispose'`, or `'withdraw'`.
671
+ - `ReentrantDisposeError` (`reentrant-dispose`) is removed. A `stop()`, `dispose()`, or `withdraw()` from a cleanup that its wait includes resolves at once with a warning. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
672
+ - A custom `Logger` needs `warn(message)`. `createDefaultLogger`, `createLoggLogger`, and `createNoopLogger` have it. injeca also writes stop timeouts and late failures of abandoned callbacks with it.
673
+ - `injeca/testing` exports `manualClock` and `drained`. See [Testing with a manual clock](#testing-with-a-manual-clock).
674
+ - The `enabled` option of `createContainer` is removed. The lifecycle logs are off by default. `logging: true` turns them on. Warnings always go to the logger. See [Logging](#logging).
675
+
676
+ ```ts
677
+ // 0.3
678
+ createContainer({ enabled: false }) // no logs
679
+ createContainer() // lifecycle logs
680
+
681
+ // 0.4
682
+ createContainer({ logger: createNoopLogger() }) // no logs and no warnings. Or remove the option to keep the warnings.
683
+ createContainer({ logging: true }) // lifecycle logs
684
+ ```
448
685
 
449
686
  ## License
450
687