Skip to content

Use nodejs+arkweb - #10

Open
zxdong262 wants to merge 32 commits into
mainfrom
dev2
Open

Use nodejs+arkweb#10
zxdong262 wants to merge 32 commits into
mainfrom
dev2

Conversation

@zxdong262

Copy link
Copy Markdown
Member

No description provided.

zxdong262 and others added 30 commits August 28, 2026 12:41
…/ohos-node)

- src/, build/, package.json: switch from electerm desktop source to the
  electerm-web codebase (same base as electerm-android) — pure-node backend,
  no electron APIs
- build/web/build.mjs: vite frontend + esbuild backend bundle into
  entry/src/main/resources/resfile/electerm (readable directly at runtime,
  no extraction)
- scripts/prepare-node.sh: download hqzing/ohos-node v24.19.0, strip,
  install as entry/libs/arm64-v8a/libnode.so
- entry module rewritten as pure ArkTS app:
  - Index.ets: Web component + start node backend via
    childProcessManager.startNativeChildProcess + HTTP poll + loadUrl
  - cpp/node_launcher.c: native child entry that execv()s libnode.so
    (memfd fallback if the lib dir is noexec), full boot log
  - cpp/node_ctl.c: NAPI kill(pid) for EntryAbility.onDestroy
- scripts/build-web-app.sh: unsigned hvigor assembleApp + hap-sign-tool
  signing, entry module only (no web_engine)
- .github/workflows/build-web.yml: CI for dev2 branch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d artifacts

- cdc36aa accidentally committed the whole build/ directory deletion and
  never included build/web/{build,vite.web}.mjs (CI failed: MODULE_NOT_FOUND);
  restored build/{bin,vite,web} + logos from electerm-android / transcript
- untrack entry/.cxx (CMake/Ninja), entry/oh_modules, local.properties and
  ignore them going forward

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tep)

The dir is gitignored and was only present locally from an earlier manual
run, so CI's vite build failed on unresolved ../electerm-react imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bare entry/src/main/resources/rawfile ignore rule was for the old
electron build's generated rawfile content; dev2 generates nothing there
and the Web component loads loading.html from $rawfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s resources

Device log (cloud debug) showed the native child spawn worked
(cterm:Native_libnode_launcher0, pid 56633) but exited with the launcher's
code 40 (script missing): the packaged HAP contains
resources/resfile/electerm (plural), Index.ets built .../entry/resource/...

