detached.ts 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. /**
  2. * Quiescence tracking for a bridge's DETACHED hook runs. The waterfall-shaped
  3. * hook points (`UserPromptSubmit`, `PreToolUse`, …) are awaited by their seams,
  4. * but the emit-shaped points (`SessionStart`, `SubagentStart`, `SubagentStop`)
  5. * run fire-and-forget: no seam awaits them, so without tracking a bridge's
  6. * disposal could strand a live hook process and let a late continuation fire
  7. * into a disposed context (docs/defensive-patterns.md: dispose must reach
  8. * quiescence). A bridge creates one tracker in `apply()`, passes
  9. * {@link DetachedRuns.signal} to each detached {@link runHook} call, wraps the
  10. * full run chain (the hook run PLUS its `.then` continuation) in
  11. * {@link DetachedRuns.track}, and registers {@link DetachedRuns.drain} as its
  12. * disposer.
  13. *
  14. * @module @deepseek-ai/dsh-hook-protocol/detached
  15. */
  16. /** In-flight registry for one bridge's detached hook runs; see the module doc for the wiring contract. */
  17. export interface DetachedRuns {
  18. /**
  19. * The abort signal every tracked run must hand to {@link runHook} (via its
  20. * `signal` option). {@link drain} fires it so a still-running hook process is
  21. * killed rather than awaited out to its timeout (default 10 minutes).
  22. */
  23. readonly signal: AbortSignal
  24. /**
  25. * Register one detached run until it settles. Pass the FULL chain — the hook
  26. * run and its continuation/error handler — so {@link drain} waits for the
  27. * side effects (an inject, a warn), not just the process exit. A rejected
  28. * chain is absorbed here (settlement bookkeeping only), but rejection
  29. * handling is still the caller's job: an untracked `.catch` is what turns a
  30. * failure into a logged warning instead of silence.
  31. * @param run - the detached run chain to hold until settled.
  32. */
  33. track(run: Promise<unknown>): void
  34. /**
  35. * Abort {@link signal}, then resolve once every tracked chain has settled —
  36. * including chains tracked while the drain is in progress. The bridge
  37. * registers this as its effect disposer; cordis awaits it, so
  38. * `fiber.dispose()` resolving means the bridge's detached work is quiescent.
  39. * A run tracked AFTER drain resolves is not awaited by anyone — by then the
  40. * bridge's listeners are disposed, so nothing can start one.
  41. * @returns resolves when all tracked runs have settled.
  42. */
  43. drain(): Promise<void>
  44. }
  45. /**
  46. * Create a {@link DetachedRuns} tracker (one per bridge `apply()`); settled
  47. * runs are pruned so a long-lived session does not accumulate them.
  48. * @returns the tracker.
  49. */
  50. export function createDetachedRuns(): DetachedRuns {
  51. const inflight = new Set<Promise<unknown>>()
  52. const controller = new AbortController()
  53. return {
  54. signal: controller.signal,
  55. track(run: Promise<unknown>): void {
  56. inflight.add(run)
  57. const settled = (): void => { inflight.delete(run) }
  58. void run.then(settled, settled)
  59. },
  60. async drain(): Promise<void> {
  61. controller.abort(new Error('hook bridge disposed'))
  62. // Re-check after each wave: a chain can be tracked while a prior wave is
  63. // settling; loop until the registry is observed empty.
  64. while (inflight.size > 0) {
  65. await Promise.allSettled([...inflight])
  66. }
  67. },
  68. }
  69. }