@@ -2367,6 +2367,147 @@ describe("EventSent Handler", () => {
23672367 expect ( failureDetails ?. getErrortype ( ) ) . toEqual ( "NonDeterminismError" ) ;
23682368 expect ( failureDetails ?. getErrormessage ( ) ) . toMatch ( / s e n d E v e n t / ) ;
23692369 } ) ;
2370+
2371+ // Regression coverage for issue #292: the classic "activity vs. timeout" race
2372+ // where the winner cancels the loser timer (cancellable TimerTask).
2373+ it ( "should complete the timer-vs-activity race when the activity wins and the loser timer is canceled" , async ( ) => {
2374+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2375+
2376+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2377+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2378+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2379+
2380+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2381+
2382+ // Identity must be preserved: whenAny returns the exact activity task instance.
2383+ if ( winner === activityTask ) {
2384+ if ( ! timeoutTask . isCompleted ) {
2385+ timeoutTask . cancel ( ) ;
2386+ }
2387+ return activityTask . getResult ( ) ;
2388+ }
2389+ return "timed out" ;
2390+ } ;
2391+
2392+ const registry = new Registry ( ) ;
2393+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2394+ const activityName = registry . addActivity ( hello ) ;
2395+
2396+ // Turn 1: schedules the timer (action 1) and the activity (action 2), then yields on whenAny.
2397+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2398+ let executor = new OrchestrationExecutor ( registry , testLogger ) ;
2399+ let result = await executor . execute ( TEST_INSTANCE_ID , [ ] , [
2400+ newOrchestratorStartedEvent ( startTime ) ,
2401+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2402+ ] ) ;
2403+ expect ( result . actions . length ) . toEqual ( 2 ) ;
2404+ expect ( result . actions [ 0 ] . hasCreatetimer ( ) ) . toBeTruthy ( ) ;
2405+ expect ( result . actions [ 1 ] . hasScheduletask ( ) ) . toBeTruthy ( ) ;
2406+
2407+ // Turn 2: the activity completes first; the timer has NOT fired.
2408+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2409+ const oldEvents = [
2410+ newOrchestratorStartedEvent ( startTime ) ,
2411+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2412+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2413+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2414+ ] ;
2415+ const encodedOutput = JSON . stringify ( hello ( null , "Tokyo" ) ) ;
2416+ executor = new OrchestrationExecutor ( registry , testLogger ) ;
2417+ result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [ newTaskCompletedEvent ( 2 , encodedOutput ) ] ) ;
2418+
2419+ // A single CompleteOrchestration action, carrying the activity's result, and
2420+ // no leftover CreateTimer action (the canceled timer must not be rescheduled).
2421+ const completeAction = getAndValidateSingleCompleteOrchestrationAction ( result ) ;
2422+ expect ( completeAction ?. getOrchestrationstatus ( ) ) . toEqual ( pb . OrchestrationStatus . ORCHESTRATION_STATUS_COMPLETED ) ;
2423+ expect ( completeAction ?. getResult ( ) ?. getValue ( ) ) . toEqual ( encodedOutput ) ;
2424+ expect ( result . actions . some ( ( a ) => a . hasCreatetimer ( ) ) ) . toBe ( false ) ;
2425+ } ) ;
2426+
2427+ it ( "should complete normally when the timer wins the race (no regression to timer firing)" , async ( ) => {
2428+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2429+
2430+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2431+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2432+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2433+
2434+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2435+
2436+ if ( winner === timeoutTask ) {
2437+ return "timed out" ;
2438+ }
2439+ return activityTask . getResult ( ) ;
2440+ } ;
2441+
2442+ const registry = new Registry ( ) ;
2443+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2444+ const activityName = registry . addActivity ( hello ) ;
2445+
2446+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2447+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2448+ const oldEvents = [
2449+ newOrchestratorStartedEvent ( startTime ) ,
2450+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2451+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2452+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2453+ ] ;
2454+
2455+ // The timer fires first: the orchestration should still complete via the timeout branch.
2456+ const executor = new OrchestrationExecutor ( registry , testLogger ) ;
2457+ const result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [ newTimerFiredEvent ( 1 , timerFireAt ) ] ) ;
2458+
2459+ const completeAction = getAndValidateSingleCompleteOrchestrationAction ( result ) ;
2460+ expect ( completeAction ?. getOrchestrationstatus ( ) ) . toEqual ( pb . OrchestrationStatus . ORCHESTRATION_STATUS_COMPLETED ) ;
2461+ expect ( completeAction ?. getResult ( ) ?. getValue ( ) ) . toEqual ( JSON . stringify ( "timed out" ) ) ;
2462+ } ) ;
2463+
2464+ it ( "should ignore a fired event for a canceled timer (replay-safe) and keep running" , async ( ) => {
2465+ const hello = ( _ : any , name : string ) => `Hello ${ name } !` ;
2466+
2467+ // The orchestration cancels the timer, then continues with more work. A late
2468+ // TimerFired event for the canceled timer must be ignored (no crash, no
2469+ // premature completion) rather than resuming the orchestrator.
2470+ const orchestrator : TOrchestrator = async function * ( ctx : OrchestrationContext ) : any {
2471+ const timeoutTask = ctx . createTimer ( new Date ( ctx . currentUtcDateTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ) ;
2472+ const activityTask = ctx . callActivity ( hello , "Tokyo" ) ;
2473+
2474+ const winner = yield whenAny ( [ timeoutTask , activityTask ] ) ;
2475+ if ( winner === activityTask && ! timeoutTask . isCompleted ) {
2476+ timeoutTask . cancel ( ) ;
2477+ }
2478+
2479+ // Continue after canceling the timer.
2480+ const second = yield ctx . callActivity ( hello , "Seattle" ) ;
2481+ return second ;
2482+ } ;
2483+
2484+ const registry = new Registry ( ) ;
2485+ const orchestratorName = registry . addOrchestrator ( orchestrator ) ;
2486+ const activityName = registry . addActivity ( hello ) ;
2487+
2488+ const startTime = new Date ( 2020 , 0 , 1 , 12 , 0 , 0 ) ;
2489+ const timerFireAt = new Date ( startTime . getTime ( ) + 24 * 60 * 60 * 1000 ) ;
2490+ const oldEvents = [
2491+ newOrchestratorStartedEvent ( startTime ) ,
2492+ newExecutionStartedEvent ( orchestratorName , TEST_INSTANCE_ID ) ,
2493+ newTimerCreatedEvent ( 1 , timerFireAt ) ,
2494+ newTaskScheduledEvent ( 2 , activityName , JSON . stringify ( "Tokyo" ) ) ,
2495+ ] ;
2496+
2497+ // The first activity completes AND the (canceled) timer fires in the same batch.
2498+ const encodedOutput = JSON . stringify ( hello ( null , "Tokyo" ) ) ;
2499+ const executor = new OrchestrationExecutor ( registry , testLogger ) ;
2500+ const result = await executor . execute ( TEST_INSTANCE_ID , oldEvents , [
2501+ newTaskCompletedEvent ( 2 , encodedOutput ) ,
2502+ newTimerFiredEvent ( 1 , timerFireAt ) ,
2503+ ] ) ;
2504+
2505+ // The fired canceled timer is ignored; the orchestration schedules the second
2506+ // activity and is NOT complete.
2507+ expect ( result . actions . length ) . toEqual ( 1 ) ;
2508+ expect ( result . actions [ 0 ] . hasScheduletask ( ) ) . toBeTruthy ( ) ;
2509+ expect ( result . actions . some ( ( a ) => a . hasCompleteorchestration ( ) ) ) . toBe ( false ) ;
2510+ } ) ;
23702511} ) ;
23712512
23722513function getAndValidateSingleCompleteOrchestrationAction (
0 commit comments