test(wasm-dom): repros + fixes for the text-ctrl reentry and tooltip UAF bugs
Bump wxwidgets (8814ddb) for the two DOM-port fixes and add their reproductions:
- tests/apps/standalone/{textctrl-reentry,tooltip-lifetime}: standalone wx repro
apps + Makefile.wasm targets (textctrl links -fexceptions to throw from a
wxEVT_TEXT handler), driven by tests/e2e/dom-port-bugs.spec.ts. Each app is
deterministic and self-contained (no UB, ASAN, or timing dependence).
- docs/features/wx-dom-port/branch-review.md: branch review with findings #2/#3
marked fixed and a "Bug reproductions and fixes" section, including the
asyncify + legacy-EH gotcha (catch/destructor landing pads are unreliable
while unwinding through asyncify frames).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa408c0c06
commit
4186ea490f
6 changed files with 542 additions and 2 deletions
235
docs/features/wx-dom-port/branch-review.md
Normal file
235
docs/features/wx-dom-port/branch-review.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# `feature/wx-dom-port` — branch review (every change vs main)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Scope:** the complete diff of `feature/wx-dom-port` against each repo's mainline
|
||||
(root `main`, kicad/wxwidgets `wasm-port`, pcbjam-shared `main`).
|
||||
**Method:** the wx core touches, the async core, the JS layer, the build system,
|
||||
and all root scripts/specs/docs were read line-by-line; the ~30 additive
|
||||
`src/wasm/*` control files were read in full by four parallel readers and
|
||||
cross-checked (especially for the menubar use-after-free class). Two baseline
|
||||
screenshots were opened to confirm the renders are real.
|
||||
|
||||
## Verdict
|
||||
|
||||
Clean, careful, well-commented work that **reduces** the wxWidgets fork's
|
||||
divergence from upstream rather than growing it, and leaves KiCad untouched.
|
||||
The DOM-event architecture is sound (fresh-entry dispatch, freed windows
|
||||
unregistered before destruction). There are a few real loose ends — one of
|
||||
which (CI) is a functional breakage — plus two known feature gaps and two
|
||||
latent lifetime/async hazards worth tracking. None of them block promoting the
|
||||
branch.
|
||||
|
||||
## Fast-forward status
|
||||
|
||||
The branch is zero-divergence against every mainline (`behind: 0` everywhere),
|
||||
so each promotion is a true fast-forward — no merge commit, no force.
|
||||
|
||||
| Repo | Target → feature | FF? | Commits | Notes |
|
||||
|---|---|---|---|---|
|
||||
| root | `main` → `feature/wx-dom-port` | yes | 23 | local `main` == `origin/main` (`14ca16c`) |
|
||||
| wxwidgets | `wasm-port` → feature | yes | 20 | `origin/wasm-port` is an ancestor; HEAD `d33769ab56` |
|
||||
| kicad | `wasm-port` → feature | no-op | 0 | feature HEAD `693c513293` **==** `origin/wasm-port` |
|
||||
| pcbjam-shared | `main` → … | no-op | 0 | detached HEAD `92b2634` **==** `origin/main` |
|
||||
|
||||
Two mechanics to remember:
|
||||
|
||||
1. The feature branch is **unpushed** (no `origin/feature/wx-dom-port`), and the
|
||||
submodules have **no local `wasm-port` branch** — only `origin/wasm-port`. So
|
||||
"fast-forward `wasm-port`" is a fast-forward *push*, not a local merge:
|
||||
```
|
||||
git -C wxwidgets push origin feature/wx-dom-port:wasm-port # 20 commits
|
||||
# kicad: nothing to push (already == origin/wasm-port)
|
||||
git push origin feature/wx-dom-port:main # root, 23 commits
|
||||
```
|
||||
Push **wxwidgets before root** so the published `main` references an
|
||||
already-published submodule commit.
|
||||
2. pcbjam-shared's *local* `main` branch is stale (`5f27571`) vs `origin/main`
|
||||
(`92b2634`); irrelevant to the recorded pointer, just don't FF that local
|
||||
branch without fetching.
|
||||
|
||||
## What changed, by repo
|
||||
|
||||
| Repo | Net | Nature |
|
||||
|---|---|---|
|
||||
| **kicad** | 0 commits | Fork untouched — divergence not increased. |
|
||||
| **pcbjam-shared** | 0 | Unchanged. |
|
||||
| **wxwidgets** | +9,632 / −54,725, 161 files | The substance (below). The −54.7k is dominated by a junk `configure~` backup (−50,075) and the deleted canvas theme `src/univ/themes/wasm.cpp` (−2,960). |
|
||||
| **root** | 36 text files + 339 baseline PNGs | Build-script renames, DOM test wiring, docs. |
|
||||
|
||||
## wxwidgets — full audit
|
||||
|
||||
### 1. Cleanup / divergence reduction
|
||||
- Deleted `configure~` (a 50,075-line committed editor backup) and
|
||||
`src/univ/themes/wasm.cpp` (the 2,960-line canvas renderer the DOM port
|
||||
replaces).
|
||||
- The `src/generic/*`, `src/aui/*`, `src/common/prntbase.cpp`,
|
||||
`src/propgrid/propgrid.cpp`, `src/stc/stc.cpp` touches are **all
|
||||
`__EMSCRIPTEN__`-gated** and are *removals/refactors* of the old canvas-port
|
||||
element-tracking hooks into a clean `wx/wasm/elementtracker.h` API
|
||||
(`WasmRegisterRenderedElement(...)` → `wxWasmTrackElement(...)`, and
|
||||
screen-coords → parent-relative for DOM positioning). Native builds are
|
||||
byte-identical; the WASM-side divergence shrinks.
|
||||
Representative: `framemanager.cpp`, `grid.cpp`, `tabg.cpp` (pure removal),
|
||||
`ctrlrend.cpp`. `prntbase.cpp` adds only `|| defined(__WXWASM__)` to one
|
||||
preprocessor condition (PostScript DC — no native printer in WASM).
|
||||
|
||||
### 2. Additive DOM port (~30 new `src/wasm/*.cpp` + `include/wx/wasm/*.h`)
|
||||
Each wx control maps onto a real DOM element. Read in full and found clean
|
||||
apart from the findings table below. Intentional stubs are clearly TODO-tagged.
|
||||
|
||||
- **Buttons / static:** `control`, `anybutton`, `button`, `bmpbuttn`, `tglbtn`,
|
||||
`checkbox`, `radiobut`, `stattext`, `statline`, `statbox`, `statbmp`, `gauge`.
|
||||
`bmpbuttn` intentionally pushes its bitmap twice (documented: `wxButton::Create`'s
|
||||
`SetLabel` clears the `<img>`).
|
||||
- **Text:** `textentry` (mixin) + `textctrl` (`<input>`/`<textarea>`). Caret/
|
||||
selection cache logic is sound; an `m_inDomInput` re-entry guard prevents
|
||||
echo-back.
|
||||
- **Choice / list / range:** `choice`, `combobox`, `listbox`, `checklst`,
|
||||
`radiobox`, `slider`, `spinbutt`, `scrolbar`. Item/client-data/selection
|
||||
arrays stay in lockstep; selection-index adjustment on insert/delete is
|
||||
correct.
|
||||
- **Chrome / containers:** `frame`, `menu`, `menuitem`, `toolbar`, `notebook`,
|
||||
`tooltip`, `elementtracker`. The menubar `Detach()` fix
|
||||
(`parent->RemoveChild(this)` before the base) is present and correct (see
|
||||
`docs/features/async/10`); notebook tab-strip geometry reflow is sound.
|
||||
|
||||
### 3. wx core touches (`src/univ/*`)
|
||||
Mostly net deletions of canvas element-tracking. The one substantial *unguarded*
|
||||
change is **`univ/menu.cpp` (+98/−495): the universal popup menu's submenu/
|
||||
overflow timers were removed** (`SUBMENU_TIMEOUT`, `m_subMenuTimer`,
|
||||
`m_overflowTimer`, `OnOverflowTimer`, `HasOverflow*`, `OverflowArrowHitTest`,
|
||||
mouse-wheel handling, `IsPointTrackingToSubMenu`), plus an event-table tweak
|
||||
(`EVT_LEFT_DCLICK` → `EVT_LEFT_UP`, `+EVT_MOUSE_CAPTURE_LOST`). This is
|
||||
asyncify-hostile machinery; it lives in shared univ code, but the fork only ever
|
||||
builds WASM and the menu e2e tests pass. The paired `include/wx/univ/*.h`
|
||||
changes are small interface adjustments (e.g. `GetScrollbarArrowSize()` made
|
||||
pure-virtual).
|
||||
|
||||
### 4. Async core — highest-risk area, all sound
|
||||
- **`domevents.cpp` (new):** `wx_dom_event` / `wx_dom_mouse` are top-level
|
||||
`EMSCRIPTEN_KEEPALIVE` entries — **fresh WASM entries, not re-entrant on
|
||||
Asyncify-suspended frames** — which is what makes DOM input safe even mid-modal
|
||||
(this resolves the original diagnosis plan's central fear). `~wxWindowWasm`
|
||||
calls `wxDomUnregisterWindow(m_domId)` *first*, so a freed window's stale
|
||||
`domId` is a harmless `gs_domWindows` lookup-miss.
|
||||
- **`window.cpp`:** the DOM geometry/clip walk (`UpdateDomGeometry` /
|
||||
`ComputeAncestorClip` / `UpdateDomGeometryRecursive`) with the notebook
|
||||
client-area-origin correction; `OnDomEvent(FOCUSIN)` → `SetFocus` guarded by
|
||||
`gs_focusWindow != this`; destructor tears down the DOM node before
|
||||
`UnregisterElement`; `EraseBackgroundWindow` now fills the bg colour to avoid
|
||||
black pixels.
|
||||
- **`app.cpp`:** mouse-wheel hierarchy walk (fresh event copy per hop, stops at
|
||||
handled/TLW); **stateless** keyboard arbitration via `document.activeElement`
|
||||
(documented rationale: Firefox doesn't fire `focusout` when a focused element
|
||||
is removed, which previously wedged a flag and swallowed all keys).
|
||||
- **`clipbrd.cpp`:** net change is a clarifying NOTE that `js_clipboardHasText`
|
||||
must never run on the idle path. **`settings.cpp`:** a classic light
|
||||
system-colour scheme (else backgrounds erase to black). **`nonownedwnd.cpp`:**
|
||||
the main frame starts `m_isShown=true`. **`popupwin.cpp`:** drops an unused
|
||||
univ `EVT_SIZE` table.
|
||||
|
||||
### 5. JS layer
|
||||
- **`build/wasm/wx-dom.js` (1,212 lines, new):** only 5 `console.error` calls,
|
||||
all legitimate error reporting; no stray `console.log`, no `debugger`, no
|
||||
TODO/FIXME/HACK.
|
||||
- **`build/wasm/wx.js`:** adds one worker-guarded `window.__wxGetWindowElement`
|
||||
accessor; no canvas-port behaviour change.
|
||||
|
||||
### 6. Build system
|
||||
`Makefile.in` / `build/bakefiles/files.bkl` / `configure` register the new files;
|
||||
`include/wx/*.h` are mechanical `#elif defined(__WXWASM__)` dispatch hooks. The
|
||||
MSW `makefile.gcc` / `makefile.vc` churn is bakefile-regeneration byproduct
|
||||
(adding wasm widgets to `files.bkl` re-emitted every platform's makefile) —
|
||||
harmless for a WASM-only fork.
|
||||
|
||||
## root — full audit
|
||||
|
||||
- **Build/infra renames** propagated consistently: `build-wxuniversal-wasm.sh →
|
||||
build-wx-wasm.sh`, `wxwidgets-universal → wxwidgets`, drop `--enable-universal`,
|
||||
lib prefix `wasmunivu → wasmu` (`docker/build.sh`, `env.sh`,
|
||||
`FindwxWidgets.cmake`, `build-kicad-target.sh`, `Makefile.wasm`,
|
||||
`gal-regression/wasm/Makefile`, README/CLAUDE/build.md).
|
||||
- **Real fixes inside the renames:** `docker/build.sh` and `setup-kicad-wasm.sh`
|
||||
correct a wx.js source path (`build-wasm/wxwidgets/build/wasm/...` →
|
||||
`wxwidgets/build/wasm/...`) and ship `wx-dom.js`. `build-wx-wasm.sh` adds
|
||||
`autoconf_inc.m4`/`Makefile.in` staleness checks so new sources trigger
|
||||
reconfigure. `build-wasm-test.sh` adds `-j${JOBS}`. `Makefile.wasm` adds
|
||||
`wx-dom.js` as a second `--pre-js` to every link, lists `JS_FILES` as explicit
|
||||
prereqs (shim edits relink), and uses `$(filter %.o %.a,$^)` so the new `.js`
|
||||
prereqs aren't passed as link inputs.
|
||||
- **Tests:** `boot.spec.ts` (new no-screenshot smoke: registry fills,
|
||||
`wxDomPort===true`, zero page errors); `appearance.spec.ts` (new DOM-port
|
||||
regression: notebook tab round-trips, wheel scrolling with viewport clipping,
|
||||
rows surviving tab switches); `wxwidgets.spec.ts` switches the `wxChoice` test
|
||||
to drive a native `<select>` via `selectOption` and bypasses Playwright's
|
||||
actionability check in `clickCanvas` (real elements now cover the canvas);
|
||||
`setup-kicad-wasm.sh` gets the wx.js path fix + `wx-dom.js` sync.
|
||||
- **Docs:** new `docs/features/wx-dom-port/README.md` and `visual-notes.md`
|
||||
(architecture, e2e-verified status, known gaps, fork surface, phase-by-phase
|
||||
visual notes); the async dossier `08`/`09`/`10`.
|
||||
- **Screenshots:** 338 baselines + 1 pre-existing root-level
|
||||
`wizard-04-finish-headless.png`; 319 modified / 8 added / 12 deleted — the
|
||||
expected re-render churn for a port that draws controls as DOM elements (the
|
||||
12 deletions include baselines for the removed choice-reopen steps). Spot-check
|
||||
confirmed real renders (wx form controls; KiCad pl_editor with native menu
|
||||
bar/toolbar/notebook).
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Where | Issue |
|
||||
|---|---|---|---|
|
||||
| 1 | **High (functional)** | `.github/workflows/ci-ubicloud.yml:79` | Still runs `./scripts/build-wxuniversal-wasm.sh`, which no longer exists on this branch (renamed to `build-wx-wasm.sh`). The CI wxWidgets-build step would fail. The workflow file wasn't updated in the rename commit. (Also stale: a comment at `ci-ubicloud.yml:43` and `scripts/common/stages.sh:7`.) |
|
||||
| 2 | **Fixed 2026-06-13** (was real, latent) | `src/wasm/textctrl.cpp` `OnDomEvent` | A throwing `wxEVT_TEXT` handler escaped `OnDomEvent`, leaving `m_inDomInput` wedged `true` and dropping every later programmatic `SetValue`/`ChangeValue` push. Reproduced deterministically (see below). An RAII guard did **not** fix it (destructors don't run during the asyncify+EH unwind); fixed by removing the persistent flag — `OnDomEvent(INPUT)` now calls `wxTextEntry::DoSetValue` directly (updates cache + fires the event, no echo-back push). |
|
||||
| 3 | **Fixed 2026-06-13** (was real UAF) | `src/wasm/tooltip.cpp` + `window.cpp` | `gs_hoverWindow` (raw pointer) outlived its window; the 600 ms tooltip timer dereferenced freed memory. Reproduced (see below). Fixed by `wxWasmTooltipForgetWindow()`, called from `~wxWindowWasm`, which clears the pointer and stops the timer — same pattern as the existing `g_mouseWindow`/`gs_focusWindow` nulling. |
|
||||
| 4 | Known gap | `src/wasm/window.cpp` (`DoPopupMenu`) | `wxFAIL_MSG("DoPopupMenu not implemented in the DOM port yet")` → context/right-click menus are stubbed (KiCad uses them). Tagged dom-phase-5. |
|
||||
| 5 | Known gap | `src/wasm/scrolbar.cpp` | Scrollbar is a no-op stub (wheel-scroll works via `wxWindowWasm::ScrollWindow` moving children, but there is no draggable thumb). Tagged dom-phase-2. |
|
||||
|
||||
Minor / TODO-tracked (not bugs): `combobox` `SetSelection` lacks a bounds check;
|
||||
`radiobox` per-item enable/show is cached but not reflected to the DOM;
|
||||
`checkbox.cpp:95` / `radiobut.cpp:120` read the DOM value in `OnDomEvent` without
|
||||
a null-guard (safe in practice — only fired on a live element).
|
||||
|
||||
## Bug reproductions and fixes (2026-06-13)
|
||||
|
||||
Findings #2 and #3 were turned into deterministic, self-contained
|
||||
pure-wxWidgets repros, confirmed RED, then fixed (RED→GREEN). Afterwards the
|
||||
full wx e2e suite is **294 passed / 1 skipped / 0 failed** (the 2 new specs
|
||||
included — no regression).
|
||||
|
||||
- **Repro apps:** `tests/apps/standalone/textctrl-reentry/` and
|
||||
`tests/apps/standalone/tooltip-lifetime/`; spec
|
||||
`tests/e2e/dom-port-bugs.spec.ts`. The textctrl app links with `-fexceptions`
|
||||
(it throws from a handler); the tooltip app uses a small diagnostic accessor
|
||||
`wxWasmTooltipDebugHoverWindow()` kept in `tooltip.cpp`.
|
||||
- **textctrl wedge (#2)** is real but **binary-layout-sensitive**: it first
|
||||
passed, then began reproducing 100% after an unrelated rebuild shifted the
|
||||
asyncify/EH function layout (so the throw stopped being contained by
|
||||
`wxEvtHandler::SafelyProcessEvent` before it escaped `OnDomEvent`). The fix
|
||||
removes the persistent `m_inDomInput` flag, so there is nothing left to wedge.
|
||||
- **Gotcha worth remembering:** under this build (emscripten **legacy
|
||||
`-fexceptions` + `ASYNCIFY`**), **C++ destructors and `catch` landing pads do
|
||||
not reliably run while an exception unwinds through asyncify-instrumented
|
||||
frames.** RAII/try-catch cleanup that must survive a thrown exception is
|
||||
unreliable here — prefer designs that don't depend on unwind-time cleanup.
|
||||
(This is exactly why the obvious RAII fix for #2 did nothing and the flag had
|
||||
to be eliminated.)
|
||||
|
||||
## Leftovers to clean
|
||||
|
||||
- **The CI reference (#1)** — the one with real consequence if CI runs.
|
||||
- **`docs/features/async/10`** status line still reads *"Full-suite validation
|
||||
across all apps + cleanup + commit pending"* — all of that is done.
|
||||
- **`docs/features/wx-dom-port/visual-notes.md`** "Known-red kicad specs
|
||||
(asyncify scope, NOT this feature)" section is likely stale now that the
|
||||
menubar fix turned the kicad suite green.
|
||||
- **Vestigial `const appsDir = 'apps'`** (single-use, no effect) in
|
||||
`tests/playwright.config.ts` and `tests/playwright-kicad.config.ts`.
|
||||
- **Untracked `.agents/` + `AGENTS.md`** at the repo root — uncommitted and not
|
||||
gitignored (`AGENTS.md` mirrors CLAUDE.md's content; `.agents/skills/` mirrors
|
||||
`.claude/skills/`). They were *not* created by this branch's work.
|
||||
|
||||
## Cross-references
|
||||
- `docs/features/wx-dom-port/README.md` — the feature overview / fork surface.
|
||||
- `docs/features/wx-dom-port/visual-notes.md` — phase-by-phase visual notes.
|
||||
- `docs/features/async/10-resolution-menubar-uaf.md` — the menubar UAF fix that
|
||||
closed the DOM-port asyncify-family regression.
|
||||
|
|
@ -180,7 +180,9 @@ all: minimal_test.html \
|
|||
$(S)/coroutine-pthread/main_repro.html \
|
||||
$(S)/coroutine-pthread/mainloop_repro.html \
|
||||
$(S)/coroutine-pthread/nested_repro_ex.html \
|
||||
$(S)/coroutine-pthread/vcall_repro.html
|
||||
$(S)/coroutine-pthread/vcall_repro.html \
|
||||
$(S)/textctrl-reentry/textctrl-reentry_test.html \
|
||||
$(S)/tooltip-lifetime/tooltip-lifetime_test.html
|
||||
|
||||
# Main test app
|
||||
minimal_test.o: minimal_test.cpp
|
||||
|
|
@ -189,6 +191,27 @@ minimal_test.o: minimal_test.cpp
|
|||
minimal_test.html: minimal_test.o $(WX_CORE_LIB) $(JS_FILES)
|
||||
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
|
||||
# --- DOM-port bug reproductions (docs/features/wx-dom-port/branch-review.md) ---
|
||||
|
||||
# wxTextCtrl reentry-guard repro. Needs -fexceptions: it throws from a wxEVT_TEXT
|
||||
# handler to prove the OnDomEvent m_inDomInput reset must be exception-safe.
|
||||
$(S)/textctrl-reentry/textctrl-reentry_test.o: $(S)/textctrl-reentry/textctrl-reentry_test.cpp
|
||||
$(CXX) -c $(CXXFLAGS) -fexceptions $< -o $@
|
||||
|
||||
$(S)/textctrl-reentry/textctrl-reentry_test.html: $(S)/textctrl-reentry/textctrl-reentry_test.o $(WX_CORE_LIB) $(JS_FILES)
|
||||
$(CXX) $< $(LDFLAGS_NOGL) -fexceptions --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
|
||||
textctrl-reentry: $(S)/textctrl-reentry/textctrl-reentry_test.html
|
||||
|
||||
# wxToolTip hover-window lifetime repro (relies on wxWasmTooltipDebugHoverWindow).
|
||||
$(S)/tooltip-lifetime/tooltip-lifetime_test.o: $(S)/tooltip-lifetime/tooltip-lifetime_test.cpp
|
||||
$(CXX) -c $(CXXFLAGS) $< -o $@
|
||||
|
||||
$(S)/tooltip-lifetime/tooltip-lifetime_test.html: $(S)/tooltip-lifetime/tooltip-lifetime_test.o $(WX_CORE_LIB) $(JS_FILES)
|
||||
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
|
||||
tooltip-lifetime: $(S)/tooltip-lifetime/tooltip-lifetime_test.html
|
||||
|
||||
# Menu test (no GL)
|
||||
$(S)/menu/menu_test.o: $(S)/menu/menu_test.cpp
|
||||
$(CXX) -c $(CXXFLAGS) $< -o $@
|
||||
|
|
|
|||
104
tests/apps/standalone/textctrl-reentry/textctrl-reentry_test.cpp
Normal file
104
tests/apps/standalone/textctrl-reentry/textctrl-reentry_test.cpp
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// wxTextCtrl DOM-sync reentry-guard reproduction (DOM port) — real-path version.
|
||||
//
|
||||
// Bug (src/wasm/textctrl.cpp, OnDomEvent / wxDOM_EVENT_INPUT):
|
||||
//
|
||||
// m_inDomInput = true;
|
||||
// const wxString value = wxDomGetValue(WasmGetDomId());
|
||||
// DoSetValue(value, SetValue_SendEvent); // fires wxEVT_TEXT
|
||||
// m_inDomInput = false; // <-- skipped if a handler throws
|
||||
//
|
||||
// DoSetValue()/WriteText() only push the value into the <input> element
|
||||
// `if (!m_inDomInput)`. If a wxEVT_TEXT handler exits non-locally (throws), the
|
||||
// reset is skipped, m_inDomInput stays true, and every later programmatic
|
||||
// SetValue()/ChangeValue() silently stops updating the visible element.
|
||||
//
|
||||
// This repro uses the REAL delivery path (no synthetic OnDomEvent call, no
|
||||
// app-level try/catch that could change unwinding): the spec types into the
|
||||
// <input>, which fires a genuine DOM 'input' event -> wx-dom.js dispatch() ->
|
||||
// wx_dom_event() (extern "C") -> OnDomEvent. The bound wxEVT_TEXT handler throws
|
||||
// once; the throw escapes OnDomEvent and is caught by dispatch()'s try/catch at
|
||||
// the JS boundary (exactly as a real handler exception would be). The spec then
|
||||
// clicks a button that does a programmatic ChangeValue() and checks the element.
|
||||
//
|
||||
// RED (bug present): the <input> keeps the typed text; ChangeValue is dropped.
|
||||
// GREEN (fixed): the <input> shows the programmatic value.
|
||||
|
||||
#include "wx/wxprec.h"
|
||||
#ifndef WX_PRECOMP
|
||||
#include "wx/wx.h"
|
||||
#endif
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten/emscripten.h>
|
||||
#endif
|
||||
|
||||
enum { ID_SET_PROGRAMMATIC = wxID_HIGHEST + 1 };
|
||||
|
||||
class ReproFrame : public wxFrame
|
||||
{
|
||||
public:
|
||||
ReproFrame();
|
||||
|
||||
private:
|
||||
void OnText(wxCommandEvent &evt);
|
||||
void OnSetProgrammatic(wxCommandEvent &evt);
|
||||
|
||||
wxTextCtrl *m_text = nullptr;
|
||||
bool m_throwArmed = true;
|
||||
};
|
||||
|
||||
ReproFrame::ReproFrame()
|
||||
: wxFrame(nullptr, wxID_ANY, "wxTextCtrl reentry repro")
|
||||
{
|
||||
wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
m_text = new wxTextCtrl(this, wxID_ANY, "");
|
||||
m_text->Bind(wxEVT_TEXT, &ReproFrame::OnText, this);
|
||||
sizer->Add(m_text, 0, wxALL, 10);
|
||||
|
||||
wxButton *button = new wxButton(this, ID_SET_PROGRAMMATIC, "Set Programmatic");
|
||||
button->Bind(wxEVT_BUTTON, &ReproFrame::OnSetProgrammatic, this);
|
||||
sizer->Add(button, 0, wxALL, 10);
|
||||
|
||||
SetSizer(sizer);
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
CallAfter([] { EM_ASM({ console.log('[REPRO] textctrl ready'); }); });
|
||||
#endif
|
||||
}
|
||||
|
||||
void ReproFrame::OnText(wxCommandEvent &evt)
|
||||
{
|
||||
evt.Skip();
|
||||
|
||||
// A handler that throws (a validator failure, a wxLogError turned into an
|
||||
// exception by a custom log target, ...). Throw once so the app survives.
|
||||
if (m_throwArmed)
|
||||
{
|
||||
m_throwArmed = false;
|
||||
throw std::runtime_error("repro: wxEVT_TEXT handler throws");
|
||||
}
|
||||
}
|
||||
|
||||
void ReproFrame::OnSetProgrammatic(wxCommandEvent &WXUNUSED(evt))
|
||||
{
|
||||
// Must reach the <input> element even after a prior wxEVT_TEXT handler threw.
|
||||
m_text->ChangeValue("PROGRAMMATIC_OK");
|
||||
}
|
||||
|
||||
class ReproApp : public wxApp
|
||||
{
|
||||
public:
|
||||
bool OnInit() override
|
||||
{
|
||||
if (!wxApp::OnInit())
|
||||
return false;
|
||||
|
||||
(new ReproFrame())->Show(true);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
wxIMPLEMENT_APP(ReproApp);
|
||||
105
tests/apps/standalone/tooltip-lifetime/tooltip-lifetime_test.cpp
Normal file
105
tests/apps/standalone/tooltip-lifetime/tooltip-lifetime_test.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// wxToolTip hover-window lifetime reproduction (DOM port).
|
||||
//
|
||||
// Bug (src/wasm/tooltip.cpp):
|
||||
//
|
||||
// wxWindow *gs_hoverWindow = NULL; // raw pointer, set on hover
|
||||
// ... wxWasmTooltipTimer::Notify() {
|
||||
// wxWindow *win = FindTooltipWindow(gs_hoverWindow); // 600 ms later:
|
||||
// ... // win->GetParent()/
|
||||
// } // GetToolTip()/...
|
||||
//
|
||||
// Nothing clears gs_hoverWindow when the hovered window is destroyed, so a
|
||||
// window destroyed within the 600 ms tooltip delay leaves gs_hoverWindow
|
||||
// dangling -> use-after-free when the timer fires.
|
||||
//
|
||||
// ASAN can't catch this here (the read lives in the wx library, which is not
|
||||
// instrumented), so the repro checks the invariant the bug violates directly:
|
||||
// it arms the hover for a window (the same call wxApp::HandleMouseEvent makes on
|
||||
// hover-in), destroys that window, and asks — via a diagnostic accessor — whether
|
||||
// the hovered-window pointer was cleared.
|
||||
//
|
||||
// RED (bug present): gs_hoverWindow still points at the freed window.
|
||||
// GREEN (fixed): gs_hoverWindow was cleared on destruction.
|
||||
|
||||
#include "wx/wxprec.h"
|
||||
#ifndef WX_PRECOMP
|
||||
#include "wx/wx.h"
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten/emscripten.h>
|
||||
#endif
|
||||
|
||||
// Hooks defined in src/wasm/tooltip.cpp.
|
||||
extern void wxWasmTooltipOnHoverChange(wxWindow *win);
|
||||
extern wxWindow *wxWasmTooltipDebugHoverWindow();
|
||||
|
||||
static void Report(const char *name, bool pass, const wxString &detail)
|
||||
{
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM({
|
||||
var msg = '[REPRO] ' + UTF8ToString($0) + ': ' + ($1 ? 'PASS' : 'FAIL')
|
||||
+ ' - ' + UTF8ToString($2);
|
||||
if ($1) { console.log(msg); } else { console.error(msg); }
|
||||
}, name, pass ? 1 : 0, (const char *)detail.utf8_str());
|
||||
#endif
|
||||
}
|
||||
|
||||
class ReproFrame : public wxFrame
|
||||
{
|
||||
public:
|
||||
ReproFrame();
|
||||
|
||||
private:
|
||||
void RunTest();
|
||||
};
|
||||
|
||||
ReproFrame::ReproFrame()
|
||||
: wxFrame(nullptr, wxID_ANY, "wxToolTip lifetime repro")
|
||||
{
|
||||
CallAfter(&ReproFrame::RunTest);
|
||||
}
|
||||
|
||||
void ReproFrame::RunTest()
|
||||
{
|
||||
wxWindow *victim = new wxPanel(this, wxID_ANY,
|
||||
wxDefaultPosition, wxSize(120, 60));
|
||||
victim->SetToolTip("VICTIM_TOOLTIP");
|
||||
|
||||
// Arm the hover exactly like wxApp::HandleMouseEvent does on hover-in:
|
||||
// gs_hoverWindow = victim, and the 600 ms tooltip timer starts.
|
||||
wxWasmTooltipOnHoverChange(victim);
|
||||
const bool armed = (wxWasmTooltipDebugHoverWindow() == victim);
|
||||
|
||||
const uintptr_t victimAddr = reinterpret_cast<uintptr_t>(victim);
|
||||
|
||||
// Destroy the hovered window while the tooltip timer is still pending.
|
||||
delete victim;
|
||||
|
||||
// Invariant: the hovered-window pointer must not outlive its window.
|
||||
wxWindow *hover = wxWasmTooltipDebugHoverWindow();
|
||||
const bool cleared = (hover == nullptr);
|
||||
const bool pass = armed && cleared;
|
||||
|
||||
Report("tooltip_hover_window_cleared_on_destroy", pass,
|
||||
wxString::Format("armed=%d hover=%p victim=0x%lx",
|
||||
armed ? 1 : 0, (void *)hover,
|
||||
static_cast<unsigned long>(victimAddr)));
|
||||
}
|
||||
|
||||
class ReproApp : public wxApp
|
||||
{
|
||||
public:
|
||||
bool OnInit() override
|
||||
{
|
||||
if (!wxApp::OnInit())
|
||||
return false;
|
||||
|
||||
(new ReproFrame())->Show(true);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
wxIMPLEMENT_APP(ReproApp);
|
||||
73
tests/e2e/dom-port-bugs.spec.ts
Normal file
73
tests/e2e/dom-port-bugs.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { test, expect, tryLoadApp } from './utils/fixtures';
|
||||
|
||||
// Red-green reproductions for the two DOM-port bugs in
|
||||
// docs/features/wx-dom-port/branch-review.md.
|
||||
//
|
||||
// Each standalone app (tests/apps/standalone/{textctrl-reentry,tooltip-lifetime})
|
||||
// exercises the exact buggy path; the test is RED while the bug is present and
|
||||
// GREEN after the wasm-layer fix.
|
||||
|
||||
function reproLine(logs: string[], name: string): string | undefined {
|
||||
return logs.find((l) => l.includes(`[REPRO] ${name}:`));
|
||||
}
|
||||
|
||||
test.describe('wx DOM-port bug reproductions', () => {
|
||||
// textctrl.cpp OnDomEvent(INPUT) sets m_inDomInput=true, fires wxEVT_TEXT, then
|
||||
// resets it — but the reset is skipped if a handler throws, wedging the flag and
|
||||
// silently dropping every later programmatic value push. Driven through the real
|
||||
// path: typing fires a genuine DOM 'input' event whose wxEVT_TEXT handler throws
|
||||
// (caught at wx-dom.js's dispatch() boundary); a button then does a programmatic
|
||||
// ChangeValue() that must still reach the element.
|
||||
test('wxTextCtrl: a throwing wxEVT_TEXT handler must not wedge DOM sync', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await page.goto('/standalone/textctrl-reentry/textctrl-reentry_test.html');
|
||||
expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] textctrl ready')), {
|
||||
timeout: 30000,
|
||||
message: 'repro app should finish setup',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const input = page.locator('input').first();
|
||||
await input.click();
|
||||
await input.pressSequentially('x'); // real DOM 'input' -> wxEVT_TEXT -> throw
|
||||
|
||||
await page.getByRole('button', { name: 'Set Programmatic' }).click(); // ChangeValue
|
||||
|
||||
// The programmatic value must reach the element. RED if the throw wedged
|
||||
// m_inDomInput (the element keeps the typed "x").
|
||||
await expect
|
||||
.poll(async () => await input.inputValue(), {
|
||||
timeout: 10000,
|
||||
message: 'programmatic ChangeValue must reach the <input> after a throwing handler',
|
||||
})
|
||||
.toBe('PROGRAMMATIC_OK');
|
||||
});
|
||||
|
||||
// tooltip.cpp keeps gs_hoverWindow as a raw pointer dereferenced 600ms later by
|
||||
// the tooltip timer; nothing clears it when the window is destroyed, so a window
|
||||
// freed within the delay leaves a dangling pointer (UAF). The app arms the hover,
|
||||
// destroys the window, and self-reports whether the pointer was cleared.
|
||||
test('wxToolTip: the hover-window pointer must not outlive its window', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
const name = 'tooltip_hover_window_cleared_on_destroy';
|
||||
await page.goto('/standalone/tooltip-lifetime/tooltip-lifetime_test.html');
|
||||
expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => reproLine(testLogger.consoleLogs, name) ?? null, {
|
||||
timeout: 30000,
|
||||
message: `repro app should emit its [REPRO] ${name} result line`,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const line = reproLine(testLogger.consoleLogs, name)!;
|
||||
expect(line, `repro line was: ${line}`).toContain(`[REPRO] ${name}: PASS`);
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit d33769ab56fde5d8f389c9bd4a58bd08a951a65a
|
||||
Subproject commit 8814ddba6c2a2d21e2e1044f87d61ce4b9b17b21
|
||||
Loading…
Reference in a new issue