Skip to content

feat(emit): optional per-call signal + timeout cancellation (REVIEW P2)#2

Merged
yshengliao merged 1 commit into
mainfrom
feat/emit-per-call-cancellation
Jun 13, 2026
Merged

feat(emit): optional per-call signal + timeout cancellation (REVIEW P2)#2
yshengliao merged 1 commit into
mainfrom
feat/emit-per-call-cancellation

Conversation

@yshengliao

Copy link
Copy Markdown
Member

Resilience gap (REVIEW.md P2 — `emit()` cancellation, Open)

`emit()` has no per-call `signal` or timeout. If adapter readiness or `post()` hangs, callers must reset/dispose externally.

The README already flagged the asymmetry: "`call()` supports per-call `signal` and `timeoutMs`. `emit()` does not." A single stuck fire-and-forget event forced a bridge-wide `reset()`/`dispose()` — coarse blast radius.

Change (additive, backwards-compatible)

New optional third argument to `emit()`, a dedicated `EmitOptions` interface mirroring `CallOptions`:

```ts
emit(event: string, payload?: unknown, options?: EmitOptions): Promise

interface EmitOptions {
timeoutMs?: number; // opt-in; positive bounds ready()+post(), <=0 disables (mirrors call() BRG-R-02)
signal?: AbortSignal; // aborts the readiness wait or post(); rejects with signal.reason
}
```

Mirrors the existing `call()` cancellation conventions exactly:

  • `signal` threaded into `ready({ signal })` so a hung `adapter.ready()` is abortable; also races `adapter.post()` so a hung `post()` (e.g. a Flutter `callHandler` that never settles) rejects promptly.
  • positive `timeoutMs` → `BridgeTimeoutError`; `timeoutMs <= 0` disables the timer.
  • pre-aborted signal short-circuits before any adapter side effect.
  • timer + abort listener torn down on every settle path — no orphaned timer, no listener pinned on the signal.

Backwards-compat: with no option, `emit()` keeps its exact pre-existing contract (a bare awaited `post()`, no timer) via an explicit fast path. Per-call timeout is opt-in only — unlike `call()`, `emit()` does NOT fold in the bridge default timeout, so today's unbounded `emit()` is unchanged.

No cross-package imports; no provider-specific leakage. `EmitOptions` exported from the root subpath.

RED → GREEN evidence

6 new-API tests authored before implementation, confirmed failing against the old `emit(event, payload?)`:

```
RED: Tests 6 failed | 2 passed (E4 resolved instead of rejecting; E1/E2/E5 timed out — option ignored)
GREEN: Tests 8 passed
Full suite: 145 passed (was 137)
```

Tests added (`test/bridge.test.ts`):

  • E1 signal aborts mid-flight on a hung `post()` → rejects with the abort reason
  • E1b abort detaches its listener (no orphaned pending state)
  • E2 per-call `timeoutMs` on a hung `post()` → `BridgeTimeoutError`
  • E2b timer cleared on successful post (no late fire)
  • E3 regression: no-option `emit()` arms no timer, awaits ready once, posts once
  • E3b `timeoutMs <= 0` disables the timer (still signal-cancellable)
  • E4 pre-aborted signal rejects and never posts
  • E5 abort during the readiness wait rejects without posting

Gate — `pnpm prepublishOnly` passes

  • typecheck ✓ · lint ✓ · coverage ✓ (bridge.ts 100% lines/funcs; floors held)
  • `verify:docs` ✓ · `verify:dist` ✓ · `verify:exports` ✓ (5 subpaths) · `verify:llms` ✓
  • `check:size` ✓ — `dist/index.js` 2,808 → 2,949 B / 3,500 B (84%), +141 B gzip, within budget (no bump)

Draft: leader owns the release. STABILITY/README/CHANGELOG not touched here — exact new signature above for the leader to document.

🤖 Generated with Claude Code

Resilience gap (REVIEW.md P2 "emit() cancellation"): emit() had no
per-call signal or timeout. If adapter readiness or post() hung, the
caller's emit() promise hung with it, forcing a bridge-wide reset() or
dispose() just to unstick one fire-and-forget event — coarse blast
radius for a single stuck call.

Add an OPTIONAL third argument `EmitOptions { signal?, timeoutMs? }`,
mirroring the existing call() cancellation conventions:

- signal is threaded into ready() so a hung adapter.ready() is abortable,
  and races adapter.post() so a hung post() (e.g. a Flutter callHandler
  that never settles) rejects promptly with the signal's reason.
- positive timeoutMs bounds the readiness-wait + post() window and rejects
  with BridgeTimeoutError; timeoutMs <= 0 disables the timer, matching
  call()'s BRG-R-02 contract (pair with signal if the adapter may hang).
- a pre-aborted signal short-circuits before any adapter side effect.
- timer and abort listener are torn down on every settle path, leaving no
  orphaned timer and no listener pinned on the signal.

Additive and backwards-compatible: with no option, emit() keeps its exact
pre-existing contract (a bare awaited post(), no timer) via an explicit
fast path. Per-call timeout is opt-in only — unlike call(), emit() does
NOT fold in the bridge default timeout, so today's unbounded emit() is
unchanged.

No cross-package imports; no provider-specific leakage. New public type
EmitOptions is exported from the root subpath.

Tests (RED-first): signal abort mid-flight + listener teardown, per-call
timeout + timer-clear-on-success, timeoutMs<=0 disable, pre-aborted
signal (no post), abort during readiness (no post), and a regression that
no-option emit() arms no timer and posts exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yshengliao yshengliao marked this pull request as ready for review June 13, 2026 16:28
Copilot AI review requested due to automatic review settings June 13, 2026 16:28
@yshengliao yshengliao merged commit 5eb9a93 into main Jun 13, 2026
2 checks passed
@yshengliao yshengliao deleted the feat/emit-per-call-cancellation branch June 13, 2026 16:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds opt-in per-call cancellation and timeouts to bridge.emit(), bringing emit() closer to call()’s resilience story while preserving the existing no-options “fire-and-forget” behavior.

Changes:

  • Adds EmitOptions (signal, timeoutMs) and extends Bridge.emit(event, payload?, options?).
  • Implements per-call abort/timeout handling in emit() (with a no-options fast path to preserve current behavior).
  • Adds a focused test suite covering abort/timeout behavior for emit().

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/bridge.ts Extends emit() to accept EmitOptions and races post() against per-call abort/timeout.
src/types.ts Introduces exported EmitOptions and updates the Bridge.emit() type signature.
src/index.ts Re-exports EmitOptions from the public root entrypoint.
test/bridge.test.ts Adds new tests for emit() abort and timeout semantics plus regression coverage for the no-options path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/bridge.ts
Comment on lines +411 to +418
if (signal) {
abortHandler = (): void => {
settleReject(signal.reason);
};
signal.addEventListener("abort", abortHandler, { once: true });
}

adapter.post(envelope).then(settleResolve, settleReject);
Comment thread src/bridge.ts
Comment on lines 339 to +343
const capturedEpoch = resetEpoch;
await ready();
// Thread the signal through readiness so a hung adapter.ready() can be
// unstuck by this single emit's abort, not only by reset()/dispose().
await (signal !== undefined ? ready({ signal }) : ready());

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants