pi-mono/apps/p2/src/runtime/server.ts
Mario Zechner 0a730c8a76 fix(p2): archiving a viewed session shows no error and starts no refused worker
Archiving from the session sheet navigates away first, which unmounted SessionOptions and released its Sessions
reference; Chord cancels a released reference's calls, so the archive call rejected with "p2.sessions: released",
shown as the composer toast over the owner-notice lane. The archive now holds a reference of its own until it is done,
and the server runs its steps in a root of its own.

The archive stopped the worker with Hosts.stop, which restarts it for its holders: the page's lingering
conversation started a worker the archived session refused (resolve_failed, "Worker: Session is archived" notice).
SessionWorkerControl.suspend stops it and leaves its holders suspended until a restore (retry) or their release;
archive, a failed fork's archived copy and an archived session's index rebuild use it.
2026-11-04 13:19:01 +01:00

342 lines
13 KiB
TypeScript

// Locals, registries and natives the server host provides to plugins (Chord v2), and the server's core facet.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { constant, defineRegistry, defineState, local, method, untilAborted } from "@earendil-works/chord";
import type { Context } from "@earendil-works/chord/context";
import type { JsonValue } from "@earendil-works/chord/json";
import type { CredentialStore, ModelsStore } from "@earendil-works/pi-ai";
import type { MutableModels } from "@earendil-works/pi-ai/models";
import { arr, bool, num, obj, opt, type Static, str } from "@earendil-works/pi-schema";
import { Access } from "./access.ts";
import {
type Ctx,
defineFacet,
type Facet,
type FacetHost,
forbidden,
type Host,
keyedRegistry,
type Self,
serveKeyed,
signalOf,
} from "./chord.ts";
import { isGuest, isOwner, type P2Principal } from "./principals.ts";
import { record } from "./schema.ts";
export const ServerInfoSchema = obj({
/** The p2 app directory (`apps/p2`): its sources, docs and vendored files, for facets that start or describe them. */
app: str(),
dataDir: str(),
fake: bool(),
/** Hosts reachable through a trusted HTTPS tunnel besides loopback (`P2_ALLOWED_HOSTS`). */
allowedHosts: arr(str()),
/** The listening port. */
port: num(),
});
export type ServerInfo = Static<typeof ServerInfoSchema>;
/**
* The server process (lifetime "host"): its configuration (constant), and revocation. `revokeGuests` ends every object
* of one guest's principals, here and in workers: their references end `forbidden/revoked`, and the server closes
* their sockets.
*/
export const Server = local("p2.server", {
version: 2,
params: {},
members: {
info: ServerInfoSchema,
/** Ends remote owners' live links and objects (one device, or every remote owner). */
revokeOwners: method(obj({ device: opt(str()) })),
revokeGuests: method(obj({ id: str() })),
},
});
/** Overrides of p2's Resources (tests only): e.g. `createModels` with a fixture provider. */
export type ServerResourceOptions = {
readonly createModels?: (credentials: CredentialStore, modelsStore: ModelsStore) => MutableModels;
};
/** A lifecycle step of a running worker that the server calls over its route (`WorkerBoundary`). */
export type RelocationSteps = {
readonly prepare: () => Promise<string | undefined>;
readonly update: () => Promise<void>;
readonly move: () => Promise<void>;
readonly cancel: () => Promise<void>;
};
/** The session workers (the server's `SessionWorkers`): their generations, and the lifecycle steps plugins run. */
export interface SessionWorkerControl {
/** Generation of the session's latest worker (0: never started). */
generation(session: string): number;
/** Sessions whose worker is running now. */
running(): string[];
/**
* Moves a session, serialized with starts, stops and pauses: a stopped worker only gets `update`; a running one is
* fenced (`prepare`), updated, then follows in place (`move`), restarted from the catalog when it cannot.
*/
relocate(session: string, steps: RelocationSteps): Promise<void>;
/** Runs `work` while the session's worker is stopped (its store closed), restarting one that ran afterwards. */
pause(
session: string,
prepare: () => Promise<string | undefined>,
work: () => Promise<void>,
doing: string,
): Promise<void>;
/** Owner-requested retry: stops the previous instance and clears the failure before a fresh reference. */
retry(session: string): Promise<void>;
/** Stops the session's worker; its holders start a new one (a restart, e.g. for a changed environment). */
stop(session: string): Promise<void>;
/**
* Stops an archived session's worker and starts none for its holders: they wait (suspended) until `retry` (restore)
* or their release, instead of a start the archived session refuses.
*/
suspend(session: string): Promise<void>;
/** Every change of a worker's state. */
subscribe(listener: (event: HostEvent) => void): () => void;
}
/** The server's natives, contributed by the core facet from the start. */
export type ServerParts = {
readonly resourceOptions: ServerResourceOptions;
readonly workers: SessionWorkerControl;
/**
* Runs `fn` in a root of the server's own (`SERVER`), not a caller's: lifecycle steps a caller giving up (an aborted
* call, a closed tab) must not cut off, and code outside Chord calls (Durable tools, routes). `signal` ends it early.
*/
readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, signal?: AbortSignal) => Promise<T>;
};
export const ServerNatives = defineRegistry<ServerParts, ServerParts | undefined>("p2.server-natives", { version: 1 });
/** The server's natives for a factory (the core contributes them before anything activates). */
export function serverOf(self: Self): () => ServerParts {
const natives = self.registry(ServerNatives);
return () => {
const parts = natives.value;
if (parts === undefined) throw new Error("No server natives (the core facet contributes them)");
return parts;
};
}
/** Where a request came from: loopback (`http://127.0.0.1:<port>`), or an allowed tunnel host (always https). */
export type RequestOrigin = {
readonly origin: string;
readonly remote: boolean;
readonly port: number;
/** The Radius shell origin allowed to frame the UI (relayed requests only); everything else may not frame it. */
readonly relayShellOrigin?: string;
/**
* A signed-in Radius user other than the owner (relayed requests only). Radius verified who they are and admitted
* them because a session is shared with their email; authenticators map them to what is shared with them now.
*/
readonly relayUser?: RelayUser;
};
/** A person Radius authenticated: their Radius user ID and normalized, verified email. */
export type RelayUser = { readonly id: string; readonly email: string };
/** Who sent an HTTP request (undefined: nobody authenticated, e.g. a guest before joining) and from where. */
export type RequestAccess = { readonly principal: P2Principal | undefined; readonly origin: RequestOrigin };
/**
* Trusted transports may authenticate a marked loopback request as remote, before ordinary Host checks. Without a
* principal, the authenticators decide who the request is (a relayed Radius user: see `RequestOrigin.relayUser`).
*/
export type RequestResolver = (request: IncomingMessage, ctx: Ctx) => RequestAccess | undefined;
export const RequestResolvers = keyedRegistry<RequestResolver>("p2.request-resolvers");
/**
* HTTP handlers by `METHOD /path`, or `METHOD /prefix/*` for every path below a prefix (the longest match wins).
* The server dispatches each request in a root of its code version; handlers authorize the request themselves.
*/
export type RouteHandler = (
request: IncomingMessage,
response: ServerResponse,
access: RequestAccess,
ctx: Ctx,
) => void | Promise<void>;
export const Routes = keyedRegistry<RouteHandler>("p2.routes");
/** Authorized WebSocket upgrades by exact path. Plugins own their byte channels, not the server host. */
export type UpgradeHandler = (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
principal: P2Principal,
ctx: Ctx,
) => void | Promise<void>;
export const Upgrades = keyedRegistry<UpgradeHandler>("p2.upgrades");
/** The handler for `method` and `path`: exact, then `/a/b/*`, `/a/*`, `/*`. */
export function findRoute(
table: ReadonlyMap<string, RouteHandler>,
method: string,
path: string,
): RouteHandler | undefined {
const exact = table.get(`${method} ${path}`);
if (exact !== undefined) return exact;
const segments = path.split("/");
for (let end = segments.length - 1; end >= 1; end--) {
const route = table.get(`${method} ${segments.slice(0, end).join("/")}/*`);
if (route !== undefined) return route;
}
return undefined;
}
/**
* Authenticates requests from tunnel hosts (loopback is the device owner): the first principal returned wins.
* Example: the sharing plugin maps a guest cookie to its current grant.
*/
export type Authenticator = (
request: IncomingMessage,
origin: RequestOrigin,
ctx: Ctx,
) => P2Principal | undefined | Promise<P2Principal | undefined>;
export const Authenticators = keyedRegistry<Authenticator>("p2.authenticators");
/**
* Where people a session is shared with by email open it, through a transport that authenticates them (the brain's
* page in the Radius shell), or undefined while unknown. Without one, sharing with people is unavailable.
*/
export type ShareUrl = (session: string) => string | undefined;
export const ShareUrls = keyedRegistry<ShareUrl>("p2.share-urls");
/**
* Whether a principal other than the owner may reach a session (its worker host, its files): any entry saying yes
* admits. Example: the sharing plugin admits a guest of that session whose grant is current.
*/
export type SessionAuthorizer = (principal: P2Principal, session: string, ctx: Ctx) => boolean | Promise<boolean>;
export const SessionAccess = keyedRegistry<SessionAuthorizer>("p2.session-access");
/** Owner, the server's own code, or a principal some `SessionAccess` entry (the registry's value) admits to `session`. */
export async function mayReach(
access: ReadonlyMap<string, SessionAuthorizer>,
principal: P2Principal,
session: string,
ctx: Ctx,
): Promise<boolean> {
if (isOwner(principal) || principal.kind === "host" || principal.kind === "server") return true;
if (!isGuest(principal)) return false;
for (const authorize of access.values()) {
if (await authorize(principal, session, ctx)) return true;
}
return false;
}
/**
* Contributions to a worker's spawn parameters (`WorkerInit.values[key]`). Each runs before a session worker
* starts; a throw refuses the start (e.g. an archived or unknown session).
*/
export type WorkerInitContribution = (session: string, context: Context) => JsonValue | Promise<JsonValue>;
export const WorkerInitValues = keyedRegistry<WorkerInitContribution>("p2.worker-init");
export const WorkerStateSchema = obj({
running: bool(),
generation: num(),
pid: opt(num()),
/** A failed start is sticky until an explicit retry. */
error: opt(str()),
});
export type WorkerState = Static<typeof WorkerStateSchema>;
export type HostEvent = { readonly session: string } & WorkerState;
/** Session worker processes: which run (by session ID), and restarting one for its holders (`stop`). */
export const Hosts = local("p2.hosts", {
version: 2,
params: {},
members: {
workers: record(WorkerStateSchema),
stop: method(obj({ session: str() })),
},
});
const Workers = defineState("p2.hosts.workers", {
schema: obj({ workers: record(WorkerStateSchema) }),
version: 1,
initial: () => ({ workers: {} }),
});
/** Serves the base keyed registries of the server (the core facet; test hosts without one). */
export function serveServerRegistries(facet: FacetHost): void {
for (const registry of [
RequestResolvers,
Routes,
Upgrades,
Authenticators,
ShareUrls,
SessionAccess,
WorkerInitValues,
])
serveKeyed(facet, registry as typeof Routes);
}
/**
* The server's core facet: its start data and revocation (`Server`), its natives, the base registries, the workers'
* states (`Hosts`) and who the caller is (`Access`).
*/
export function serverCore(host: Host, info: ServerInfo, parts: ServerParts): Facet {
return defineFacet((facet, self) => {
serveServerRegistries(facet);
facet.serve(ServerNatives, { compose: (table) => table[0]?.value });
self.contribute(ServerNatives, parts);
facet.serve(Server, () => ({
info: constant(info),
revokeOwners: async ({ device }, ctx) => {
if (device !== undefined) host.revoke({ device }, ctx);
else host.revoke({ owners: "remote" }, ctx);
return null;
},
revokeGuests: async ({ id }, ctx) => {
host.revoke({ guest: id }, ctx);
return null;
},
}));
facet.serve(Hosts, (_params, self) => {
const state = self.state(Workers);
self.onLive(async (ctx) => {
const stop = parts.workers.subscribe(({ session, ...worker }) => {
// An exit heard while this routine stops would change the state with an ended ctx.
if (signalOf(ctx).aborted) return;
void state
.change((draft) => {
// A late exit of an older generation never overwrites a newer worker.
if ((draft.workers[session]?.generation ?? 0) > worker.generation) return;
draft.workers[session] = worker;
}, ctx)
.catch(() => {});
});
try {
await untilAborted(ctx);
} finally {
stop();
}
});
return {
workers: self.derive([state], ([value]) => value.workers),
stop: async ({ session }) => {
await parts.workers.stop(session);
return null;
},
};
});
facet.serve(Access, (_params, principal) => {
if (isOwner(principal)) return { variant: "owner", members: {} };
if (!isGuest(principal)) throw forbidden("owners and guests only");
// One object per grant revision: the grant never changes within it.
const { id, name, email, home, sessions } = principal;
return {
variant: "guest",
members: {
grant: constant({
id,
name,
session: home,
mode: sessions[home] ?? "view",
sessions,
...(email === undefined ? {} : { email }),
}),
},
};
});
});
}