From 9c74c471971573917e61b51a226766436aee9af8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 24 Jun 2026 14:11:38 -0700 Subject: [PATCH 1/2] fix(alfred): clear default timeout timers --- CHANGELOG.md | 14 ++++++ alfred-live/CHANGELOG.md | 6 +++ alfred-live/jsr.json | 2 +- alfred-live/package.json | 4 +- alfred/CHANGELOG.md | 8 ++++ alfred/jsr.json | 2 +- alfred/package.json | 2 +- alfred/src/policies/timeout.js | 81 +++++++++++++++++++++----------- alfred/test/unit/timeout.test.js | 13 +++++ package.json | 2 +- pnpm-lock.yaml | 2 +- 11 files changed, 102 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33893be..3fee6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.4] - 2026-06-24 (@git-stunts/alfred) + +### Fixed + +- **Timeout cleanup**: `timeout()` now clears the default runtime timer when the + wrapped operation resolves or rejects before the deadline, while preserving + injected-clock behavior for deterministic tests. + ## [0.10.3] - 2026-02-08 (@git-stunts/alfred) ### Fixed @@ -186,6 +194,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.4] - 2026-06-24 (@git-stunts/alfred-live) + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.3] - 2026-02-08 (@git-stunts/alfred-live) ### Changed diff --git a/alfred-live/CHANGELOG.md b/alfred-live/CHANGELOG.md index 0832dd1..5df4717 100644 --- a/alfred-live/CHANGELOG.md +++ b/alfred-live/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.4] - 2026-06-24 + +### Changed + +- Version bump to keep lockstep alignment with the Alfred package family (no API changes). + ## [0.10.3] - 2026-02-08 ### Changed diff --git a/alfred-live/jsr.json b/alfred-live/jsr.json index 888ff59..20104d8 100644 --- a/alfred-live/jsr.json +++ b/alfred-live/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.3", + "version": "0.10.4", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "license": "Apache-2.0", "exports": { diff --git a/alfred-live/package.json b/alfred-live/package.json index ebdc04a..08abaac 100644 --- a/alfred-live/package.json +++ b/alfred-live/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-live", - "version": "0.10.3", + "version": "0.10.4", "description": "In-memory control plane for Alfred: adaptive values, config registry, command router.", "type": "module", "sideEffects": false, @@ -16,7 +16,7 @@ "alfredctl": "./bin/alfredctl.js" }, "dependencies": { - "@git-stunts/alfred": "0.10.3" + "@git-stunts/alfred": "0.10.4" }, "engines": { "node": ">=20.0.0" diff --git a/alfred/CHANGELOG.md b/alfred/CHANGELOG.md index afc09ec..aae42b7 100644 --- a/alfred/CHANGELOG.md +++ b/alfred/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.4] - 2026-06-24 + +### Fixed + +- **Timeout cleanup**: `timeout()` now clears the default runtime timer when the + wrapped operation resolves or rejects before the deadline, while preserving + injected-clock behavior for deterministic tests. + ## [0.10.3] - 2026-02-08 ### Fixed diff --git a/alfred/jsr.json b/alfred/jsr.json index 8f08787..47e6e3b 100644 --- a/alfred/jsr.json +++ b/alfred/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.3", + "version": "0.10.4", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "license": "Apache-2.0", "exports": { diff --git a/alfred/package.json b/alfred/package.json index 5022820..5eebd05 100644 --- a/alfred/package.json +++ b/alfred/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred", - "version": "0.10.3", + "version": "0.10.4", "description": "Production-grade resilience patterns for async ops: retry/backoff+jitter, circuit breaker, bulkhead, timeout.", "type": "module", "sideEffects": false, diff --git a/alfred/src/policies/timeout.js b/alfred/src/policies/timeout.js index 0548dff..16f5973 100644 --- a/alfred/src/policies/timeout.js +++ b/alfred/src/policies/timeout.js @@ -19,6 +19,41 @@ import { resolve } from '../utils/resolvable.js'; * @property {{ now(): number, sleep(ms: number): Promise }} [clock] - Clock for testing */ +function rejectWithTimeout(context) { + const { controller, clock, isCompleted, onTimeout, reject, startTime, telemetry, timeoutMs } = + context; + + if (isCompleted()) { + return; + } + + controller.abort(); + const elapsed = clock.now() - startTime; + + if (onTimeout) { + onTimeout(elapsed); + } + + telemetry.emit({ + type: 'timeout', + timestamp: clock.now(), + timeout: timeoutMs, + elapsed, + metrics: { timeouts: 1, failures: 1 }, + }); + + reject(new TimeoutError(timeoutMs, elapsed)); +} + +function scheduleTimeout(context) { + if (context.usesInjectedClock) { + context.clock.sleep(context.timeoutMs).then(() => rejectWithTimeout(context)); + return null; + } + + return setTimeout(() => rejectWithTimeout(context), context.timeoutMs); +} + /** * Executes a function with a timeout limit. * @@ -53,35 +88,26 @@ import { resolve } from '../utils/resolvable.js'; * await clock.advance(5000); // Triggers timeout */ export async function timeout(ms, fn, options = {}) { - const { onTimeout, telemetry = new NoopSink(), clock = new SystemClock() } = options; + const { onTimeout, telemetry = new NoopSink() } = options; + const clock = options.clock || new SystemClock(); const timeoutMs = resolve(ms); const controller = new AbortController(); const startTime = clock.now(); let completed = false; + let timeoutHandle = null; const timeoutPromise = new Promise((_, reject) => { - clock.sleep(timeoutMs).then(() => { - if (completed) { - return; - } - - controller.abort(); - const elapsed = clock.now() - startTime; - - if (onTimeout) { - onTimeout(elapsed); - } - - telemetry.emit({ - type: 'timeout', - timestamp: clock.now(), - timeout: timeoutMs, - elapsed, - metrics: { timeouts: 1, failures: 1 }, - }); - - reject(new TimeoutError(timeoutMs, elapsed)); + timeoutHandle = scheduleTimeout({ + controller, + clock, + isCompleted: () => completed, + onTimeout, + reject, + startTime, + telemetry, + timeoutMs, + usesInjectedClock: Boolean(options.clock), }); }); @@ -90,11 +116,12 @@ export async function timeout(ms, fn, options = {}) { const fnAcceptsSignal = fn.length > 0; const operationPromise = fnAcceptsSignal ? fn(controller.signal) : fn(); - const result = await Promise.race([operationPromise, timeoutPromise]); - completed = true; - return result; - } catch (error) { + return await Promise.race([operationPromise, timeoutPromise]); + } finally { completed = true; - throw error; + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } } } diff --git a/alfred/test/unit/timeout.test.js b/alfred/test/unit/timeout.test.js index 79ffd12..05ae0d6 100644 --- a/alfred/test/unit/timeout.test.js +++ b/alfred/test/unit/timeout.test.js @@ -272,6 +272,19 @@ describe('timeout', () => { expect(results).toEqual([0, 1, 2, 3, 4]); }); + it('clears the default runtime timer after successful completion', async () => { + vi.useFakeTimers(); + + try { + const result = await timeout(1000, () => Promise.resolve('quick')); + + expect(result).toBe('quick'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('preserves original error type on failure before timeout', async () => { class CustomError extends Error { constructor() { diff --git a/package.json b/package.json index 92da7b4..3f19887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/alfred-workspace", - "version": "0.10.3", +"version": "0.10.4", "scripts": { "test": "pnpm -r test", "lint": "pnpm -r run lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 511ba07..65bb96c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: alfred-live: dependencies: '@git-stunts/alfred': - specifier: 0.10.3 + specifier: 0.10.4 version: link:../alfred devDependencies: '@eslint/js': From 360934b1892abc8f8336f9b98b62c77866f66d20 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 24 Jun 2026 14:19:00 -0700 Subject: [PATCH 2/2] fix(alfred): preserve timeout errors --- alfred/src/policies/timeout.js | 34 ++++++++++++++------ alfred/test/unit/timeout.test.js | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/alfred/src/policies/timeout.js b/alfred/src/policies/timeout.js index 16f5973..8d67f4b 100644 --- a/alfred/src/policies/timeout.js +++ b/alfred/src/policies/timeout.js @@ -30,19 +30,33 @@ function rejectWithTimeout(context) { controller.abort(); const elapsed = clock.now() - startTime; + notifyTimeout({ elapsed, onTimeout }); + emitTimeoutTelemetry({ clock, elapsed, telemetry, timeoutMs }); + reject(new TimeoutError(timeoutMs, elapsed)); +} + +function notifyTimeout({ elapsed, onTimeout }) { if (onTimeout) { - onTimeout(elapsed); + try { + onTimeout(elapsed); + } catch { + // Timeout side effects must not replace the TimeoutError result. + } } +} - telemetry.emit({ - type: 'timeout', - timestamp: clock.now(), - timeout: timeoutMs, - elapsed, - metrics: { timeouts: 1, failures: 1 }, - }); - - reject(new TimeoutError(timeoutMs, elapsed)); +function emitTimeoutTelemetry({ clock, elapsed, telemetry, timeoutMs }) { + try { + telemetry.emit({ + type: 'timeout', + timestamp: clock.now(), + timeout: timeoutMs, + elapsed, + metrics: { timeouts: 1, failures: 1 }, + }); + } catch { + // Timeout telemetry must not replace the TimeoutError result. + } } function scheduleTimeout(context) { diff --git a/alfred/test/unit/timeout.test.js b/alfred/test/unit/timeout.test.js index 05ae0d6..9d9765a 100644 --- a/alfred/test/unit/timeout.test.js +++ b/alfred/test/unit/timeout.test.js @@ -285,6 +285,19 @@ describe('timeout', () => { } }); + it('clears the default runtime timer after early rejection', async () => { + vi.useFakeTimers(); + + try { + const failure = new Error('quick failure'); + + await expect(timeout(1000, () => Promise.reject(failure))).rejects.toBe(failure); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('preserves original error type on failure before timeout', async () => { class CustomError extends Error { constructor() { @@ -450,6 +463,47 @@ describe('timeout', () => { expect(clock.pendingCount).toBe(0); }); + it('rejects with TimeoutError when onTimeout throws', async () => { + vi.useFakeTimers(); + + try { + const promise = timeout(50, () => new Promise(() => {}), { + onTimeout: () => { + throw new Error('callback failed'); + }, + }); + const timeoutExpectation = expect(promise).rejects.toThrow(TimeoutError); + + await vi.advanceTimersByTimeAsync(50); + + await timeoutExpectation; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects with TimeoutError when telemetry emit throws', async () => { + vi.useFakeTimers(); + + try { + const telemetry = { + emit: () => { + throw new Error('telemetry failed'); + }, + }; + const promise = timeout(50, () => new Promise(() => {}), { telemetry }); + const timeoutExpectation = expect(promise).rejects.toThrow(TimeoutError); + + await vi.advanceTimersByTimeAsync(50); + + await timeoutExpectation; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('can test race between operation and timeout', async () => { const clock = new TestClock();