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.

87 changes: 62 additions & 25 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,20 @@ export class Container<Env = unknown> extends DurableObject<Env> {
await this.stop();
}

/**
* Called when the hard timeout expires.
* Override this method to handle timeout events.
* By default, 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 @@ -560,12 +580,25 @@ export class Container<Env = unknown> extends DurableObject<Env> {

/**
* Renew the container's activity timeout
*
* Call this method whenever there is activity on the container
*/
public renewActivityTimeout() {
const timeoutInMs = parseTimeExpression(this.sleepAfter) * 1000;
this.sleepAfterMs = Date.now() + timeoutInMs;
if (this.container.running) {
const timeoutInMs = parseTimeExpression(this.sleepAfter) * 1000;
// Type assertion needed until @cloudflare/workers-types is updated
const containerAny = this.container as any;
if (typeof containerAny.setInactivityTimeout === 'function') {
containerAny.setInactivityTimeout(timeoutInMs).catch((error: any) => {
console.error('Failed to set inactivity timeout:', error);
});
}
}
}

/**
* Set up timeouts when the container starts
*/
private setupTimeout() {
this.renewActivityTimeout();
}

// ==================
Expand Down Expand Up @@ -770,7 +803,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {

private monitorSetup = false;

private sleepAfterMs = 0;
// Timeout properties removed - handled by workerd

// ==========================
// GENERAL HELPERS
Expand Down Expand Up @@ -896,6 +929,13 @@ export class Container<Env = unknown> extends DurableObject<Env> {

if (envVars && Object.keys(envVars).length > 0) startConfig.env = envVars;
if (entrypoint) startConfig.entrypoint = entrypoint;

// Add hardTimeout if configured
if (this.timeout) {
const hardTimeoutMs = parseTimeExpression(this.timeout) * 1000;
// Type assertion needed until @cloudflare/workers-types is updated
(startConfig as any).hardTimeout = hardTimeoutMs;
}

this.renewActivityTimeout();
const handleError = async () => {
Expand Down Expand Up @@ -928,6 +968,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 +1063,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 @@ -1069,7 +1112,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
}>`
SELECT * FROM container_schedules;
`;
let minTime = Date.now() + 3 * 60 * 1000;
// minTime will be calculated later based on scheduled tasks

const now = Date.now() / 1000;
// Process each due schedule
Expand Down Expand Up @@ -1111,7 +1154,10 @@ export class Container<Env = unknown> extends DurableObject<Env> {
}>`
SELECT * FROM container_schedules;
`;
const minTimeFromSchedules = Math.min(...resultForMinTime.map(r => r.time * 1000));
// Calculate next scheduled task time, or default to 3 minutes if no tasks
const minTimeFromSchedules = resultForMinTime.length > 0
? Math.min(...resultForMinTime.map(r => r.time * 1000))
: Date.now() + 3 * 60 * 1000; // 3 minutes default

// if not running and nothing to do, stop
if (!this.container.running) {
Expand All @@ -1126,15 +1172,8 @@ export class Container<Env = unknown> extends DurableObject<Env> {
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)
minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs);
// Timeouts handled natively by workerd
const minTime = minTimeFromSchedules;
const timeout = Math.max(0, minTime - Date.now());

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

this.timeout = setTimeout(() => {
this.timeoutId = setTimeout(() => {
resolve();
}, timeout);
});
Expand All @@ -1157,7 +1196,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 +1238,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 @@ -1268,7 +1307,5 @@ export class Container<Env = unknown> extends DurableObject<Env> {
return this.toSchedule(schedule);
}

private isActivityExpired(): boolean {
return this.sleepAfterMs <= Date.now();
}
// Timeout methods removed - handled by workerd
}
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