Skip to content

Commit 875c31d

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: - Progress-based: Detects when no upload progress events are fired - 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) 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 875c31d

File tree

13 files changed

+700
-20
lines changed

13 files changed

+700
-20
lines changed

docs/api.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,34 @@ Following example will trigger up to three retries, each after 1s, 3s and 5s res
208208
retryDelays: [1000, 3000, 5000]
209209
```
210210

211+
#### stallDetection
212+
213+
_Default value:_ `{ enabled: false, stallTimeout: 30000, checkInterval: 5000 }`
214+
215+
An object controlling the stall detection feature, which can automatically detect when an upload has stopped making progress and trigger a retry. This is useful for recovering from frozen uploads caused by network issues that don't trigger explicit errors.
216+
217+
The stall detection options are:
218+
- `enabled`: Boolean indicating whether stall detection is active (default: `false`)
219+
- `stallTimeout`: Time in milliseconds without progress before considering the upload stalled (default: `30000`)
220+
- `checkInterval`: How often in milliseconds to check for stalls (default: `5000`)
221+
222+
**Note:** Stall detection only works with HTTP stacks that support progress events. Currently, this includes:
223+
- `XHRHttpStack` (browser default) - Supported
224+
- `NodeHttpStack` (Node.js default) - Supported
225+
- `FetchHttpStack` - Not supported
226+
227+
When a stall is detected, the upload will be automatically retried according to your `retryDelays` configuration. If `retryDelays` is `null`, the stall will trigger an error instead.
228+
229+
Example configuration:
230+
231+
```js
232+
stallDetection: {
233+
enabled: true,
234+
stallTimeout: 15000, // 15 seconds without progress
235+
checkInterval: 2000 // Check every 2 seconds
236+
}
237+
```
238+
211239
#### storeFingerprintForResuming
212240

213241
_Default value:_ `true`
@@ -326,6 +354,7 @@ An object used as the HTTP stack for making network requests. This is an abstrac
326354
interface HttpStack {
327355
createRequest(method: string, url: string): HttpRequest;
328356
getName(): string;
357+
supportsProgressEvents(): boolean;
329358
}
330359

331360
interface HttpRequest {
@@ -367,6 +396,14 @@ interface HttpResponse {
367396

368397
```
369398

399+
The `supportsProgressEvents()` method should return `true` if the HTTP stack implementation supports progress events during upload, or `false` otherwise. This is used by tus-js-client to determine whether features like stall detection can be enabled. The built-in HTTP stacks have the following support:
400+
401+
- `XHRHttpStack` (browser default): Returns `true` - XMLHttpRequest supports progress events
402+
- `NodeHttpStack` (Node.js default): Returns `true` - Node.js HTTP module supports progress events
403+
- `FetchHttpStack`: Returns `false` - Fetch API does not support upload progress events
404+
405+
If you're implementing a custom HTTP stack, you should return `true` only if your implementation can reliably call the progress handler set via `setProgressHandler` during the upload process.
406+
370407
#### urlStorage
371408

372409
_Default value:_ Environment-specific implementation

lib/StallDetector.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { log } from './logger.js'
2+
import type { StallDetectionOptions } from './options.js'
3+
import type { HttpStack } from './options.js'
4+
5+
export class StallDetector {
6+
private options: StallDetectionOptions
7+
private httpStack: HttpStack
8+
private onStallDetected: (reason: string) => void
9+
10+
private intervalId: ReturnType<typeof setInterval> | null = null
11+
private lastProgressTime = 0
12+
private isActive = false
13+
14+
constructor(
15+
options: StallDetectionOptions,
16+
httpStack: HttpStack,
17+
onStallDetected: (reason: string) => void,
18+
) {
19+
this.options = options
20+
this.httpStack = httpStack
21+
this.onStallDetected = onStallDetected
22+
}
23+
24+
/**
25+
* Start monitoring for stalls
26+
*/
27+
start() {
28+
if (this.intervalId) {
29+
return // Already started
30+
}
31+
32+
this.lastProgressTime = Date.now()
33+
this.isActive = true
34+
35+
log(
36+
`tus: starting stall detection with checkInterval: ${this.options.checkInterval}ms, stallTimeout: ${this.options.stallTimeout}ms`,
37+
)
38+
39+
// Setup periodic check
40+
this.intervalId = setInterval(() => {
41+
if (!this.isActive) {
42+
return
43+
}
44+
45+
const now = Date.now()
46+
if (this._isProgressStalled(now)) {
47+
this._handleStall('no progress events received')
48+
}
49+
}, this.options.checkInterval)
50+
}
51+
52+
/**
53+
* Stop monitoring for stalls
54+
*/
55+
stop(): void {
56+
this.isActive = false
57+
if (this.intervalId) {
58+
clearInterval(this.intervalId)
59+
this.intervalId = null
60+
}
61+
}
62+
63+
/**
64+
* Update progress information
65+
*/
66+
updateProgress(): void {
67+
this.lastProgressTime = Date.now()
68+
}
69+
70+
/**
71+
* Check if upload has stalled based on progress events
72+
*/
73+
private _isProgressStalled(now: number): boolean {
74+
const timeSinceProgress = now - this.lastProgressTime
75+
const stallTimeout = this.options.stallTimeout
76+
const isStalled = timeSinceProgress > stallTimeout
77+
78+
if (isStalled) {
79+
log(`tus: no progress for ${timeSinceProgress}ms (limit: ${stallTimeout}ms)`)
80+
}
81+
82+
return isStalled
83+
}
84+
85+
/**
86+
* Handle a detected stall
87+
*/
88+
private _handleStall(reason: string): void {
89+
log(`tus: upload stalled: ${reason}`)
90+
this.stop()
91+
this.onStallDetected(reason)
92+
}
93+
}

