Skip to content

Commit b9d079a

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 b9d079a

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 {
@@ -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
/**
@@ -337,6 +353,10 @@ export class BaseUpload {
337353
if (totalSize == null) {
338354
throw new Error('tus: Expected totalSize to be set')
339355
}
356+
357+
// Update progress timestamp for the parallel upload to track stalls
358+
upload._lastProgressTime = Date.now()
359+
340360
this._emitProgress(totalProgress, totalSize)
341361
},
342362
// Wait until every partial upload has an upload URL, so we can add
@@ -457,6 +477,9 @@ export class BaseUpload {
457477
// Set the aborted flag before any `await`s, so no new requests are started.
458478
this._aborted = true
459479

480+
// Clear any stall detection
481+
this._clearStallDetection()
482+
460483
// Stop any parallel partial uploads, that have been started in _startParallelUploads.
461484
if (this._parallelUploads != null) {
462485
for (const upload of this._parallelUploads) {
@@ -551,6 +574,12 @@ export class BaseUpload {
551574
* @api private
552575
*/
553576
private _emitProgress(bytesSent: number, bytesTotal: number | null): void {
577+
// Update stall detection state if progress has been made
578+
if (bytesSent > this._lastProgress) {
579+
this._lastProgress = bytesSent
580+
this._lastProgressTime = Date.now()
581+
}
582+
554583
if (typeof this.options.onProgress === 'function') {
555584
this.options.onProgress(bytesSent, bytesTotal)
556585
}
@@ -985,6 +1014,133 @@ export class BaseUpload {
9851014
_sendRequest(req: HttpRequest, body?: SliceType): Promise<HttpResponse> {
9861015
return sendRequest(req, body, this.options)
9871016
}
1017+
1018+
/**
1019+
* Apply default stall detection options
1020+
*/
1021+
private _getStallDetectionDefaults(
1022+
options?: Partial<StallDetectionOptions>
1023+
): StallDetectionOptions {
1024+
return {
1025+
enabled: options?.enabled ?? true,
1026+
stallTimeout: options?.stallTimeout ?? 30000,
1027+
checkInterval: options?.checkInterval ?? 5000,
1028+
minimumBytesPerSecond: options?.minimumBytesPerSecond ?? 1
1029+
}
1030+
}
1031+
1032+
/**
1033+
* Detect if current HttpStack supports progress events
1034+
*/
1035+
private _supportsProgressEvents(): boolean {
1036+
const httpStack = this.options.httpStack
1037+
// Check if getName method exists and if it returns one of our known stacks
1038+
return typeof httpStack.getName === 'function' &&
1039+
["NodeHttpStack", "XHRHttpStack"].includes(httpStack.getName())
1040+
}
1041+
1042+
/**
1043+
* Check if upload has stalled based on progress events
1044+
*/
1045+
private _isProgressStalled(now: number): boolean {
1046+
const stallDetection = this.options.stallDetection
1047+
if (!stallDetection) return false
1048+
1049+
const timeSinceProgress = now - this._lastProgressTime
1050+
const stallTimeout = stallDetection.stallTimeout ?? 30000
1051+
const isStalled = timeSinceProgress > stallTimeout
1052+
1053+
if (isStalled) {
1054+
log(`No progress for ${timeSinceProgress}ms (limit: ${stallTimeout}ms)`)
1055+
}
1056+
1057+
return isStalled
1058+
}
1059+
1060+
/**
1061+
* Check if upload has stalled based on transfer rate
1062+
*/
1063+
private _isTransferRateStalled(now: number): boolean {
1064+
const stallDetection = this.options.stallDetection
1065+
if (!stallDetection) return false
1066+
1067+
const totalTime = Math.max((now - this._uploadStartTime) / 1000, 0.001) // in seconds, prevent division by zero
1068+
const bytesPerSecond = this._offset / totalTime
1069+
1070+
// Need grace period for initial connection setup (5 seconds)
1071+
const hasGracePeriodPassed = totalTime > 5
1072+
const minBytes = stallDetection.minimumBytesPerSecond ?? 1
1073+
const isStalled = hasGracePeriodPassed && bytesPerSecond < minBytes
1074+
1075+
if (isStalled) {
1076+
log(`Transfer rate too low: ${bytesPerSecond.toFixed(2)} bytes/sec (minimum: ${minBytes} bytes/sec)`)
1077+
}
1078+
1079+
return isStalled
1080+
}
1081+
1082+
/**
1083+
* Handle a detected stall by forcing a retry
1084+
*/
1085+
private _handleStall(reason: string): void {
1086+
log(`Upload stalled: ${reason}`)
1087+
1088+
this._clearStallDetection()
1089+
1090+
// Just abort the current request, not the entire upload
1091+
// Each parallel upload instance has its own stall detection
1092+
if (this._req) {
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 {

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)