Skip to content

Commit c2a2634

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 c22f67f commit c2a2634

File tree

5 files changed

+386
-0
lines changed

5 files changed

+386
-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 {
@@ -109,6 +112,13 @@ export class BaseUpload {
109112
// upload options or HEAD response)
110113
private _uploadLengthDeferred: boolean
111114

115+
// Stall detection properties
116+
private _lastProgress = 0
117+
private _lastProgressTime = 0
118+
private _uploadStartTime = 0
119+
private _stallCheckInterval?: ReturnType<typeof setTimeout>
120+
private _hasProgressEvents = false
121+
112122
constructor(file: UploadInput, options: UploadOptions) {
113123
// Warn about removed options from previous versions
114124
if ('resume' in options) {
@@ -127,6 +137,9 @@ export class BaseUpload {
127137
this._uploadLengthDeferred = this.options.uploadLengthDeferred
128138

129139
this.file = file
140+
141+
// Initialize stall detection options
142+
this.options.stallDetection = this._getStallDetectionDefaults(options.stallDetection)
130143
}
131144

132145
async findPreviousUploads(): Promise<PreviousUpload[]> {
@@ -268,6 +281,9 @@ export class BaseUpload {
268281
} else {
269282
await this._startSingleUpload()
270283
}
284+
285+
// Setup stall detection
286+
this._setupStallDetection()
271287
}
272288

273289
/**
@@ -343,6 +359,10 @@ export class BaseUpload {
343359
if (totalSize == null) {
344360
throw new Error('tus: Expected totalSize to be set')
345361
}
362+
363+
// Update progress timestamp for the parallel upload to track stalls
364+
upload._lastProgressTime = Date.now()
365+
346366
this._emitProgress(totalProgress, totalSize)
347367
},
348368
// Wait until every partial upload has an upload URL, so we can add
@@ -463,6 +483,9 @@ export class BaseUpload {
463483
// Set the aborted flag before any `await`s, so no new requests are started.
464484
this._aborted = true
465485

486+
// Clear any stall detection
487+
this._clearStallDetection()
488+
466489
// Stop any parallel partial uploads, that have been started in _startParallelUploads.
467490
if (this._parallelUploads != null) {
468491
for (const upload of this._parallelUploads) {
@@ -557,6 +580,12 @@ export class BaseUpload {
557580
* @api private
558581
*/
559582
private _emitProgress(bytesSent: number, bytesTotal: number | null): void {
583+
// Update stall detection state if progress has been made
584+
if (bytesSent > this._lastProgress) {
585+
this._lastProgress = bytesSent
586+
this._lastProgressTime = Date.now()
587+
}
588+
560589
if (typeof this.options.onProgress === 'function') {
561590
this.options.onProgress(bytesSent, bytesTotal)
562591
}
@@ -995,6 +1024,133 @@ export class BaseUpload {
9951024
_sendRequest(req: HttpRequest, body?: SliceType): Promise<HttpResponse> {
9961025
return sendRequest(req, body, this.options)
9971026
}
1027+
1028+
/**
1029+
* Apply default stall detection options
1030+
*/
1031+
private _getStallDetectionDefaults(
1032+
options?: Partial<StallDetectionOptions>
1033+
): StallDetectionOptions {
1034+
return {
1035+
enabled: options?.enabled ?? true,
1036+
stallTimeout: options?.stallTimeout ?? 30000,
1037+
checkInterval: options?.checkInterval ?? 5000,
1038+
minimumBytesPerSecond: options?.minimumBytesPerSecond ?? 1
1039+
}
1040+
}
1041+
1042+
/**
1043+
* Detect if current HttpStack supports progress events
1044+
*/
1045+
private _supportsProgressEvents(): boolean {
1046+
const httpStack = this.options.httpStack
1047+
// Check if getName method exists and if it returns one of our known stacks
1048+
return typeof httpStack.getName === 'function' &&
1049+
["NodeHttpStack", "XHRHttpStack"].includes(httpStack.getName())
1050+
}
1051+
1052+
/**
1053+
* Check if upload has stalled based on progress events
1054+
*/
1055+
private _isProgressStalled(now: number): boolean {
1056+
const stallDetection = this.options.stallDetection
1057+
if (!stallDetection) return false
1058+
1059+
const timeSinceProgress = now - this._lastProgressTime
1060+
const stallTimeout = stallDetection.stallTimeout ?? 30000
1061+
const isStalled = timeSinceProgress > stallTimeout
1062+
1063+
if (isStalled) {
1064+
log(`No progress for ${timeSinceProgress}ms (limit: ${stallTimeout}ms)`)
1065+
}
1066+
1067+
return isStalled
1068+
}
1069+
1070+
/**
1071+
* Check if upload has stalled based on transfer rate
1072+
*/
1073+
private _isTransferRateStalled(now: number): boolean {
1074+
const stallDetection = this.options.stallDetection
1075+
if (!stallDetection) return false
1076+
1077+
const totalTime = Math.max((now - this._uploadStartTime) / 1000, 0.001) // in seconds, prevent division by zero
1078+
const bytesPerSecond = this._offset / totalTime
1079+
1080+
// Need grace period for initial connection setup (5 seconds)
1081+
const hasGracePeriodPassed = totalTime > 5
1082+
const minBytes = stallDetection.minimumBytesPerSecond ?? 1
1083+
const isStalled = hasGracePeriodPassed && bytesPerSecond < minBytes
1084+
1085+
if (isStalled) {
1086+
log(`Transfer rate too low: ${bytesPerSecond.toFixed(2)} bytes/sec (minimum: ${minBytes} bytes/sec)`)
1087+
}
1088+
1089+
return isStalled
1090+
}
1091+
1092+
/**
1093+
* Handle a detected stall by forcing a retry
1094+
*/
1095+
private _handleStall(reason: string): void {
1096+
log(`Upload stalled: ${reason}`)
1097+
1098+
this._clearStallDetection()
1099+
1100+
// Just abort the current request, not the entire upload
1101+
// Each parallel upload instance has its own stall detection
1102+
if (this._req) {
1103+
this._req.abort()
1104+
}
1105+
1106+
// Force a retry via the error mechanism
1107+
this._retryOrEmitError(new Error(`Upload stalled: ${reason}`))
1108+
}
1109+
1110+
/**
1111+
* Clear stall detection timer if running
1112+
*/
1113+
private _clearStallDetection(): void {
1114+
if (this._stallCheckInterval) {
1115+
clearInterval(this._stallCheckInterval)
1116+
this._stallCheckInterval = undefined
1117+
}
1118+
}
1119+
1120+
/**
1121+
* Setup stall detection monitoring
1122+
*/
1123+
private _setupStallDetection(): void {
1124+
const stallDetection = this.options.stallDetection
1125+
1126+
// Early return if disabled or undefined
1127+
if (!stallDetection?.enabled) {
1128+
return
1129+
}
1130+
1131+
// Initialize state
1132+
this._uploadStartTime = Date.now()
1133+
this._lastProgressTime = Date.now()
1134+
this._hasProgressEvents = this._supportsProgressEvents()
1135+
this._clearStallDetection()
1136+
1137+
// Setup periodic check with default interval of 5000ms if undefined
1138+
this._stallCheckInterval = setInterval(() => {
1139+
// Skip check if already aborted
1140+
if (this._aborted) {
1141+
return
1142+
}
1143+
1144+
const now = Date.now()
1145+
1146+
// Different stall detection based on stack capabilities
1147+
if (this._hasProgressEvents && this._isProgressStalled(now)) {
1148+
this._handleStall("No progress events received")
1149+
} else if (!this._hasProgressEvents && this._isTransferRateStalled(now)) {
1150+
this._handleStall("Transfer rate too low")
1151+
}
1152+
}, stallDetection.checkInterval ?? 5000)
1153+
}
9981154
}
9991155

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

test/spec/browser-index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ import './test-terminate.js'
1111
import './test-web-stream.js'
1212
import './test-binary-data.js'
1313
import './test-end-to-end.js'
14+
import './test-stall-detection.js'

test/spec/node-index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ import './test-terminate.js'
55
import './test-web-stream.js'
66
import './test-binary-data.js'
77
import './test-end-to-end.js'
8+
import './test-stall-detection.js'

0 commit comments

Comments
 (0)