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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions alfred-live/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion alfred-live/jsr.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions alfred-live/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions alfred/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion alfred/jsr.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion alfred/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
95 changes: 68 additions & 27 deletions alfred/src/policies/timeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,55 @@ import { resolve } from '../utils/resolvable.js';
* @property {{ now(): number, sleep(ms: number): Promise<void> }} [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;

notifyTimeout({ elapsed, onTimeout });
emitTimeoutTelemetry({ clock, elapsed, telemetry, timeoutMs });
reject(new TimeoutError(timeoutMs, elapsed));
}

function notifyTimeout({ elapsed, onTimeout }) {
if (onTimeout) {
try {
onTimeout(elapsed);
} catch {
// Timeout side effects must not replace the TimeoutError result.
}
}
}

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) {
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.
*
Expand Down Expand Up @@ -53,35 +102,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),
});
});

Expand All @@ -90,11 +130,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;
}
}
}
67 changes: 67 additions & 0 deletions alfred/test/unit/timeout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,32 @@ 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('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() {
Expand Down Expand Up @@ -437,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();

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading