Understanding AbortSignal
Every scope in go-go-scope has anAbortSignal that propagates cancellation to all child tasks. This signal is based on the standard AbortController API.
Basic Cancellation
import { scope } from 'go-go-scope';
await using s = scope();
const task = s.task(async ({ signal }) => {
// Check if already cancelled
if (signal.aborted) {
throw new Error('Task was cancelled');
}
// Listen for cancellation
signal.addEventListener('abort', () => {
console.log('Task cancelled:', signal.reason);
});
// Long-running operation
await longOperation(signal);
});
// Manually dispose scope to trigger cancellation
await s[Symbol.asyncDispose]();
// Output: "Task cancelled: Error: Scope disposed"
The
signal parameter is automatically provided to every task. Always pass it to async operations that support cancellation (like fetch).Automatic Cancellation Scenarios
go-go-scope automatically cancels tasks in several scenarios:1. Scope Timeout
await using s = scope({ timeout: 1000 }); // 1 second timeout
const task = s.task(async ({ signal }) => {
signal.addEventListener('abort', () => {
console.log('Timed out!');
});
// This will be cancelled after 1 second
await new Promise(resolve => setTimeout(resolve, 5000));
});
await task;
// After 1 second: "Timed out!"
2. Scope Disposal
async function handleRequest() {
await using s = scope();
s.task(async ({ signal }) => {
signal.addEventListener('abort', () => {
console.log('Request ended, task cancelled');
});
await infiniteLoop();
});
// When function exits, scope is disposed
// All tasks are automatically cancelled
}
3. Parent Signal Abort
const parentController = new AbortController();
await using s = scope({
signal: parentController.signal
});
s.task(async ({ signal }) => {
signal.addEventListener('abort', () => {
console.log('Parent aborted!');
});
await longOperation();
});
// Aborting parent automatically cancels scope and all tasks
parentController.abort();
4. Task-Specific Timeout
await using s = scope();
const task = s.task(async ({ signal }) => {
signal.addEventListener('abort', () => {
console.log('Task timeout!');
});
await slowOperation();
}, {
timeout: 2000 // Task-specific 2 second timeout
});
await task;
Cancellation Propagation Tree
Cancellation flows down the scope hierarchy:await using root = scope({ name: 'root', timeout: 5000 });
const child1 = root.createChild({ name: 'child-1' });
const child2 = root.createChild({ name: 'child-2' });
const grandchild = child1.createChild({ name: 'grandchild' });
// All scopes listen to parent signals
root.signal.addEventListener('abort', () =>
console.log('Root aborted'));
child1.signal.addEventListener('abort', () =>
console.log('Child 1 aborted'));
grandchild.signal.addEventListener('abort', () =>
console.log('Grandchild aborted'));
// After 5 seconds (root timeout):
// "Root aborted"
// "Child 1 aborted"
// "Grandchild aborted"
Cancellation Utilities
go-go-scope provides utility functions for working withAbortSignal:
Source location: /home/daytona/workspace/source/packages/go-go-scope/src/cancellation.ts:1-149
onAbort
Register a callback for when the signal aborts:import { onAbort } from 'go-go-scope';
await using s = scope();
const task = s.task(async ({ signal }) => {
// Register cleanup that runs on abort
using cleanup = onAbort(signal, (reason) => {
console.log('Cleaning up because:', reason);
});
try {
await longOperation();
} finally {
// Cleanup is automatically unregistered here
cleanup[Symbol.dispose]();
}
});
abortPromise
Create a promise that rejects when signal aborts:import { abortPromise } from 'go-go-scope';
await using s = scope({ timeout: 1000 });
const task = s.task(async ({ signal }) => {
// Race operation against cancellation
const result = await Promise.race([
fetchData(),
abortPromise(signal) // Rejects when signal aborts
]);
return result;
});
whenAborted
Wait for signal to abort:import { whenAborted } from 'go-go-scope';
await using s = scope();
const task = s.task(async ({ signal }) => {
// Start background work
const worker = startBackgroundWork();
// Wait for cancellation
await whenAborted(signal);
// Cleanup when cancelled
worker.stop();
console.log('Task cancelled gracefully');
});
await s[Symbol.asyncDispose]();
Use
whenAborted for graceful shutdown patterns where you need to perform cleanup after receiving cancellation signal.Real-World Cancellation Patterns
Pattern 1: HTTP Request with Cancellation
import { scope } from 'go-go-scope';
async function fetchWithCancellation(url: string, timeoutMs: number) {
await using s = scope({ timeout: timeoutMs });
const [err, data] = await s.task(async ({ signal }) => {
// Pass signal to fetch for automatic cancellation
const response = await fetch(url, { signal });
return response.json();
});
if (err) {
if (err.name === 'AbortError') {
throw new Error(`Request cancelled or timed out after ${timeoutMs}ms`);
}
throw err;
}
return data;
}
// Usage
try {
const data = await fetchWithCancellation('https://api.example.com/data', 5000);
console.log(data);
} catch (err) {
console.error('Request failed:', err.message);
}
Pattern 2: Polling with Cancellation
import { scope } from 'go-go-scope';
async function pollUntilReady(checkUrl: string, maxAttempts: number = 10) {
await using s = scope({ timeout: 60000 }); // 1 minute max
const [err, result] = await s.task(async ({ signal }) => {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if cancelled
if (signal.aborted) {
throw new Error('Polling cancelled');
}
// Check status
const response = await fetch(checkUrl, { signal });
const status = await response.json();
if (status.ready) {
return status;
}
// Wait before next attempt (respect cancellation)
await new Promise((resolve, reject) => {
const timeoutId = setTimeout(resolve, 1000);
signal.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new Error('Polling cancelled'));
});
});
}
throw new Error('Max attempts reached');
});
if (err) throw err;
return result;
}
Pattern 3: Parallel Tasks with Cancellation
import { scope } from 'go-go-scope';
async function loadUserDashboard(userId: string) {
await using s = scope({
timeout: 10000, // Cancel all if any takes > 10s
name: `dashboard-${userId}`
});
// Start all tasks in parallel
const profileTask = s.task(async ({ signal }) => {
const res = await fetch(`/api/users/${userId}/profile`, { signal });
return res.json();
});
const postsTask = s.task(async ({ signal }) => {
const res = await fetch(`/api/users/${userId}/posts`, { signal });
return res.json();
});
const friendsTask = s.task(async ({ signal }) => {
const res = await fetch(`/api/users/${userId}/friends`, { signal });
return res.json();
});
// Wait for all tasks
const [profileErr, profile] = await profileTask;
const [postsErr, posts] = await postsTask;
const [friendsErr, friends] = await friendsTask;
// If any failed, all others are already cancelled
if (profileErr) throw profileErr;
if (postsErr) throw postsErr;
if (friendsErr) throw friendsErr;
return { profile, posts, friends };
}
Pattern 4: Graceful Cancellation with Cleanup
import { scope, onAbort } from 'go-go-scope';
async function processStreamWithCleanup(stream: ReadableStream) {
await using s = scope();
const [err, result] = await s.task(async ({ signal }) => {
const reader = stream.getReader();
// Register cleanup on cancellation
using cleanup = onAbort(signal, async () => {
console.log('Cancelling stream processing...');
await reader.cancel();
reader.releaseLock();
});
const chunks: Uint8Array[] = [];
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
} finally {
reader.releaseLock();
}
});
if (err) throw err;
return result;
}
Always pass the
signal to async operations that support it. Failing to do so means the operation won’t be cancelled when the scope exits.AbortSignal Composition
Combine multiple abort signals:import { scope } from 'go-go-scope';
async function fetchWithMultipleSignals(
url: string,
userSignal: AbortSignal,
timeout: number
) {
await using s = scope({
signal: userSignal, // Respect user cancellation
timeout // Also timeout after specified duration
});
const [err, data] = await s.task(async ({ signal }) => {
// This signal combines:
// 1. User cancellation (userSignal)
// 2. Scope timeout
// 3. Scope disposal
const response = await fetch(url, { signal });
return response.json();
});
if (err) throw err;
return data;
}
// Usage with user-provided AbortController
const userController = new AbortController();
const promise = fetchWithMultipleSignals(
'https://api.example.com/data',
userController.signal,
5000
);
// User can cancel at any time
setTimeout(() => userController.abort(), 2000);
Best Practices
✅ Do
// ✅ Always pass signal to async operations
await fetch(url, { signal });
// ✅ Check signal.aborted in loops
while (!signal.aborted) {
await processItem();
}
// ✅ Use onAbort for cleanup
using cleanup = onAbort(signal, () => {
resource.cleanup();
});
// ✅ Respect cancellation in retry logic
for (let i = 0; i < retries; i++) {
if (signal.aborted) throw new Error('Cancelled');
await attempt();
}
❌ Don’t
// ❌ Don't ignore the signal parameter
s.task(async () => {
// Signal not used - task can't be cancelled!
await fetch(url);
});
// ❌ Don't create unnecessary AbortControllers
const controller = new AbortController(); // Not needed!
s.task(async ({ signal }) => {
// Use provided signal instead
});
// ❌ Don't forget to check signal in long loops
for (let i = 0; i < 1000000; i++) {
// This loop can't be interrupted!
await processItem();
}
Error Handling with Cancellation
Handle cancellation errors gracefully:import { scope } from 'go-go-scope';
await using s = scope({ timeout: 1000 });
const [err, result] = await s.task(async ({ signal }) => {
try {
return await fetch(url, { signal });
} catch (error) {
// Check if error is due to cancellation
if (error.name === 'AbortError') {
console.log('Request was cancelled');
// Don't treat cancellation as an error
return null;
}
throw error; // Re-throw other errors
}
});
if (err) {
// Handle non-cancellation errors
console.error('Request failed:', err);
}
Debugging Cancellation
Use debug logging to track cancellation:import { scope } from 'go-go-scope';
// Enable debug logging
// Set DEBUG=go-go-scope:* in environment
await using s = scope({
name: 'debug-scope',
timeout: 5000,
hooks: {
afterTask: (name, duration, error) => {
if (error?.name === 'AbortError') {
console.log(`Task ${name} was cancelled after ${duration}ms`);
}
}
}
});
const task = s.task(async ({ signal }) => {
signal.addEventListener('abort', () => {
console.log('Abort reason:', signal.reason);
});
await longOperation();
}, {
otel: { name: 'long-operation' } // Named for debugging
});
Next Steps
Error Handling
Handle errors gracefully with Result tuples
Timeouts
Configure scope and task timeouts
Parallel Execution
Run multiple tasks with automatic cancellation
Retry Logic
Implement retry with cancellation support