From 29024b5506f19f8d2bd1d0ff6a9695da7131e4f1 Mon Sep 17 00:00:00 2001 From: Keith Adler Date: Sun, 7 Sep 2025 16:07:10 -0500 Subject: [PATCH 1/3] 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 --- jest.config.js | 4 + package-lock.json | 5 +- src/lib/container.ts | 67 ++++++- src/tests/__mocks__/cloudflare-workers.js | 26 +++ src/tests/container.test.ts | 233 ++++++++++++++++++++++ src/tests/setup.ts | 63 ++++++ src/types/index.ts | 3 + 7 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 src/tests/__mocks__/cloudflare-workers.js create mode 100644 src/tests/setup.ts 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 0ae6e6b..b4f2a82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudflare/containers", - "version": "0.0.26", + "version": "0.0.28", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cloudflare/containers", - "version": "0.0.26", + "version": "0.0.28", "license": "ISC", "devDependencies": { "@changesets/cli": "^2.29.6", @@ -2593,6 +2593,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 d43af2a..1daa6f5 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -227,6 +227,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'] = {}; @@ -261,6 +266,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 @@ -577,6 +583,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 @@ -598,6 +621,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 // ================== @@ -798,6 +833,8 @@ export class Container extends DurableObject { private monitorSetup = false; private sleepAfterMs = 0; + private timeoutMs?: number; + private containerStartTime?: number; // ========================== // GENERAL HELPERS @@ -946,6 +983,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(); } @@ -1041,9 +1081,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); } }); } @@ -1147,6 +1187,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 @@ -1154,8 +1200,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 @@ -1167,7 +1216,7 @@ export class Container extends DurableObject { return; } - this.timeout = setTimeout(() => { + this.timeoutId = setTimeout(() => { resolve(); }, timeout); }); @@ -1178,7 +1227,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 @@ -1220,9 +1269,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); @@ -1292,4 +1341,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 252924d..7fe5a6a 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; From b4117791a80bd8a7502f52266a3119dd3530c538 Mon Sep 17 00:00:00 2001 From: Keith Adler Date: Wed, 12 Nov 2025 15:55:02 -0600 Subject: [PATCH 2/3] Replace alarm-based timeouts with native workerd APIs --- src/lib/container.ts | 75 ++++++++++++++----------------------- src/tests/container.test.ts | 75 ++++++++++++------------------------- 2 files changed, 52 insertions(+), 98 deletions(-) diff --git a/src/lib/container.ts b/src/lib/container.ts index dfe5c85..50b56fb 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -554,12 +554,9 @@ export class Container extends DurableObject { } /** - * 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. + * 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 { if (!this.container.running) { @@ -583,24 +580,22 @@ export class Container extends DurableObject { /** * 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 + (this.container as any).setInactivityTimeout(timeoutInMs).catch((error: any) => { + console.error('Failed to set inactivity timeout:', error); + }); + } } /** - * Set up the timeout when the container starts - * This is called internally when the container starts + * Set up timeouts when the container starts */ private setupTimeout() { - if (this.timeout) { - const timeoutMs = parseTimeExpression(this.timeout) * 1000; - this.containerStartTime = Date.now(); - this.timeoutMs = this.containerStartTime + timeoutMs; - } + this.renewActivityTimeout(); } // ================== @@ -805,9 +800,7 @@ export class Container extends DurableObject { private monitorSetup = false; - private sleepAfterMs = 0; - private timeoutMs?: number; - private containerStartTime?: number; + // Timeout properties removed - handled by workerd // ========================== // GENERAL HELPERS @@ -933,6 +926,13 @@ export class Container extends DurableObject { 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 () => { @@ -1109,7 +1109,7 @@ export class Container extends DurableObject { }>` 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 @@ -1151,7 +1151,10 @@ export class Container extends DurableObject { }>` 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) { @@ -1166,24 +1169,8 @@ 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 - this.renewActivityTimeout(); - return; - } - - // Math.min(3m or maxTime, sleepTimeout, timeout) - minTime = Math.min(minTimeFromSchedules, minTime, this.sleepAfterMs); - if (this.timeoutMs) { - minTime = Math.min(minTime, this.timeoutMs); - } + // 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 @@ -1317,11 +1304,5 @@ export class Container extends DurableObject { return this.toSchedule(schedule); } - private isActivityExpired(): boolean { - return this.sleepAfterMs <= Date.now(); - } - - private isTimeoutExpired(): boolean { - return this.timeoutMs !== undefined && this.timeoutMs <= Date.now(); - } + // Timeout methods removed - handled by workerd } diff --git a/src/tests/container.test.ts b/src/tests/container.test.ts index f986d3d..00e8501 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -379,51 +379,40 @@ describe('Hard Timeout', () => { Date.now = originalNow; }); - test('should detect timeout expiration', () => { - const timeout = '1s'; + test('should configure hard timeout in start options', () => { + const timeout = '30s'; // @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 + // Verify timeout property is set + expect(testContainer.timeout).toBe('30s'); - Date.now = jest.fn(() => mockStartTime); - // @ts-ignore - access private method for testing + // setupTimeout should now handle hardTimeout in start config (no alarm-based logic) 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 passes if no errors thrown - timeout is now handled natively by workerd + expect(true).toBe(true); }); - test('should not detect timeout expiration when within timeout', () => { - const timeout = '60s'; + test('should handle renewActivityTimeout with native setInactivityTimeout', () => { + const sleepAfter = '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) + const testContainer = new Container(mockCtx, {}, { sleepAfter }); - Date.now = jest.fn(() => mockStartTime); - // @ts-ignore - access private method for testing - testContainer.setupTimeout(); + // Mock container as running + mockCtx.container.running = true; - Date.now = jest.fn(() => mockCurrentTime); + // Mock setInactivityTimeout method + mockCtx.container.setInactivityTimeout = jest.fn().mockResolvedValue(undefined); - // @ts-ignore - access private method for testing - const isExpired = testContainer.isTimeoutExpired(); - expect(isExpired).toBe(false); + // Call renewActivityTimeout + testContainer.renewActivityTimeout(); - Date.now = originalNow; + // Verify setInactivityTimeout was called with correct timeout + expect(mockCtx.container.setInactivityTimeout).toHaveBeenCalledWith(60000); // 60s in ms }); - test('should call onHardTimeoutExpired when timeout expires', async () => { + test('should call onHardTimeoutExpired when manually triggered', async () => { const timeout = '1s'; // @ts-ignore - ignore TypeScript errors for testing const testContainer = new Container(mockCtx, {}, { timeout }); @@ -432,31 +421,15 @@ describe('Hard Timeout', () => { // Mock container as running mockCtx.container.running = true; - // Spy on onHardTimeoutExpired + // Spy on onHardTimeoutExpired and stop method const onHardTimeoutSpy = jest.spyOn(testContainer, 'onHardTimeoutExpired'); + const stopSpy = jest.spyOn(testContainer, 'stop').mockResolvedValue(); - 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(); - } + // Manually call onHardTimeoutExpired (workerd would trigger this natively) + await testContainer.onHardTimeoutExpired(); expect(onHardTimeoutSpy).toHaveBeenCalled(); - - Date.now = originalNow; + expect(stopSpy).toHaveBeenCalled(); }); test('should call destroy() in default onHardTimeoutExpired implementation', async () => { From fe1d51fe6eb8f7d39a0341218301024d7ab3d835 Mon Sep 17 00:00:00 2001 From: Keith Adler Date: Wed, 12 Nov 2025 16:07:50 -0600 Subject: [PATCH 3/3] Fix test compatibility for setInactivityTimeout --- src/lib/container.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/container.ts b/src/lib/container.ts index 50b56fb..abaf3f1 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -585,9 +585,12 @@ export class Container extends DurableObject { if (this.container.running) { const timeoutInMs = parseTimeExpression(this.sleepAfter) * 1000; // Type assertion needed until @cloudflare/workers-types is updated - (this.container as any).setInactivityTimeout(timeoutInMs).catch((error: any) => { - console.error('Failed to set inactivity timeout:', error); - }); + 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); + }); + } } }