mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Investigation verdict: - HTMX does NOT swap the FilePond container on the edit page; the htmx:targetError in the crash log is unrelated noise - The pre-destroy abort in destroyFilePondsIn has a wrong status check (filters for nonexistent status 4, misses status 7 LOADING) but is moot because no HTMX swap targets the FilePond container - The load-file-error -> DID_THROW_ITEM_INVALID path is vulnerable (passes t.status directly, unlike every other error handler which wraps it), but for local files the LOAD_FILE plugins always wrap rejections properly - Likely actual trigger: Firefox XHR abort edge case in server.load for the existing cover file, racing with addition of a new local file that replaces it in the single-file queue
279 lines
14 KiB
Markdown
279 lines
14 KiB
Markdown
# HTMX/destroy race hypothesis — investigation report
|
|
|
|
## HTMX destroy triggers
|
|
|
|
### What fires `destroy()` and when, relative to HTMX swap lifecycle
|
|
|
|
The only code path that destroys FilePond instances is `destroyFilePondsIn(el)`, called by the `htmx:beforeSwap` listener:
|
|
|
|
```js
|
|
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
|
|
// → onHtmxBeforeSwap(evt) { destroyFilePondsIn(evt.detail.target); }
|
|
```
|
|
|
|
On the **edit page** (`/admin/edit.php`), the following HTMX targets exist on page load:
|
|
|
|
| Element | Trigger | Target selector | Scope |
|
|
|---------|---------|-----------------|-------|
|
|
| `#toast-region` | `load` | `#toast-region` | Footer `<aside>` |
|
|
| `.licence-license-choice` (hidden input) | `load` | `.licence-license-choice` | Inside licence fieldset |
|
|
| Language checkboxes | `change` | `#languages-required-asterisk` | A `<span>` |
|
|
| File browser buttons | `click` | `#relink-modal-body` | Modal dialog body |
|
|
| Jury autocomplete | `change` | various small targets | Form fields |
|
|
| Tag search input | `input` | pill list container | Form field |
|
|
| Licence radio buttons | `change` | `.licence-license-choice` | Inside licence fieldset |
|
|
|
|
**None of these targets are ancestors of the `#format-fichiers-block` div,** which contains all FilePond inputs including the cover queue. Every HTMX swap target is either:
|
|
- A sibling fieldset (licence), or
|
|
- A small DOM fragment inside a fieldset (language asterisk), or
|
|
- The toast region in the footer, or
|
|
- A modal dialog body
|
|
|
|
Therefore: **no HTMX swap on the edit page can trigger `destroyFilePondsIn` on the FilePond container during normal operation.**
|
|
|
|
The `htmx:targetError` in the crash log is confirmed to be unrelated noise. htmx fires `targetError` when a response targets a missing element. On `targetError`, htmx **does not** fire `htmx:beforeSwap` — no DOM swap occurs. The error most likely originates from the toast-region's load-triggered request if the toast region is somehow malformed, or from an internal htmx processing issue unrelated to FilePond.
|
|
|
|
### Verdict: HTMX does not swap the FilePond container. The race hypothesis as stated is **refuted**.
|
|
|
|
---
|
|
|
|
## In-flight state at file-pick time
|
|
|
|
### Is any HTMX request in flight when the crash occurs?
|
|
|
|
On the edit page at file-pick time, the toast-region's `hx-get="/admin/toast-fragment.php"` fires on page load but completes quickly (sub-second HTTP request returning 204 when empty, or a small HTML fragment). By the time a human user clicks "Parcourir" and selects a file, this request has long completed.
|
|
|
|
Other HTMX triggers (`change`, `click`) require explicit user interaction and are not triggered by file selection. The browser's native file picker dialog is modal and blocks the main thread while open, preventing any HTMX polling or background requests from completing during the dialog interaction.
|
|
|
|
**Conclusion: no HTMX request is in flight when the user selects a file.**
|
|
|
|
---
|
|
|
|
## `znunoqpw` abort analysis
|
|
|
|
### Does the existing abort resolve or reject the filter chain promise?
|
|
|
|
Commit `znunoqpw` added a pre-destroy abort in `destroyFilePondsIn`:
|
|
|
|
```js
|
|
var files = pond.getFiles();
|
|
for (var i = 0; i < files.length; i++) {
|
|
var f = files[i];
|
|
if (f.status === 4 || f.status === 2 || f.status === 3) {
|
|
try { pond.removeFile(f); } catch (_abort) {}
|
|
}
|
|
}
|
|
pond.destroy();
|
|
```
|
|
|
|
**The status check is incorrect.** FilePond 4.32.12 internal status constants are:
|
|
- `INIT: 1`, `IDLE: 2`, `PROCESSING: 3`, `PROCESSING_COMPLETE: 5`, `LOADING: 7`, `LOAD_ERROR: 8`, `PROCESSING_QUEUED: 9`
|
|
|
|
The check `f.status === 4 || f.status === 2 || f.status === 3` catches:
|
|
- Status `2` = IDLE (already idle, no-op)
|
|
- Status `3` = PROCESSING (upload in progress)
|
|
- Status `4` — **does not exist** in FilePond 4.32.12
|
|
|
|
**Status `7` (LOADING)** — which is the state of a file in the LOAD_FILE filter chain — is **not caught**. Therefore, for a file being loaded (after blob read, during LOAD_FILE validation), `removeFile` is never called.
|
|
|
|
`pond.destroy()` then calls `ABORT_ALL`, which freezes items and calls `abortLoad()`:
|
|
|
|
```js
|
|
ABORT_ALL: function() {
|
|
Pe(n.items).forEach(function(e) {
|
|
e.freeze();
|
|
e.abortLoad();
|
|
e.abortProcessing();
|
|
});
|
|
}
|
|
```
|
|
|
|
`abortLoad()` checks `i.activeLoader`:
|
|
```js
|
|
abortLoad: function() {
|
|
i.activeLoader ? i.activeLoader.abort() : (u(Ie.INIT), l("load-abort"));
|
|
}
|
|
```
|
|
|
|
**Critical finding:** `activeLoader` is set to `null` **before** the LOAD_FILE filter chain starts (inside the loader's "load" event handler). So when `abortLoad()` is called during the LOAD_FILE chain phase, `activeLoader` is already `null` → the ELSE branch runs → status is set to INIT and `load-abort` event fires.
|
|
|
|
**The LOAD_FILE filter chain Promise continues running** because JavaScript Promises are independent of the item lifecycle. However, the item is now frozen (`i.frozen = true`), which gates the event dispatcher:
|
|
|
|
```js
|
|
l = function(e) {
|
|
if (!i.released && !i.frozen) {
|
|
f.fire.apply(f, [e].concat(n));
|
|
}
|
|
};
|
|
```
|
|
|
|
When the LOAD_FILE filter chain eventually resolves or rejects, its `.then()` or `.catch()` callbacks execute, but they call `l(...)` which is suppressed by the freeze gate. **No events reach the dispatch system after freeze.**
|
|
|
|
Therefore: **the abort mechanism does NOT cancel the LOAD_FILE filter chain, but it DOES prevent its results from dispatching events.** The chain is orphaned (still resolves/rejects in memory) but cannot cause a crash because its event callbacks are gated.
|
|
|
|
**However:** `znunoqpw`'s abort only helps when `destroyFilePondsIn` is actually called. Since HTMX never swaps the FilePond container (see above), `destroyFilePondsIn` is never called during the standard reproduction. The abort mechanism is **not exercised** in the crash scenario.
|
|
|
|
---
|
|
|
|
## Line 6878 catch reachability
|
|
|
|
### Can `load-file-error` fire during destroy, and with what value of `e.status`?
|
|
|
|
Two code paths dispatch `DID_THROW_ITEM_INVALID`, which reaches the `file-status` view writer `Wt`:
|
|
|
|
```js
|
|
// file-status view writer (minified equivalent of line 7847)
|
|
Wt = function(e) {
|
|
var t = e.root, n = e.action;
|
|
Nt(t.ref.main, n.status.main); // ← crashes if n.status is undefined
|
|
Nt(t.ref.sub, n.status.sub);
|
|
};
|
|
```
|
|
|
|
### Path A: `load-request-error` → SAFE
|
|
|
|
Dispatched by the **loader's error event** (XHR onerror). The handler PROPERLY wraps the rejection:
|
|
|
|
```js
|
|
v.on("load-request-error", function(t) {
|
|
var r = Dt(n.options.labelFileLoadError)(t);
|
|
if (t.code >= 400 && t.code < 500)
|
|
return e("DID_THROW_ITEM_INVALID", {
|
|
id: h, error: t,
|
|
status: { main: r, sub: t.code + " (" + t.body + ")" } // ✅ wrapped
|
|
});
|
|
e("DID_THROW_ITEM_LOAD_ERROR", {
|
|
id: h, error: t,
|
|
status: { main: r, sub: n.options.labelTapToRetry } // ✅ wrapped
|
|
});
|
|
});
|
|
```
|
|
|
|
Both branches create `status: { main, sub }` objects. **No crash possible from this path.**
|
|
|
|
### Path B: `load-file-error` → VULNERABLE
|
|
|
|
Dispatched by the LOAD_FILE filter chain rejection. The handler passes `t.status` **directly**, without wrapping:
|
|
|
|
```js
|
|
v.on("load-file-error", function(t) {
|
|
e("DID_THROW_ITEM_INVALID", {
|
|
id: h,
|
|
error: t.status, // t.status passed through directly
|
|
status: t.status, // ← crash if t.status is undefined
|
|
});
|
|
f({ error: t.status, file: Te(v) });
|
|
});
|
|
```
|
|
|
|
For **local files**, the LOAD_FILE chain processes the File/Blob object. The only registered LOAD_FILE filters are `FileValidateType` and `FileValidateSize`. Both plugins reject with:
|
|
|
|
```js
|
|
{ status: { main: "label...", sub: "details..." } }
|
|
```
|
|
|
|
This produces a valid `t.status = { main, sub }` — **no crash on normal local file selection.**
|
|
|
|
For **server-loaded files** (existing DB files), the `Ot` XHR loader creates `createResponse` objects with `.code` (HTTP status), **not** `.status`. When the server returns an error blob (e.g., HTML error page on 404):
|
|
|
|
1. XHR `onload` fires → `t(ot("load", status, blob, headers))` → `createResponse` object with `.code`, no `.status`
|
|
2. Loader's "load" event fires → `serverFileReference` **not set** (meta handler didn't set it for the error response)
|
|
3. `else` branch runs → LOAD_FILE filter chain receives the `createResponse` object (with `type: "load"`)
|
|
4. FileValidateType rejects because `"load"` is not a recognized MIME type → error callback → `load-file-error`
|
|
5. **But** FileValidateType rejects with `{ status: { main, sub } }` — a properly wrapped object
|
|
6. `t.status` IS `{ main, sub }` → **no crash**
|
|
|
|
**Caveat with znunoqpw:** After znunoqpw, `server.load` is configured with custom `onload`/`onerror` handlers. The custom `onerror` returns a string, which the `ut` factory routes to the loader's error callback → `load-request-error` → SAFE. The custom `onload` returns the blob directly, which bypasses the `createResponse` wrapping path for successful loads.
|
|
|
|
### Can it fire during destroy?
|
|
|
|
During `pond.destroy()` → `ABORT_ALL` → items frozen → event dispatch gated. Even if the LOAD_FILE chain completes after destroy, events are suppressed. **The crash cannot fire after `destroy()` is called.**
|
|
|
|
### Does the `.catch` handler at line 6878 fire?
|
|
|
|
The `.catch` handler on the item's before-add validation chain:
|
|
|
|
```js
|
|
.catch(function(t) {
|
|
if (!t || !t.error || !t.status) return r(!1); // guarded ✅
|
|
e("DID_THROW_ITEM_INVALID", {id: h, error: t.error, status: t.status});
|
|
})
|
|
```
|
|
|
|
Has an explicit guard and returns early on missing status. **Cannot crash.**
|
|
|
|
---
|
|
|
|
## Verdict
|
|
|
|
### HTMX race hypothesis: **REFUTED**
|
|
|
|
The hypothesis that HTMX swaps out the DOM containing the FilePond instance while a LOAD_FILE filter chain is in flight is **not supported by the evidence**:
|
|
|
|
1. No HTMX swap targets the FilePond container on the edit page — every HTMX target is a sibling or distant element
|
|
2. `htmx:targetError` fires without triggering `htmx:beforeSwap`, so even if it fires, it cannot trigger destroy
|
|
3. Even if a destroy were triggered, the freeze mechanism prevents events from reaching the dispatch system
|
|
4. The abort in `znunoqpw` is insufficient but also unnecessary — the event gate alone prevents the crash after destroy
|
|
|
|
### Actual crash cause: **INDETERMINATE (but narrowed)**
|
|
|
|
The only vulnerable code path is `load-file-error` → `DID_THROW_ITEM_INVALID` with `status: undefined` reaching the `file-status` view writer `Wt`. For the standard LOCAL file selection reproduction, I cannot identify how `status` becomes `undefined`:
|
|
|
|
- LOAD_FILE plugin rejections are properly wrapped with `{ status: { main, sub } }`
|
|
- Server-load errors go through `load-request-error` which wraps properly
|
|
- No `createResponse` objects (with `.code` instead of `.status`) enter the LOAD_FILE chain for local files
|
|
- The before-add catch handler is guarded
|
|
|
|
**However, the vulnerability is real and the crash is reproducible.** The most likely explanation is a race between an existing file's `server.load` XHR completing at the same moment a new file is added (replacing the existing one in a single-file queue). When the existing file's server.load XHR completes with an error AFTER the item has been removed but BEFORE it is frozen, the `load-file-error` event fires.
|
|
|
|
The `server.load` for the existing cover file returns a `createResponse` object (with `.code`, no `.status`). If `serverFileReference` is NOT set by the meta handler (which happens for error responses), the loader's "load" handler routes the `createResponse` to the LOAD_FILE filter chain. The FileValidateType filter rejects it because the type is `"load"` (not a MIME type), but wraps it with `{status: {main, sub}}` → safe.
|
|
|
|
However, if the existing file's server.load request is ABORTED (by the removal of the existing file when a new one is added), Firefox may fire XHR `onload` with `status: 0` or a malformed `response`. If `We(n.response, name)` throws (because `n.response` is null), the exception propagates as an unhandled error, not through the normal filter rejection path.
|
|
|
|
**This is the most likely trigger for the crash — a Firefox-specific XHR abort edge case in the server.load path for the existing cover file, racing with the addition of a new local file.**
|
|
|
|
---
|
|
|
|
## Recommended next step
|
|
|
|
Add targeted `console.log` instrumentation to `file-upload-filepond.js` at the `server.load` object to determine whether the crash correlates with an in-flight server.load request:
|
|
|
|
```js
|
|
// In buildServerConfig(), inside the load: { ... } block:
|
|
load: {
|
|
url: `${base}/load.php?id=`,
|
|
method: "GET",
|
|
onload: (response) => {
|
|
console.log("[filepond:diag] server.load onload | response type=" + (response ? response.constructor.name : "null") + " | size=" + (response ? response.size : 0));
|
|
return response;
|
|
},
|
|
onerror: (response) => {
|
|
var body = typeof response === 'string' ? response : (response && response.body ? response.body : String(response || ''));
|
|
console.error("[filepond:diag] server.load onerror | body=" + body + " | raw type=" + typeof response);
|
|
return body || "Fichier introuvable.";
|
|
},
|
|
},
|
|
```
|
|
|
|
Additionally, add a global listener to trap the crash before it propagates:
|
|
|
|
```js
|
|
document.addEventListener("FilePond:error", (e) => {
|
|
if (!e.detail || !e.detail.error) {
|
|
console.error("[filepond:diag] FilePond:error with null/undefined error detail", e.detail);
|
|
}
|
|
});
|
|
window.addEventListener("error", (e) => {
|
|
if (e.filename && e.filename.includes("filepond")) {
|
|
console.error("[filepond:diag] UNCAUGHT error from filepond", {
|
|
message: e.message,
|
|
lineno: e.lineno,
|
|
colno: e.colno,
|
|
stack: e.error ? e.error.stack : null,
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
Then reproduce with `just dev` in Firefox. If the diagnostic logs show `server.load onload` firing immediately before the crash, the race theory is confirmed and the fix is to replace `server.load` with a custom `fetch`-based function (Option B from the crash analysis) that never routes server responses through the LOAD_FILE filter chain.
|