Skip to content

Native engine port: SMP app-launch, networking, recovery + x86 interact-freeze root-cause & fixes#16

Open
squirelboy360 wants to merge 191 commits into
developfrom
feat/native-engine-port
Open

Native engine port: SMP app-launch, networking, recovery + x86 interact-freeze root-cause & fixes#16
squirelboy360 wants to merge 191 commits into
developfrom
feat/native-engine-port

Conversation

@squirelboy360

Copy link
Copy Markdown
Contributor

What & why

This branch (feat/native-engine-port, 174 commits ahead of develop) runs a custom prebuilt Flutter engine on the OSCortex microkernel (x86_64 + aarch64) as the shell/app runtime — SMP app-launch, app networking (TCP/DNS), crash auto-recovery, and a signed package pipeline. This session's focus was the x86 in-app interact-freeze.

The interact #GP (ip=0x140d84cdb, dart:io EventHandler::Poll) is the engine reading the epoll_wait syscall number (0x47B = 1147) as an event count, overrunning its 16-slot events[] array and dereferencing a garbage DescriptorInfo*. Kernel root cause: epoll_wait yields are re-execution-saved; a yield saved in RETURN mode (rip left past the syscall) with rax=the nr returns the nr instead of re-executing the syscall.

Fixes / hardening this session:

  • perform_cooperative_yield: save_return_contextsave_return_context_reexec — it was the only re-exec helper using RETURN mode (the de-starve/block helpers already use _reexec). A real bug; fixes the cooperative-resume slice of the leak.
  • x86 entry_user_rip arch-parity: capture the user rip at the IRQ-masked syscall-entry point and prefer it in save_return_context_inner (mirrors aarch64); also corrects cross-thread wake rip.
  • Syscall correctness: epoll/timerfd/eventfd close-cleanup (poll::on_fd_closed), pipe write atomicity, SYSCALL-entry FMASK DF-clear, MAP_FIXED honors hint only when fixed, translate_user_page non-canonical-VA guard (recoverable, not a panic); app crash-recovery unwedge.
  • Flag-gated Phase-3 per-CPU scheduler groundwork (percpu-sched, default OFF → shipping ISO byte-identical) + foundation specs + headless harnesses.

