Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/portals-and-agent-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ WEBAPP_PORT=3300
INSTANT_PORT=5968
```

Host Portals survive gateway restarts, but are not permanent services:

- Archiving a session stops its owned host Portals, including those in attached
repositories. A sibling session's Portal in a shared checkout is left alone.
The five-minute reaper also catches archived and deleted owners missed by an
interrupted cleanup.
- On Linux, a host Portal sleeps after 30 minutes without authenticated Portal
requests or an established connection to its service port. WebSockets count
as use; session-list and readiness polling do not. A gateway restart grants a
fresh idle window. If connection telemetry is unavailable, idle cleanup is
skipped rather than risking an active preview. Running owners are protected.
A page navigation wakes a sleeping Portal; background requests do not.
Archive and orphan cleanup stop Portals permanently, without automatic wake.
- New host processes are refused when available RAM falls below 5% or 2 GiB,
memory full-stall pressure reaches 10% over ten seconds, or the shared user
workload slice reaches 90% of its memory soft limit. The existing configurable
host Portal count cap and free-memory floor also apply. Existing, matching
Portals can still be reused. These are admission guards, not permission to
kill another session's active work.

Every listening `*_PORT` service is a Portal. Host services map to
`https://<host>:<port+6000>`; Sandbox services get an allocated route in
20000–27999 that relays over the Sandbox's authenticated outbound connection,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/opensession-server/opensession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
findSession,
findSessionAsync,
getCachedSessions,
getSessionListSnapshotAsync,
invalidateSessionsCache,
reconcileRecoverableSafetyFences,
recordRunOutcome,
Expand Down Expand Up @@ -863,7 +864,7 @@ if (!g.__opensessionBooted) {

// Portal processes survive a coordinator restart by design. Reconcile their
// durable owner records immediately and keep reaping deleted-session husks.
startPortalReaper(getAllSessions);
startPortalReaper(getSessionListSnapshotAsync);
startRunnerPortalReaper();

// Desk todo reminders: push + Slack DM when a remindAt passes (todos.ts)
Expand Down
12 changes: 12 additions & 0 deletions packages/core/opensession-server/src/server/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ function releasePreviewLease(sessionId: string): void {
}
}

async function stopArchivedPortals(sessionId: string): Promise<void> {
try {
const { stopArchivedSessionPortals } = await import("./portal-supervisor");
await stopArchivedSessionPortals(sessionId);
} catch (error) {
// Archiving remains committed. The periodic lifecycle reaper retries.
console.error(`[portals] archive cleanup failed for ${sessionId}:`, error);
}
}

function toEntry(raw: RawEntry): Entry {
return typeof raw === "string" ? { at: raw, reason: "manual" } : raw;
}
Expand Down Expand Up @@ -126,6 +136,7 @@ export async function setArchived(
if (archived) {
releasePreviewLease(id);
await unpinEverywhere([id]);
await stopArchivedPortals(id);
}
}

Expand Down Expand Up @@ -155,6 +166,7 @@ export async function archiveOlderThan(
releasePreviewLease(session.id);
await setIndexedSessionArchived(session.id, true, "idle");
publishSessionRow(session.id);
await stopArchivedPortals(session.id);
}
// Registry is written, so isArchivedId now reflects this batch — drop the
// stale session/alias pins and any workspace pin whose last session just went.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import {
establishedPorts,
HostPortalActivity,
PORTAL_IDLE_MS,
portalCapacityProblem,
} from "./portal-lifecycle";

const healthy = {
meminfo: "MemTotal: 100000000 kB\nMemAvailable: 50000000 kB\n",
};

