Skip to content

Commit 625cc69

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 625cc69

7 files changed

Lines changed: 387 additions & 3 deletions

File tree

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: 55 additions & 1 deletion
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+
// Hard 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+
hardTimeout?: 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.hardTimeout !== undefined) this.hardTimeout = options.hardTimeout;
264270
}
265271

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

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

625+
/**
626+
* Set up the hard timeout when the container starts
627+
* This is called internally when the container starts
628+
*/
629+
private setupHardTimeout() {
630+
if (this.hardTimeout) {
631+
const hardTimeoutMs = parseTimeExpression(this.hardTimeout) * 1000;
632+
this.containerStartTime = Date.now();
633+
this.hardTimeoutMs = this.containerStartTime + hardTimeoutMs;
634+
}
635+
}
636+
601637
// ==================
602638
// SCHEDULING
603639
// ==================
@@ -798,6 +834,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
798834
private monitorSetup = false;
799835

800836
private sleepAfterMs = 0;
837+
private hardTimeoutMs?: number;
838+
private containerStartTime?: number;
801839

802840
// ==========================
803841
// GENERAL HELPERS
@@ -946,6 +984,9 @@ export class Container<Env = unknown> extends DurableObject<Env> {
946984
await this.scheduleNextAlarm();
947985
this.container.start(startConfig);
948986
this.monitor = this.container.monitor();
987+
988+
// Set up hard timeout when container starts
989+
this.setupHardTimeout();
949990
} else {
950991
await this.scheduleNextAlarm();
951992
}
@@ -1147,15 +1188,24 @@ export class Container<Env = unknown> extends DurableObject<Env> {
11471188
return;
11481189
}
11491190

1191+
// Check hard timeout first (takes priority over activity timeout)
1192+
if (this.isHardTimeoutExpired()) {
1193+
await this.onHardTimeoutExpired();
1194+
return;
1195+
}
1196+
11501197
if (this.isActivityExpired()) {
11511198
await this.onActivityExpired();
11521199
// renewActivityTimeout makes sure we don't spam calls here
11531200
this.renewActivityTimeout();
11541201
return;
11551202
}
11561203

1157-
// Math.min(3m or maxTime, sleepTimeout)
1204+
// Math.min(3m or maxTime, sleepTimeout, hardTimeout)
11581205
minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs);
1206+
if (this.hardTimeoutMs) {
1207+
minTime = Math.min(minTime, this.hardTimeoutMs);
1208+
}
11591209
const timeout = Math.max(0, minTime - Date.now());
11601210

11611211
// await a sleep for maxTime to keep the DO alive for
@@ -1292,4 +1342,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
12921342
private isActivityExpired(): boolean {
12931343
return this.sleepAfterMs <= Date.now();
12941344
}
1345+
1346+
private isHardTimeoutExpired(): boolean {
1347+
return this.hardTimeoutMs !== undefined && this.hardTimeoutMs <= Date.now();
1348+
}
12951349
}
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)