From 1e305485bee60303af1409eb35d36210d8879cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 5 Sep 2026 12:31:17 +0300 Subject: [PATCH] Fix async picker race --- HISTORY/2026-09-05-file-picker.md | 20 ++++++ site/app.py | 2 +- upload/frontend/table/on_files_change.js | 10 ++- upload/frontend/table/on_folder_change.js | 87 ++++++++++++----------- 4 files changed, 75 insertions(+), 44 deletions(-) diff --git a/HISTORY/2026-09-05-file-picker.md b/HISTORY/2026-09-05-file-picker.md index 41d8e53..ec69cf2 100644 --- a/HISTORY/2026-09-05-file-picker.md +++ b/HISTORY/2026-09-05-file-picker.md @@ -146,6 +146,26 @@ ZIP с `folder/../../escape.txt`, `/absolute.md`, `windows\\..\\bad.txt`, `folder/./dot.txt` и безопасным `safe/normal.txt` оставил только `paths.zip/safe/normal.txt`. Группы `..`, пустая группа и абсолютный путь больше не отображаются. Результат: `1 файлов · 2 B`. +## Исправление async race — 2026-09-05 + +### Причина + +`onFilesChange` запускал асинхронный `addFiles` без ожидания и без установки `state.busy` до первого `await`. При быстрых повторных событиях выбора ZIP несколько обработчиков могли одновременно менять дерево и вызывать `render`. + +### Исправление + +- `onFilesChange` теперь сразу устанавливает `state.busy`, ожидает `addFiles` и сбрасывает флаг в `finally`. +- `onFolderChange` использует тот же порядок: ранний выход при занятом состоянии, блокировка до асинхронного разбора ZIP и гарантированный сброс в `finally`. +- Ошибка разбора ZIP больше не оставляет picker заблокированным. + +### Проверки + +- Все frontend `.js` файлы проходят `node --input-type=module --check`. +- `site/app.py` проходит `python3 -m py_compile`. +- `git diff --check` проходит. +- Flask smoke test загрузил страницу и оба изменённых ES-модуля без HTTP-ошибок. +- Узкий Node-тест не применён: в проекте отсутствует `package.json` с `type: module`, поэтому Node трактует browser ES-модули как CommonJS; синтаксис проверен отдельным module-check, а загрузка проверена браузером. + - `c0b8788 Implement reusable upload platform` — первоначальный reusable upload platform. - `85605fe Remove VM upload layer; keep file picker only` — удаление VM upload и переход к picker-only. - `a965f18 Add hierarchical file picker` — иерархическое дерево, вложенные ZIP, удаление групп и файлов. diff --git a/site/app.py b/site/app.py index 79dbb5e..1229bb5 100644 --- a/site/app.py +++ b/site/app.py @@ -9,7 +9,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file: CONFIG = json.load(config_file) -VERSION = "0.1.8" +VERSION = "0.1.9" app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js index 6d36485..312db19 100644 --- a/upload/frontend/table/on_files_change.js +++ b/upload/frontend/table/on_files_change.js @@ -25,7 +25,13 @@ export async function addFiles(state, cfg, files, elements) { } export function onFilesChange(state, cfg, elements) { - return () => { - if (!state.busy) addFiles(state, cfg, elements.fileInputEl.files, elements); + return async () => { + if (state.busy) return; + state.busy = true; + try { + await addFiles(state, cfg, elements.fileInputEl.files, elements); + } finally { + state.busy = false; + } }; } \ No newline at end of file diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js index a4aaa07..ac5506f 100644 --- a/upload/frontend/table/on_folder_change.js +++ b/upload/frontend/table/on_folder_change.js @@ -12,49 +12,54 @@ function rebaseTree(root, prefix) { export function onFolderChange(state, cfg, elements) { return async () => { if (state.busy) return; - const roots = new Map(); - for (const file of Array.from(elements.folderInputEl.files)) { - const parts = (file.webkitRelativePath || file.name).split('/'); - const relativePath = parts.slice(1).join('/') || file.name; - const lowerPath = relativePath.toLowerCase(); - const rootName = parts[0] || file.name; - if (!roots.has(rootName)) { - roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName, - path: rootName, children: [], expanded: true }); - } - const root = roots.get(rootName); - const addToFolder = (node) => { - let current = root; - const nodeParts = node.path.split('/').slice(1); - nodeParts.forEach((part, index) => { - const last = index === nodeParts.length - 1; - let child = current.children.find((item) => item.name === part); - if (!child) { - child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part, - path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`, - children: [], expanded: true }; - current.children.push(child); - } - current = child; - }); - }; - if (lowerPath.endsWith('.zip')) { - try { - const zip = await listZipFiles(file, cfg.allowedExt); - if (zip) { - rebaseTree(zip, rootName); - addToFolder(zip); - } - } catch (error) { - continue; + state.busy = true; + try { + const roots = new Map(); + for (const file of Array.from(elements.folderInputEl.files)) { + const parts = (file.webkitRelativePath || file.name).split('/'); + const relativePath = parts.slice(1).join('/') || file.name; + const lowerPath = relativePath.toLowerCase(); + const rootName = parts[0] || file.name; + if (!roots.has(rootName)) { + roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName, + path: rootName, children: [], expanded: true }); + } + const root = roots.get(rootName); + const addToFolder = (node) => { + let current = root; + const nodeParts = node.path.split('/').slice(1); + nodeParts.forEach((part, index) => { + const last = index === nodeParts.length - 1; + let child = current.children.find((item) => item.name === part); + if (!child) { + child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part, + path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`, + children: [], expanded: true }; + current.children.push(child); + } + current = child; + }); + }; + if (lowerPath.endsWith('.zip')) { + try { + const zip = await listZipFiles(file, cfg.allowedExt); + if (zip) { + rebaseTree(zip, rootName); + addToFolder(zip); + } + } catch (error) { + continue; + } + } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { + addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1), + path: `${rootName}/${relativePath}`, file, children: [], expanded: true }); } - } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { - addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1), - path: `${rootName}/${relativePath}`, file, children: [], expanded: true }); } + roots.forEach((root) => addFileWithDedup(state, root)); + elements.folderInputEl.value = ''; + render(state, elements); + } finally { + state.busy = false; } - roots.forEach((root) => addFileWithDedup(state, root)); - elements.folderInputEl.value = ''; - render(state, elements); }; } \ No newline at end of file