describe("host Portal admission", () => {
test("allows healthy hosts with or without a configured cgroup budget", () => {
expect(portalCapacityProblem(healthy)).toBeNull();
expect(
portalCapacityProblem({ ...healthy, current: "500", high: "max" }),
).toBeNull();
expect(
portalCapacityProblem({ ...healthy, current: "500", high: "1000" }),
).toBeNull();
});
test("rejects memory starvation and malformed availability", () => {
expect(
portalCapacityProblem({
meminfo: "MemTotal: 100000000 kB\nMemAvailable: 1000000 kB",
}),
).toContain("nearly full");
expect(portalCapacityProblem({ meminfo: "" })).toContain(
"could not be measured",
);
});
test("rejects sustained reclaim stalls, not old swap usage", () => {
expect(
portalCapacityProblem({
...healthy,
pressure: "some avg10=90.00\nfull avg10=10.00 avg60=4.00",
}),
).toContain("stalled");
expect(
portalCapacityProblem({
...healthy,
pressure: "some avg10=5.00\nfull avg10=0.00 avg60=90.00",
}),
).toBeNull();
});
test("reserves headroom before the aggregate soft limit", () => {
expect(
portalCapacityProblem({ ...healthy, current: "899", high: "1000" }),
).toBeNull();
expect(
portalCapacityProblem({ ...healthy, current: "900", high: "1000" }),
).toContain("preview memory budget");
});
});

describe("Portal idle activity", () => {
test("expiry counts from discovery, not an ancient process start time", () => {
const activity = new HostPortalActivity();
activity.observe(4000, "old-process", 100);
expect(activity.idle(4000, "old-process", 100 + PORTAL_IDLE_MS - 1)).toBe(
false,
);
expect(activity.idle(4000, "old-process", 100 + PORTAL_IDLE_MS)).toBe(true);
});
test("HTTP traffic extends the window but repeated observation does not", () => {
const activity = new HostPortalActivity();
activity.observe(4000, "a", 0);
activity.observe(4000, "a", PORTAL_IDLE_MS - 1);
expect(activity.idle(4000, "a", PORTAL_IDLE_MS)).toBe(true);
activity.touch(4000, PORTAL_IDLE_MS);
expect(activity.idle(4000, "a", 2 * PORTAL_IDLE_MS - 1)).toBe(false);
});
test("a replacement process gets its own idle window", () => {
const activity = new HostPortalActivity();
activity.observe(4000, "a", 0);
activity.observe(4000, "b", PORTAL_IDLE_MS);
expect(activity.idle(4000, "a", 2 * PORTAL_IDLE_MS)).toBe(false);
expect(activity.idle(4000, "b", PORTAL_IDLE_MS + 1)).toBe(false);
activity.retain(new Set());
activity.touch(4000, 3 * PORTAL_IDLE_MS);
expect(activity.idle(4000, "b", 4 * PORTAL_IDLE_MS)).toBe(false);
});
test("recognizes established IPv4/IPv6 server ports, not listeners or peers", () => {
expect(
establishedPorts([
"sl local_address rem_address st\n0: 0100007F:0FA0 0100007F:CB22 01\n1: 0100007F:0FA1 00000000:0000 0A",
"sl local_address rem_address st\n0: 00000000000000000000000001000000:0FA2 00000000000000000000000001000000:CAAA 01\nmalformed",
]),
).toEqual(new Set([4000, 4002]));
});
});
144 changes: 144 additions & 0 deletions packages/core/opensession-server/src/server/portal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { readFile } from "node:fs/promises";

/** An unused host Portal gets one quiet half-hour, including after gateway boot. */
export const PORTAL_IDLE_MS = 30 * 60_000;

/** Request timestamps are process-local. Rediscovery grants a fresh idle window
* rather than killing a browser's Portal using a pre-restart timestamp. */
export class HostPortalActivity {
private readonly ports = new Map<
number,
{ generation: string; lastUsedAt: number }
>();

observe(port: number, generation: string, now: number): void {
if (this.ports.get(port)?.generation !== generation)
this.ports.set(port, { generation, lastUsedAt: now });
}

touch(port: number, now = Date.now()): void {
const entry = this.ports.get(port);
if (entry) entry.lastUsedAt = now;
}

lastUsedAt(port: number, generation: string): number | undefined {
const entry = this.ports.get(port);
return entry?.generation === generation ? entry.lastUsedAt : undefined;
}

idle(
port: number,
generation: string,
now: number,
idleMs = PORTAL_IDLE_MS,
): boolean {
const entry = this.ports.get(port);
return entry?.generation === generation && now - entry.lastUsedAt >= idleMs;
}

retain(ports: ReadonlySet<number>): void {
for (const port of this.ports.keys())
if (!ports.has(port)) this.ports.delete(port);
}
}

export const hostPortalActivity = new HostPortalActivity();

/** /proc reports server-side local ports too, so an established WebSocket
* protects an open preview even when no further HTTP auth probes arrive. */
export function establishedPorts(tables: readonly string[]): Set<number> {
const ports = new Set<number>();
for (const table of tables) {
for (const line of table.split("\n").slice(1)) {
const fields = line.trim().split(/\s+/);
if (fields[3] !== "01") continue;
const port = Number.parseInt(fields[1]?.split(":")[1] ?? "", 16);
if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port);
}
}
return ports;
}

async function optionalFile(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf8");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT")
return undefined;
throw error;
}
}

export async function activeHostPortalPorts(): Promise<Set<number> | null> {
if (process.platform !== "linux") return null;
try {
const tables = await Promise.all([
optionalFile("/proc/net/tcp"),
optionalFile("/proc/net/tcp6"),
]);
const available = tables.filter((table) => table !== undefined);
return available.length ? establishedPorts(available) : null;
} catch {
// No connection evidence means no idle termination. Archive/orphan cleanup
// does not depend on this probe and still runs.
return null;
}
}

export function portalCapacityProblem(input: {
meminfo: string;
pressure?: string;
current?: string;
high?: string;
}): string | null {
const total = Number(input.meminfo.match(/^MemTotal:\s+(\d+)/m)?.[1]);
const available = Number(input.meminfo.match(/^MemAvailable:\s+(\d+)/m)?.[1]);
if (!Number.isFinite(total) || !Number.isFinite(available) || total <= 0)
return "host memory availability could not be measured";
// Leave at least 2 GiB and 5% of RAM for the control plane and OS.
if (available < Math.max(2 * 1024 * 1024, total * 0.05))
return "host memory is nearly full";
const fullStall = Number(
input.pressure?.match(/^full\s+avg10=([\d.]+)/m)?.[1],
);
if (fullStall >= 10) return "the host is stalled reclaiming memory";
const current = Number(input.current?.trim());
const high = Number(input.high?.trim());
// Stop admitting more previews before the shared soft limit starts reclaim.
if (
Number.isFinite(current) &&
Number.isFinite(high) &&
high > 0 &&
current >= high * 0.9
)
return "the preview memory budget is nearly full";
return null;
}

let capacityProbe: (() => Promise<void>) | null = null;

/** Unit tests spawn real Portals; the live host's memory pressure must not
* decide whether they pass. */
export function _setHostPortalCapacityProbeForTests(
probe: (() => Promise<void>) | null,
): void {
capacityProbe = probe;
}

export async function assertHostPortalCapacity(): Promise<void> {
if (capacityProbe) return capacityProbe();
if (process.platform !== "linux") return;
const uid = process.getuid?.() ?? 1000;
const slice = `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/opensession.slice`;
const [meminfo, pressure, current, high] = await Promise.all([
readFile("/proc/meminfo", "utf8"),
optionalFile("/proc/pressure/memory"),
optionalFile(`${slice}/memory.current`),
optionalFile(`${slice}/memory.high`),
]);
const problem = portalCapacityProblem({ meminfo, pressure, current, high });
if (problem)
throw new Error(
`Cannot start Portal: ${problem}. Stop an unused Portal and try again.`,
);
}
Loading
Loading