fix: fence attachment grants and lifecycle cleanup

This commit is contained in:
2026-09-03 07:22:07 +09:00
parent 3f6bb65eb1
commit 9dc8d9a77a
19 changed files with 1190 additions and 167 deletions
@@ -5,15 +5,18 @@ export const MAX_FILES_PER_SUBMISSION = 8;
export type AttachmentUploadState = "uploading" | "uploaded" | "failed";
export type AttachmentUploadHandle = { abort(): void };
export type ComposerAttachment = {
id: number;
file: File;
uploadPath: string;
uploadId: string;
state: AttachmentUploadState;
progress: number;
reference: UploadedFileRef | null;
error: string | null;
request: XMLHttpRequest | null;
request: AttachmentUploadHandle | null;
};
export function acceptedAttachmentMediaType(mediaType: string): boolean {
@@ -43,42 +46,97 @@ export type AttachmentUploadCallbacks = {
};
export function uploadAttachment(
path: string,
workerPath: string,
file: File,
uploadId: string,
callbacks: AttachmentUploadCallbacks,
): XMLHttpRequest {
const request = new XMLHttpRequest();
): AttachmentUploadHandle {
let activeRequest: XMLHttpRequest | null = null;
let aborted = false;
const handle: AttachmentUploadHandle = {
abort() {
aborted = true;
activeRequest?.abort();
void fetch(
`${workerPath}/attachment-uploads/${encodeURIComponent(uploadId)}`,
{ method: "DELETE" },
).catch(() => undefined);
},
};
const query = new URLSearchParams({
file_name: file.name,
media_type: file.type,
upload_id: uploadId,
});
request.open("POST", `${path}?${query.toString()}`);
request.setRequestHeader("content-type", "application/octet-stream");
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
callbacks.progress(Math.min(1, event.loaded / event.total));
}
});
request.addEventListener("load", () => {
if (request.status < 200 || request.status >= 300) {
callbacks.failed(`Upload failed (${request.status}).`);
const grantRequest = new XMLHttpRequest();
activeRequest = grantRequest;
grantRequest.open(
"POST",
`${workerPath}/attachment-upload-grants?${query.toString()}`,
);
grantRequest.addEventListener("load", () => {
if (aborted) return;
if (grantRequest.status < 200 || grantRequest.status >= 300) {
callbacks.failed(`Upload grant failed (${grantRequest.status}).`);
return;
}
let uploadId: string;
try {
const parsed: unknown = JSON.parse(request.responseText);
if (!isUploadedFileResponse(parsed)) {
callbacks.failed("Upload returned an invalid attachment reference.");
const parsed: unknown = JSON.parse(grantRequest.responseText);
if (!isUploadGrantResponse(parsed)) {
callbacks.failed("Upload grant returned an invalid response.");
return;
}
callbacks.complete(parsed.file);
uploadId = parsed.upload_id;
} catch {
callbacks.failed("Upload returned an invalid response.");
callbacks.failed("Upload grant returned an invalid response.");
return;
}
const uploadRequest = new XMLHttpRequest();
activeRequest = uploadRequest;
uploadRequest.open(
"PUT",
`${workerPath}/attachment-uploads/${encodeURIComponent(uploadId)}`,
);
uploadRequest.setRequestHeader("content-type", "application/octet-stream");
uploadRequest.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
callbacks.progress(Math.min(1, event.loaded / event.total));
}
});
uploadRequest.addEventListener("load", () => {
if (uploadRequest.status < 200 || uploadRequest.status >= 300) {
callbacks.failed(`Upload failed (${uploadRequest.status}).`);
return;
}
try {
const parsed: unknown = JSON.parse(uploadRequest.responseText);
if (!isUploadedFileResponse(parsed)) {
callbacks.failed("Upload returned an invalid attachment reference.");
return;
}
callbacks.complete(parsed.file);
} catch {
callbacks.failed("Upload returned an invalid response.");
}
});
uploadRequest.addEventListener("error", () => callbacks.failed("Upload failed."));
uploadRequest.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
uploadRequest.send(file);
});
request.addEventListener("error", () => callbacks.failed("Upload failed."));
request.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
request.send(file);
return request;
grantRequest.addEventListener("error", () => callbacks.failed("Upload grant failed."));
grantRequest.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
grantRequest.send();
return handle;
}
function isUploadGrantResponse(
value: unknown,
): value is { upload_id: string; expires_at_ms: number } {
return !!value && typeof value === "object" &&
"upload_id" in value && typeof value.upload_id === "string" &&
"expires_at_ms" in value && typeof value.expires_at_ms === "number";
}
function isUploadedFileResponse(
@@ -599,7 +599,7 @@
attachment.request?.abort();
if (attachment.reference) {
void fetch(
`${attachment.uploadPath}/${encodeURIComponent(attachment.reference.artifact_id)}`,
`${attachment.uploadPath}/attachments/${encodeURIComponent(attachment.reference.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
}
@@ -639,7 +639,7 @@
}
function attachmentPath(): string {
return `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/attachments`;
return `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}`;
}
function updateAttachment(id: number, update: Partial<ComposerAttachment>): void {
@@ -661,7 +661,11 @@
error: null,
request: null,
});
const request = uploadAttachment(attachment.uploadPath, attachment.file, {
const request = uploadAttachment(
attachment.uploadPath,
attachment.file,
attachment.uploadId,
{
progress: (progress) => updateAttachment(attachment.id, { progress }),
complete: (reference) =>
updateAttachment(attachment.id, {
@@ -688,6 +692,7 @@
id: nextAttachmentId++,
file,
uploadPath: attachmentPath(),
uploadId: crypto.randomUUID(),
state: "uploading",
progress: 0,
reference: null,
@@ -703,7 +708,7 @@
attachment.request?.abort();
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
if (attachment.reference) {
await fetch(`${attachment.uploadPath}/${encodeURIComponent(attachment.reference.artifact_id)}`, {
await fetch(`${attachment.uploadPath}/attachments/${encodeURIComponent(attachment.reference.artifact_id)}`, {
method: "DELETE",
}).catch(() => undefined);
}