Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/proactive/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
activateProactive,
deactivateProactive,
isProactivePaused,
setContextBlocked,
subscribeToProactiveChanges,
} from './index.js'

describe('proactive state', () => {
const unsubscribers: Array<() => void> = []

afterEach(() => {
for (const unsubscribe of unsubscribers.splice(0)) {
unsubscribe()
}
deactivateProactive()
})

test('activation clears a stale context block', () => {
setContextBlocked(true)

activateProactive()

expect(isProactivePaused()).toBe(false)
})

test('one failing listener does not block later listeners or state changes', () => {
const consoleError = console.error
const consoleErrors: unknown[][] = []
console.error = (...args: unknown[]) => {
consoleErrors.push(args)
}
try {
let notified = false
unsubscribers.push(
subscribeToProactiveChanges(() => {
throw new Error('listener failed')
}),
)
unsubscribers.push(
subscribeToProactiveChanges(() => {
notified = true
}),
)

expect(() => activateProactive()).not.toThrow()

expect(notified).toBe(true)
expect(isProactivePaused()).toBe(false)
expect(consoleErrors).toEqual([
['proactive listener error', expect.any(Error)],
])
} finally {
console.error = consoleError
}
})
})
77 changes: 77 additions & 0 deletions src/proactive/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
type ProactiveListener = () => void

const listeners = new Set<ProactiveListener>()

let proactiveActive = false
let proactivePaused = false
let contextBlocked = false
let nextTickAt: number | null = null

function notifyProactiveListeners(): void {
for (const listener of [...listeners]) {
try {
listener()
} catch (error) {
console.error('proactive listener error', error)
// Listener failures must not prevent state transitions or later listeners.
}
}
}

export function subscribeToProactiveChanges(
listener: ProactiveListener,
): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}

export function isProactiveActive(): boolean {
return proactiveActive
}

export function isProactivePaused(): boolean {
return proactivePaused || contextBlocked
}

export function getNextTickAt(): number | null {
return isProactivePaused() ? null : nextTickAt
}

export function activateProactive(_source?: string): void {
proactiveActive = true
proactivePaused = false
contextBlocked = false
nextTickAt = null
notifyProactiveListeners()
}

export function deactivateProactive(): void {
proactiveActive = false
proactivePaused = false
contextBlocked = false
nextTickAt = null
notifyProactiveListeners()
}

export function pauseProactive(): void {
proactivePaused = true
notifyProactiveListeners()
}

export function resumeProactive(): void {
if (!proactiveActive) {
return
}
proactivePaused = false
notifyProactiveListeners()
}

export function setContextBlocked(blocked: boolean): void {
contextBlocked = blocked
if (blocked) {
nextTickAt = null
}
notifyProactiveListeners()
}