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
19 changes: 11 additions & 8 deletions Client/core/common/AMCApplication.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ import AMCApplicationDialog from "./AMCDialog.js"

const CONFIG_REQUEST_TIMEOUT_MS = 2000;
const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
// /upload/finish makes the server hash the whole stream, so its duration grows with the file size
// (a large file can take more than 10 s on slow hardware). Like the chunk POSTs it runs without a timeout.
const UPLOAD_FINISH_TIMEOUT_MS = 0;
const MAX_CONSECUTIVE_FAILURES = 5;

// Client reactivity metrics: aggregate /api/frontend roundtrips over this window
Expand Down Expand Up @@ -1825,11 +1828,11 @@ export default class AMCApplication extends Common.AMCObject {

uploadObject.setStateMessageToWaiting ();

application.axiosPostRequest("/upload/" + uploadObject.streamuuid + "/finish", {
return application.axiosPostRequest("/upload/" + uploadObject.streamuuid + "/finish", {
"streamuuid": uploadObject.streamuuid,
"context": "build",
"sha256": checkSum
})
}, { timeout: UPLOAD_FINISH_TIMEOUT_MS })
.then(resultUploadFinish => {

uploadObject.clearUploadState ();
Expand Down Expand Up @@ -1879,7 +1882,7 @@ export default class AMCApplication extends Common.AMCObject {
}
})
.catch(err => {
err;
uploadObject.failUpload (err);
});


Expand Down Expand Up @@ -1914,7 +1917,7 @@ export default class AMCApplication extends Common.AMCObject {

})
.catch(err => {
err;
itemState.failUpload (err);
/* if (failureeventname)
this.triggerUIEvent(failureeventname, itemuuid, {}); */
});
Expand Down Expand Up @@ -1955,11 +1958,11 @@ export default class AMCApplication extends Common.AMCObject {

uploadObject.setStateMessageToWaiting ();

application.axiosPostRequest("/upload/finish", {
return application.axiosPostRequest("/upload/finish", {
"streamuuid": uploadObject.streamuuid,
"context": "image",
"sha256": checkSum
})
}, { timeout: UPLOAD_FINISH_TIMEOUT_MS })
.then(resultUploadFinish => {

uploadObject.clearUploadState ();
Expand Down Expand Up @@ -2010,7 +2013,7 @@ export default class AMCApplication extends Common.AMCObject {
}
})
.catch(err => {
err;
uploadObject.failUpload (err);
});


Expand Down Expand Up @@ -2047,7 +2050,7 @@ export default class AMCApplication extends Common.AMCObject {

})
.catch(err => {
err;
itemState.failUpload (err);
/* if (failureeventname)
this.triggerUIEvent(failureeventname, itemuuid, {}); */
});
Expand Down
9 changes: 9 additions & 0 deletions Client/core/common/AMCImplementation_Upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ export default class AMCUpload extends Common.AMCObject {
}
}

// Only the upload that is still active may fail the state: a newer upload in the same widget
// (the user picked another file meanwhile) is not ended by a request of the one it replaced.
failUpload (reason)
{
if (this.checkIfUploadIsActive ()) {
this.itemState.failUpload (reason);
}
}