lib/browser/FetchHttpStack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export class FetchHttpStack implements HttpStack {
1616
getName() {
1717
return 'FetchHttpStack'
1818
}
19+
20+
supportsProgressEvents(): boolean {
21+
// The Fetch API does not support progress events for uploads
22+
return false
23+
}
1924
}
2025

2126
class FetchRequest implements HttpRequest {

lib/browser/XHRHttpStack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export class XHRHttpStack implements HttpStack {
1515
getName() {
1616
return 'XHRHttpStack'
1717
}
18+
19+
supportsProgressEvents(): boolean {
20+
// XMLHttpRequest supports progress events via the upload.onprogress event
21+
return true
22+
}
1823
}
1924

2025
class XHRRequest implements HttpRequest {

lib/browser/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,32 @@ const defaultOptions = {
1919

2020
class Upload extends BaseUpload {
2121
constructor(file: UploadInput, options: Partial<UploadOptions> = {}) {
22-
const allOpts = { ...defaultOptions, ...options }
22+
const allOpts = {
23+
...defaultOptions,
24+
...options,
25+
// Deep merge stallDetection options if provided
26+
...(options.stallDetection && {
27+
stallDetection: {
28+
...defaultOptions.stallDetection,
29+
...options.stallDetection,
30+
},
31+
}),
32+
}
2333
super(file, allOpts)
2434
}
2535

2636
static terminate(url: string, options: Partial<UploadOptions> = {}) {
27-
const allOpts = { ...defaultOptions, ...options }
37+
const allOpts = {
38+
...defaultOptions,
39+
...options,
40+
// Deep merge stallDetection options if provided
41+
...(options.stallDetection && {
42+
stallDetection: {
43+
...defaultOptions.stallDetection,
44+
...options.stallDetection,
45+
},
46+
}),
47+
}
2848
return terminate(url, allOpts)
2949
}
3050
}

lib/node/NodeHttpStack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export class NodeHttpStack implements HttpStack {
2828
getName() {
2929
return 'NodeHttpStack'
3030
}
31+
32+
supportsProgressEvents(): boolean {
33+
// Node.js HTTP stack supports progress tracking through streams
34+
return true
35+
}
3136
}
3237

3338
class Request implements HttpRequest {

lib/node/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,32 @@ const defaultOptions = {
1919

2020
class Upload extends BaseUpload {
2121
constructor(file: UploadInput, options: Partial<UploadOptions> = {}) {
22-
const allOpts = { ...defaultOptions, ...options }
22+
const allOpts = {
23+
...defaultOptions,
24+
...options,
25+
// Deep merge stallDetection options if provided
26+
...(options.stallDetection && {
27+
stallDetection: {
28+
...defaultOptions.stallDetection,
29+
...options.stallDetection,
30+
},
31+
}),
32+
}
2333
super(file, allOpts)
2434
}
2535

2636
static terminate(url: string, options: Partial<UploadOptions> = {}) {
27-
const allOpts = { ...defaultOptions, ...options }
37+
const allOpts = {
38+
...defaultOptions,
39+
...options,
40+
// Deep merge stallDetection options if provided
41+
...(options.stallDetection && {
42+
stallDetection: {
43+
...defaultOptions.stallDetection,
44+
...options.stallDetection,
45+
},
46+
}),
47+
}
2848
return terminate(url, allOpts)
2949
}
3050
}

lib/options.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ 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+
}
59+
5160
export interface UploadOptions {
5261
endpoint?: string
5362

@@ -84,6 +93,8 @@ export interface UploadOptions {
8493
httpStack: HttpStack
8594

8695
protocol: typeof PROTOCOL_TUS_V1 | typeof PROTOCOL_IETF_DRAFT_03 | typeof PROTOCOL_IETF_DRAFT_05
96+
97+
stallDetection?: StallDetectionOptions
8798
}
8899

89100
export interface OnSuccessPayload {
@@ -141,6 +152,10 @@ export type SliceResult =
141152
export interface HttpStack {
142153
createRequest(method: string, url: string): HttpRequest
143154
getName(): string
155+
156+
// Indicates whether this HTTP stack implementation
157+
// supports progress events during upload.
158+
supportsProgressEvents: () => boolean
144159
}
145160

146161
export type HttpProgressHandler = (bytesSent: number) => void

0 commit comments

Comments
 (0)