We were unable to construct the commit group for this pull request: Author does not currently possess a GitClear subscription seat.

Tie the anytypeHelper process lifetime to the Electron main process #2367

Merged
requilence opened 9:31am on September 7, 2026 wanted to merge 2 commits into anyproto/anytype-ts develop from
feature/helper-process-lifeline

Pull Request Overview

  • Opened on September 7, 2026
  • Status Merged
  • Commit count 2 with first commit September 7, 2026

Total Delta

0 Total Diff Delta

Open Days

Open 10 weekdays

Test Delta

0 Diff Delta in Test Files
Breakdown by Phase

How long has this pull request spent in each phase of its lifecycle?

Data pending calculation for pull request

Author avatar

Tie the anytypeHelper process lifetime to the Electron main process

Problem

anytypeHelper could outlive the Electron main process. If the app was killed, crashed, or failed to reap the child, the helper kept running and held the account lock, so the next launch could not start.

Two independent holes:



  1. No lifeline. Nothing told the helper its owner was gone.


  2. stop() was unreliable. It resolved immediately whenever isRunning was false even with a live cp β€” exactly the quit-during-startup case that orphans a helper β€” and when isRunning was true it waited on exit with no timeout at all, so a hung helper hung the quit forever.

What this does

Opt into the middleware lifeline. anytype-heart 0.51.0-rc7 (cmd/grpcserver/lifeline.go, PR anyproto/anytype-heart#3256) reads ANYTYPE_PARENT_LIFELINE=stdin and treats EOF on stdin as proof its owner disappeared, then shuts down under its own 10s hard-exit deadline. Both spawn sites β€” the Electron main process and scripts/start-web.js β€” now set the env var and keep stdin piped for the helper's whole lifetime. This covers the case no client-side code can: the main process dying without running any cleanup.

Make stop() reliable.
- Deduplicate concurrent calls behind a single promise.
- Escalate to SIGKILL after a 12s graceful deadline; resolve false if no exit is observed 5s after that, so start() never spawns a second helper on top of a live one.
- Fall back to force-stop when the Windows stdin shutdown pipe fails or is missing.
- Clear cp/isRunning on exit, and only for the process we actually own.

Fix ownership and promise settlement in start() (second commit, from review):
- The error/stdout/stderr handlers wrote shared state with no ownership check. Node flushes a child's buffered output after it exits, so a killed helper's late ready line could publish the dead process's address and flip isRunning β€” which then caused the live helper's real ready line to be discarded and its start() to never settle.
- start() only ever resolved from the stdout ready line, so if the helper was stopped or died first, the promise never settled and main.ts's waitLibraryPromise stayed pending forever. It now resolves false on an intentional stop and rejects on a crash.
- start() is serialized against itself, mirroring stop(); two concurrent calls both spawned and the first child was dropped with no reference left to stop it.

Why 12s

The helper arms its own 10s hard-exit deadline once the lifeline fires. A matching 10s client timer made the two race non-deterministically β€” our SIGKILL would usually pre-empt the helper's own clean exit. At 12s the helper's path wins where it has one, and the extra 2s is only ever paid when the helper has already missed its own deadline. Note the helper's POSIX signal path is explicitly not covered by that deadline ("OS-signal shutdown is not subject to the desktop parent's lifeline deadline"), which is what the client-side timer really guards.

Also included

scripts/generate-protos.sh β€” pass CLIENT_DESKTOP_PATH="$ROOT_DIR" to make install-dev-js. Heart declares CLIENT_DESKTOP_PATH ?= ../anytype-ts relative to its own dir, so with a renamed heart checkout or a git worktree the old invocation either installed the freshly built binary and pb bindings into a different anytype-ts checkout β€” a silent binary-vs-bindings mismatch β€” or aborted under set -euo pipefail. Needed to test this feature locally against a heart build.

⚠️ Worth knowing: running that script in local mode overwrites the pinned dist/anytypeHelper (rc7) with a dev build from your heart working tree.

Testing

vitest.config.ts now includes electron/**/*.test.ts, and electron/ts/server.test.ts covers the lifecycle: lifeline opt-in, stdin staying open, ownership races, crash reporting, the Windows stdin path and its failure modes, dedup, force-kill escalation, and give-up semantics.


  • 22/22 new tests pass; stable across shuffled runs.

  • Full suite: 90 failed / 836 passed β€” the same 90 pre-existing failures as develop, zero new (836 = 821 + the 15 tests added here).


  • bun run typecheck clean for both tsconfigs; bun run lint has 0 errors.


  • Mutation-tested. The first version of these tests caught only 1 of 12 seeded lifecycle defects. The current suite catches 15 of 15 β€” including dropping the stopTriggered reset, ignoring kill() returning false, removing the abort-on-stop-failure branch, never releasing stopPromise, and removing either ownership guard.

Known limits (deliberately not addressed here)



  • stop()'s dedup ignores the requested signal, so a SIGKILL arriving during an in-flight SIGTERM degrades to the graceful path. Unreachable today β€” every caller passes '' β†’ SIGTERM.

  • After a give-up (finish(false)), stopTriggered stays true, so a later real crash of that zombie is not reported.


  • readyPromise is created once in the constructor and not re-armed, so start() is not genuinely retryable. It is called once, from ConfigManager.init.

  • Quit awaits stop() (api.ts:524), so a hung helper can delay quit by up to 17s behind an already-hidden window. Strictly better than the previous unbounded wait, but reworking the quit path is a separate change.

Review

Three parallel review passes (lifecycle correctness, integration/packaging, tests/build scripts). Findings on ownership, promise settlement, start serialization, the timeout race, and test strength are folded into the second commit. Verified as non-issues: the stdio: ['pipe','pipe','pipe'] addition is Node's default and both sites already drained stdout and stderr (no backpressure risk); no other spawn sites exist; a failed spawn sets exitCode to the negative errno, not null, so the already-exited guard handles it correctly. Incidental win: env is now a copy, so GOLOG_FILE no longer leaks into the main process's own process.env.

⚠️ CI note: no workflow runs vitest today, and eslint.config.js ignores electron/**, so these tests only run locally. bun run typecheck does cover them.

PR was closed without comments.