⚠️ Honest status — NOT a complete freeze fix on its own. The bulk of the leak is a rarer race (~0.004% of epoll_waits, ~50/run at the engine's ~1.2M waits/run) not yet root-caused kernel-side — the unmodified (fetched) engine still #GPs in ~2/3 interact runs with just these kernel changes. The reliable crash-free fix is an engine-side size-validation (reject epoll_wait counts > kMaxEvents=16 in HandleEvents) — verified GPF=0 across runs. That lives in the engine artifact (fetched via fetch-engine.sh) and must be re-published to take effect. Diagnostic localizer logs remain in poll.rs (gated / ERROR-level, inert in shipping) — to prune in a follow-up.

Type

  • Fix
  • Feature (flag-gated Phase-3 groundwork; foundation specs)
  • CI / tooling (headless multi-app harnesses)

Architecture compliance

  • One canonical path — no dual boot/render stacks
  • Hardware access stays in kernel; Flutter touches syscalls only
  • Reused existing modules (no duplicated patch/embedder/syscall logic)
  • Engine changes go through tools/flutter-engine/engine_patch.py only — N/A this session: no engine source is committed here (the engine .so is gitignored/fetched); the engine-side size-validation, if adopted, is a separate engine-artifact rebuild.
  • Did not touch landing/

Verification

  • QEMU interact smoke check (x86, TCG, -smp 2): boots, renders the shell, launches the Files app (rendered=True), panic=0, runs the full 110s interact test. The #GP was localized to the kernel re-exec path and the cooperative-yield slice fixed. Harness: dev-tools/test/x86-multiapp.py (SMP=1 APPS=files INTERACT=1 DUR=110).
  • bash tests/run_all.sh — not run this session
  • cargo fmt --all -- --check — not run this session
  • flutter analyze — N/A (no Dart/shell change this session)

The 174-commit branch scope predates this session; the boxes above attest specifically to this session's kernel changes. The bulk of the branch (SMP, networking, recovery) was verified in its own prior commits.

- PS/2 IntelliMouse 4-byte scroll wiring (kernel) -> EV_SCROLL -> embedder
  FlutterPointerEvent (signal_kind=kScroll); direction+speed configurable.
- Settings screen in the shell: natural-scroll toggle + speed slider, live via
  config:* messages over the oscortex/shell channel.
- Kernel boot spinner drawn on the framebuffer during JIT warm-up
  (drivers/fb.rs::draw_boot_splash, driven by compositor::tick); clears when
  Flutter presents its first frame. Plus a 'Launching <app>' overlay in the shell.
- Adaptive embedder frame pump: gentle during warm-up, 60fps after input, ~8fps
  idle (was a 1ms/1000-per-sec flood) -> req:frame ratio 115:1 -> ~7:1.
- Revert MAX_FRAMES to 1<<20 (RAM above 4GiB is not yet safely mappable; the cap
  is load-bearing) with an explanatory comment.
- docs/arch.txt: document the real JIT execution model + runtime status.
Build the Flutter engine from source as a first-class OSCortex AOT target
instead of shimming a Linux engine. Phases, port surface, build infra, and the
hack-removal checklist in docs/native-engine-port.md; direction recorded in
docs/arch.txt.
Draw the destination ahead of the work: three clean layers (kernel native ABI /
shared AOT engine / self-contained AOT app bundles with own PIDs), the shared-
framework property, and the hack-removal it enables. No Linux costume.
…ace map

Baseline libflutter_engine.so (377MB, x64, embedder API) builds from source in
the container — toolchain proven. Mapped the exact port surface: ~1,200 lines
(Dart VM os_oscortex/os_thread_oscortex ~1000, fml message_loop_oscortex+paths,
reuse posix) + GN glue. The fml message loop is the critical file — its emulated
equivalent is what livelocks rendering today, so the native port also fixes sync.
Capture the validated Phase 0 setup as one-command scripts so the repetitive,
heavy engine-build infra is reproducible, not tribal knowledge:
- setup-engine-build.sh: idempotent container + depot_tools + pinned gclient sync
  (encodes the name='.' fix and the flags that work).
- build-engine.sh: gn configure + ninja for baseline | oscortex targets.
- README.md: the contributor flow, the edit->build incremental loop, and the
  pitfalls already solved (gclient layout, emulation, disk, prebuilt-dart).
Engine checkout stays OUTSIDE the repo (22GB, never committed).
Add 'oscortex' as a --target-os. Since OSCortex has no sysroot/libc yet (runs
linux-ABI via emulation), it links against linux but sets a new is_oscortex GN
flag to select the OSCortex backend sources later; a true OSCortex toolchain is
a later sub-phase. gn configures cleanly (1714 targets; args.gn = target_os
linux + is_oscortex true).

- engine-port/patches/: the two tracked diffs (gn tool, BUILDCONFIG).
- engine-port/apply-port.sh: applies patches + (Phase 2) backend sources into a
  fresh checkout, idempotent.
Temp patch files cleaned from the workspace (no remnants).
This is a public-facing repo; debugging slop and dead-path artifacts don't belong.

Removed (all dead, none load-bearing for the working JIT build):
- scratch/ (192 files): the entire AOT-deser debugging tree — analyze_*/disasm_*/
  assemble_*/check_* scripts, .bin/.log artifacts, and vendored gen_snapshot/
  shader binaries. Referenced only by build-iso.sh's dead shell-AOT step.
- tools/flutter-engine/libflutter_engine.so.bak: 91MB orphaned 'pristine engine'
  backup, zero references.
- gen_help.txt, test_size, test_size.c, .DS_Store: stray one-offs.
- build-iso.sh: excised the dead [0.3/5] shell-AOT block (gen_snapshot -> libapp.so
  -> patch_libapp) — the shell runs JIT off kernel_blob.bin, not an AOT libapp.so;
  also dropped libapp.so from REQUIRED_FILES. Native AOT is done properly via the
  engine port (docs/native-engine-port.md).
- .gitignore: ignore /scratch/ and /qemu-pipe.* so they can't return.

Deliberately KEPT (still load-bearing for the current JIT build; removed in the
port's Phase 4 once the native engine works): engine_patch.py, patch_libapp.py
(still used by tools/build-flutter-osx.sh for the apps), the Linux-emulation shim.

Verified: build-iso.sh and build-kernel-iso-fast.sh both pass bash -n; the working
fast build references neither scratch/ nor libapp.so.
Add the OSCortex fml platform backend and wire is_oscortex selection. Verified:
fml.message_loop_oscortex.o + fml.paths_oscortex.o compile into libfml, the linux
message loop is excluded, and message_loop_impl.cc selects MessageLoopOscortex.

- engine-port/src/flutter/fml/platform/oscortex/: message_loop_oscortex.{cc,h}
  (epoll+timerfd loop — the sync-bug-fixer, starts as a clean clone to diverge to
  native primitives) + paths_oscortex.cc.
- patches/0003: build_config.h defines FML_OS_OSCORTEX (additive, gated on the
  FLUTTER_OSCORTEX define), message_loop_impl.cc selects MessageLoopOscortex first,
  fml/BUILD.gn swaps in the oscortex sources (excludes linux) + sets the define.
- apply-port.sh applies 0003 + copies src/.

Note: the backend uses the Linux-ABI calls (epoll/timerfd) that OSCortex natively
implements — OSCortex is a native kernel + own libc that is Linux-ABI-compatible
(like Fuchsia/Starnix), not Linux. These calls are the divergence seam if we ever
move to a fully custom ABI.
…t VM clone

- Verified a release/AOT oscortex build configures and gen_snapshot is buildable
  from our tree -> engine+snapshot version-matched, dissolving the old AOT
  dead-end (the '1247 base objects' mismatch). AOT is now a build step, not a
  research problem.
- Deferred the Dart VM os_oscortex clone: on path A os_linux.cc already works;
  cloning it byte-identically is busywork. Add a Dart VM backend only when a
  primitive must actually diverge.
libflutter_engine.so (377MB) links for the oscortex target and bakes in OUR
backend: 17 MessageLoopOscortex refs, 0 MessageLoopLinux. First Flutter engine
built from source FOR OSCortex.

Refined the gn target (patch 0001): oscortex now configures as a HOST linux-x64
build + is_oscortex flag (identical to the proven baseline, our platform backend
selected), instead of an explicit target_os that tripped ANGLE/wayland scope and
embedder-constructor mismatches. Runtime still renders software via kSoftware.
…ld) flow

Make the from-source build a one-time maintainer task, not something every dev
repeats — exactly how Flutter distributes its own engine.

- artifact.config: pins ARTIFACT_VERSION + Flutter rev + the R2 base URL.
- fetch-engine.sh: CONSUMER flow — downloads the pinned prebuilt engine
  (libflutter_engine.so + gen_snapshot + icudtl.dat) from Cloudflare R2, verifies
  sha256, stages it where the OS build expects. Seconds, no checkout.
- publish-engine.sh: MAINTAINER flow — packages the built artifacts and uploads
  to R2 (rclone or aws/S3 to the R2 endpoint). Run after build-engine.sh when the
  port/version changes; bump ARTIFACT_VERSION.
- README: two-tier model (consumers fetch, maintainers build+publish), R2 layout,
  multi-arch = one tarball per ISA.

Scaffolding is ready now; first publish happens once the release/AOT engine lands
(Phase 3). Set ARTIFACT_BASE_URL to your R2 URL to activate fetch.
- setup-r2.sh: create the R2 bucket + enable public r2.dev URL + auto-write
  ARTIFACT_BASE_URL into artifact.config. Idempotent. Needs Cloudflare auth first
  (npx wrangler login, or CLOUDFLARE_API_TOKEN).
- publish-engine.sh: upload via wrangler (npx, no install) as the primary R2
  backend, rclone/aws as fallbacks.
- README: one-time R2 host setup steps.

Bucket creation requires the account owner's Cloudflare auth, so this is the
single manual step; everything else is automated.
R2 needed dashboard activation; switched the artifact host to Google Cloud
Storage (gs://dotcorr-oscortex-engine, public read, billing active). Verified
end-to-end: gcloud upload -> anonymous https://storage.googleapis.com/... fetch.
- artifact.config: ARTIFACT_BASE_URL -> GCS public URL + GCS_BUCKET.
- publish-engine.sh: gcloud storage cp as the primary backend (wrangler/rclone/aws
  remain as fallbacks). fetch-engine.sh is unchanged (curl, host-agnostic).
The repo is public, so GitHub Release assets download anonymously — free, no
egress fees (vs GCS ~$0.12/GB), no separate cloud account, and gh is already
authed. Verified end-to-end: gh release upload -> anonymous
https://github.com/DotCorr/oscortex/releases/download/... fetch.

- artifact.config: ARTIFACT_BASE_URL -> GitHub releases download URL; GITHUB_REPO.
- publish-engine.sh: gh release create/upload as the PRIMARY backend (GCS/R2/S3
  remain as documented fallbacks). fetch-engine.sh unchanged (host-agnostic curl).
- README: GitHub Releases is the host; no bucket setup needed.
- Removed the unused GCS bucket (no remnants).
…erified

The release oscortex engine (33MB, vs 377MB debug — no JIT) and a version-matched
gen_snapshot (6.5MB) built from one tree. Published the first artifact to GitHub
Releases (oscortex-engine-1) and verified the full round trip:
  publish-engine.sh -> gh release  ->  fetch-engine.sh downloads+checksums+stages.
Engine + gen_snapshot from the same tree are version-matched, which dissolves the
old AOT dead-end (the '1247 base objects' mismatch).

Fix: publish-engine.sh derives the workspace from the container's /work mount
(robust to the workspace dir name) instead of assuming a fixed name.
shell app -> frontend_server -> AOT dill (24MB) -> our version-matched
gen_snapshot -> libapp.so (4.4MB native ELF). Verified a real AOT snapshot: all 4
_kDart*Snapshot{Data,Instructions} present with REAL native instructions (T/text,
not zeroed stubs), ELF64 x86-64. The multi-session 'Snapshot expects N base
objects, provided 0' blocker is dissolved because engine + gen_snapshot are built
from one tree (version-matched). compile-app-aot.sh makes it reproducible.
The native, from-source, AOT-compiled Flutter engine RENDERS the shell on
OSCortex: FlutterEngineRunsAOTCompiledDartCode -> libapp.so AOT path ->
present_callback 393 frames, ZERO JIT warmup (no kernel_blob, no codegen). The
UI comes up immediately, no 60-90s compile. This is the whole point of the port.

Fix to get here: the release/AOT engine rejects the JIT-era GC/heap dart-flags
(switches.cc:478 disallowed) — pass only the 5 AOT-safe engine args (argc=5)
when is_aot, dropping the old --old_gen_heap_size etc.

Known follow-up (separate, a regression from this session's IntelliMouse 4-byte
scroll wiring): mouse clicks/Y are mangled + pointer events not reaching the
engine; to fix next.
The 4-byte scroll-packet mode added this session corrupted pointer data: dy
mis-parsed (cursor pinned to the top, y=0) and the flags byte mis-parsed (buttons
stuck at 0), so clicks never registered and pointer events stopped reaching the
engine. Revert to the proven 3-byte packet mode (working clicks > broken wheel).
Verified: cursor Y tracks normally again (y=32 start, not pinned at 0). Scroll to
be re-added later with correct 4-byte parsing + resync that doesn't regress clicks.
Input is confirmed working under AOT (122 pointer events reached Flutter, hover +
clicks register, button presses detected, dropped_total=0 — the earlier '0' was
the broken IntelliMouse 4-byte mode + minimal interaction, fixed by the 3-byte
revert). Remove the temporary DIAG logging from the embedder + ipc_display.

engine-port/compile-app-aot.sh: per-app AOT compile (frontend_server -> dill ->
version-matched gen_snapshot -> libapp.so), no patch — used to give each app its
own AOT bundle so the AOT engine can run them.
Previously every launched app reused the shell's /system/flutter/libapp.so
(aot_va=0 JIT-era skip), so tapping a tile ran the shell snapshot in the
app host and stalled. Resolve the per-app snapshot by registry lookup:
build_app_libapp_path(app_id) -> /Applications/<name>.app/libapp.so, dlopen
it, and point the AOT snapshot loader at that path. Shell keeps its own path.

Each app is AOT-compiled to its own libapp.so and staged under its .app
bundle, so a launched host loads its own native snapshot, not the shell's.
pthread_cond_signal/broadcast with no waiter parked is a no-op by contract:
the predicate is mutex-protected, so a thread that hasn't yet entered
cond_wait observes it under the lock and never waits. The kernel was instead
recording such signals in COND_PENDING_SIGNALS and letting the next cond_wait
consume one as an immediate (spurious) return. A hot mutator (pid 2) that
signals its own monitor with woke=0 then re-waits would consume its own fake
pending, return 0, find its predicate still false, and re-wait forever — the
cond-pending-consume livelock that froze render at a fixed frame count.

Remove the mechanism end to end (consume in cond_wait, posts in
cond_signal/cond_broadcast, the state map, the import). cond_wait now relies
solely on the seq protocol, which delivers every real signal race-free under
cooperative single-core scheduling (the value-check and park cannot interleave
with a signaler). No remnants.
sys::dlopen forwards path.len() to the kernel as the path length, so a
NUL-terminated byte string makes the kernel read the trailing \0 as part of
the filename and the open fails. The per-app and shell AOT paths were passing
"...libapp.so\0"; strip the NUL at the dlopen call. The AOT snapshot itself
loads via aot_snapshot_load (which maps it executable), so this only silenced
a spurious failure path, but it is a real bug and removes the warning.
The Debug-level hot per-syscall traces (epoll_ctl, mprotect, cond-signal, every
keypress) each block on a synchronous COM1 UART write AND re-render the
framebuffer text console, with interrupts disabled. Measured: ~14000 log lines
during a single boot+app-launch. Dropping to Error cut serial volume ~12x
(14000 -> 1185 lines), removing tens of seconds of emulated-bare-metal warmup.
Raise the one line in logger::init back to Debug for deep tracing.
Root cause of the sporadic render crashes. The user GPR snapshot lives in a
PER-CPU scratch (gs:[..]) written by the syscall entry stub and shared by every
thread on the core. save_full_user_gprs read it LAZILY at yield time — but the
wait loops do sti;hlt and the timer ISR can switch threads mid-handler, so
another thread's syscall entry overwrites the per-CPU snapshot before our save
runs. We then stored the OTHER thread's callee-saved regs (rbx/rbp/r12-r15) into
our context; on resume rbx was garbage.

Pinpointed via addr2line/objdump: fml::MessageLoopOscortex::Run() resuming from
epoll_wait wrote running_ through this=0x1400 (=1280*4, a Skia row stride leaked
from another thread) -> SIGSEGV pid=3. This is the whole 0/16/140/489-frames-
across-identical-boots sporadicity.

Fix: capture_user_gprs_at_entry() snapshots the GPRs into the thread's own
PTABLE slot at dispatch_fast entry, while the per-CPU snapshot is still fresh
for this thread (before any handler/yield/interrupt window). A per-CPU
'captured' flag makes later yield-time save_full_user_gprs calls no-ops, so a
clobbered shared snapshot can't leak in.

Validation: 4/4 headless boots render cleanly (present 98-105) with ZERO engine
SIGSEGV, vs the prior sporadic crashes. Render is now reliable.
The frame pump only called FlutterEngineScheduleFrame in the no-event branch,
so a hover/click/scroll waited for the event queue to drain plus a wm_event_wait
timeout (up to ~16ms) before the repaint was even requested. Request the frame
in the same iteration input is received; the engine coalesces redundant requests
and Flutter flushes the batched pointer packet at BeginFrame, so the scheduled
frame reflects the event. Removes the scheduling-side input latency (most
visible on real hardware; under cross-arch QEMU TCG the emulation dominates).
Each submitted frame did up to 5 full 1M-pixel passes: a strided re-pack into a
freshly allocated 4MB Vec, a byte-indexed B<->R swap, a full-screen fill_rect
clear, blit_rgba32, and swap_buffers. Measured ~65ms median (wildly variable
10-92ms) -> a ~15fps ceiling from the blit alone, which read as a low refresh
rate / laggy feedback.

- Fuse the strided re-pack into the swap: submit_bgra_impl reads the source
  stride directly and packs in ONE u32-wise pass (read BGRA as u32, swap bytes
  0<->2), eliminating the intermediate buffer + the 4MB/frame allocation.
- Skip the full-screen fill_rect clear when a presented surface already covers
  the screen (the common case: one full-screen Flutter surface).

Measured after: blit median 5.4ms, steady 5.0-7.6ms (~12x faster, variance
gone). Render verified correct + crash-free; colors unchanged.
next_runnable_pid_locked computed the foreground-exclusive group AFTER the
input-target and embedder-baton shortcuts, so a due shell (pid 1) baton could
schedule the shell engine even while an app is foreground — two heavy Flutter
VMs on one cooperative core, the documented cause of the launched app's crash.
Compute fg/exclusive FIRST and gate both shortcuts: suppress the pid-1 baton
when an app is exclusive, and only honour the input shortcut for a target in the
foreground group. No behavioural change when the shell is foreground (the common
case): exclusive=false short-circuits both gates.

Closes the baton concurrency hole; full app-launch validation still pending an
interactive tile-tap (HMP mouse_button injection does not produce reliable
clicks headless).
Replace the generic reply-null catch-all in platform_message_callback with
a real channel dispatcher (match on channel name). Every channel the
framework reaches for is now routed to a concrete handler that does the
platform work or returns a codec-correct typed ack; the final catch-all
logs the channel name ([embedder/chan] unbound: <name>) so nothing stays an
invisible stub.

Bound channels:
- flutter/textinput (JSONMethodCodec): setClient/setEditingState/show/hide/
  clearClient. Editing state (text + selection) is maintained in the embedder.
  PS/2 set-1 scancodes are now mapped to characters (shift/caps, backspace,
  enter, tab, space, arrows, home/end, delete); on a key press with an active
  text client the stored editing state is mutated and TextInputClient.
  updateEditingState is pushed back over flutter/textinput. Adds a small
  inline JSON reader/writer (no_std, no crates) for the editing-state maps.
- flutter/mousecursor: parse activateSystemCursor kind and ack (no kernel
  set-cursor-shape syscall exists yet; logged + acked).
- flutter/platform: Clipboard.setData/getData/hasStrings backed by an
  in-embedder buffer; SystemNavigator.pop; SystemSound/HapticFeedback/
  SystemChrome acked.
- flutter/navigation, system, accessibility, spellcheck, processtext, menu,
  contextmenu, scribe, restoration, keyevent, platform_views, isolate,
  lifecycle: explicit JSON typed-null ack.
Replace the ack-only stubs from the platform-channel contract with actual
OS-provided capabilities. OSCortex is the platform under the stock engine, so
where Flutter needs a platform service the kernel now implements and binds it.

Mouse cursor shape (flutter/mousecursor.activateSystemCursor):
- compositor: ACTIVE_CURSOR_SHAPE atomic + vector cursor sprites (arrow, I-beam,
  hand/link, forbidden, grab, horizontal/vertical resize, hidden). draw_software_cursor
  dispatches on the active shape; set_cursor_shape() repaints immediately.
- new syscall SYS_CURSOR_SHAPE_SET (0x4B2). Embedder maps the Flutter cursor kind
  string to a CURSOR_SHAPE_* and calls it, so hovering a link shows a hand, a text
  field shows an I-beam, etc.

Semantics / accessibility:
- embedder wires update_semantics_callback2 (FlutterProjectArgs off 280) and calls
  FlutterEngineUpdateSemanticsEnabled(engine, true) after run. The callback receives
  the FlutterSemanticsUpdate2 tree and stores each node (id, label, rect, flags,
  actions) in a live embedder structure for a11y / automation consumers. flutter/
  accessibility now replies with the correct StandardMessageCodec null (0x00), not JSON.

System clipboard (flutter/platform Clipboard.*):
- kernel-global clipboard buffer (embedder::clipboard) shared across every app/host,
  with SYS_CLIPBOARD_SET (0x4B3) / SYS_CLIPBOARD_GET (0x4B4). The embedder routes
  setData/getData/hasStrings to the kernel, so clipboard survives across apps.

SystemNavigator.pop (flutter/platform):
- SYS_APP_CLOSE_FOREGROUND (0x4B5): refocuses the shell (pid 1) and wakes it. The
  app embedder, when it is a launched host, flushes its reply, calls the syscall and
  exits so focus returns to the shell.

SystemSound.play (flutter/platform):
- PC-speaker beep driver (drivers::beep) via PIT channel 2 + port 0x61, exposed as
  SYS_BEEP (0x4B6). click vs alert play distinct short tones.

Deliberate no-ops (no such hardware), acked with the correct codec:
- HapticFeedback.* (no vibration motor), SystemChrome.* (single full-screen
  compositor surface, no system UI overlays / orientation).
Brings in the embedder channel dispatcher, text input (flutter/textinput),
and real OS-backed capabilities: cursor-shape sprites + SYS_CURSOR_SHAPE_SET,
live semantics, kernel-global clipboard, SystemNavigator.pop return-to-shell,
and a PC-speaker beep driver. Builds green (embedder + kernel).
…e root

The SMP crash-recovery teardown can free a dead app's pml4 while another core
still holds a stale reference to it (a futex physaddr lookup) — the walk then
dereferences a freed/reused table and hits phys_to_virt's "corrupt PTE" panic
(an unrecoverable kernel page fault, deref of phys 0 / out-of-range). The OS
should survive an app's crash, not die with it.

Read-walks (translate_user_page / _flags) and the teardown free walk now route
every table-pointer dereference through walk_table(), which returns None for a
null or out-of-addressable-RAM frame instead of dereferencing it. A garbage or
freed table is treated as "not mapped" (the lookup fails, the caller copes) and
the kernel stays alive. The bare phys_to_virt — which still panics on garbage,
a useful invariant — is unchanged on the WRITE paths. Verified: 0 panics /
0 corrupt-PTE across the SMP app-launch batch (was ~1/10 before).
packages/oscortex_ui/.dart_tool/ was tracked despite matching .gitignore
(packages/*/.dart_tool/) — it predated the ignore rule. It's a regenerated
build/tooling cache that should never be in version control. Removed from
tracking; the existing .gitignore rule keeps it out.
… Link status

Release docs for the x86 SMP app-launch milestone. Records the home-core fix, the
multi-core requirement, the honest stability picture (OS crash-proof; intermittent
non-fatal first-frame stall; not yet bare-metal-confirmed), and — per request — the
Web Link/browser status: app shell + webview pipeline scaffold with a stub engine
are done and demonstrable; the Servo web engine is NOT integrated yet.
…edger

rules.md: binding rules for every agent — never present a stub as done; "done"
means verified end-to-end on the real artifact; report status as exactly one of
DONE/UNVERIFIED/STUB/NOT-STARTED; finish the task or name the gap precisely.

CLAUDE.md: auto-loaded by agents; makes rules.md mandatory reading before starting
and before reporting done.

docs/FEATURE_STATUS.md: honest per-feature ledger (start of the stub audit) — flags
what has NOT been re-verified rather than rubber-stamping it, and is explicit that
the audit is incomplete.
…not "solved"

Live HVF test 2026-06-17: tapping an app launches the 2nd engine, which stalls at
FlutterEngineRunInitialized (no crash) and freezes the UI. Same single-core
cooperative-scheduler root cause as x86. The prior "solved via serial-GC" note was
wrong. Fix = wire the home-core SMP path for aarch64 (no aarch64 mirror of the x86
timer-ISR home-core wake-assist exists yet).
…ootstrap WIP

Progress on aarch64 home-core SMP (the ARM app-launch freeze). The prior state was
"AP comes online but stays idle (never schedules)". Now, under -smp 2 on HVF (real
parallel cores):
  - The AP idle-parks (wfi) until an app is pinned to it (CPU_HAS_HOME_WORK, set in
    spawn_with_bootstrap + woken by the existing reschedule IPI), so it does NOT
    contend with the BSP's shell first-frame bring-up (engaging at flutter_init_ready
    stalled the shell at present=1).
  - On wake it engages + runs the arch-neutral home-pinned scheduler. The launched
    app's host (pid 8) DOES run on the AP — main_embedder, FlutterEngineInitialize,
    its worker pool all execute there (aarch64 AP EL0 entry works).
  - The timer-ISR wake-assist resume is home-gated + re-enabled (multi-core only;
    single-core keeps the proven set_state_try-only path, byte-unchanged).

NOT DONE (honest, per rules.md): the app's engine bootstrap DETERMINISTICALLY stalls
at FlutterEngineRunInitialized (4/4 runs) — the cooperative thread-pool handshake
does not complete on the AP, so no frame is presented. This is the deep remaining
aarch64 SMP work. Single-core ARM (the shipped run-arm.sh path, no `smp` feature) is
unaffected — all new behaviour is behind the AP/CPU_COUNT>1 gates. x86 unaffected.
…ounter access)

The aarch64 AP never called enable_fpu_simd() (the x86 AP does, in ap_init). That
function sets CPACR_EL1 (FP/SIMD) AND CNTKCTL_EL1.EL0VCTEN/EL0PCTEN — per-CPU
registers at reset on a secondary core. Without CNTKCTL, the app's inline CNTVCT_EL0
read (liboscortex_libc monotonic clock, hit constantly by the Dart VM) trapped to EL1
with EC=0x18 → report_unhandled → the engine bootstrap deterministically wedged on the
AP (4/4 runs, confirmed by an HVF register dump: AP stuck in report_unhandled,
ESR EC=0x18). Adding the call clears EC=0x18. The freeze now advances to a later
fault (EC=0x24 EL0 data abort) — distinct, deeper issue, still WIP; aarch64 SMP
app-launch is NOT yet working. AP-only change; single-core + x86 unaffected.
…s EL0 state

Re-enabling enter_user_by_pid_noreturn_try from the AP timer ISR reproduced the
prior session's "pid=2 Dart corruption" exactly: a real EL0 data abort (EC=0x24) in
engine code. Reverted to wake-only (set_state_try), which clears the corruption
(unhandled_exc=0, 3/3). Net aarch64 SMP state with the CNTKCTL fix: the AP runs the
app past the CNTVCT trap, but the engine bootstrap stalls at FlutterEngineRunInit —
the cooperative hand-off alone doesn't complete it and the only pump (ISR resume)
corrupts thread state. The real fix is a corruption-free per-CPU context switch
(Redox-style switch_to), a multi-session rewrite. aarch64 SMP app-launch NOT working
yet; single-core + x86 unaffected (all behind AP/CPU_COUNT>1 gates).
… bootstrap needs switch_to rework

The home-gated timer-ISR wake-assist resume now fires only when the target has a
valid saved FP (aarch64_fp_valid), so it can never re-enter a thread with no FP image
and zero its AAPCS64 callee-saved v8–v15 (the EC=0x24 SMP corruption). Verified: 4/4
runs, 0 unhandled exceptions (corruption gone).

But the app bootstrap STILL stalls (4/4, RunInitialized never completes): the threads
that need re-entry during bootstrap are freshly created and have no FP yet, so the
FP-gated resume skips them, and the cooperative hand-off doesn't re-enter them either.
This is the catch-22 proven across 5 distinct attempts (CNTKCTL fix, resume on/off,
FP-gated): a correct resume must restore ANY thread's full state without corruption —
i.e. a real per-CPU switch_to context save/restore, not the build_image rebuild. That
is focused multi-session architectural work (precisely scoped in [[smp-bringup]] M5),
NOT a blind loop hack. aarch64 SMP app-launch remains BROKEN (honest). Single-core +
x86 unaffected (multi-core/AP-gated).
…77% headless HVF)

The launched app now paints its full UI on the app core (core 1) while the
shell runs on core 0, screenshot-confirmed under `-smp 2` HVF (10/13 runs
clean-render, on par with x86 ~70-85%). aarch64 went from always-freeze/crash
to mostly-working. Single-core (shipped run-arm.sh) byte-behaviour unchanged,
verified renders; x86 compiles (the lock change is arch-neutral + more correct).

Root causes fixed (all latent SMP bugs the cooperative single-core path hid):

1. PTABLE_LOCK desync under true parallel cores. It wrapped a spin::Mutex,
   recorded the owner in a SEPARATE `holder` atomic, and stashed the guard in a
   single `static mut PTABLE_LOCK_GUARD`. Under HVF those three desynchronized:
   windows where the Mutex was locked but `holder` read FREE, plus a raced
   single guard slot — leaving inner permanently locked with holder=FREE, so
   every core spun forever on acquire. Replaced with one CAS'd owner word
   (acquire + owner-record are one atomic step). This was THE wedge.

2. ACTIVE_EL1_SP was a single global ("smp=1 bring-up: one slot"). Cores
   clobbered each other's EL1 syscall-stack pointer; a cooperative resume on one
   core reset SP_EL1 to the other core's stack → two cores on one EL1 stack →
   EC=0x24 data abort, FAR=-1. Now per-CPU [MAX_CPUS].

3. The AP never armed its generic timer. CNTV_CTL/TVAL and the timer PPI's GIC
   enable are per-CPU; the BSP's start_scheduler_tick only armed core 0, so the
   AP took ZERO timer interrupts → no preemption, no ISR wake-assist → the app's
   engine bootstrap deadlocked (worker threads need an async wake the
   cooperative-only path can't deliver). Armed in ap_main once an app is pinned.

4. The wake-assist FP gate (aarch64_fp_valid) skipped FRESH workers — a freshly
   spawned thread has no captured FP yet, but zeroing its FP on first entry is
   CORRECT. FlutterEngineRunInitialized's isolate-launch worker is fresh, so it
   was never re-entered → bootstrap stall. Replaced with aarch64_resume_fp_safe_try
   (entered_once-aware): fresh ⇒ safe; already-run ⇒ needs valid FP (else logged).

5. get_group_leader walked the parent chain in an unbounded loop; a cyclic chain
   hung a core inside it while holding PTABLE_LOCK, wedging the scheduler. Bounded
   to MAX_PROCS with a one-shot cycle log.

Remaining: ~23% intermittent EC=0x24 shell-corruption during concurrent
shell+app execution (same class as x86's intermittent crash) — the deeper
concurrent-engine/context-switch hardening, tracked in FEATURE_STATUS.md.
…cher crash dump

- dev-tools/test/arm-smp-applaunch.py: the headless HVF -smp 2 app-launch test that
  proved the fix, now committed (was a volatile /tmp script that got cleaned).
  On a clean host: 15/15 clean render, 0 crash, 0 freeze. The earlier 10/13 was an
  overloaded test host (30+ concurrent QEMU + builds), not an OS bug.
- FEATURE_STATUS.md: x86 app-launch was dishonestly "DONE" while identical in
  maturity to aarch64 — both are now "WORKS ~X% headless, UNVERIFIED on bare metal,
  NOT DONE" (rules.md Rule 3: DONE = real-artifact-verified). aarch64 row updated to
  the accurate 15/15 clean-host figure + the load-induced EC=0x24 residual.
- vectors.rs report_unhandled: also print focus_pid / cpu / home_cpu, so an EL0
  fault dump shows whether the faulting thread was the foreground app or a
  backgrounded shell thread (concurrent-execution context). Runs only on a crash.
…ix + syscall correctness hardening

The x86 in-app interact #GP (ip=0x140d84cdb, dart:io EventHandler::Poll)
is the engine reading the epoll_wait syscall number (0x47B=1147) as an
event count, overrunning its 16-slot events[] array, and dereferencing a
garbage DescriptorInfo*. Kernel root: epoll_wait yields are re-execution-
saved; a yield saved in RETURN mode (rip left PAST the syscall) with
rax=the nr returns the nr instead of re-executing the syscall.

Fixes / hardening:
- perform_cooperative_yield: save_return_context -> save_return_context_reexec.
  It was the only re-exec helper using RETURN mode (the de-starve/block
  helpers already use _reexec) -- a real bug. Fixes the cooperative-resume
  slice of the leak.
- x86 entry_user_rip arch-parity: capture the user rip at the IRQ-masked
  syscall-entry point and prefer it in save_return_context_inner (mirrors
  aarch64, which already does this); also corrects cross-thread wake rip.
- syscall correctness: epoll/timerfd/eventfd close-cleanup (poll::on_fd_closed
  from sys_close), pipe write atomicity (block, don't tear, for <=PIPE_BUF),
  SYSCALL-entry FMASK DF clear, MAP_FIXED honors hint only when fixed,
  translate_user_page non-canonical-VA guard (recoverable, not a panic).
- app crash-recovery unwedge (defocus a dead foreground group so the shell
  can drain relaunches).
- flag-gated Phase-3 per-CPU scheduler groundwork (percpu-sched feature,
  default OFF -> shipping ISO byte-identical).
- headless multi-app harnesses (dev-tools/test) + foundation specs (docs).

HONEST STATUS -- NOT a complete freeze fix on its own:
The bulk of the leak is a rarer race (~0.004% of epoll_waits, but ~50/run
at the engine's ~1.2M waits/run) that is NOT yet root-caused kernel-side;
the unmodified (fetched) engine still #GPs in ~2/3 interact runs with just
these kernel changes. The reliable crash-free fix is an engine-side
size-validation -- reject epoll_wait counts > kMaxEvents (16) in
HandleEvents -- verified GPF=0 across runs. That lives in the engine
artifact (fetched via fetch-engine.sh) and must be re-published to take
effect. Diagnostic localizer logs remain in poll.rs (gated / ERROR-level,
inert in the shipping config) -- to be pruned in a follow-up.
…the interact-freeze crash-free defense

The reliable crash-free fix for the x86 interact #GP (the engine reads the
epoll_wait syscall number 0x47B=1147 as an event count and overruns its
16-slot events[] array) is an engine-side validation in dart:io's
HandleEvents: reject bogus epoll_wait counts > kMaxEvents=16, plus skip a
non-canonical DescriptorInfo cookie. Verified GPF=0 across runs; the
unmodified engine #GPs in ~2/3 interact runs.

The engine .so is gitignored/fetched, so this cannot land as a binary in
this repo. Added as a reproducible engine patch instead:
- engine-port/patches/0005-epoll-size-validation.patch
- engine-port/apply-port.sh: wire 0005 in (rooted at the dart dep)
- engine-port/REPUBLISH-epoll-fix.md: apply -> build -> bump artifact.config
  -> publish -> fetch -> verify. The publish step uploads to the GitHub
  release / R2 and needs maintainer credentials.

Companion to the kernel-side partial fix (perform_cooperative_yield ->
save_return_context_reexec) in the prior commit. Together they make the
build crash-free; the kernel rare-race residual remains a follow-up.
…ser-resume paths (kernel-only crash-free interact)

The x86 in-app interact #GP (ip=0x140d84cdb, dart:io EventHandler::HandleEvents
→ di->Mask() on a non-canonical DescriptorInfo*) is the kernel intermittently
delivering rax = the epoll_wait syscall number (0x47B=1147) as epoll_wait's
return value. The engine reads 1147 as the event count, overruns its 16-slot
events[] array, and #GPs. ~50 leaks/run.

The prior cooperative-yield re-exec fix (save_return_context_reexec) closed only
the cooperative-resume slice (~28/run). The remaining bulk is delivered via the
two timer-ISR direct-iretq preempt resumes (apic_resched_handler,
apic_timer_handler) and the wake-assist enter_user_by_pid_noreturn_try path — not
the cooperative path. The crash register/syscall trace proved every nr=0x47B
epoll_wait is issued from the syscall-stub trampoline at ~0x7fffeefa, NOT the
engine .text.

Fix: a delivery-point guard (epoll_nr_leak_guard) at ALL FOUR user-resume paths.
After the CR3 switch (so the read sees the resuming thread's mapping), if rax ==
the epoll_wait nr AND the resume rip is inside the syscall-stub trampoline
[TRAMPOLINE_PAGES_VA, +0x2000) but is NOT the `syscall` instruction (0F 05) — the
thread is RETURNING the nr rather than re-executing — neutralize rax to 0. epoll
is level-triggered, so the ready fd re-fires on the next wait. Scoped to the
trampoline pages, which are always mapped (the thread was executing there), so the
[rip] read can never fault.

This is the kernel-only crash-free fix: no engine rebuild/republish needed. The
engine size-validation patch (0005) remains as defense-in-depth.

Verified on the real ISO with the UNMODIFIED (fetched) engine, SMP=1 interact:
- pre-fix (engine-.text range): guard-fired=0, GPF=2 every real run (leak crashes).
- this fix: GPF=0 across 6 runs; guard-fired=36-57/run (every leak caught). The
  engine stays alive (schedule_frame loop, input processed to end-of-run);
  remaining "FROZEN" is the static-UI present-flatline false-positive.

Adds a lock-free guard-fired counter dumped (rate-limited, ERROR level) from the
safe epoll_wait syscall path — never the boot-fragile resume path — for
regression visibility.
Boots the ISO headless, waits for the shell, launches Files via QMP clicks, and
screendumps shell + app + post-interact frames to PNG. Used to confirm the
epoll-nr-leak fix (55189d5): shell renders, Files paints its full UI, and in-app
clicks navigate (/Applications -> /Applications/Files.app/libapp.so, 30->0 items)
with no #GP. Complements x86-multiapp.py (which only samples present_callback).
…ash-free

The interact #GP is fixed kernel-only (55189d5); single core now renders Files +
navigates on click with GPF=0 (8 runs, unmodified engine). Supersedes the stale
'doesn't render / SMP is the fix' row. Engine republish no longer required.
…IVE + crash-free (3/3 HVF)

Both arches now verified crash-free on interact (single core). aarch64 needs no
guard — nr in x8, return in x0 are separate registers, so the x86 nr-aliases-return
leak can't occur. EC=0x24 is a -smp 2 concurrent issue only, not single core.
… clock-independent SMP wait

Two real-hardware failures found by booting on bare metal (HP ProBook i3-470 +
Intel MacBook), neither reproducible under QEMU:

1. NO INPUT on the ProBook. Input was xHCI-only (no xHCI on 2010 EHCI/UHCI-era
   hardware) AND PS/2 was deliberately skipped on bare metal — a "safe mode" guard
   in platform.rs that was stale (it dated from a since-fixed unbounded i8042 flush
   that hung; ps2::init is now fully bounded). On a laptop the keyboard IS the i8042,
   so the box had no input at all. Fix: run the bounded/fail-safe ps2::init on all
   x86, not only under a detected hypervisor; register the PS/2 native whenever it
   binds (drop the qemu_like gate). USB-HID still binds via the xHCI probe where present.

2. BOOT HANG at `cortex::smp` on the MacBook. The AP-online wait was bounded only by
   a wall-clock deadline from rdtsc_ns() (raw TSC / fixed rate). Mac firmware doesn't
   wire the legacy PIT, so that clock never advances as assumed -> the deadline is
   never reached -> the BSP spins forever (observed: frozen at "cortex::smp 0.5s").
   Fix: add a clock-INDEPENDENT spin cap alongside the deadline so the wait ALWAYS
   terminates and the BSP continues, advertising only the APs that actually came
   online (boots single-core-safe). QEMU unaffected: APs come online normally and the
   cap is never reached (verified -smp 2: 2/2 online, 0 timeouts, GPF=0, no regression).

Both verified non-regressing in QEMU; bare-metal validation pending on the user's
machines. First instances of the broader hardware-generality framework: fail-safe
boot (no wait bounded only by a firmware-variable clock) + probe-don't-assume input
(no hypervisor-gated drivers).
…ng (fixes landed, UNVERIFIED on metal)

First real bare-metal boots (HP ProBook i3-470 + Intel MacBook) exposed: input
non-functional (xHCI-only USB + PS/2 skipped on bare metal) and smp-on hangs the
Mac at cortex::smp (clock-dependent AP-wait). Both fixed (9543368); only the user's
hardware can confirm. Single-core is the safe bare-metal config.
… fault (bare-metal input)

On bare metal (HP ProBook) a working PS/2 mouse streams ~100+ move events/sec. Hovering
an interactive card then froze the OS: the prebuilt engine cannot run hover hit-tests
that fast on a single core, backs up internally, and faults (#GP at an engine address,
then a fault-loop). Reproduced headless: a 40/s move-flood on a card faults after ~600
events; the SAME ~600 events at 10/s sail through -> rate-bound, not a leak.

Fix: deliver pointer MOVES at the engine's real throughput, not the raw mouse rate. A
move is delivered only once the engine has caught up (presented the frame the last move
caused -- hooked at sys_surface_present / sys_gpu_submit), with a hard 10/s floor for
moves that cause no repaint (jitter inside an already-hovered card -- the exact trigger).
The latest position is held and flushed on the next present. Button transitions
(clicks/releases) bypass the throttle and are never dropped/delayed; the software cursor
is drawn from CURSOR_X/Y (updated on every event) so the visible pointer stays smooth --
only hover hit-testing is rate-limited. Generic for any high-rate pointer source.

Verified on the real ISO (single-core, unmodified engine): the flood that froze at 601
moves now survives 28104 moves (1874/s), present still advancing, zero faults; a normal
interact still lands clicks (Files launches + navigates). No regression.
…l stall (uncached fb)

On a 2016 Intel MacBook boot reached cortex::spawn_shell and stalled 115s+ (counter
climbing) with no shell, but booted fine on the ProBook's small panel. Root cause: the
bootloader maps the framebuffer UNCACHED (it is in the PCI MMIO hole, phys 0xfd000000;
no PAT/MTRR is set up anywhere). On a 2880x1800 Retina panel (~20 MB) every full-frame
paint is tens of MB of uncached MMIO writes, so once spawn_shell starts the engine the
present path livelocks. The ProBook's small panel makes the same writes cheap.

Fix: remap the framebuffer write-combining.
- setup_pat_wc(): reprogram IA32_PAT entry PA1 (PWT=1) from WT to WC. Nothing uses WT;
  mmio() uses PA3=UC, so this is safe.
- map_write_combining(): map ONLY the fb's physical pages at a fresh kernel-half VA with
  PWT set (-> PA1=WC), in an empty PML4 slot, leaving the bootloader's original uncached
  mapping (and any neighbouring MMIO in the same huge page) untouched. fb writes go from
  ~tens-of-MB/s to GB/s, scaling away with panel size.
- leaf_entry_of(): read-only cache-attribute probe (diagnostic).

x86-only (gated); aarch64 (ramfb) unaffected. Verified on the real ISO: WC engages
(FB_ADDR=0xffffc00000000000 -- the WC VA, not the uncached fallback), shell + Files
render correctly with no garbling, RESPONSIVE, panic=0, GPF=0. The fb is confirmed
uncached (Limine logs phys 0xfd000000). The speed win is only observable on real
uncached-MMIO hardware (a large panel) -- QEMU's fb is already fast -- so bare metal
confirms the Mac boot; QEMU confirms correctness (engaged + renders). PAT is per-CPU:
setup_pat_wc runs on the BSP (single-core shipping config); an SMP build writing the fb
from APs would need it in ap_init too.
… (pointer throttle) + MacBook spawn_shell fixed (fb write-combining)

Both root-caused, fixed, mechanism-verified headless (3375d98 throttle, f0786e9 fb-WC).
ProBook input user-confirmed working on metal; MacBook boots past cortex::smp. Speed/boot
payoff on the user's hardware is the final gate.
…el (foundation, behavior-identical)

First step of the per-CPU preemptive scheduler rework — the generic fix for the freeze
class (app-launch / install-demo / Mac stall all = the cooperative scheduler can't
reclaim the core from a busy/stuck/GC-safepointed thread).

A0: timer_preempt_switch_try (the single funnel that switches away a running thread,
both arches + BSP/AP) now also bails when the interrupted thread is non-preemptable --
preempting + migrating a thread mid-Dart-isolate-critical-section is the EC=0x24/#GP
isolate-confinement class. is_preemptable (the dead Redox primitive) is set by the engine
via SYS_SET_PREEMPTABLE (A1, not yet wired), so the field stays always-true (init) and
this is a BEHAVIOR-IDENTICAL no-op today. Verified: SMP=2 multiapp RESPONSIVE, GPF=0, no
regression; the single-core shipping path is unaffected (the bail never fires while true).

Also lands the re-verified, architecturally-corrected execution roadmap in
docs/spec-phase3-percpu-scheduler.md (EXECUTION LOG): the single bail site (NOT scattered
in the ISRs), the SYS_SET_PREEMPTABLE ABI + slice-expiry safety valve (A1 -- a
non-preemptable thread can delay a discretionary switch but NEVER freeze a core), the 8
exact run-queue enqueue hooks (B), and the highest-risk two-layer switch_to (step 4).
aarch64 EL0 preemption is hard-off -- untouched.
…t freeze bug) + foundation blueprint

The 12-agent research synthesis (Redox/Linux/seL4/Zircon/Dart-engine) pinned the freeze class
to ONE root cause: the kernel cannot involuntarily reclaim a CPU from a busy thread, because
the mandatory time-slice preempt SILENTLY never fires -- account_tick_try did
PTABLE_LOCK.try_lock() and returned None on contention, so while threads churn the lock the
quantum is never decremented and a CPU-bound thread (install loop, Dart GC) is never forced
off -> the whole OS (incl. the kernel-drawn cursor) freezes.

Step 1a: a LOCK-FREE per-CPU quantum (PER_CPU_QUANTUM, advanced by the timer ISR with no lock)
is now the slice verdict, so a spent quantum ALWAYS forces the switch regardless of lock
contention. PCB cpu_ticks/slice_left stays best-effort under the lock and no longer gates
preemption. Per-CPU single-writer -> race-free without a lock.

Verified on the real ISO: SMP=2 x2 + SMP=1, GPF=0, panic=0, no regression (app-launch +
static-UI plateau unchanged). The load-bearing backstop the rest of the rework builds on
(the forced-quantum valve + is_preemptable counter come next).

Also lands docs/foundation-blueprint.md: the unified diagnosis + the ordered 7-step,
arch-neutral, lowest-risk-first plan (forced valve -> engine is_preemptable signal -> atomic
futex WAIT -> address-space futex key -> correct cond_var -> two-layer switch_to -> delete the
crutches) with exact files, verification gates, risks, and the Redox/Linux pattern per step.
…eup window)

Blueprint Step 3. The FUTEX_WAIT value-check and the waiter enqueue were in SEPARATE
FUTEX_WAITERS lock acquisitions, so a FUTEX_WAKE landing in the gap found no waiter and was
LOST -- the thread then parked forever (a face of the post-app-launch render freeze).

Now value-check + enqueue + the Running->Blocked transition all happen under ONE
FUTEX_WAITERS critical section: lock once, read_volatile(uaddr) UNDER the lock, compare
(EAGAIN if changed), push the waiter, then try_block (Running->Blocked ONLY, returns false
if a wake already flipped us -> skip sleeping). The lock is dropped BEFORE any cooperative
hand-off, never held across the yield (no cross-core strand). The wake side uses try_unblock
(Blocked->Running, idempotent) after dropping FUTEX_WAITERS. Same ordering on FUTEX_WAIT_BITSET
(op 9); its bitset/timeout semantics are unchanged (Step 5).

New process primitives: try_block (Running->Blocked-only, bool) and try_unblock
(Blocked->Running-only, idempotent, resched IPI). Lock order is FUTEX_WAITERS -> PTABLE_LOCK
in the WAIT path; audited every FUTEX_WAITERS site -- no reverse nest exists (no ABBA).

Verified on the real ISO: SMP=2 (files, files+weblink) + SMP=1, GPF=0, panic=0, deadlock=0,
sustained rendering (present_callback -> n=395 in the long run), no hang. Single-core behavior
preserved (a wake can't interleave a held lock there -> try_block always succeeds -> identical
block-then-halt). Crutches (S6/S9, cond_miss_bridge, 0x47B) untouched -- they retire in Step 7.
The futex KEY is still raw VA -- Step 4 fixes the cross-process collision next.
…l the cross-process collision)

Blueprint Step 4. FUTEX_WAITERS was keyed by raw VA -- process-local. The shell (pid1) and a
launched app load the same libc/Flutter .so at IDENTICAL VAs, so their pthread mutex/cond words
collided in one bucket and a wake could pop the WRONG process's waiter -> park forever (the
post-app-launch render freeze). The previous physical-address overlay (futex_phys_of) translated
the VA on EVERY op -> double-faulted (Attempt-1).

Fix: key by FutexKey::Private{root_phys, va}, root_phys = the process's page-table root (pml4_phys)
-- a single field read, NO page-table walk. Same-process threads share pml4_phys
(spawn_thread/clone_thread copy the parent's) -> correctly collide; different processes -> different
roots -> correctly separated. MAP_SHARED is impossible (sys_mmap has no flags param -> all mappings
private), so every futex is private and this key is provably exact; Shared{frame_phys} is reserved.
futex_phys_of + its per-op translate are DELETED (the #DF cause is gone).

Key computed ONCE per op from current_user_root() BEFORE FUTEX_WAITERS (preserves the Step-3
FUTEX_WAITERS->PTABLE_LOCK order). CondWaitState carries a pre-computed cond_key so the timer-ISR
cond-timeout path removes waiters WITHOUT touching page tables under the IRQ mask. caller==0
force-wake drains all VA-matching buckets (bring-up path intact).

Verified on the real ISO: SMP=2 (files, files+weblink) + SMP=1. DF=0 across all runs (the Attempt-1
double-fault is structurally eliminated), GPF=0, panic=0, strong sustained rendering (present_callback
-> n=448). KNOWN: an intermittent SOFT freeze (present flatlines after rendering, no crash) persists
-- the cooperative cond-timing / scheduler gap, addressed by Steps 5-6, not a collision/#DF issue.
…g — kill the "Poll failed" soft freeze

The intermittent post-app-launch soft freeze (app renders, then present flatlines, no crash)
was the epoll de-starve's 0x47B re-exec leaking: on resume the _reexec rip occasionally advances
PAST the syscall, so the epoll_wait nr (0x47B) reaches the engine as the return value. The guard
caught it but NEUTRALIZED to 0 -- a spurious epoll_wait=0 the engine's I/O loop logged as "Poll
failed" and degraded into the freeze (correlated 1:1: fired=8 <-> frozen, fired=1 <-> responsive).

Fix: the guard now RE-EXECUTES instead of faking 0. On a leak it rewinds rip to the `syscall`
instruction (rip-2, verified 0F 05) and keeps rax=0x47B, so epoll_wait re-issues cleanly and the
thread re-blocks in-kernel -- exactly the de-starve's intent. The de-starve (load-bearing for
focus-prioritized render -- removing it stalls the shell at the event loop, confirmed) is
untouched; only the leak HANDLING changes. Guard returns (rax, rip); the 4 resume sites (idt.rs
direct-iretq x2, enter_user_by_pid_noreturn + _try) apply both.

Verified on the real ISO: SMP=1 x3 with guard fired=8 (the exact frozen frequency before) all
RESPONSIVE, present_callback -> n=394-401, PollFailed=0; SMP=2 files RESPONSIVE, present n=416.
PollFailed=0 across all runs. The underlying frame-rewrite resume race still fires the guard;
re-executing makes it harmless (the full structural elimination is the Step-6 two-layer switch).
…UI, cursor moves" degradation

After extended heavy use the Flutter UI goes unresponsive while the kernel cursor still moves
(ProBook, user-reported). Root cause: process::exit() + the reapers free page tables but touch NONE of
the per-pid kernel tables (COND_WAIT_STATE, FUTEX_WAITERS, EPOLL_BLOCKED, malloc bookkeeping). The Dart
VM spawns+joins short-lived worker threads continuously, so every dead thread ORPHANS its entries
forever. Those tables are scanned O(n) on the timer ISR + every cooperative yield, so they bloat until
the scheduler burns its time on phantom wakes to dead/recycled pids and the engine's real frame threads
starve -- a one-way ratchet, permanent, with the kernel (cursor/timer) still alive. A short test never
repros it; it needs long heavy thread churn.

Fix: cleanup_dead_pid(pid, pml4, is_thread) at the single exit chokepoint (covers sys_exit / kill /
kill_group / crash recovery), run AFTER PTABLE_LOCK is dropped (the futex/cond locks order before it).
Drains the pid from COND_WAIT_STATE + every FUTEX_WAITERS bucket + EPOLL_BLOCKED, and -- standalone
process only (threads share the parent's pml4) -- purges its malloc blocks by pml4. Helpers where the
tables live: syscall::cleanup_dead_pid orchestrates, poll::cleanup_dead_pid, posix::cleanup_dead_pml4.

Verified: compiles + boots + renders on the real ISO (present->474), no regression (single-core). The
accumulation is now bounded -- every thread death reclaims its own entries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants