Skip to content

feat(webui): one client owns a Mini App at a time - #263

Open
Fectivnfy112357 wants to merge 3 commits into
feat/miniapp-loopback-guardfrom
feat/miniapp-ownership-probe
Open

Fectivnfy112357 wants to merge 3 commits into
feat/miniapp-loopback-guardfrom
feat/miniapp-ownership-probe

Conversation

@Fectivnfy112357

Copy link
Copy Markdown

Part of #254. Stacked on #262 (feat/miniapp-loopback-guard), which is stacked on #261. Merge top-down;
GitHub retargets each PR as its base merges.

Change

Two clients share one data directory (ADR 0006), and a Mini App's port is derived from its plugin id, so
without a rule both compute the same first port and the second starts a second process over the same
plugin data. This PR decides ownership rather than arbitration: one client owns a Mini App at a time, per
Mini App.

  • Ownership is an exclusively bound endpoint in the Mini App's own plugin data directory — a socket
    file on POSIX, a named pipe on Windows, named from the plugin id. Binding is the ownership: a second
    bind fails while the owner lives, and the operating system releases it when the owner dies. A record
    beside it states which client holds it and since when, so record present, nobody listening is stale by
    definition rather than by timestamp.
  • A crashed owner is reclaimable: the dead owner leaves the socket file behind, so a failed bind is
    told apart from a leftover by connecting — a refused connection proves it is stale, and only then is it
    removed and the bind retried once. Nothing is unlinked before the first bind attempt, which is why this
    does not copy the OAuth lease server's path cleanup.
  • The refusal precedes spawn: prepareRuntime takes ownership before the port loop and before any
    spawn, and a held endpoint raises OWNED_ELSEWHERE naming the holder
    (Mini App <id> is already running in <client> (pid <pid>)). The acquisition is released on stop, on a
    fatal abort, and on any failure between taking it and returning a runtime.
  • A Mini App running outside the record — including one the desktop client started, which writes no
    record — is detected by probeMiniAppPortOwnership, which answers free | guarded | foreign from the
    guard's own answer rather than a bare connect, so a stray listener is never mistaken for a Mini App.

ADR 0020's ownership section now records the mechanism and the reason the endpoint is never unlinked
first.

Validation

  • pnpm typecheck — passed
  • pnpm run test:capabilities — 237 files, 5833 passed, 18 skipped
  • pnpm check:source after node scripts/source-inventory.mjs --write — 5457 files, passed
  • New suites: test/unit/miniapp/owner-lease.test.ts (5 cases) and the ownership cases inside
    test/unit/miniapp/loopback-guard.test.ts (3 cases) and node-runtime-credential.test.ts (1 case)
  • The refusal-before-spawn case asserts the second child receives no init message
  • NOT RUN: pnpm verify in full; browser and release-package steps are left to CI

Not in this PR

Reporting the refusal in the plugin catalogue's apps area and the picker. That moved to #256 with your
agreement — the WebUI's Mini App service is not enabled yet, and ADR 0020 requires the service cell, the
presenter and the view to land as one change. #256 receives the readable refusal this PR produces.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes: none.

Shared-source port: not needed — the endpoint naming follows this repository's own
@mavis/oauth-lease-protocol convention rather than copying internal code.

@Fectivnfy112357 Fectivnfy112357 added enhancement New feature or request miniapp WebUI Mini App hosting programme (ADR 0020) labels Oct 26, 2026
First slice of #254. The desktop client writes no ownership record, so for anything
already running — including a Mini App the desktop client started — the only signal is
the Mini App's own derived port. This adds the probe that reads it.

`probeMiniAppPortOwnership` answers with three outcomes rather than a boolean, because
the runtime reacts differently to each: `free` (the port is this client's to take),
`guarded` (a Mini App's guard rejected the anonymous request, so another owner is
running this Mini App and this client must refuse), and `foreign` (something else holds
the port and does not answer like the guard, so another port is fine).

The answer is the guard's, not the socket's: a port that accepts but answers 200 is
`foreign`, not `guarded`. That is the distinction that keeps a stray listener from being
mistaken for a Mini App, and it is what the ownership record's liveness check cannot
cover on its own.

Proven against real servers: a closed port reports free, the guarded fixture reports
guarded, and a plain `node:http` server in a child process reports foreign.

Still to do for #254: the ownership record with an OS-released liveness proof, the
refusal surfaced in the listing and the picker, per-Mini-App granularity across two
clients, and refusing before spawn.

Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5827 passed, 18
skipped); pnpm check:source after the inventory write.
…point

Part of #254. ADR 0020 required a liveness proof the operating system releases
when the owner dies rather than a timestamp, and did not name one. This picks it and
records the choice in the ADR's ownership section: a socket file on POSIX, a named
pipe on Windows, named from the plugin id the way the OAuth lease endpoint is named
from its data directory.

Binding is the ownership. A second bind fails while the owner lives and the operating
system releases it when the owner dies, so a record beside it states who holds the
Mini App and since when, while the endpoint remains the truth. Nothing is unlinked
before the first bind attempt — doing that would steal a live owner's endpoint, which
is why this does not copy the OAuth lease server's path cleanup.

The case that matters is the crashed owner: it leaves a socket file with nothing
listening on it. A failed bind is therefore told apart from a leftover by connecting —
a refused connection proves the file is stale, and only then is it removed and the bind
retried once. An endpoint that still answers is somebody's, and the acquisition reports
the recorded holder instead.

Proven: acquire and re-acquire after release; a second client is refused with the
holder's name; a socket file left by a dead owner is reclaimed; two different Mini Apps
are owned at once; the endpoint is named from the plugin data directory rather than a
global path.

Still to do for #254: wire the acquisition into `prepareRuntime` before the port loop
so the refusal precedes spawn, and surface the held state in the listing and the picker.

Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5832 passed, 18
skipped); pnpm check:source after the inventory write.
Part of #254. Ownership is now taken in `prepareRuntime`, before the port loop and
before any spawn, so a second client cannot reach the point of starting a process over
the same plugin data. The refusal is a `MiniAppNodeRuntimeError` with code
`OWNED_ELSEWHERE` whose text names the holder (`client` and `pid` from the record), so
the listing has something readable to report rather than a silent failure.

The acquisition is released where the rest of the preparation is cleaned up: on stop,
on a fatal abort, and on any failure between taking it and returning a runtime — so a
failed start does not leave the Mini App locked to this client.

Proven through the scripted-child harness: with one client holding the Mini App, a
second preparation for the same plugin data directory rejects with `OWNED_ELSEWHERE`
and the second child receives no init message at all, which is what "before spawn"
means in observable terms.

Still to do for #254: surface the held state in the WebUI listing and picker (that is
the client half), and decide whether the desktop client's presence needs a record the
port probe cannot see — it writes none, so today it is detected only by its port
answering like the guard.

Verified: pnpm typecheck; pnpm run test:capabilities (237 files, 5833 passed, 18
skipped); pnpm check:source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request miniapp WebUI Mini App hosting programme (ADR 0020)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant