Skip to content

Commit 12bf9fa

Browse files
committed
Add stall detection to recover from frozen uploads
This feature addresses the issue of uploads hanging indefinitely in unreliable network conditions, particularly in Node.js environments where no default timeout exists. When uploads stall due to network issues, TCP connections can enter a degraded state where no data is transferred but no error is triggered. This implementation detects such stalls and forces a retry. Implementation details: - Supports two detection methods: - Progress-based: Detects when no upload progress events are fired - Rate-based: Detects when overall transfer rate drops below threshold - Automatically selects the appropriate method based on HTTP stack capabilities - Gracefully integrates with the existing retry mechanism - Fully configurable with sensible defaults: - 30s stall timeout (time with no progress before considering stalled) - 5s check interval (how often to check for stalls) - 1 byte/s minimum transfer rate This is especially important for uploads over satellite links, cellular networks, or other unreliable connections where TCP backoff can cause indefinite stalls.
1 parent bef505f commit 12bf9fa

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

lib/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export type UploadInput =
4848
// available in React Native
4949
| ReactNativeFile
5050

51+
/**
52+
* Options for configuring stall detection behavior
53+
*/
54+
export interface StallDetectionOptions {
55+
enabled: boolean
56+
stallTimeout: number // Time in ms before considering progress stalled
57+
checkInterval: number // How often to check for stalls
58+
minimumBytesPerSecond: number // For stacks without progress events
59+
}
60+
5161
export interface UploadOptions {
5262
endpoint?: string
5363

@@ -84,6 +94,8 @@ export interface UploadOptions {
8494
httpStack: HttpStack
8595

8696
protocol: typeof PROTOCOL_TUS_V1 | typeof PROTOCOL_IETF_DRAFT_03 | typeof PROTOCOL_IETF_DRAFT_05
97+
98+
stallDetection?: Partial<StallDetectionOptions>
8799
}
88100

89101
export interface OnSuccessPayload {

lib/upload.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type SliceType,
1616
type UploadInput,
1717
type UploadOptions,
18+
type StallDetectionOptions,
1819
} from './options.js'
1920
import { uuid } from './uuid.js'
2021

@@ -54,6 +55,8 @@ export const defaultOptions = {
5455
httpStack: undefined,
5556

5657
protocol: PROTOCOL_TUS_V1 as UploadOptions['protocol'],
58+
59+
stallDetection: undefined,
5760
}
5861

5962
export class BaseUpload {
@@ -105,6 +108,13 @@ export class BaseUpload {
105108
// parts, if the parallelUploads option is used.
106109
private _parallelUploadUrls?: string[]
107110

111+
// Stall detection properties
112+
private _lastProgress = 0
113+
private _lastProgressTime = 0
114+
private _uploadStartTime = 0
115+
private _stallCheckInterval?: ReturnType<typeof setTimeout>
116+
private _hasProgressEvents = false
117+
108118
constructor(file: UploadInput, options: UploadOptions) {
109119
// Warn about removed options from previous versions
110120
if ('resume' in options) {
@@ -121,6 +131,9 @@ export class BaseUpload {
121131
this.options.chunkSize = Number(this.options.chunkSize)
122132

123133
this.file = file
134+
135+
// Initialize stall detection options
136+
this.options.stallDetection = this._getStallDetectionDefaults(options.stallDetection)
124137
}
125138

126139
async findPreviousUploads(): Promise<PreviousUpload[]> {
@@ -262,6 +275,9 @@ export class BaseUpload {
262275
} else {
263276
await this._startSingleUpload()
264277
}
278+
279+
// Setup stall detection
280+
this._setupStallDetection()
265281
}
266282

267283
/**
@@ -457,6 +473,9 @@ export class BaseUpload {
457473
// Set the aborted flag before any `await`s, so no new requests are started.
458474
this._aborted = true
459475

476+
// Clear any stall detection
477+
this._clearStallDetection()
478+
460479
// Stop any parallel partial uploads, that have been started in _startParallelUploads.
461480
if (this._parallelUploads != null) {
462481
for (const upload of this._parallelUploads) {
@@ -551,6 +570,12 @@ export class BaseUpload {
551570
* @api private
552571
*/
553572
private _emitProgress(bytesSent: number, bytesTotal: number | null): void {
573+
// Update stall detection state if progress has been made
574+
if (bytesSent > this._lastProgress) {
575+
this._lastProgress = bytesSent
576+
this._lastProgressTime = Date.now()
577+
}
578+
554579
if (typeof this.options.onProgress === 'function') {
555580
this.options.onProgress(bytesSent, bytesTotal)
556581
}
@@ -985,6 +1010,137 @@ export class BaseUpload {
9851010
_sendRequest(req: HttpRequest, body?: SliceType): Promise<HttpResponse> {
9861011
return sendRequest(req, body, this.options)
9871012
}
1013+
1014+
/**
1015+
* Apply default stall detection options
1016+
*/
1017+
private _getStallDetectionDefaults(
1018+
options?: Partial<StallDetectionOptions>
1019+
): StallDetectionOptions {
1020+
return {
1021+
enabled: options?.enabled ?? true,
1022+
stallTimeout: options?.stallTimeout ?? 30000,
1023+
checkInterval: options?.checkInterval ?? 5000,
1024+
minimumBytesPerSecond: options?.minimumBytesPerSecond ?? 1
1025+
}
1026+
}
1027+
1028+
/**
1029+
* Detect if current HttpStack supports progress events
1030+
*/
1031+
private _supportsProgressEvents(): boolean {
1032+
const httpStack = this.options.httpStack
1033+
// Check if getName method exists and if it returns one of our known stacks
1034+
return typeof httpStack.getName === 'function' &&
1035+
["NodeHttpStack", "XHRHttpStack"].includes(httpStack.getName())
1036+
}
1037+
1038+
/**
1039+
* Check if upload has stalled based on progress events
1040+
*/
1041+
private _isProgressStalled(now: number): boolean {
1042+
const stallDetection = this.options.stallDetection
1043+
if (!stallDetection) return false
1044+
1045+
const timeSinceProgress = now - this._lastProgressTime
1046+
const stallTimeout = stallDetection.stallTimeout ?? 30000
1047+
const isStalled = timeSinceProgress > stallTimeout
1048+
1049+
if (isStalled) {
1050+
log(`No progress for ${timeSinceProgress}ms (limit: ${stallTimeout}ms)`)
1051+
}
1052+
1053+
return isStalled
1054+
}
1055+
1056+
/**
1057+
* Check if upload has stalled based on transfer rate
1058+
*/
1059+
private _isTransferRateStalled(now: number): boolean {
1060+
const stallDetection = this.options.stallDetection
1061+
if (!stallDetection) return false
1062+
1063+
const totalTime = Math.max((now - this._uploadStartTime) / 1000, 0.001) // in seconds, prevent division by zero
1064+
const bytesPerSecond = this._offset / totalTime
1065+
1066+
// Need grace period for initial connection setup (5 seconds)
1067+
const hasGracePeriodPassed = totalTime > 5
1068+
const minBytes = stallDetection.minimumBytesPerSecond ?? 1
1069+
const isStalled = hasGracePeriodPassed && bytesPerSecond < minBytes
1070+
1071+
if (isStalled) {
1072+
log(`Transfer rate too low: ${bytesPerSecond.toFixed(2)} bytes/sec (minimum: ${minBytes} bytes/sec)`)
1073+
}
1074+
1075+
return isStalled
1076+
}
1077+
1078+
/**
1079+
* Handle a detected stall by forcing a retry
1080+
*/
1081+
private _handleStall(reason: string): void {
1082+
log(`Upload stalled: ${reason}`)
1083+
1084+
this._clearStallDetection()
1085+
1086+
// If using parallel uploads, abort them all
1087+
if (this._parallelUploads) {
1088+
for (const upload of this._parallelUploads) {
1089+
upload.abort()
1090+
}
1091+
} else if (this._req) {
1092+
// For single uploads, abort the current request
1093+
this._req.abort()
1094+
}
1095+
1096+
// Force a retry via the error mechanism
1097+
this._retryOrEmitError(new Error(`Upload stalled: ${reason}`))
1098+
}
1099+
1100+
/**
1101+
* Clear stall detection timer if running
1102+
*/
1103+
private _clearStallDetection(): void {
1104+
if (this._stallCheckInterval) {
1105+
clearInterval(this._stallCheckInterval)
1106+
this._stallCheckInterval = undefined
1107+
}
1108+
}
1109+
1110+
/**
1111+
* Setup stall detection monitoring
1112+
*/
1113+
private _setupStallDetection(): void {
1114+
const stallDetection = this.options.stallDetection
1115+
1116+
// Early return if disabled or undefined
1117+
if (!stallDetection?.enabled) {
1118+
return
1119+
}
1120+
1121+
// Initialize state
1122+
this._uploadStartTime = Date.now()
1123+
this._lastProgressTime = Date.now()
1124+
this._hasProgressEvents = this._supportsProgressEvents()
1125+
this._clearStallDetection()
1126+
1127+
// Setup periodic check with default interval of 5000ms if undefined
1128+
this._stallCheckInterval = setInterval(() => {
1129+
// Skip check if already aborted
1130+
if (this._aborted) {
1131+
return
1132+
}
1133+
1134+
const now = Date.now()
1135+
1136+
// Different stall detection based on stack capabilities
1137+
if (this._hasProgressEvents && this._isProgressStalled(now)) {
1138+
this._handleStall("No progress events received")
1139+
} else if (!this._hasProgressEvents && this._isTransferRateStalled(now)) {
1140+
this._handleStall("Transfer rate too low")
1141+
}
1142+
}, stallDetection.checkInterval ?? 5000)
1143+
}
9881144
}
9891145

9901146
function encodeMetadata(metadata: Record<string, string>): string {

0 commit comments

Comments
 (0)