Review round 2 on the host-backend collapse. Two defects the collapse introduced, both fixed on top of #118275's server-side `profile` plumbing (which this PR now requires: it must merge first). 1. The collapse ate the invariant "a route the server cannot profile-scope must keep a process-scoped backend". Routing case 6 sent EVERY local request at the shared primary, so a mutating handler that reads no `profile` executed against the primary's HERMES_HOME. Restored as a mechanical gate, not a route list: `unscopableMutatingRequest()` is true only when the method is unsafe AND `localPrimaryRequestScope()` returns null, and `sharesHostBackend()` refuses to collapse that request. The day a handler learns `profile`, it joins the scoped table and the gate stops seeing it. `ensureBackend()` now re-resolves with the ORIGINATING request, so its own route decision matches the caller's `resolveProfileApiRequest` one instead of silently collapsing a request routing deliberately kept pooled, and the spawn guard is told when a pooled spawn is the legitimate outcome. After #118275 the residual unscopable-mutating set is the host-path families (`POST /api/files/upload`, `DELETE /api/files/managed`) plus any unlisted mutating method (`POST /api/skills`). Session writes are scopable by `body.profile`, so they share the backend with their path left alone. 2. Events from non-primary local profiles arrived unowned. `sharedPrimaryRoute` returns the primary gateway without `createSecondary`, so nothing ran `stampSecondaryProfileOwner`, and use-gateway-boot captured the source profile ONCE in a `useEffect(..., [])` — every later profile's events were stamped with the BOOT-time profile. The profile is now read PER EVENT and the owner marker is applied whenever the active descriptor is `sharedPrimary`, so `runtimeSessionOwner()` resolves again and the live sessions/cron sync stops degrading to slow polling. The renderer is the right place: `_event_frame` carries no `profile`, so a server echo would be a cross-surface wire-contract change. Session PATCHes (rename/pin/archive/mark-read) now always name the owning profile in the body — falling back to the ACTIVE profile when the caller knows none — because the handler resolves its state.db from `body.profile` alone and no per-profile process remains to stand in for it. Also: the routing-table docstring entries 4-6 and the `hermes:api` comment now describe the behaviour they gate, and the spawn guard reads the same `profileRouteOptions()` the router does so the two cannot drift (it was dropping the per-profile SSH-override term). (cherry picked from commit 1e2354fb15866cc24d98e8e552a59240efe97a25)
82 lines
3.8 KiB
TypeScript
82 lines
3.8 KiB
TypeScript
// Multiplex-only invariants for the Desktop backend pool: one host backend
|
|
// serves every profile, and the local pooled-spawn path is unreachable.
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import { resolveProfileBackendRoute, unscopableMutatingRequest } from './connection-config'
|
|
import { assertNoSecondLocalBackend, SecondLocalBackendError, sharesHostBackend } from './host-backend-singleton'
|
|
|
|
const LOCAL = { globalRemote: false, primaryProfile: 'default', profileRemoteOverride: false }
|
|
|
|
test('two local profiles connecting concurrently produce ZERO additional backends, each bound to its own profile', () => {
|
|
// The routing decision is the whole spawn decision: `ensureBackend` spawns a
|
|
// pooled child if and only if the route says `pool`. Resolve both profiles
|
|
// the way two concurrent renderer dials would.
|
|
const routes = ['worker', 'venture'].map(profile => resolveProfileBackendRoute(profile, LOCAL))
|
|
|
|
assert.deepEqual(
|
|
routes.filter(route => route.backend === 'pool'),
|
|
[],
|
|
'a local profile must never resolve to a pooled backend of its own'
|
|
)
|
|
|
|
// Both land on the SAME backend and still carry distinct wire identities, so
|
|
// each connection's turns bind to its own home (`session.create {profile}` ->
|
|
// `profile_home`; sessionless RPCs take the explicit `profile` argument).
|
|
assert.deepEqual(
|
|
routes.map(route => [route.backend, route.descriptorProfile, route.scopePath]),
|
|
[
|
|
['primary', 'worker', true],
|
|
['primary', 'venture', true]
|
|
]
|
|
)
|
|
})
|
|
|
|
test('the local pool spawn path is unreachable, and the escape hatches still reach it', () => {
|
|
assert.throws(() => assertNoSecondLocalBackend('worker', { isolated: false }), SecondLocalBackendError)
|
|
|
|
// HERMES_DESKTOP_ISOLATED_BACKEND=1: a private backend for this app.
|
|
assert.equal(sharesHostBackend({ isolated: true }), false)
|
|
assertNoSecondLocalBackend('worker', { isolated: true })
|
|
assert.equal(resolveProfileBackendRoute('worker', { ...LOCAL, isolatedBackend: true }).backend, 'pool')
|
|
|
|
// A remote/SSH backend is a DIFFERENT host: out of scope for the singleton,
|
|
// and its pooled descriptor never meant a local child anyway.
|
|
assert.equal(sharesHostBackend({ profileRemoteOverride: true }), false)
|
|
assert.equal(sharesHostBackend({ primaryRemoteActive: true }), false)
|
|
assertNoSecondLocalBackend('worker', { profileRemoteOverride: true })
|
|
assert.equal(resolveProfileBackendRoute('worker', { ...LOCAL, profileRemoteOverride: true }).backend, 'pool')
|
|
|
|
// A mutating request the server cannot profile-scope is the third way
|
|
// through: the pooled backend's HERMES_HOME is its only scope, so the guard
|
|
// must let that spawn happen instead of refusing a legitimate route.
|
|
assert.equal(sharesHostBackend({ unscopableRequest: true }), false)
|
|
assertNoSecondLocalBackend('worker', { unscopableRequest: true })
|
|
})
|
|
|
|
test('the spawn guard and the router agree on which requests keep a backend', () => {
|
|
// The guard is a backstop, not a second opinion: every request the router
|
|
// sends to the pool must be one the guard admits, or the destructive write
|
|
// fails with SecondLocalBackendError instead of reaching the right home.
|
|
const cases: Array<[string, string]> = [
|
|
['POST', '/api/files/upload'],
|
|
['DELETE', '/api/files/managed'],
|
|
['POST', '/api/skills'],
|
|
['POST', '/api/memory/reset'],
|
|
['GET', '/api/config'],
|
|
['PATCH', '/api/sessions/session-1']
|
|
]
|
|
|
|
for (const [requestMethod, requestPath] of cases) {
|
|
const opts = { ...LOCAL, requestMethod, requestPath }
|
|
const pooled = resolveProfileBackendRoute('worker', opts).backend === 'pool'
|
|
|
|
assert.equal(
|
|
sharesHostBackend({ unscopableRequest: unscopableMutatingRequest(opts) }),
|
|
!pooled,
|
|
`${requestMethod} ${requestPath}: guard and router disagree`
|
|
)
|
|
}
|
|
})
|