Skip to content

Commit 2a1de0e

Browse files
committed
feat: Add hard timeout functionality to Container class
- Adds hardTimeout configuration option with duration parsing - Implements hard timeout timer that starts on container initialization - Hard timeout never resets (unlike soft timeout which resets on activity) - Provides onHardTimeoutExpired() hook for custom cleanup logic - Hard timeout takes precedence over soft timeout when both expire - Comprehensive test coverage including timeout interactions - Improved Jest configuration for Cloudflare Workers environment
1 parent feab875 commit 2a1de0e

File tree

7 files changed

+388
-5
lines changed

7 files changed

+388
-5
lines changed

jest.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ module.exports = {
66
transform: {
77
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }]
88
},
9+
moduleNameMapper: {
10+
'^cloudflare:workers$': '<rootDir>/src/tests/__mocks__/cloudflare-workers.js'
11+
},
12+
setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'],
913
collectCoverage: true,
1014
coverageDirectory: 'coverage',
1115
coverageReporters: ['text', 'lcov'],

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/container.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ export class Container<Env = unknown> extends DurableObject<Env> {
227227
// The container won't get a SIGKILL if this threshold is triggered.
228228
sleepAfter: string | number = DEFAULT_SLEEP_AFTER;
229229

230+
// Timeout after which the container will be forcefully killed
231+
// This timeout is absolute from container start time, regardless of activity
232+
// When this timeout expires, the container is sent a SIGKILL signal
233+
timeout?: string | number;
234+
230235
// Container configuration properties
231236
// Set these properties directly in your container instance
232237
envVars: ContainerStartOptions['env'] = {};
@@ -261,6 +266,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
261266
if (options) {
262267
if (options.defaultPort !== undefined) this.defaultPort = options.defaultPort;
263268
if (options.sleepAfter !== undefined) this.sleepAfter = options.sleepAfter;
269+
if (options.timeout !== undefined) this.timeout = options.timeout;
264270
}
265271

266272
// Create schedules table if it doesn't exist
@@ -577,6 +583,23 @@ export class Container<Env = unknown> extends DurableObject<Env> {
577583
await this.stop();
578584
}
579585

586+
/**
587+
* Called when the timeout expires and the container needs to be forcefully killed.
588+
* This is a timeout that is absolute from container start time, regardless of activity.
589+
* When this timeout expires, the container will be forcefully killed with SIGKILL.
590+
*
591+
* Override this method in subclasses to handle timeout events.
592+
* By default, this method calls `this.destroy()` to forcefully kill the container.
593+
*/
594+
public async onHardTimeoutExpired(): Promise<void> {
595+
if (!this.container.running) {
596+
return;
597+
}
598+
599+
console.log(`Container timeout expired after ${this.timeout}. Forcefully killing container.`);
600+
await this.destroy();
601+
}
602+
580603
/**
581604
* Error handler for container errors
582605
* Override this method in subclasses to handle container errors
@@ -598,6 +621,18 @@ export class Container<Env = unknown> extends DurableObject<Env> {
598621
this.sleepAfterMs = Date.now() + timeoutInMs;
599622
}
600623

624+
/**
625+
* Set up the timeout when the container starts
626+
* This is called internally when the container starts
627+
*/
628+
private setupTimeout() {
629+
if (this.timeout) {
630+
const timeoutMs = parseTimeExpression(this.timeout) * 1000;
631+
this.containerStartTime = Date.now();
632+
this.timeoutMs = this.containerStartTime + timeoutMs;
633+
}
634+
}
635+
601636
// ==================
602637
// SCHEDULING
603638
// ==================
@@ -798,6 +833,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
798833
private monitorSetup = false;
799834

800835
private sleepAfterMs = 0;
836+
private timeoutMs?: number;
837+
private containerStartTime?: number;
801838

802839
// ==========================
803840
// GENERAL HELPERS
@@ -946,6 +983,9 @@ export class Container<Env = unknown> extends DurableObject<Env> {
946983
await this.scheduleNextAlarm();
947984
this.container.start(startConfig);
948985
this.monitor = this.container.monitor();
986+
987+
// Set up timeout when container starts
988+
this.setupTimeout();
949989
} else {
950990
await this.scheduleNextAlarm();
951991
}
@@ -1147,15 +1187,24 @@ export class Container<Env = unknown> extends DurableObject<Env> {
11471187
return;
11481188
}
11491189

1190+
// Check timeout first (takes priority over activity timeout)
1191+
if (this.isTimeoutExpired()) {
1192+
await this.onHardTimeoutExpired();
1193+
return;
1194+
}
1195+
11501196
if (this.isActivityExpired()) {
11511197
await this.onActivityExpired();
11521198
// renewActivityTimeout makes sure we don't spam calls here
11531199
this.renewActivityTimeout();
11541200
return;
11551201
}
11561202

1157-
// Math.min(3m or maxTime, sleepTimeout)
1203+
// Math.min(3m or maxTime, sleepTimeout, timeout)
11581204
minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs);
1205+
if (this.timeoutMs) {
1206+
minTime = Math.min(minTime, this.timeoutMs);
1207+
}
11591208
const timeout = Math.max(0, minTime - Date.now());
11601209

11611210
// await a sleep for maxTime to keep the DO alive for
@@ -1167,7 +1216,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
11671216
return;
11681217
}
11691218

1170-
this.timeout = setTimeout(() => {
1219+
this.timeoutId = setTimeout(() => {
11711220
resolve();
11721221
}, timeout);
11731222
});
@@ -1178,7 +1227,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
11781227
// the next alarm is the one that decides if it should stop the loop.
11791228
}
11801229

1181-
timeout?: ReturnType<typeof setTimeout>;
1230+
timeoutId?: ReturnType<typeof setTimeout>;
11821231
resolve?: () => void;
11831232

11841233
// synchronises container state with the container source of truth to process events
@@ -1292,4 +1341,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
12921341
private isActivityExpired(): boolean {
12931342
return this.sleepAfterMs <= Date.now();
12941343
}
1344+
1345+
private isTimeoutExpired(): boolean {
1346+
return this.timeoutMs !== undefined && this.timeoutMs <= Date.now();
1347+
}
12951348
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Mock for cloudflare:workers module
2+
const DurableObject = class MockDurableObject {
3+
constructor(ctx, env) {
4+
this.ctx = ctx;
5+
this.env = env;
6+
}
7+
8+
fetch() {
9+
return new Response('Mock response');
10+
}
11+
12+
async alarm() {
13+
// Mock alarm implementation
14+
}
15+
};
16+
17+
// Mock ExecutionContext
18+
const ExecutionContext = class MockExecutionContext {
19+
waitUntil() {}
20+
passThroughOnException() {}
21+
};
22+
23+
module.exports = {
24+
DurableObject,
25+
ExecutionContext
26+
};

0 commit comments

Comments
 (0)