diff --git a/jest.config.js b/jest.config.js index 50712e8..e4a7d55 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,10 @@ module.exports = { transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] }, + moduleNameMapper: { + '^cloudflare:workers$': '/src/tests/__mocks__/cloudflare-workers.js' + }, + setupFilesAfterEnv: ['/src/tests/setup.ts'], collectCoverage: true, coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov'], diff --git a/package-lock.json b/package-lock.json index d2d01f4..d6d8e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2594,6 +2594,7 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" diff --git a/src/lib/container.ts b/src/lib/container.ts index e0bce6f..dfe5c85 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -217,6 +217,11 @@ export class Container extends DurableObject { // 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'] = {}; @@ -251,6 +256,7 @@ export class Container extends DurableObject { 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 @@ -547,6 +553,23 @@ export class Container extends DurableObject { 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 { + 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 @@ -568,6 +591,18 @@ export class Container extends DurableObject { 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 // ================== @@ -771,6 +806,8 @@ export class Container extends DurableObject { private monitorSetup = false; private sleepAfterMs = 0; + private timeoutMs?: number; + private containerStartTime?: number; // ========================== // GENERAL HELPERS @@ -928,6 +965,9 @@ export class Container extends DurableObject { await this.scheduleNextAlarm(); this.container.start(startConfig); this.monitor = this.container.monitor(); + + // Set up timeout when container starts + this.setupTimeout(); } else { await this.scheduleNextAlarm(); } @@ -1020,9 +1060,9 @@ export class Container extends DurableObject { }) .finally(() => { this.monitorSetup = false; - if (this.timeout) { + if (this.timeoutId) { if (this.resolve) this.resolve(); - clearTimeout(this.timeout); + clearTimeout(this.timeoutId); } }); } @@ -1126,6 +1166,12 @@ export class Container extends DurableObject { 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 @@ -1133,8 +1179,11 @@ export class Container extends DurableObject { 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 @@ -1146,7 +1195,7 @@ export class Container extends DurableObject { return; } - this.timeout = setTimeout(() => { + this.timeoutId = setTimeout(() => { resolve(); }, timeout); }); @@ -1157,7 +1206,7 @@ export class Container extends DurableObject { // the next alarm is the one that decides if it should stop the loop. } - timeout?: ReturnType; + timeoutId?: ReturnType; resolve?: () => void; // synchronises container state with the container source of truth to process events @@ -1199,9 +1248,9 @@ export class Container extends DurableObject { 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); @@ -1271,4 +1320,8 @@ export class Container extends DurableObject { private isActivityExpired(): boolean { return this.sleepAfterMs <= Date.now(); } + + private isTimeoutExpired(): boolean { + return this.timeoutMs !== undefined && this.timeoutMs <= Date.now(); + } } diff --git a/src/tests/__mocks__/cloudflare-workers.js b/src/tests/__mocks__/cloudflare-workers.js new file mode 100644 index 0000000..f3c495d --- /dev/null +++ b/src/tests/__mocks__/cloudflare-workers.js @@ -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 +}; diff --git a/src/tests/container.test.ts b/src/tests/container.test.ts index 9779554..f986d3d 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -298,6 +298,239 @@ describe('Container', () => { }); }); +// Hard Timeout Tests +describe('Hard Timeout', () => { + let mockCtx: any; + let container: Container; + + beforeEach(() => { + // Create a mock context with necessary container methods + mockCtx = { + storage: { + sql: { + exec: jest.fn().mockReturnValue([]), + }, + put: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(undefined), + setAlarm: jest.fn().mockResolvedValue(undefined), + deleteAlarm: jest.fn().mockResolvedValue(undefined), + sync: jest.fn().mockResolvedValue(undefined), + }, + blockConcurrencyWhile: jest.fn(fn => fn()), + container: { + running: false, + start: jest.fn(), + destroy: jest.fn(), + monitor: jest.fn().mockReturnValue(Promise.resolve()), + getTcpPort: jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ + status: 200, + body: 'test', + }), + }), + }, + }; + + // @ts-ignore - ignore TypeScript errors for testing + container = new Container(mockCtx, {}, { defaultPort: 8080 }); + }); + + test('should initialize with timeout from constructor options', () => { + const timeout = '30s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + expect(testContainer.timeout).toBe(timeout); + }); + + test('should set up timeout when container starts', async () => { + const timeout = '30s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + testContainer.defaultPort = 8080; + + // Mock the setupTimeout method to spy on it + const setupSpy = jest.spyOn(testContainer as any, 'setupTimeout'); + + // @ts-ignore - ignore TypeScript errors for testing + await testContainer.startAndWaitForPorts(8080); + + expect(setupSpy).toHaveBeenCalled(); + }); + + test('should calculate timeout correctly', () => { + const timeout = '60s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + // Access private method for testing + const originalNow = Date.now; + const mockNow = 1000000; + Date.now = jest.fn(() => mockNow); + + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + // @ts-ignore - access private properties for testing + expect(testContainer.containerStartTime).toBe(mockNow); + // @ts-ignore - access private properties for testing + expect(testContainer.timeoutMs).toBe(mockNow + 60000); // 60 seconds in ms + + Date.now = originalNow; + }); + + test('should detect timeout expiration', () => { + const timeout = '1s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + const originalNow = Date.now; + const mockStartTime = 1000000; + const mockCurrentTime = mockStartTime + 2000; // 2 seconds later + + Date.now = jest.fn(() => mockStartTime); + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + Date.now = jest.fn(() => mockCurrentTime); + + // @ts-ignore - access private method for testing + const isExpired = testContainer.isTimeoutExpired(); + expect(isExpired).toBe(true); + + Date.now = originalNow; + }); + + test('should not detect timeout expiration when within timeout', () => { + const timeout = '60s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + const originalNow = Date.now; + const mockStartTime = 1000000; + const mockCurrentTime = mockStartTime + 30000; // 30 seconds later (within 60s timeout) + + Date.now = jest.fn(() => mockStartTime); + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + Date.now = jest.fn(() => mockCurrentTime); + + // @ts-ignore - access private method for testing + const isExpired = testContainer.isTimeoutExpired(); + expect(isExpired).toBe(false); + + Date.now = originalNow; + }); + + test('should call onHardTimeoutExpired when timeout expires', async () => { + const timeout = '1s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + testContainer.defaultPort = 8080; + + // Mock container as running + mockCtx.container.running = true; + + // Spy on onHardTimeoutExpired + const onHardTimeoutSpy = jest.spyOn(testContainer, 'onHardTimeoutExpired'); + + const originalNow = Date.now; + const mockStartTime = 1000000; + + Date.now = jest.fn(() => mockStartTime); + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + // Move time forward past hard timeout + Date.now = jest.fn(() => mockStartTime + 2000); + + // Simulate alarm checking timeouts + // @ts-ignore - access private method for testing + const isExpired = testContainer.isTimeoutExpired(); + expect(isExpired).toBe(true); + + if (isExpired) { + await testContainer.onHardTimeoutExpired(); + } + + expect(onHardTimeoutSpy).toHaveBeenCalled(); + + Date.now = originalNow; + }); + + test('should call destroy() in default onHardTimeoutExpired implementation', async () => { + const timeout = '1s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + // Mock container as running + mockCtx.container.running = true; + + // Spy on destroy method + const destroySpy = jest.spyOn(testContainer, 'destroy'); + + await testContainer.onHardTimeoutExpired(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + test('should not call destroy() when container is not running', async () => { + const timeout = '1s'; + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout }); + + // Mock container as not running + mockCtx.container.running = false; + + // Spy on destroy method + const destroySpy = jest.spyOn(testContainer, 'destroy'); + + await testContainer.onHardTimeoutExpired(); + + expect(destroySpy).not.toHaveBeenCalled(); + }); + + test('should handle different time expression formats for hard timeout', () => { + const testCases = [ + { input: '30s', expectedMs: 30000 }, + { input: '5m', expectedMs: 300000 }, + { input: '1h', expectedMs: 3600000 }, + { input: 60, expectedMs: 60000 }, // number in seconds + ]; + + testCases.forEach(({ input, expectedMs }) => { + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { timeout: input }); + + const originalNow = Date.now; + const mockNow = 1000000; + Date.now = jest.fn(() => mockNow); + + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + // @ts-ignore - access private properties for testing + expect(testContainer.timeoutMs).toBe(mockNow + expectedMs); + + Date.now = originalNow; + }); + }); + + test('should not set up timeout when timeout is not specified', () => { + // @ts-ignore - ignore TypeScript errors for testing + const testContainer = new Container(mockCtx, {}, { defaultPort: 8080 }); + + // @ts-ignore - access private method for testing + testContainer.setupTimeout(); + + // @ts-ignore - access private properties for testing + expect(testContainer.timeoutMs).toBeUndefined(); + // @ts-ignore - access private properties for testing + expect(testContainer.containerStartTime).toBeUndefined(); + }); +}); + // Create load balance tests describe('getRandom', () => { test('should return a container stub', async () => { diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..866d9f6 --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1,63 @@ +// Jest setup file for containers tests +// This file configures the test environment for Cloudflare Workers + +// Mock global fetch if needed +if (typeof global.fetch === 'undefined') { + global.fetch = jest.fn(); +} + +// Mock Request and Response constructors if needed +if (typeof global.Request === 'undefined') { + global.Request = class MockRequest { + constructor(url: string, init?: RequestInit) { + this.url = url; + this.method = init?.method || 'GET'; + this.headers = new Headers(init?.headers); + this.signal = init?.signal; + } + url: string; + method: string; + headers: Headers; + signal?: AbortSignal; + } as any; +} + +if (typeof global.Response === 'undefined') { + global.Response = class MockResponse { + constructor(body?: any, init?: ResponseInit) { + this.status = init?.status || 200; + this.body = body; + } + status: number; + body: any; + } as any; +} + +if (typeof global.Headers === 'undefined') { + global.Headers = class MockHeaders extends Map { + constructor(init?: HeadersInit) { + super(); + if (init) { + if (Array.isArray(init)) { + init.forEach(([key, value]) => this.set(key, value)); + } else if (init instanceof Headers) { + init.forEach((value, key) => this.set(key, value)); + } else { + Object.entries(init).forEach(([key, value]) => this.set(key, value)); + } + } + } + + get(name: string): string | null { + return super.get(name.toLowerCase()) || null; + } + + set(name: string, value: string): void { + super.set(name.toLowerCase(), value); + } + + has(name: string): boolean { + return super.has(name.toLowerCase()); + } + } as any; +} diff --git a/src/types/index.ts b/src/types/index.ts index f9381f0..6be63d7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,6 +33,9 @@ export interface ContainerOptions { /** How long to keep the container alive without activity */ sleepAfter?: string | number; + /** Timeout for container - kills container after this time regardless of activity */ + timeout?: string | number; + /** Environment variables to pass to the container */ envVars?: Record;