Skip to content
Open
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
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ module.exports = {
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }]
},
moduleNameMapper: {
'^cloudflare:workers$': '<rootDir>/src/tests/__mocks__/cloudflare-workers.js'
},
setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

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

67 changes: 60 additions & 7 deletions src/lib/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export class Container<Env = unknown> extends DurableObject<Env> {
// The container won't get a SIGKILL if this threshold is triggered.
sleepAfter: string | number = DEFAULT_SLEEP_AFTER;

// Timeout after which the container will be forcefully killed
// This timeout is absolute from container start time, regardless of activity
// When this timeout expires, the container is sent a SIGKILL signal
timeout?: string | number;

// Container configuration properties
// Set these properties directly in your container instance
envVars: ContainerStartOptions['env'] = {};
Expand Down Expand Up @@ -251,6 +256,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
if (options) {
if (options.defaultPort !== undefined) this.defaultPort = options.defaultPort;
if (options.sleepAfter !== undefined) this.sleepAfter = options.sleepAfter;
if (options.timeout !== undefined) this.timeout = options.timeout;
}

// Create schedules table if it doesn't exist
Expand Down Expand Up @@ -547,6 +553,23 @@ export class Container<Env = unknown> extends DurableObject<Env> {
await this.stop();
}

/**
* Called when the timeout expires and the container needs to be stopped.
* This is a timeout that is absolute from container start time, regardless of activity.
* When this timeout expires, the container will be gracefully stopped.
*
* Override this method in subclasses to handle timeout events.
* By default, this method calls `this.stop()` to gracefully stop the container.
*/
public async onHardTimeoutExpired(): Promise<void> {
if (!this.container.running) {
return;
}

console.log(`Container timeout expired after ${this.timeout}. Stopping container.`);
await this.stop();
}

/**
* Error handler for container errors
* Override this method in subclasses to handle container errors
Expand All @@ -568,6 +591,18 @@ export class Container<Env = unknown> extends DurableObject<Env> {
this.sleepAfterMs = Date.now() + timeoutInMs;
}

/**
* Set up the timeout when the container starts
* This is called internally when the container starts
*/
private setupTimeout() {
if (this.timeout) {
const timeoutMs = parseTimeExpression(this.timeout) * 1000;
this.containerStartTime = Date.now();
this.timeoutMs = this.containerStartTime + timeoutMs;
}
}

// ==================
// SCHEDULING
// ==================
Expand Down Expand Up @@ -771,6 +806,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
private monitorSetup = false;

private sleepAfterMs = 0;
private timeoutMs?: number;
private containerStartTime?: number;

// ==========================
// GENERAL HELPERS
Expand Down Expand Up @@ -928,6 +965,9 @@ export class Container<Env = unknown> extends DurableObject<Env> {
await this.scheduleNextAlarm();
this.container.start(startConfig);
this.monitor = this.container.monitor();

// Set up timeout when container starts
this.setupTimeout();
} else {
await this.scheduleNextAlarm();
}
Expand Down Expand Up @@ -1020,9 +1060,9 @@ export class Container<Env = unknown> extends DurableObject<Env> {
})
.finally(() => {
this.monitorSetup = false;
if (this.timeout) {
if (this.timeoutId) {
if (this.resolve) this.resolve();
clearTimeout(this.timeout);
clearTimeout(this.timeoutId);
}
});
}
Expand Down Expand Up @@ -1126,15 +1166,24 @@ export class Container<Env = unknown> extends DurableObject<Env> {
return;
}

// Check timeout first (takes priority over activity timeout)
if (this.isTimeoutExpired()) {
await this.onHardTimeoutExpired();
return;
}

if (this.isActivityExpired()) {
await this.onActivityExpired();
// renewActivityTimeout makes sure we don't spam calls here
this.renewActivityTimeout();
return;
}

// Math.min(3m or maxTime, sleepTimeout)
// Math.min(3m or maxTime, sleepTimeout, timeout)
minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs);
if (this.timeoutMs) {
minTime = Math.min(minTime, this.timeoutMs);
}
const timeout = Math.max(0, minTime - Date.now());

// await a sleep for maxTime to keep the DO alive for
Expand All @@ -1146,7 +1195,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
return;
}

this.timeout = setTimeout(() => {
this.timeoutId = setTimeout(() => {
resolve();
}, timeout);
});
Expand All @@ -1157,7 +1206,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
// the next alarm is the one that decides if it should stop the loop.
}

timeout?: ReturnType<typeof setTimeout>;
timeoutId?: ReturnType<typeof setTimeout>;
resolve?: () => void;

// synchronises container state with the container source of truth to process events
Expand Down Expand Up @@ -1199,9 +1248,9 @@ export class Container<Env = unknown> extends DurableObject<Env> {
const nextTime = ms + Date.now();

// if not already set
if (this.timeout) {
if (this.timeoutId) {
if (this.resolve) this.resolve();
clearTimeout(this.timeout);
clearTimeout(this.timeoutId);
}

await this.ctx.storage.setAlarm(nextTime);
Expand Down Expand Up @@ -1271,4 +1320,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
private isActivityExpired(): boolean {
return this.sleepAfterMs <= Date.now();
}

private isTimeoutExpired(): boolean {
return this.timeoutMs !== undefined && this.timeoutMs <= Date.now();
}
}
26 changes: 26 additions & 0 deletions src/tests/__mocks__/cloudflare-workers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Mock for cloudflare:workers module
const DurableObject = class MockDurableObject {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
}

fetch() {
return new Response('Mock response');
}

async alarm() {
// Mock alarm implementation
}
};

// Mock ExecutionContext
const ExecutionContext = class MockExecutionContext {
waitUntil() {}
passThroughOnException() {}
};

module.exports = {
DurableObject,
ExecutionContext
};
Loading