Skip to content

Commit e6d0f8e

Browse files
committed
fixup! Add stall detection to recover from frozen uploads
1 parent c2a2634 commit e6d0f8e

File tree

10 files changed

+572
-214
lines changed

10 files changed

+572
-214
lines changed

lib/StallDetector.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
private isPaused = false
14+
15+
constructor(
16+
options: StallDetectionOptions,
17+
httpStack: HttpStack,
18+
onStallDetected: (reason: string) => void,
19+
) {
20+
this.options = options
21+
this.httpStack = httpStack
22+
this.onStallDetected = onStallDetected
23+
}
24+
25+
/**
26+
* Start monitoring for stalls
27+
*/
28+
start() {
29+
if (this.intervalId) {
30+
return // Already started
31+
}
32+
33+
// Only enable stall detection if the HTTP stack supports progress events
34+
if (!this._supportsProgressEvents()) {
35+
log(
36+
'Stall detection disabled: HTTP stack does not support progress events. Consider using chunkSize with appropriate timeouts instead.',
37+
)
38+
return
39+
}
40+
41+
this.lastProgressTime = Date.now()
42+
this.isActive = true
43+
44+
log(
45+
`Starting stall detection with checkInterval: ${this.options.checkInterval}ms, stallTimeout: ${this.options.stallTimeout}ms`,
46+
)
47+
48+
// Setup periodic check
49+
this.intervalId = setInterval(() => {
50+
if (!this.isActive || this.isPaused) {
51+
return
52+
}
53+
54+
const now = Date.now()
55+
if (this._isProgressStalled(now)) {
56+
this._handleStall('No progress events received')
57+
}
58+
}, this.options.checkInterval)
59+
}
60+
61+
/**
62+
* Stop monitoring for stalls
63+
*/
64+
stop(): void {
65+
this.isActive = false
66+
if (this.intervalId) {
67+
clearInterval(this.intervalId)
68+
this.intervalId = null
69+
}
70+
}
71+
72+
/**
73+
* Update progress information
74+
*/
75+
updateProgress(): void {
76+
this.lastProgressTime = Date.now()
77+
}
78+
79+
/**
80+
* Pause stall detection temporarily (e.g., during onBeforeRequest callback)
81+
*/
82+
pause(): void {
83+
this.isActive = false
84+
this.isPaused = true
85+
}
86+
87+
/**
88+
* Resume stall detection after pause
89+
*/
90+
resume(): void {
91+
if (this.isPaused) {
92+
this.isPaused = false
93+
this.isActive = true
94+
// Reset the last progress time to avoid false positives
95+
this.lastProgressTime = Date.now()
96+
}
97+
}
98+
99+
/**
100+
* Detect if current HttpStack supports progress events
101+
*/
102+
private _supportsProgressEvents(): boolean {
103+
// Check if the HTTP stack explicitly declares support for progress events
104+
return (
105+
typeof this.httpStack.supportsProgressEvents === 'function' &&
106+
this.httpStack.supportsProgressEvents()
107+
)
108+
}
109+
110+
/**
111+
* Check if upload has stalled based on progress events
112+
*/
113+
private _isProgressStalled(now: number): boolean {
114+
const timeSinceProgress = now - this.lastProgressTime
115+
const stallTimeout = this.options.stallTimeout
116+
const isStalled = timeSinceProgress > stallTimeout
117+
118+
if (isStalled) {
119+
log(`No progress for ${timeSinceProgress}ms (limit: ${stallTimeout}ms)`)
120+
}
121+
122+
return isStalled
123+
}
124+
125+
/**
126+
* Handle a detected stall
127+
*/
128+
private _handleStall(reason: string): void {
129+
log(`Upload stalled: ${reason}`)
130+
this.stop()
131+
this.onStallDetected(reason)
132+
}
133+
}

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: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -53,49 +53,54 @@ export type UploadInput =
5353
*/
5454
export interface StallDetectionOptions {
5555
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
56+
stallTimeout: number // Time in ms before considering progress stalled
57+
checkInterval: number // How often to check for stalls
5958
}
6059

61-
export interface UploadOptions {
62-
endpoint?: string
63-
64-
uploadUrl?: string
65-
metadata: { [key: string]: string }
66-
metadataForPartialUploads: UploadOptions['metadata']
67-
fingerprint: (file: UploadInput, options: UploadOptions) => Promise<string | null>
68-
uploadSize?: number
60+
export type Part = { start: number; end: number }
6961

70-
onProgress?: (bytesSent: number, bytesTotal: number | null) => void
71-
onChunkComplete?: (chunkSize: number, bytesAccepted: number, bytesTotal: number | null) => void
72-
onSuccess?: (payload: OnSuccessPayload) => void
73-
onError?: (error: Error | DetailedError) => void
74-
onShouldRetry?: (error: DetailedError, retryAttempt: number, options: UploadOptions) => boolean
75-
onUploadUrlAvailable?: () => void | Promise<void>
62+
export interface UploadOptions {
63+
endpoint?: string | null
64+
uploadUrl?: string | null
65+
metadata: Record<string, string>
66+
metadataForPartialUploads?: Record<string, string>
67+
uploadSize?: number | null
68+
onProgress: ((bytesSent: number, bytesTotal: number | null) => void) | null
69+
onChunkComplete:
70+
| ((chunkSize: number, bytesAccepted: number, bytesTotal: number | null) => void)
71+
| null
72+
onSuccess: ((payload: OnSuccessPayload) => void) | null
73+
onError: ((error: Error | DetailedError) => void) | null
74+
onUploadUrlAvailable: (() => void) | null
75+
onShouldRetry:
76+
| ((err: Error | DetailedError, retryAttempt: number, options: UploadOptions) => boolean)
77+
| null
7678

7779
overridePatchMethod: boolean
78-
headers: { [key: string]: string }
80+
headers: Record<string, string>
7981
addRequestId: boolean
80-
onBeforeRequest?: (req: HttpRequest) => void | Promise<void>
81-
onAfterResponse?: (req: HttpRequest, res: HttpResponse) => void | Promise<void>
82+
83+
onBeforeRequest: ((req: HttpRequest) => void | Promise<void>) | null
84+
onAfterResponse: ((req: HttpRequest, res: HttpResponse) => void | Promise<void>) | null
8285

8386
chunkSize: number
84-
retryDelays: number[]
87+
retryDelays: number[] | null
8588
parallelUploads: number
86-
parallelUploadBoundaries?: { start: number; end: number }[]
89+
parallelUploadBoundaries?: Part[] | null
8790
storeFingerprintForResuming: boolean
8891
removeFingerprintOnSuccess: boolean
8992
uploadLengthDeferred: boolean
9093
uploadDataDuringCreation: boolean
9194

9295
urlStorage: UrlStorage
9396
fileReader: FileReader
97+
fingerprint: (file: UploadInput, options: UploadOptions) => Promise<string | null>
98+
// TODO: Types need to be double-checked
9499
httpStack: HttpStack
95100

96101
protocol: typeof PROTOCOL_TUS_V1 | typeof PROTOCOL_IETF_DRAFT_03 | typeof PROTOCOL_IETF_DRAFT_05
97102

98-
stallDetection?: Partial<StallDetectionOptions>
103+
stallDetection?: StallDetectionOptions
99104
}
100105

101106
export interface OnSuccessPayload {
@@ -153,6 +158,9 @@ export type SliceResult =
153158
export interface HttpStack {
154159
createRequest(method: string, url: string): HttpRequest
155160
getName(): string
161+
// Indicates whether this HTTP stack implementation supports progress events
162+
// during upload. If false, stall detection will use overall transfer rate instead.
163+
supportsProgressEvents?: () => boolean
156164
}
157165

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

0 commit comments

Comments
 (0)