3232import static com .google .common .truth .Truth .assertThat ;
3333import static org .junit .jupiter .api .Assertions .assertThrows ;
3434import static org .mockito .ArgumentMatchers .any ;
35+ import static org .mockito .ArgumentMatchers .anyLong ;
36+ import static org .mockito .ArgumentMatchers .eq ;
3537import static org .mockito .Mockito .lenient ;
3638import static org .mockito .Mockito .mock ;
3739import static org .mockito .Mockito .times ;
5557import java .io .IOException ;
5658import java .io .InputStream ;
5759import java .nio .charset .StandardCharsets ;
60+ import java .time .Duration ;
5861import java .util .List ;
5962import java .util .concurrent .CancellationException ;
6063import java .util .concurrent .CountDownLatch ;
6164import java .util .concurrent .ExecutionException ;
65+ import java .util .concurrent .ScheduledExecutorService ;
66+ import java .util .concurrent .ScheduledFuture ;
6267import java .util .concurrent .TimeUnit ;
6368import java .util .concurrent .atomic .AtomicReference ;
6469import org .jspecify .annotations .Nullable ;
@@ -78,6 +83,7 @@ class ResumableUploadCallableImplTest {
7883
7984 private ResumableUploadCallSettings defaultSettings ;
8085 private FakeCallContext callContext ;
86+ private ClientContext clientContext ;
8187 private ResumableUploadCallableImpl <String , String > callable ;
8288
8389 @ BeforeEach
@@ -94,8 +100,7 @@ void setUp() {
94100
95101 defaultSettings = ResumableUploadCallSettings .newBuilder ().setChunkSize (8 ).build ();
96102 callContext = FakeCallContext .createDefault ();
97- ClientContext clientContext =
98- ClientContext .newBuilder ().setDefaultCallContext (callContext ).build ();
103+ clientContext = ClientContext .newBuilder ().setDefaultCallContext (callContext ).build ();
99104 callable = new ResumableUploadCallableImpl <>(mockClient , defaultSettings , clientContext );
100105 }
101106
@@ -792,6 +797,210 @@ void testRecovery_queryTransientError_retriesAndSucceeds() throws Exception {
792797 verify (mockChunkCallable , times (2 )).futureCall (any (), any ());
793798 }
794799
800+ @ Test
801+ void testGlobalTimeout_firesAndFailsSessionWithDeadlineExceeded () throws Exception {
802+ stubStartSession ("https://upload.url/timeout-fire" );
803+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
804+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
805+
806+ ResumableUploadCallSettings timeoutSettings =
807+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (100 )).build ();
808+
809+ ResumableUploadFuture <String > future =
810+ callable .futureCall ("resource-path" , streamOf ("hello" ), timeoutSettings );
811+
812+ ExecutionException exception =
813+ assertThrows (ExecutionException .class , () -> future .get (5 , TimeUnit .SECONDS ));
814+ assertThat (exception .getCause ()).isInstanceOf (DeadlineExceededException .class );
815+ DeadlineExceededException cause = (DeadlineExceededException ) exception .getCause ();
816+ assertThat (cause .getStatusCode ().getCode ()).isEqualTo (StatusCode .Code .DEADLINE_EXCEEDED );
817+ assertThat (cause .getMessage ()).contains ("https://upload.url/timeout-fire" );
818+ assertThat (future .isDone ()).isTrue ();
819+ assertThat (future .isCancelled ()).isFalse ();
820+ }
821+
822+ @ Test
823+ void testGlobalTimeout_cancelledCleanlyOnSuccess () throws Exception {
824+ stubStartSession ("https://upload.url/timeout-success" );
825+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
826+ .thenReturn (
827+ ApiFutures .immediateFuture (
828+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "ok" )));
829+
830+ ScheduledExecutorService mockExecutor = mock (ScheduledExecutorService .class );
831+ ScheduledFuture <?> mockScheduledFuture = mock (ScheduledFuture .class );
832+ when (mockExecutor .schedule (any (Runnable .class ), anyLong (), any ()))
833+ .thenAnswer (inv -> mockScheduledFuture );
834+
835+ ClientContext customClientContext = clientContext .toBuilder ().setExecutor (mockExecutor ).build ();
836+ ResumableUploadCallableImpl <String , String > customCallable =
837+ new ResumableUploadCallableImpl <>(mockClient , defaultSettings , customClientContext );
838+
839+ ResumableUploadCallSettings timeoutSettings =
840+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofSeconds (60 )).build ();
841+
842+ ResumableUploadFuture <String > future =
843+ customCallable .futureCall ("resource-path" , streamOf ("hello" ), timeoutSettings );
844+
845+ assertThat (future .get ()).isEqualTo ("ok" );
846+ verify (mockScheduledFuture ).cancel (false );
847+ }
848+
849+ @ Test
850+ void testGlobalTimeout_cancelledCleanlyOnFailure () throws Exception {
851+ when (mockStartCallable .futureCall (any (), any ()))
852+ .thenReturn (
853+ ApiFutures .immediateFailedFuture (
854+ createApiException (401 , StatusCode .Code .UNAUTHENTICATED )));
855+
856+ ScheduledExecutorService mockExecutor = mock (ScheduledExecutorService .class );
857+ ScheduledFuture <?> mockScheduledFuture = mock (ScheduledFuture .class );
858+ when (mockExecutor .schedule (any (Runnable .class ), anyLong (), any ()))
859+ .thenAnswer (inv -> mockScheduledFuture );
860+
861+ ClientContext customClientContext = clientContext .toBuilder ().setExecutor (mockExecutor ).build ();
862+ ResumableUploadCallableImpl <String , String > customCallable =
863+ new ResumableUploadCallableImpl <>(mockClient , defaultSettings , customClientContext );
864+
865+ ResumableUploadCallSettings timeoutSettings =
866+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofSeconds (60 )).build ();
867+
868+ ResumableUploadFuture <String > future =
869+ customCallable .futureCall ("resource-path" , streamOf ("hello" ), timeoutSettings );
870+
871+ assertThrows (ExecutionException .class , future ::get );
872+ verify (mockScheduledFuture ).cancel (false );
873+ }
874+
875+ @ Test
876+ void testGlobalTimeout_cancelledCleanlyOnUserCancel () throws Exception {
877+ stubStartSession ("https://upload.url/timeout-cancel" );
878+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
879+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
880+
881+ ScheduledExecutorService mockExecutor = mock (ScheduledExecutorService .class );
882+ ScheduledFuture <?> mockScheduledFuture = mock (ScheduledFuture .class );
883+ when (mockExecutor .schedule (any (Runnable .class ), anyLong (), any ()))
884+ .thenAnswer (inv -> mockScheduledFuture );
885+
886+ ClientContext customClientContext = clientContext .toBuilder ().setExecutor (mockExecutor ).build ();
887+ ResumableUploadCallableImpl <String , String > customCallable =
888+ new ResumableUploadCallableImpl <>(mockClient , defaultSettings , customClientContext );
889+
890+ ResumableUploadCallSettings timeoutSettings =
891+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofSeconds (60 )).build ();
892+
893+ ResumableUploadFuture <String > future =
894+ customCallable .futureCall ("resource-path" , streamOf ("hello" ), timeoutSettings );
895+
896+ assertThat (future .cancel (true )).isTrue ();
897+ verify (mockScheduledFuture ).cancel (false );
898+ }
899+
900+ @ Test
901+ void testGlobalTimeout_usesDefaultWhenUnset () throws Exception {
902+ stubStartSession ("https://upload.url/default-timeout" );
903+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
904+ .thenReturn (
905+ ApiFutures .immediateFuture (
906+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "ok" )));
907+
908+ ScheduledExecutorService mockExecutor = mock (ScheduledExecutorService .class );
909+ ScheduledFuture <?> mockScheduledFuture = mock (ScheduledFuture .class );
910+ when (mockExecutor .schedule (any (Runnable .class ), anyLong (), any ()))
911+ .thenAnswer (inv -> mockScheduledFuture );
912+
913+ ClientContext customClientContext = clientContext .toBuilder ().setExecutor (mockExecutor ).build ();
914+
915+ // 1. Unset on both stub and per-request -> falls back to GAX default (15m)
916+ ResumableUploadCallableImpl <String , String > unsetStubCallable =
917+ new ResumableUploadCallableImpl <>(mockClient , defaultSettings , customClientContext );
918+ assertThat (unsetStubCallable .futureCall ("resource-path" , streamOf ("hello" ), null ).get ())
919+ .isEqualTo ("ok" );
920+ verify (mockExecutor )
921+ .schedule (
922+ any (Runnable .class ), eq (Duration .ofMinutes (15 ).toMillis ()), eq (TimeUnit .MILLISECONDS ));
923+
924+ // 2. Stub-level timeout (30m, e.g. from generator/client settings) + null per-request -> 30m
925+ ResumableUploadCallSettings stubWith30m =
926+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMinutes (30 )).build ();
927+ ResumableUploadCallableImpl <String , String > configuredStubCallable =
928+ new ResumableUploadCallableImpl <>(mockClient , stubWith30m , customClientContext );
929+ assertThat (configuredStubCallable .futureCall ("resource-path" , streamOf ("hello" ), null ).get ())
930+ .isEqualTo ("ok" );
931+ verify (mockExecutor )
932+ .schedule (
933+ any (Runnable .class ), eq (Duration .ofMinutes (30 ).toMillis ()), eq (TimeUnit .MILLISECONDS ));
934+
935+ // 3. Stub-level timeout (30m) + per-request with only chunkSize set -> preserves 30m
936+ ResumableUploadCallSettings perRequestChunkSizeOnly =
937+ ResumableUploadCallSettings .newBuilder ().setChunkSize (16 ).build ();
938+ assertThat (
939+ configuredStubCallable
940+ .futureCall ("resource-path" , streamOf ("hello" ), perRequestChunkSizeOnly )
941+ .get ())
942+ .isEqualTo ("ok" );
943+ verify (mockExecutor , times (2 ))
944+ .schedule (
945+ any (Runnable .class ), eq (Duration .ofMinutes (30 ).toMillis ()), eq (TimeUnit .MILLISECONDS ));
946+
947+ // 4. Stub-level timeout (30m) + per-request globalTimeout (5m) -> per-request wins (5m)
948+ ResumableUploadCallSettings perRequestWith5m =
949+ ResumableUploadCallSettings .newBuilder ().setGlobalTimeout (Duration .ofMinutes (5 )).build ();
950+ assertThat (
951+ configuredStubCallable
952+ .futureCall ("resource-path" , streamOf ("hello" ), perRequestWith5m )
953+ .get ())
954+ .isEqualTo ("ok" );
955+ verify (mockExecutor )
956+ .schedule (
957+ any (Runnable .class ), eq (Duration .ofMinutes (5 ).toMillis ()), eq (TimeUnit .MILLISECONDS ));
958+ }
959+
960+ @ Test
961+ void testGlobalTimeout_timeoutWhileAttemptInFlight_cancelsInFlightFutureAndDoesNotCorruptBuffer ()
962+ throws Exception {
963+ stubStartSession ("https://upload.url/in-flight-timeout" );
964+ SettableApiFuture <ChunkUploadResponse <String >> inFlightFuture = SettableApiFuture .create ();
965+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
966+ .thenReturn (inFlightFuture );
967+
968+ ResumableUploadCallSettings timeoutSettings =
969+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (80 )).build ();
970+
971+ TrackableStream stream = new TrackableStream ("01234567890123456789" );
972+ ResumableUploadFuture <String > future =
973+ callable .futureCall ("resource-path" , stream , timeoutSettings );
974+
975+ ExecutionException exception =
976+ assertThrows (ExecutionException .class , () -> future .get (5 , TimeUnit .SECONDS ));
977+ assertThat (exception .getCause ()).isInstanceOf (DeadlineExceededException .class );
978+ // In-flight attempt future must be cancelled
979+ assertThat (inFlightFuture .isCancelled ()).isTrue ();
980+
981+ // Stream should have been read only up to the first chunk (chunkSize = 8), not refilled or
982+ // advanced
983+ assertThat (stream .totalBytesRead ).isEqualTo (8 );
984+ }
985+
986+ @ Test
987+ void testGlobalTimeout_coversStartSessionTimeout () throws Exception {
988+ SettableApiFuture <ResumableUploadSession > hungStartFuture = SettableApiFuture .create ();
989+ when (mockStartCallable .futureCall (any (), any ())).thenReturn (hungStartFuture );
990+
991+ ResumableUploadCallSettings timeoutSettings =
992+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (80 )).build ();
993+
994+ ResumableUploadFuture <String > future =
995+ callable .futureCall ("resource-path" , streamOf ("hello" ), timeoutSettings );
996+
997+ ExecutionException exception =
998+ assertThrows (ExecutionException .class , () -> future .get (5 , TimeUnit .SECONDS ));
999+ assertThat (exception .getCause ()).isInstanceOf (DeadlineExceededException .class );
1000+ assertThat (exception .getCause ().getMessage ()).contains ("before session initiation completed" );
1001+ assertThat (hungStartFuture .isCancelled ()).isTrue ();
1002+ }
1003+
7951004 private static class HttpStatusStatusCode implements StatusCode {
7961005 private final int httpStatus ;
7971006 private final StatusCode .Code code ;
0 commit comments