# DirtyChannel

> How DirtyChannel accumulates marks, coalesces them per scheduler window, notifies interested subscribers, handles re-entrancy and reports errors.

Source: https://dirtytalk.42.dog/docs/engine/dirty-channel/

`DirtyChannel<Region>` accumulates dirty regions passed to `mark`, asks a `Scheduler` for a flush, and on flush calls every subscriber whose interest intersects the accumulated region.

## Constructor

```ts
import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;

const channel = new DirtyChannel(space, new SyncScheduler(), {
  onError: (err) => console.error(err),
});
```

| Parameter         | Type                                | Description                                                                                      |
| ----------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------ |
| `space`           | `Space<Region>`                     | Defines how regions are combined and compared. See [Custom spaces](/docs/engine/custom-spaces/). |
| `scheduler`       | `Scheduler`                         | Decides when flushes run. See [Schedulers](/docs/engine/schedulers/).                            |
| `options.onError` | `(err: unknown) => void` (optional) | Receives errors thrown by subscribers during a flush. See [Error handling](#error-handling).     |

Construction does not call the scheduler.

## mark

```ts
mark(r: Region): void
```

Merges `r` into the accumulated dirty region and, if no flush is pending, calls `scheduler.request` with the channel's flush function.

```ts
import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

const BitSpace: Space<number> = {
  empty: () => 0,
  isEmpty: (r) => r === 0,
  union: (a, b) => a | b,
  intersects: (i, d) => (i & d) !== 0,
};

const scheduler = new ManualScheduler();
const channel = new DirtyChannel(BitSpace, scheduler);

channel.mark(0b01);
channel.mark(0b10);
scheduler.pump();
```

- Marking is cheap and synchronous; no subscriber runs inside `mark` unless the scheduler flushes synchronously (`SyncScheduler`).
- Marking an empty region (per `space.isEmpty`) still requests a flush, but the flush notifies nobody.
- After `dispose()`, `mark` does nothing.
- The first mark of a window is stored by reference. Do not mutate a region after passing it to `mark`.

## subscribe

```ts
subscribe(interest: () => Region, cb: (dirty: Region) => void): () => void
```

Registers a subscriber and returns an unsubscribe function.

```ts
import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<Set<string>>;
const channel = new DirtyChannel(space, new MicrotaskScheduler());

const unsubscribe = channel.subscribe(
  () => new Set(['users']),
  (dirty) => console.log('users changed', dirty),
);
```

- `interest` is a thunk. It is called at flush time, once per flush per subscriber, never at subscribe time. A subscriber can therefore move or resize its interest between flushes.
- If `interest` returns `null` or `undefined`, the subscriber has no interest in that flush and `intersects` is not called.
- `cb` runs only when `space.intersects(interest(), dirty)` is true. It receives the full accumulated dirty region, not an intersection.
- All subscribers in one flush receive the same `dirty` object. Treat it as read-only.
- Subscribers are called in registration order.
- After `dispose()`, `subscribe` returns a no-op function and never calls `cb`.

## Unsubscribing

The function returned by `subscribe` removes the subscriber. It is idempotent, and safe to call at any time, including from inside a callback. A subscriber unsubscribed during a flush is skipped for the rest of that flush, even if it has not run yet.

```ts
import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const channel = new DirtyChannel(space, new SyncScheduler());

const unsubscribe = channel.subscribe(
  () => 1,
  () => unsubscribe(),
);
```

Subscribing during a flush is also safe: the new subscriber is not called in the current flush and first sees the next one.

## Coalescing

Marks made within one scheduler window are merged with `space.union` into one region, and the scheduler is asked for a flush once. At flush time the accumulated region is taken and reset, then each subscriber is evaluated once against it. A flush whose accumulated region is empty calls no interest thunks and no callbacks.

If the space implements the optional `unionInto`, the channel uses it to accumulate in place, but only on a region the channel itself allocated through `union`. See [Custom spaces](/docs/engine/custom-spaces/#unioninto-optional).

## Re-entrancy

Calling `mark` from inside a subscriber callback is allowed. The mark accumulates into a fresh region and is delivered by a new flush, which the channel requests after the current flush finishes. It never runs in the same flush, so callbacks cannot loop within a single flush. A callback that marks unconditionally will, however, keep producing flushes, one per scheduler window.

```ts
import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

const BitSpace: Space<number> = {
  empty: () => 0,
  isEmpty: (r) => r === 0,
  union: (a, b) => a | b,
  intersects: (i, d) => (i & d) !== 0,
};

const scheduler = new ManualScheduler();
const channel = new DirtyChannel(BitSpace, scheduler);

channel.subscribe(
  () => 0b01,
  () => channel.mark(0b10),
);
channel.subscribe(
  () => 0b10,
  (d) => console.log('second flush', d),
);

channel.mark(0b01);
scheduler.pump(); // runs the first subscriber, which marks 0b10
scheduler.pump(); // logs: second flush 2
```

With `SyncScheduler` the follow-up flush runs synchronously once the current one has finished.

## Error handling

An error thrown by an interest thunk, by `space.intersects`, or by a callback does not stop the flush: the remaining subscribers still run and the channel keeps working afterwards.

| Configuration | Behaviour                                                                                                                                                                                            |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No `onError`  | Errors are collected. After all subscribers have run, a single error is rethrown as-is; two or more are rethrown as an `AggregateError` with message `DirtyChannel: subscriber errors during flush`. |
| `onError` set | Each error is passed to `onError` as it happens. The flush does not throw.                                                                                                                           |

Where a rethrown error surfaces depends on the scheduler: from `mark` with `SyncScheduler`, from `pump()` with `ManualScheduler`, and as an uncaught exception in the microtask or timer callback for `MicrotaskScheduler` and `RAFScheduler`. If you use those, pass `onError`.

> **Warning:** Errors thrown by `onError` itself are not caught. They abort the flush; subscribers not yet reached are skipped for that flush, and no follow-up flush is requested for marks made during it. The next `mark` requests one.

Errors from `space.union`, `space.isEmpty` and `unionInto` are not isolated; they propagate from `mark` or the flush.

## dispose

```ts
dispose(): void
```

Ends the channel permanently.

- Cancels a pending flush via `scheduler.cancel` if the scheduler provides it and a flush is pending.
- Drops all subscribers and any accumulated region.
- Idempotent.
- Called from inside a callback, it stops the remaining callbacks of the current flush.
- Afterwards `mark` does nothing and `subscribe` returns a no-op unsubscribe.

## Summary

| Member        | Signature                                                             |
| ------------- | --------------------------------------------------------------------- |
| `constructor` | `(space, scheduler, options?: { onError? })`                          |
| `mark`        | `(r: Region) => void`                                                 |
| `subscribe`   | `(interest: () => Region, cb: (dirty: Region) => void) => () => void` |
| `dispose`     | `() => void`                                                          |

The class has no other public members. See the [API reference](/docs/engine/api/#dirtychannel).