clearUploadState ()
{
if (this.itemState) {
Expand Down
20 changes: 20 additions & 0 deletions Client/core/common/AMCImplementation_UploadState.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default class AMCUploadState extends Common.AMCObject {
this.messages = [];
this.itemUUID = itemUUID;
this.cancelMessage = "Upload canceled";
this.failMessage = "Upload failed";
this.finishMessage = "Upload finished";
this.startMessage = "Starting upload";
this.progressMessage = "Uploading...";
Expand Down Expand Up @@ -95,6 +96,25 @@ export default class AMCUploadState extends Common.AMCObject {
}
}

// Ends the upload after a failed request: the frontend widget learns about it through _onError
// instead of waiting forever.
failUpload (reason)
{
if (reason)
console.error ("Upload failed:", reason);

this.uploadID = 0;
this.chosenFile = null;
this.progress = 0;
this.setMessage (this.failMessage);
if (this._onError) {
try { this._onError (); } catch (_) { /* noop */ }
}
this._onComplete = null;
this._onError = null;
this._onProgress = null;
}

setChosenFile (file)
{
Assert.ObjectValue (file);
Expand Down
139 changes: 139 additions & 0 deletions Client/tests/uploadFinish.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Run with: cd Client/tests && npm test (or from the repository root: node --test 'Client/tests/*.test.mjs')
//
// /upload/finish makes the server hash the whole stream (twice: SHA-256 and blockwise SHA-256), so its
// duration grows with the file size. On slow hardware a large file takes longer than the default 10 s
// request timeout: the client aborted the request, swallowed the error and the upload widget stayed at
// 100 % forever, although the server had stored and validated the file. The finish request therefore
// runs without a timeout (like the chunk POSTs), and any failed upload request is reported to the
// upload state so the frontend can show it.

import { test } from "node:test";
import assert from "node:assert/strict";

import { importCore } from "./support/coreLoader.mjs";

const AMCApplication = await importCore ("common/AMCApplication.js");
const AMCUploadState = await importCore ("common/AMCImplementation_UploadState.js");

const ITEM_UUID = "00000000-0000-4000-8000-000000000001";
const STREAM_UUID = "00000000-0000-4000-8000-000000000002";
const CONTEXT_UUID = "00000000-0000-4000-8000-000000000003";

// Node has Blob and File but no FileReader; AMCUpload only uses readAsArrayBuffer + onload.
globalThis.FileReader = class {
readAsArrayBuffer (blob) {
blob.arrayBuffer ().then ((buffer) => { this.result = buffer; this.onload (); });
}
};

function flushPromises () {
return new Promise ((resolve) => setImmediate (resolve));
}

async function settle () {
for (let i = 0; i < 20; i++)
await flushPromises ();
}

// finish: how the /upload/finish request ends ("resolve" or "reject").
function createUpload (finish) {
const app = new AMCApplication ("http://127.0.0.1:1/api", null);
app.API.authToken = "1".repeat (64);

const requests = { posts: [], events: [] };
app.axiosPostRequest = (url, data, config) => {
requests.posts.push ({ url, data, config });
if (url === "/upload/")
return Promise.resolve ({ data: { streamuuid: STREAM_UUID } });
if (url.endsWith ("/finish")) {
if (finish === "reject")
return Promise.reject (new Error ("timeout of 10000ms exceeded"));
return Promise.resolve ({ data: { contextuuid: CONTEXT_UUID } });
}
return Promise.reject (new Error ("unexpected POST " + url));
};
app.axiosPostFormData = () => Promise.resolve ({ data: {} });
app.triggerUIEvent = (eventName, sender, values) => requests.events.push ({ eventName, sender, values });

const state = new AMCUploadState (ITEM_UUID);
state.setChosenFile (new File ([new Uint8Array (1024)], "part.mp4", { type: "video/mp4" }));
state.generateUploadID ();

const callbacks = { complete: 0, error: 0 };
state._onComplete = () => { callbacks.complete++; };
state._onError = () => { callbacks.error++; };

return { app, state, requests, callbacks };
}

for (const [label, start] of [
["image", (app, state) => app.performImageUpload (state, "onsuccess", "onfailure")],
["build", (app, state) => app.performJobUpload (state, "onsuccess", "onfailure")],
]) {
test (`${label} upload: the finish request has no timeout`, async () => {
const { app, state, requests, callbacks } = createUpload ("resolve");
start (app, state);
await settle ();

const finish = requests.posts.find ((post) => post.url.endsWith ("/finish"));
assert.ok (finish, "finish request sent");
assert.equal (finish.config?.timeout, 0);
assert.equal (callbacks.complete, 1);
assert.deepEqual (requests.events.map ((event) => event.eventName), ["onsuccess"]);
});

test (`${label} upload: a failed finish request is reported to the upload state`, async () => {
const { app, state, requests, callbacks } = createUpload ("reject");
start (app, state);
await settle ();

assert.equal (callbacks.error, 1);
assert.equal (callbacks.complete, 0);
assert.deepEqual (requests.events, []);
assert.equal (state.getUploadID (), 0, "upload no longer active");
});
}

// A finish request of an upload that a newer upload in the same widget has replaced (the user picked or
// dropped another file meanwhile) must not end the newer upload when it fails.
for (const [label, start] of [
["image", (app, state) => app.performImageUpload (state, "onsuccess", "onfailure")],
["build", (app, state) => app.performJobUpload (state, "onsuccess", "onfailure")],
]) {
test (`${label} upload: a failed finish of a replaced upload leaves the newer upload alone`, async () => {
const { app, state, requests } = createUpload ("resolve");

const pendingFinishes = [];
app.axiosPostRequest = (url, data, config) => {
requests.posts.push ({ url, data, config });
if (url === "/upload/")
return Promise.resolve ({ data: { streamuuid: STREAM_UUID } });
if (url.endsWith ("/finish"))
return new Promise ((resolve, reject) => pendingFinishes.push ({ resolve, reject }));
return Promise.reject (new Error ("unexpected POST " + url));
};

start (app, state);
await settle ();
assert.equal (pendingFinishes.length, 1, "first finish request pending");

const second = { complete: 0, error: 0 };
state.setChosenFile (new File ([new Uint8Array (1024)], "second.mp4", { type: "video/mp4" }));
const secondUploadID = state.generateUploadID ();
state._onComplete = () => { second.complete++; };
state._onError = () => { second.error++; };
start (app, state);
await settle ();
assert.equal (pendingFinishes.length, 2, "second finish request pending");

pendingFinishes[0].reject (new Error ("checksum mismatch"));
await settle ();
assert.equal (second.error, 0, "newer upload not failed");
assert.equal (state.getUploadID (), secondUploadID, "newer upload still active");

pendingFinishes[1].resolve ({ data: { contextuuid: CONTEXT_UUID } });
await settle ();
assert.equal (second.complete, 1);
assert.deepEqual (requests.events.map ((event) => event.eventName), ["onsuccess"]);
});
}
Loading