- resolveScriptPath(): try both bundleCodeDir shapes, verify with fs.stat
- switch Index logging to hilog (console.info is filtered in release —
  that's why no electerm.Index lines appeared in the device log)
- on boot failure, dump node-boot.log tail via hilog for cloud-debug
- EntryAbility.onDestroy: actually kill the child (BackendManager), the
  old LocalStorage lookup always failed
- launcher: retry boot log via /data/storage/el2/base junction if the
  parent's filesDir path is not mounted in the child namespace
- build-web-app.sh: extract SDK version with python (BSD sed lacks \+,
  broke local builds with SDK component missing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reen

The 13:25 device capture started after spawn and showed only connection-
refused polling — child fate unknown, node-boot.log unreachable from the
cloud-debug mirror. Make the next round self-diagnosing:

- dataDir now uses /data/storage/el2/base/files/electerm-data when the
  junction exists (mounted in every app process incl. the native child,
  unlike the parent's sandbox-style filesDir string)
- while polling, the overlay shows the latest node-boot.log line (~2s),
  so a stuck/failing boot is readable from the screen mirror alone
- on timeout the overlay shows the last log line (or 'no node-boot.log —
  child never ran'), and the 800-char tail goes to hilog (electerm.Index)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ode= param)

Device round 2: script path fixed (exit 40 gone) but launcher exited 41 —
none of the el1-junction candidates matched in the native child's mount
namespace. Now:

- launcher logs every step to native hilog (electerm.launcher, via
  libhilog_ndk.z.so) so diagnosis no longer depends on pulling the boot log
- node binary candidates: parent-provided node= param (verbatim), dladdr()
  on Main() (authoritative load path), every arm64 .so dir visible in
  /proc/self/maps, then the el1 junction layouts
- Index.ets logs the libs dir listing (parent-side proof whether the
  installer extracted the 92MB libnode.so) and passes node= through

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ument

execv(libnode.so) and memfd execveat both failed on device (exit 42) —
suspected XPM code-integrity / noexec on the bundle mount. New ladder
step 6a: exec the system's own signed ld-musl loader with the node
binary as its program argument; the loader maps the binary itself
(PROT_EXEC mmap, same as dlopen which provably works for app .so files
in this process).

Also logs node stat() mode/size and the mountinfo options of the
containing mount, so noexec/code-integrity is visible in node-boot.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device test: execv errno=13 EACCES — even loader-exec. XPM only runs
signed code. Build now embeds a code signature in the node ELF via the
official binary-sign-tool (openharmony/developtools_hapsigner dist,
sha256-pinned download):

- cert mode with the APP signing identity when KEYSTORE_PASSWORD/
  KEY_PASSWORD are set (CI), self-sign fallback (local)
- packed-HAP verification step fails the build if the signature is
  lost during packaging
- entry build-profile: nativeLib.debugSymbol.strip=false so the
  non-alloc signature section is never stripped
- libnode.so is signed in place; restore locally with
  git checkout -- entry/libs/arm64-v8a/libnode.so

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third device failure with errno=13: direct execv, exec via the signed
system loader, and memfd execveat are ALL refused for the app child —
even with the binary cert-signed with the APP identity. XPM blocks the
exec syscall for app processes, not the signature.

New strategy 1 (nodejs-mobile / WineHua pattern): dlopen the bundled
node binary and call its exported embedder entry
  int node::Start(int argc, char *argv[])  (_ZN4node5StartEiPPc)
on a dedicated 32MB-stack thread. OHOS musl's load_library has no
PT_INTERP/DF_1_PIE rejection, so the PIE executable loads as a shared
object. dlopen is how app code legitimately gets mapped executable
(nativespawn loaded this very launcher). Falls back to the exec ladder
only if dlopen/dlsym cannot start.

Also: chdir(dataDir) before running node; the failure overlay now shows
the last 8 boot-log lines so the whole dlopen → dlsym → Start ladder is
readable from the screen alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…th lost its dir

Device log (temp/2026-08-28 15_03_43.log) shows:
- node stat mode=100644: hvigor installs libs without the execute bit,
  execve returns EACCES for that plain reason — the el1 mount is
  rw,nosuid,nodev,noatime, NOT noexec
- loader execv errno=2: findLoader sliced the maps line at 'ld-musl',
  dropping /lib/ — exec'd a bare filename

Fixes:
- strategy 2: copy the signed binary into the app-owned el2 data dir
  (data/bin/node), chmod 0755, exec the copy — size-checked, copied
  once, kept across restarts
- findLoader takes the full last path token of the maps line
- chmod failures are logged everywhere now

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device log (temp/2026-08-28 16_00_16.log): dlopen succeeded, node::Start
resolved and entered, then the node thread died of signal 31 (SIGSYS,
si_code=1 SYS_SECCOMP) — the app sandbox seccomp filter kills syscalls
V8 probes at startup (membarrier / pkey_mprotect / perf_event_open
class). The signal was delivered to a handler (OHOS DfxSignalHandler),
so it is catchable: install our own SIGSYS handler that logs the
trapped syscall number (si_syscall), advances PC past the 4-byte svc
instruction, and sets x0 = -ENOSYS. V8/uv treat those probes as
optional and degrade gracefully on ENOSYS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device: SIGSYS shim worked — node ran deep into libuv and died on
assert(fd > STDERR_FILENO) in uv__close. That assert fires when a uv
handle ends up on fd 0/1/2, i.e. a stdio slot was closed and reused.

- setupStdioForNode(): snapshot the fd table the child was born with
  into the boot log, then close 0/1/2 and rebuild deterministically
  (0=/dev/null, 1=2=boot log, log reopened on slot 1) — the
  nodejs-mobile pattern; no closed or aliased stdio slots can exist
- installCrashMarkers(): SIGABRT/SEGV/BUS/ILL/FPE handlers log which
  signal killed the child before re-raising — an assert-abort now
  leaves a marker in the boot log instead of a silent end

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Numbers taken from the SDK's asm-generic/unistd.h (aarch64) — the log
now reads 'syscall 283 (membarrier)' instead of a bare number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nativespawn child runs under a stricter seccomp filter than the app
itself — node's libuv aborts there (SIGABRT; the fd>-2 assert in
uv__close fires on uv_loop_init's error path when epoll-class syscalls
come back ENOSYS from the SIGSYS shim). The MAIN app process runs
libuv-class loops of its own (NETSTACK/curl), so node now runs there,
the electron-harmony pattern:

- libnode_ctl.so startBackend(entryParams): dlopen libnode.so +
  node::Start on a 32MB-stack pthread in the app process, SIGSYS shim +
  crash markers, same node-boot.log channel (tag electerm.embed)
- Index.ets tries in-process first, falls back to startNativeChildProcess
- BackendManager: skip kill for in-process mode (it IS the app)
- node-boot.log hilog dump at failure: 800 → 2400 chars

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilog

The in-process run killed the whole app on node's first fatal signal
(app closes instantly, no overlay, no dump — the capture window missed
it entirely). Now:

- fatal signals print the marker to hilog IMMEDIATELY (electerm.embed
  'fatal: signal N on tid T') — survives the process
- if the signal hit the node thread, park that thread forever instead
  of dying: the app stays on the loading screen and the 8-line overlay
  shows the boot-log tail
- boot log truncated per attempt so the overlay reflects this run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cloud-debug hilog export delivers the OLDEST buffered lines and
system noise (~1600 lines/s) flushes the ring long before the 90s boot
deadline — every evidence dump so far landed outside the exported
window. Now the tail (last 1100 chars) is re-dumped to hilog every ~2s
while waiting, so a copy of the ladder + crash marker always sits inside
whatever window survives. Failure overlay: 8 → 14 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the child we rebuilt stdio, so assert/abort text reached the log; in
embed mode node's stderr was the app's fd 2 (/dev/null) — abort messages
vanished, leaving only the crash marker. Redirect 1/2 onto the boot log
(the app runtime logs via hilog, not stdio) so the next run shows the
actual assertion text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g truncation

Device evidence (17:23:49): in-main-process run got all the way to
node::Start on tid 30146; the ONLY seccomp-trapped syscall is
io_uring_setup (425) → ENOSYS, which libuv handles gracefully
(uv__iou_init returns on ringfd<0). Signal 6 abort follows inside
node/V8 startup — but its stderr text never reached us: hilog truncates
messages at ~140 bytes and ArkWeb stdout junk buries the file tail.

- pipe fd 1/2 to a reader thread that logs every line to the boot log
  AND hilog ([io] prefix, electerm.embed) — assert/abort text arrives
  live, un-truncated
- UV_USE_IO_URING=0 (the one trapped syscall, now skipped up front)
- tail dumps split into 110-char chunks, one hilog line each

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The libuv assert (fd > STDERR_FILENO, uv__close core.c:646) names the
dying function but not its caller; static analysis of every startup
uv__close path (loop init fail paths, io_uring init, signal pipes) came
back clean, so make the next device run self-diagnosing:

- crashMarkerHandler: backtrace() up to 24 frames, each annotated with
  dladdr symbol+offset (libnode.so is unstripped — real symbol names are
  resolvable) and written to the boot log AND hilog, plus full
  backtrace_symbols_fd dump to the boot log.
- Before the stdio pipe: fstat fds 0/1/2 and open /dev/null onto any
  EBADF fd — a closed std fd at app spawn means pipe() returns fd 0/1,
  dup2(x,x) no-ops, close() re-closes it, and every later cleanup closes
  a std fd, tripping exactly this assert. Repair + log the fd layout.
- node_ctl built with -fno-omit-frame-pointer so backtrace() walks our
  frames reliably.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e of the uv__close assert

Device backtrace (b3ba359 run, symbolized against the unstripped
libnode.so) finally named the abort caller:

  uv__close_nocheckstdio ← uv__close (assert fd > STDERR_FILENO)
  ← uv__iou_init fail path (linux.c:624)
  ← uv__platform_loop_init ← uv_loop_init (loop.c:79)
  ← node::tracing::LegacyTracingAgent ctor
  ← node::tracing::Agent::CreateDefault ← node::V8Platform::Initialize
  ← node::InitializeOncePerProcessInternal ← node::Start

Chain of events: io_uring_setup is seccomp-trapped (SIGSYS), the shim
resumed with x0 = -ENOSYS (-38); OHOS musl's syscall() passes raw x0
through WITHOUT upstream musl's __syscall_ret errno-translation, so
uv__iou_init received ringfd = -38, passed its 'if (ringfd == -1)
return;' guard, failed mmap/epoll_ctl on the bogus fd, and its cleanup
called uv__close(-38) → libuv assert → SIGABRT on the node thread.

Fix: return exactly -1 (the universal failure value every caller
checks) and set errno = ENOSYS (TLS store, async-signal-safe) in BOTH
shims — node_ctl.c (main process) and node_launcher.c (native child,
which died of the same assert). The tracing-agent loop is the FIRST
libuv loop node creates, so this fired before any script ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device log 18_11_18 (521ce7a): the uv__close assert is GONE (the -1
shim fix worked) but the node thread now dies of SIGSEGV inside strlen
called from printf machinery, with the interrupted frames being
uv__io_uring_setup <- uv__iou_init — i.e. the crash is INSIDE our own
SIGSYS handler: its first-sight logging used logWrite (vsnprintf +
OH_LOG_Print), which take libc locks; the second seccomp trap fired
while the node thread held one, so the handler's own formatting
crashed. Classic async-signal-safety violation.

- sigsysHandler (node_ctl.c + node_launcher.c): logging is now
  async-signal-safe — fixed strings + manual decimal into a stack
  buffer, write(2) only, to the boot log and to fd 2 (the main-process
  stdio pipe, where the reader thread relays it to hilog in normal
  context). Per-syscall dedup via a 512-bit bitmap.
- stdioReaderThread: filter ArkWeb/chromium framework lines (nweb_,
  render_, updater, cloud-control, compositor, …) so node's console
  output, asserts and stack traces are not buried — the on-screen boot
  overlay shows the boot log tail and was 100% ArkWeb spam last run.
- Generated backend entry (build/web/build.mjs): append [backend]
  milestones directly to node-boot.log (entry running / bundle imported
  / uncaughtException / unhandledRejection / exit code) — direct file
  writes bypass stdout entirely, immune to fd-level noise.
- CI: setup-java v4 -> v5 (v4 is deprecated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device log (two launches, pids 20467 and 23899) showed the window never
getting past its splash:

  - pid 20467: APP_INPUT_BLOCK from 06:18:47, killed at 06:19:00 by the
    system ANR dialog (callingPid=23491, com.huawei.hmos.alertdialog).
  - pid 23899: node thread (tid 24556) died with SIGSEGV one second after
    node::Start, and the ArkTS probe then waited out a 90s timeout on a
    dark overlay.

Four independent causes, all fixed here.

1. Blocking dlopen on the UI thread.
   pages/Index called startBackend() (NAPI) from aboutToAppear(), which did
   dlopen(126MB libnode.so, RTLD_NOW) + dlsym + node::Start inline. The Index
   page never painted, so the window stayed on its splash until the ANR
   watchdog fired.
   -> startBackend() now spawns a detached native bootstrap thread and
      returns immediately; the bootstrap thread does the dlopen/dlsym and
      runs node::Start directly. Added getBackendStatus() so ArkTS can still
      see hard failures and fall back to the native child process.

2. Permission dialog gating loadContent.
   onWindowStageCreate awaited requestPermissionsFromUser() before
   loadContent(). On an unattended device (cloud debugging) nobody taps
   Allow, so loadContent never ran.
   -> loadContent first, then request permissions.

3. The SIGSYS shim wrote into the shared stdio pipe.
   fd 1/2 are dup2'd onto a pipe that ArkWeb/Chromium also writes to at
   thousands of lines a second. The shim's write(2, ...) can block on a full
   pipe from inside a signal handler, and the old reader did one hilog IPC
   per line, which is what let the pipe fill at all. That is how the node
   thread ended up faulting inside syscall() during the trapped
   io_uring_setup (confirmed by disassembling the shipped libnode.so:
   uv_loop_init -> uv__iou_init -> syscall(425), with the UV_USE_IO_URING
   getenv block skipped because flags bit 1 is clear, so setting that env
   var never helped).
   -> the shim now writes raw bytes to the boot-log file only, and bails out
      to SIG_DFL when si_code != SYS_SECCOMP; the reader drains in 8KB
      chunks, filters noise with memcmp only, and rate-limits hilog to ~4
      lines/sec with a 400-line budget. Pipe raised to 1MB.

4. putenv() with a stack buffer.
   Extra env vars were installed with putenv(extraEnv[i]), storing pointers
   into a stack array of a frame that returns before node reads them -
   getenv() then strlen()s recycled stack. Switched to setenv() (copies).

Also: boot timeout 90s -> 20s, boot-log reads size-guarded, and the boot is
deferred one frame so the overlay paints first.
"Use self build arm64 nodejs for harmony" switched prepare-node.sh from
hqzing/ohos-node to our own --shared libnode.so release, but left the
workflow's NODE_VERSION at 24.19.0. prepare-node.sh derives the tag as
"ohos-node-shared-v${NODE_VERSION}", so CI tried

  .../releases/download/ohos-node-shared-v24.19.0/libnode-arm64.so

which does not exist — the only published tag is ohos-node-shared-v24.2.0.
The run for that commit failed at "Prepare Node.js runtime" with
curl: (22) 404 (it had been passing before only because the node-runtime
cache still held the old hqzing binary).

Bump the workflow to 24.2.0 to match the script default, and derive the
build-summary "Runtime" line from env.NODE_VERSION so the two cannot drift
silently again.
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.

1 participant