Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/api/uploads/MultiputUpload.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class MultiputUpload extends BaseMultiput {

isResumableUploadsEnabled: boolean;

isPaused: boolean;

successCallback: Function;

progressCallback: Function;
Expand Down Expand Up @@ -134,6 +136,7 @@ class MultiputUpload extends BaseMultiput {
this.clientId = null;
this.isResumableUploadsEnabled = false;
this.numResumeRetries = 0;
this.isPaused = false;
}

/**
Expand Down Expand Up @@ -758,6 +761,7 @@ class MultiputUpload extends BaseMultiput {
shouldComputeDigestForNextPart(): boolean {
return (
!this.isDestroyed() &&
!this.isPaused &&
this.numPartsDigestComputing === 0 &&
this.numPartsNotStarted > 0 &&
this.numPartsDigestReady < this.config.digestReadahead
Expand Down Expand Up @@ -1126,7 +1130,12 @@ class MultiputUpload extends BaseMultiput {
* @return {boolean}
*/
canStartMorePartUploads(): boolean {
return !this.isDestroyed() && this.numPartsUploading < this.config.parallelism && this.numPartsDigestReady > 0;
return (
!this.isDestroyed() &&
!this.isPaused &&
this.numPartsUploading < this.config.parallelism &&
this.numPartsDigestReady > 0
);
}

/**
Expand Down Expand Up @@ -1259,6 +1268,47 @@ class MultiputUpload extends BaseMultiput {
this.destroy();
}

/**
* Pauses an upload in progress without destroying the session. In-flight parts
* are reset and paused so the upload can be resumed later via unpause(). The
* reported progress is intentionally left untouched so it stays frozen in the UI.
*
* @return {void}
*/
pause(): void {
if (this.isDestroyed() || this.isPaused) {
return;
}

this.isPaused = true;

let nextUploadIndex = this.firstUnuploadedPartIndex;
while (this.numPartsUploading > 0) {
const part = this.parts[nextUploadIndex];
if (part && part.state === PART_STATE_UPLOADING) {
part.reset();
part.pause();
this.numPartsUploading -= 1;
this.numPartsDigestReady += 1;
}
nextUploadIndex += 1;
}
Comment on lines +1285 to +1295

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the pause scan by the parts array.

Line 1286 loops until numPartsUploading reaches zero, but the loop can run past this.parts.length forever if the counter and part states drift. Since Cancel All calls pause() synchronously, that can hang the UI instead of freezing it.

Suggested bounded scan
-        let nextUploadIndex = this.firstUnuploadedPartIndex;
-        while (this.numPartsUploading > 0) {
-            const part = this.parts[nextUploadIndex];
+        let numPartsPaused = 0;
+        for (let nextUploadIndex = this.firstUnuploadedPartIndex; nextUploadIndex < this.parts.length; nextUploadIndex += 1) {
+            const part = this.parts[nextUploadIndex];
             if (part && part.state === PART_STATE_UPLOADING) {
                 part.reset();
                 part.pause();
-                this.numPartsUploading -= 1;
-                this.numPartsDigestReady += 1;
+                numPartsPaused += 1;
             }
-            nextUploadIndex += 1;
         }
+
+        this.numPartsUploading -= numPartsPaused;
+        this.numPartsDigestReady += numPartsPaused;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let nextUploadIndex = this.firstUnuploadedPartIndex;
while (this.numPartsUploading > 0) {
const part = this.parts[nextUploadIndex];
if (part && part.state === PART_STATE_UPLOADING) {
part.reset();
part.pause();
this.numPartsUploading -= 1;
this.numPartsDigestReady += 1;
}
nextUploadIndex += 1;
}
let numPartsPaused = 0;
for (let nextUploadIndex = this.firstUnuploadedPartIndex; nextUploadIndex < this.parts.length; nextUploadIndex += 1) {
const part = this.parts[nextUploadIndex];
if (part && part.state === PART_STATE_UPLOADING) {
part.reset();
part.pause();
numPartsPaused += 1;
}
}
this.numPartsUploading -= numPartsPaused;
this.numPartsDigestReady += numPartsPaused;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/uploads/MultiputUpload.js` around lines 1285 - 1295, The pause scan
in the Multipart upload flow can run past the end of the parts list if
numPartsUploading and the part states get out of sync, so bound the loop by
this.parts.length in the upload pause path. Update the logic around
this.firstUnuploadedPartIndex in MultiputUpload.js so the scan stops once all
parts have been checked, while still resetting and pausing any
PART_STATE_UPLOADING entries and adjusting numPartsUploading/numPartsDigestReady
as needed.

}

/**
* Resumes a previously paused upload, continuing from where it left off.
*
* @return {void}
*/
unpause(): void {
if (this.isDestroyed() || !this.isPaused) {
return;
}

this.isPaused = false;
this.processNextParts();
}

/**
* Resolves upload conflict by overwriting or renaming
*
Expand Down
96 changes: 82 additions & 14 deletions src/api/uploads/__tests__/MultiputUpload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,18 @@ describe('api/uploads/MultiputUpload', () => {
// upload pipeline full
// upload pipeline not full and not ended
// upload pipeline not full and not ended but no digest is ready
// paused
test.each`
expected | ended | numPartsUploading | numPartsDigestReady
${false} | ${true} | ${1} | ${undefined}
${false} | ${false} | ${2} | ${1}
${true} | ${false} | ${1} | ${1}
${false} | ${false} | ${1} | ${0}
`('should return correct value:', ({ expected, ended, numPartsUploading, numPartsDigestReady }) => {
expected | ended | paused | numPartsUploading | numPartsDigestReady
${false} | ${true} | ${false} | ${1} | ${undefined}
${false} | ${false} | ${false} | ${2} | ${1}
${true} | ${false} | ${false} | ${1} | ${1}
${false} | ${false} | ${false} | ${1} | ${0}
${false} | ${false} | ${true} | ${1} | ${1}
`('should return correct value:', ({ expected, ended, paused, numPartsUploading, numPartsDigestReady }) => {
// Setup
multiputUploadTest.destroyed = ended;
multiputUploadTest.isPaused = paused;
multiputUploadTest.numPartsUploading = numPartsUploading;
multiputUploadTest.numPartsDigestReady = numPartsDigestReady;
// Execute
Expand Down Expand Up @@ -787,6 +790,68 @@ describe('api/uploads/MultiputUpload', () => {
});
});

describe('pause()', () => {
beforeEach(() => {
multiputUploadTest.numPartsUploading = 2;
multiputUploadTest.numPartsDigestReady = 0;
multiputUploadTest.firstUnuploadedPartIndex = 0;
multiputUploadTest.parts = [
{ state: PART_STATE_UPLOADING, reset: jest.fn(), pause: jest.fn() },
{ state: PART_STATE_UPLOADED, reset: jest.fn(), pause: jest.fn() },
{ state: PART_STATE_UPLOADING, reset: jest.fn(), pause: jest.fn() },
];
});

test('should set isPaused and reset/pause only in-flight parts', () => {
multiputUploadTest.pause();

expect(multiputUploadTest.isPaused).toBe(true);
expect(multiputUploadTest.numPartsUploading).toBe(0);
expect(multiputUploadTest.numPartsDigestReady).toBe(2);
expect(multiputUploadTest.parts[0].pause).toBeCalled();
expect(multiputUploadTest.parts[1].pause).not.toBeCalled();
expect(multiputUploadTest.parts[2].pause).toBeCalled();
});

test('should do nothing when destroyed', () => {
multiputUploadTest.destroyed = true;

multiputUploadTest.pause();

expect(multiputUploadTest.isPaused).toBe(false);
expect(multiputUploadTest.parts[0].pause).not.toBeCalled();
});

test('should not pause twice', () => {
multiputUploadTest.isPaused = true;

multiputUploadTest.pause();

expect(multiputUploadTest.parts[0].pause).not.toBeCalled();
});
});

describe('unpause()', () => {
test('should clear isPaused and resume processing parts', () => {
multiputUploadTest.isPaused = true;
multiputUploadTest.processNextParts = jest.fn();

multiputUploadTest.unpause();

expect(multiputUploadTest.isPaused).toBe(false);
expect(multiputUploadTest.processNextParts).toBeCalled();
});

test('should do nothing when not paused', () => {
multiputUploadTest.isPaused = false;
multiputUploadTest.processNextParts = jest.fn();

multiputUploadTest.unpause();

expect(multiputUploadTest.processNextParts).not.toBeCalled();
});
});

describe('updateProgress()', () => {
test('should call progressCallback() properly', () => {
const prevUploadedBytes = 10;
Expand Down Expand Up @@ -814,18 +879,21 @@ describe('api/uploads/MultiputUpload', () => {
// all parts started
// readahead is full
// no part computing, there is a part not started and readahead not full
// paused
test.each`
expected | ended | numPartsDigestComputing | numPartsNotStarted | numPartsDigestReady
${false} | ${true} | ${undefined} | ${undefined} | ${undefined}
${false} | ${false} | ${1} | ${undefined} | ${undefined}
${false} | ${false} | ${0} | ${0} | ${undefined}
${false} | ${false} | ${0} | ${1} | ${2}
${true} | ${false} | ${0} | ${1} | ${1}
expected | ended | paused | numPartsDigestComputing | numPartsNotStarted | numPartsDigestReady
${false} | ${true} | ${false} | ${undefined} | ${undefined} | ${undefined}
${false} | ${false} | ${false} | ${1} | ${undefined} | ${undefined}
${false} | ${false} | ${false} | ${0} | ${0} | ${undefined}
${false} | ${false} | ${false} | ${0} | ${1} | ${2}
${true} | ${false} | ${false} | ${0} | ${1} | ${1}
${false} | ${false} | ${true} | ${0} | ${1} | ${1}
`(
'should return correct value',
({ expected, ended, numPartsDigestComputing, numPartsNotStarted, numPartsDigestReady }) => {
({ expected, ended, paused, numPartsDigestComputing, numPartsNotStarted, numPartsDigestReady }) => {
// Setup
multiputUploadTest.ended = ended;
multiputUploadTest.destroyed = ended;
multiputUploadTest.isPaused = paused;
multiputUploadTest.numPartsDigestComputing = numPartsDigestComputing;
multiputUploadTest.numPartsNotStarted = numPartsNotStarted;
multiputUploadTest.numPartsDigestReady = numPartsDigestReady;
Expand Down
99 changes: 86 additions & 13 deletions src/elements/content-uploader/ContentUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ class ContentUploader extends Component<ContentUploaderProps, State> {

isAutoExpanded: boolean = false;

isCancelAllPaused: boolean = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think based on the implementation of the methods, you could have a generic isPaused state and pauseUploads / resumeUploads instead of making it specific to "Cancel All"


modernizedDismissTimer: ReturnType<typeof setTimeout> | null = null;

isPanelHovered: boolean = false;
Expand Down Expand Up @@ -890,6 +892,10 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
upload = () => {
const { enableModernizedUploads, isUpgradeModalEnabled, maxFileSize } = this.props;

if (this.isCancelAllPaused) {
return;
}

if (
maxFileSize &&
enableModernizedUploads &&
Expand All @@ -899,6 +905,7 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
if (!this.state.isLargeFileWarningModalOpen) {
this.setState({ isLargeFileWarningModalOpen: true });
}

return;
}

Expand All @@ -919,6 +926,10 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
const { enableModernizedUploads, overwrite, rootFolderId } = this.props;
const { api, file, options } = item;

if (this.isCancelAllPaused) {
return;
}

const numItemsUploading = this.itemsRef.current.filter(item_t => item_t.status === STATUS_IN_PROGRESS).length;

if (numItemsUploading >= UPLOAD_CONCURRENCY) {
Expand Down Expand Up @@ -1255,6 +1266,23 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
this.updateViewAndCollection(updatedItems);
};

/**
* Whether an item uploads through the chunked (multiput) API rather than the
* plain oneshot API. This is the same decision getUploadAPI makes when picking
* the API instance, and it determines which items can be paused/resumed in place
* versus cancelled and restarted from scratch.
*
* @param {UploadItem} item - The upload item
* @return {boolean}
*/
getIsChunkedUpload = (item: UploadItem): boolean => {
const { chunked } = this.props;
const { file, isFolder } = item;
return Boolean(
chunked && !isFolder && file && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(),
);
Comment on lines +1278 to +1283

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid recomputing chunkedness from mutable props.

Line 1232 classifies an existing item using the current chunked prop, but the concrete API was selected when the item was queued. If chunked changes while uploads are active, this can call api.pause() on a plain API or cancel a real chunked upload instead of pausing it. Prefer checking the actual API capability or storing the upload mode on the item when it is created.

Proposed fix
     getIsChunkedUpload = (item: UploadItem): boolean => {
-        const { chunked } = this.props;
-        const { file, isFolder } = item;
+        const { api, file, isFolder } = item;
         return Boolean(
-            chunked && !isFolder && file && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(),
+            !isFolder &&
+                file &&
+                file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES &&
+                api &&
+                typeof api.pause === 'function' &&
+                typeof api.unpause === 'function',
         );
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getIsChunkedUpload = (item: UploadItem): boolean => {
const { chunked } = this.props;
const { file, isFolder } = item;
return Boolean(
chunked && !isFolder && file && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(),
);
getIsChunkedUpload = (item: UploadItem): boolean => {
const { api, file, isFolder } = item;
return Boolean(
!isFolder &&
file &&
file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES &&
api &&
typeof api.pause === 'function' &&
typeof api.unpause === 'function',
);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/elements/content-uploader/ContentUploader.tsx` around lines 1231 - 1236,
The chunked-upload check in ContentUploader.getIsChunkedUpload is recomputing
upload mode from the mutable chunked prop, which can diverge from the API chosen
when the item was queued. Update the logic around getIsChunkedUpload and any
pause/cancel handling to use the actual upload API capability or a mode flag
stored on the UploadItem at creation time, so active uploads are classified
consistently even if props change.

};

/**
* Updates item based on its status.
*
Expand All @@ -1263,11 +1291,9 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
* @return {void}
*/
onClick = (item: UploadItem) => {
const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props;
const { file, status, error } = item;
const isChunkedUpload =
chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported();
const isResumable = isResumableUploadsEnabled && isChunkedUpload && item.api.sessionId;
const { isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props;
const { status, error } = item;
const isResumable = isResumableUploadsEnabled && this.getIsChunkedUpload(item) && item.api.sessionId;

switch (status) {
case STATUS_IN_PROGRESS:
Expand Down Expand Up @@ -1338,19 +1364,68 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
};

/**
* Open the Cancel All confirmation modal. Wired as the onCancelAll prop
* passed to the modernized uploads manager so the action requires explicit
* confirmation before destroying in-progress uploads.
* Freeze in-progress uploads while the Cancel All confirmation modal is open.
* Chunked uploads are paused so they can resume from the same session; plain
* uploads have no partial server state, so they are cancelled in the background
* and restarted from scratch if the user chooses to keep uploading. Item status
* and progress are intentionally left untouched so the rows stay frozen in place.
*/
pauseUploadsForCancelAll = () => {
this.itemsRef.current.forEach(item => {
// Folder containers have no partial state and their child files are
// tracked as separate queue items handled by this same loop.
if (item.status !== STATUS_IN_PROGRESS || item.isFolder) {
return;
}
const { api } = item;
if (this.getIsChunkedUpload(item)) {
api.pause();
} else {
api.cancel();
}
});
this.isCancelAllPaused = true;
};

/**
* Resume uploads frozen by pauseUploadsForCancelAll when the user dismisses the
* modal. Chunked uploads continue from their session; plain uploads (cancelled in
* the background) are restarted from scratch. Pending items that never started are
* picked up by the trailing upload() call.
*/
resumeUploadsForCancelAll = () => {
this.isCancelAllPaused = false;
this.itemsRef.current.forEach(item => {
if (item.status !== STATUS_IN_PROGRESS || item.isFolder) {
return;
}
if (this.getIsChunkedUpload(item)) {
item.api.unpause();
} else {
this.resetFile(item);
this.uploadFile(item);
}
});
this.upload();
};

/**
* Open the Cancel All confirmation modal and freeze in-progress uploads. Wired as
* the onCancelAll prop passed to the modernized uploads manager so the action
* requires explicit confirmation before destroying in-progress uploads.
*/
handleCancelAllClick = () => {
this.pauseUploadsForCancelAll();
this.setState({ isCancelAllModalOpen: true });
};

handleCancelAllDismiss = () => {
this.setState({ isCancelAllModalOpen: false });
this.resumeUploadsForCancelAll();
};

handleCancelAllConfirm = () => {
this.isCancelAllPaused = false;
this.setState({ isCancelAllModalOpen: false });
this.handleUploadsManagerCancelAll();
};
Expand All @@ -1373,17 +1448,15 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
* chunked uploads, otherwise restart from scratch. Shared by RetryAll and per-item retry.
*/
retryUploadsManagerItem = (item: UploadItem) => {
const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props;
const { file, api, error } = item;
const { isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props;
const { api, error } = item;
// Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path.
if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) {
this.removeFileFromUploadQueue(item);
onClickCancel(item);
return;
}
const isChunkedUpload =
chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported();
const isResumable = isResumableUploadsEnabled && isChunkedUpload && api?.sessionId;
const isResumable = isResumableUploadsEnabled && this.getIsChunkedUpload(item) && api?.sessionId;
if (isResumable) {
item.bytesUploadedOnLastResume = api.totalUploadedBytes;
this.resumeFile(item);
Expand Down
Loading
Loading