From 5b71a503351558cb64c6985e0de3cd195a3000d5 Mon Sep 17 00:00:00 2001 From: "open-session-os-tella-dev[bot]" <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:02:50 +0000 Subject: [PATCH 1/3] Stop host Portals on archive and expire idle previews Host Portals outlived their sessions: archiving left dev servers running, and an unused preview held memory indefinitely. This makes their lifetime match how they are used. - Archiving a session stops the host Portals it owns, including those in attached repositories, without touching a sibling's Portal in a shared checkout. The five-minute reaper also treats archived owners like missing ones, so an interrupted cleanup is retried. - On Linux a host Portal expires after 30 minutes without authenticated Portal traffic or an established connection to its port (from /proc/net tcp tables, so WebSockets count). Timestamps are process-local: a gateway restart grants a fresh window instead of killing a browser's Portal on an old timestamp. Without connection telemetry, idle cleanup is skipped. - New host Portal processes are refused when available RAM is below 5% or 2 GiB, memory full-stall pressure is at 10% over ten seconds, or the shared workload slice is at 90% of its memory soft limit. Matching awake Portals are still reused. - Registry writes go through an atomic, per-worktree serialized update that re-reads the file and applies only the caller's changed records, so concurrent starts in one checkout no longer clobber each other. Stops carry an expected generation so a stale sweep cannot kill a replacement. - The reaper takes an async catalog snapshot and all supervisor file IO is asynchronous, keeping it off the gateway thread. Tests cover archived, sibling, legacy ownerless, idle and generation cases, the admission thresholds, /proc tcp parsing, and concurrent registry updates. Supervisor tests install a no-op capacity probe so the host's own memory pressure cannot decide their outcome. Co-authored-by: Jaap Frolich --- docs/portals-and-agent-communication.md | 18 + .../core/opensession-server/opensession.ts | 3 +- .../opensession-server/src/server/archive.ts | 12 + .../src/server/portal-lifecycle.test.ts | 92 +++++ .../src/server/portal-lifecycle.ts | 137 +++++++ .../src/server/portal-supervisor.test.ts | 150 +++++++- .../src/server/portal-supervisor.ts | 346 +++++++++++++----- .../src/server/portals-mcp.ts | 2 +- .../src/server/routes/preview.ts | 4 + 9 files changed, 661 insertions(+), 103 deletions(-) create mode 100644 packages/core/opensession-server/src/server/portal-lifecycle.test.ts create mode 100644 packages/core/opensession-server/src/server/portal-lifecycle.ts diff --git a/docs/portals-and-agent-communication.md b/docs/portals-and-agent-communication.md index 8d40b23e21..40c34e4638 100644 --- a/docs/portals-and-agent-communication.md +++ b/docs/portals-and-agent-communication.md @@ -37,6 +37,24 @@ 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 expires 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. Restart an expired Portal + from the session's Portals panel or `start_portal`. +- 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. 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://:`; Sandbox services get an allocated route in 20000–27999 that relays over the Sandbox's authenticated outbound connection, diff --git a/packages/core/opensession-server/opensession.ts b/packages/core/opensession-server/opensession.ts index 2b16c2e66a..7ee1105238 100644 --- a/packages/core/opensession-server/opensession.ts +++ b/packages/core/opensession-server/opensession.ts @@ -99,6 +99,7 @@ import { findSession, findSessionAsync, getCachedSessions, + getSessionListSnapshotAsync, invalidateSessionsCache, reconcileRecoverableSafetyFences, recordRunOutcome, @@ -861,7 +862,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) diff --git a/packages/core/opensession-server/src/server/archive.ts b/packages/core/opensession-server/src/server/archive.ts index ab6de7738c..dd147a79f2 100644 --- a/packages/core/opensession-server/src/server/archive.ts +++ b/packages/core/opensession-server/src/server/archive.ts @@ -66,6 +66,16 @@ function releasePreviewLease(sessionId: string): void { } } +async function stopArchivedPortals(sessionId: string): Promise { + 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; } @@ -126,6 +136,7 @@ export async function setArchived( if (archived) { releasePreviewLease(id); await unpinEverywhere([id]); + await stopArchivedPortals(id); } } @@ -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. diff --git a/packages/core/opensession-server/src/server/portal-lifecycle.test.ts b/packages/core/opensession-server/src/server/portal-lifecycle.test.ts new file mode 100644 index 0000000000..d111f40773 --- /dev/null +++ b/packages/core/opensession-server/src/server/portal-lifecycle.test.ts @@ -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])); + }); +}); diff --git a/packages/core/opensession-server/src/server/portal-lifecycle.ts b/packages/core/opensession-server/src/server/portal-lifecycle.ts new file mode 100644 index 0000000000..935ecd54a5 --- /dev/null +++ b/packages/core/opensession-server/src/server/portal-lifecycle.ts @@ -0,0 +1,137 @@ +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; + } + + idle(port: number, generation: string, now: number): boolean { + const entry = this.ports.get(port); + return ( + entry?.generation === generation && + now - entry.lastUsedAt >= PORTAL_IDLE_MS + ); + } + + retain(ports: ReadonlySet): 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 { + const ports = new Set(); + 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 { + 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 | 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) | null = null; + +/** Unit tests spawn real Portals; the live host's memory pressure must not + * decide whether they pass. */ +export function _setHostPortalCapacityProbeForTests( + probe: (() => Promise) | null, +): void { + capacityProbe = probe; +} + +export async function assertHostPortalCapacity(): Promise { + 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.`, + ); +} diff --git a/packages/core/opensession-server/src/server/portal-supervisor.test.ts b/packages/core/opensession-server/src/server/portal-supervisor.test.ts index 2715dc4826..82d059af18 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.test.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.test.ts @@ -26,6 +26,11 @@ import { stopPortalService, stopSandboxPortalService, } from "./portal-supervisor"; +import { + _setHostPortalCapacityProbeForTests, + HostPortalActivity, + PORTAL_IDLE_MS, +} from "./portal-lifecycle"; import { sleepingSandboxPortalStatus } from "./sandbox-portals"; import type { Sandbox } from "./sandbox/provider"; @@ -54,8 +59,12 @@ if (!testSetsid) { beforeEach(() => { worktree = mkdtempSync(join(tmpdir(), "os-portals-test-")); process.env.OPENSESSION_STATE_DIR = worktree; + // Real Portals start below; the host running this suite may itself be + // under memory pressure, which must not decide the outcome. + _setHostPortalCapacityProbeForTests(async () => {}); }); afterAll(() => { + _setHostPortalCapacityProbeForTests(null); if (worktree) rmSync(worktree, { recursive: true, force: true }); if (previousStateDir == null) delete process.env.OPENSESSION_STATE_DIR; else process.env.OPENSESSION_STATE_DIR = previousStateDir; @@ -64,6 +73,141 @@ afterAll(() => { rmSync(processTools, { recursive: true, force: true }); }); +describe("host Portal lifecycle cleanup", () => { + function registry(owner: string | undefined = "owner") { + const records: PortalRecord[] = [ + { + name: "web", + key: "WEB_PORT", + command: "serve", + port: 18091, + state: "awake", + sessionId: owner, + startedAt: "2020-01-01T00:00:00Z", + }, + ]; + writeFileSync( + join(worktree, ".ports.conf"), + records + .map((record) => `# opensession-portal ${JSON.stringify(record)}`) + .join("\n"), + ); + } + + const owner = (archived = false) => ({ + id: "owner", + worktreeDir: worktree, + attachedRepos: [], + archived, + }); + + test("archived owners no longer protect their preview, even with connections", async () => { + registry(); + const result = await reapOrphanedPortalServices([owner(true)], { + activePorts: new Set([18091]), + }); + expect(result.stopped).toHaveLength(1); + expect(readPortalRegistry(worktree)[0]?.state).toBe("stopped"); + }); + + test("preserves a sibling owner's Portal in a shared worktree", async () => { + registry(); + const result = await reapOrphanedPortalServices( + [owner(), { ...owner(true), id: "sibling" }], + { activePorts: new Set() }, + ); + expect(result.stopped).toEqual([]); + }); + + test("legacy ownerless Portals survive until every worktree owner archives", async () => { + registry(""); + expect( + ( + await reapOrphanedPortalServices([ + owner(), + { ...owner(true), id: "sibling" }, + ]) + ).stopped, + ).toEqual([]); + expect( + (await reapOrphanedPortalServices([owner(true)])).stopped, + ).toHaveLength(1); + }); + + test("expires unused Portals but preserves HTTP activity and established connections", async () => { + registry(); + const activity = new HostPortalActivity(); + const sweep = ( + now: number, + ports: ReadonlySet | null = new Set(), + ) => + reapOrphanedPortalServices([owner()], { + now, + activity, + activePorts: ports, + }); + expect((await sweep(0)).stopped).toEqual([]); + activity.touch(18091, PORTAL_IDLE_MS - 1); + expect((await sweep(PORTAL_IDLE_MS)).stopped).toEqual([]); + expect((await sweep(2 * PORTAL_IDLE_MS, new Set([18091]))).stopped).toEqual( + [], + ); + expect((await sweep(3 * PORTAL_IDLE_MS, null)).stopped).toEqual([]); + expect((await sweep(3 * PORTAL_IDLE_MS)).stopped).toHaveLength(1); + }); + + test("a starved host refuses a fresh Portal and records why", async () => { + _setHostPortalCapacityProbeForTests(async () => { + throw new Error("Cannot start Portal: host memory is nearly full."); + }); + await expect( + startPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + port: 18093, + command: "sleep 60", + }), + ).rejects.toThrow("host memory is nearly full"); + expect(readPortalRegistry(worktree)[0]).toMatchObject({ + name: "web", + state: "failed", + lastError: expect.stringContaining("host memory is nearly full"), + }); + }); + + test("an expired generation cannot stop a replacement", async () => { + registry(); + await expect( + stopPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + expectedGeneration: "obsolete", + }), + ).rejects.toThrow("Portal changed"); + expect(readPortalRegistry(worktree)[0]?.state).toBe("awake"); + }); + + test("concurrent registry updates preserve unrelated services", async () => { + registry(); + const web = readPortalRegistry(worktree)[0]; + writeFileSync( + join(worktree, ".ports.conf"), + [web, { ...web, name: "api", key: "API_PORT", port: 18092 }] + .map((record) => `# opensession-portal ${JSON.stringify(record)}`) + .join("\n"), + ); + await Promise.all([ + setPortalPath(worktree, "/web", "web"), + setPortalPath(worktree, "/api", "api"), + ]); + expect( + readPortalRegistry(worktree).map((record) => record.defaultPath), + ).toEqual(["/web", "/api"]); + }); +}); + describe("portalsToRestore", () => { const record = ( name: string, @@ -162,7 +306,7 @@ describe("session Portal supervisor", () => { expect(existsSync(SANDBOX_PORTAL_AGENT_ENTRY)).toBe(true); }); - test("keeps generated portal metadata and ports together in .ports.conf", () => { + test("keeps generated portal metadata and ports together in .ports.conf", async () => { writeFileSync(join(worktree, ".ports.conf"), "WEBAPP_PORT=3300\n"); const record = { name: "api", @@ -175,7 +319,7 @@ describe("session Portal supervisor", () => { join(worktree, ".ports.conf"), `${PREFIX(record)}\nPORTAL_API_PORT=4200\nWEBAPP_PORT=3300\n`, ); - setPortalPath(worktree, "/health", "api"); + await setPortalPath(worktree, "/health", "api"); const [portal] = readPortalRegistry(worktree); expect(portal).toMatchObject({ name: "api", @@ -201,7 +345,7 @@ describe("session Portal supervisor", () => { `\x1b]0;@modal: cat .ports.conf\x07${PREFIX(record)}\nWEBAPP_PORT=4000\n`, ); - setPortalPath(worktree, "/videos", "web"); + await setPortalPath(worktree, "/videos", "web"); const text = await Bun.file(join(worktree, ".ports.conf")).text(); expect(text).not.toContain("\x1b"); diff --git a/packages/core/opensession-server/src/server/portal-supervisor.ts b/packages/core/opensession-server/src/server/portal-supervisor.ts index fad9dafc08..435a8b7a1f 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.ts @@ -6,17 +6,24 @@ * agent can inspect services without becoming their process manager. */ +import { existsSync, readFileSync } from "fs"; import { - closeSync, - existsSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - realpathSync, - writeFileSync, -} from "fs"; + mkdir, + open, + readFile, + readdir, + rename, + rm, + realpath, + writeFile, +} from "node:fs/promises"; import { join, resolve } from "path"; +import { + activeHostPortalPorts, + assertHostPortalCapacity, + hostPortalActivity, + type HostPortalActivity, +} from "./portal-lifecycle"; import { audit } from "./audit"; import { ensureAgentAwsCredsFile } from "./aws-creds"; import { configuredPaths, configuredServer } from "./config"; @@ -173,6 +180,32 @@ export function readPortalRegistry(worktreeDir: string): PortalRecord[] { : []; } +async function readHostRegistryText(worktreeDir: string): Promise { + try { + return await readFile(registryPath(worktreeDir), "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return ""; + throw error; + } +} + +async function readHostPortalRegistry( + worktreeDir: string, +): Promise { + return parsePortalRegistry(await readHostRegistryText(worktreeDir)); +} + +function portalGeneration(portal: PortalRecord): string { + return JSON.stringify([ + portal.sessionId, + portal.name, + portal.startedAt, + portal.pid, + portal.scopeUnit, + ]); +} + function serializedPortalRegistry( previousText: string, records: PortalRecord[], @@ -192,13 +225,34 @@ function serializedPortalRegistry( return [...kept, ...generated, ""].join("\n"); } -function writePortalRegistry( +const hostRegistryWrites = new Map>(); + +async function updateHostPortalRegistry( worktreeDir: string, - records: PortalRecord[], -): void { - const path = registryPath(worktreeDir); - const previous = existsSync(path) ? readFileSync(path, "utf8") : ""; - writeFileSync(path, serializedPortalRegistry(previous, records)); + update: (records: PortalRecord[]) => PortalRecord[], +): Promise { + const dir = await canonicalDir(worktreeDir); + const previous = hostRegistryWrites.get(dir) ?? Promise.resolve(); + const next = previous + .catch(() => {}) + .then(async () => { + const text = await readHostRegistryText(dir); + const records = update(parsePortalRegistry(text)); + const path = registryPath(dir); + const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await writeFile(temporary, serializedPortalRegistry(text, records)); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } + }); + hostRegistryWrites.set(dir, next); + try { + await next; + } finally { + if (hostRegistryWrites.get(dir) === next) hostRegistryWrites.delete(dir); + } } async function portListening(port: number): Promise { @@ -240,9 +294,28 @@ type PortalOps = { }; function hostPortalOps(worktreeDir: string): PortalOps { + let readSnapshot: PortalRecord[] = []; return { - readRegistry: async () => readPortalRegistry(worktreeDir), - writeRegistry: async (records) => writePortalRegistry(worktreeDir, records), + readRegistry: async () => { + readSnapshot = await readHostPortalRegistry(worktreeDir); + return readSnapshot; + }, + writeRegistry: async (records) => { + // Apply only this operation's changed records. Another Portal in a shared + // checkout may have started while this process was waiting for readiness. + const changed = records.filter( + (record) => + JSON.stringify(record) !== + JSON.stringify( + readSnapshot.find((before) => before.name === record.name), + ), + ); + await updateHostPortalRegistry(worktreeDir, (latest) => { + for (const record of changed) latest = upsert(latest, record); + return latest; + }); + readSnapshot = records; + }, probePort: portListening, pidAlive, scopeAlive: userScopeActive, @@ -297,7 +370,7 @@ async function waitForPortalPort( async function allocatePort(worktreeDir: string): Promise { const reserved = new Set( - readPortalRegistry(worktreeDir).map((record) => record.port), + (await readHostPortalRegistry(worktreeDir)).map((record) => record.port), ); for (let port = 4_000; port < 9_000; port++) { if (!reserved.has(port) && !(await portListening(port))) return port; @@ -544,10 +617,19 @@ async function terminatePortalProcess( if (await ops.pidAlive(pid)) await ops.signalGroup(pid, "SIGKILL"); } -async function stopPortal(ops: PortalOps, name: string): Promise { +async function stopPortal( + ops: PortalOps, + name: string, + expectedGeneration?: string, +): Promise { const records = await ops.readRegistry(); const current = records.find((record) => record.name === name); if (!current) throw new Error(`Portal '${name}' does not exist.`); + if ( + expectedGeneration !== undefined && + portalGeneration(current) !== expectedGeneration + ) + throw new Error("Portal changed before cleanup; retry on the next sweep."); await terminatePortalProcess(ops, current); const stopped = { ...current, @@ -609,8 +691,9 @@ export async function startPortalService(input: { urlFor: (port) => `https://${configuredServer().previewHost}:${port + 6_000}`, launch: async ({ name, command, port, url }) => { - mkdirSync(logDir, { recursive: true }); - const log = openSync(logPath, "w"); + await assertHostPortalCapacity(); + await mkdir(logDir, { recursive: true }); + const log = await open(logPath, "w"); const directCommand = ["setsid", "bash", "-lc", `exec ${command}`]; const portalEnv = { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", @@ -630,16 +713,20 @@ export async function startPortalService(input: { name, { env: portalEnv }, ); - const proc = Bun.spawn(scoped.command, { - cwd: input.worktreeDir, - // Portal commands are user-authored code. Do not hand them the Open - // Session service environment, which can include operator credentials. - env: scoped.env, - stdin: "ignore", - stdout: log, - stderr: log, - }); - closeSync(log); + let proc: ReturnType; + try { + proc = Bun.spawn(scoped.command, { + cwd: input.worktreeDir, + // Portal commands are user-authored code. Do not hand them the Open + // Session service environment, which can include operator credentials. + env: scoped.env, + stdin: "ignore", + stdout: log.fd, + stderr: log.fd, + }); + } finally { + await log.close(); + } proc.unref(); return { pid: proc.pid, @@ -647,6 +734,11 @@ export async function startPortalService(input: { }; }, }); + hostPortalActivity.observe( + started.port, + portalGeneration(started), + Date.now(), + ); audit({ msg: "portal_started", session_id: input.sessionId, @@ -660,10 +752,12 @@ export async function stopPortalService(input: { sessionId: string; worktreeDir: string; name: string; + expectedGeneration?: string; }): Promise { const stopped = await stopPortal( hostPortalOps(input.worktreeDir), validateName(input.name), + input.expectedGeneration, ); audit({ msg: "portal_stopped", @@ -679,7 +773,7 @@ export async function stopAllPortalServices(input: { sessionId: string; worktreeDir: string; }): Promise { - const records = readPortalRegistry(input.worktreeDir); + const records = await readHostPortalRegistry(input.worktreeDir); for (const record of records) { if (record.state === "stopped") continue; try { @@ -696,7 +790,8 @@ export async function stopAllPortalServices(input: { export type PortalOwnerSession = Pick< UnifiedSession, "id" | "worktreeDir" | "attachedRepos" ->; +> & + Partial>; export type PortalReapResult = { stopped: Array<{ sessionId: string; worktreeDir: string; name: string }>; }; @@ -724,70 +819,102 @@ export type PortalContainmentMigrationResult = { * spelling, the same registry read under the alias made every Portal owned by * a session spelled the other way look orphaned, and the reaper killed it. */ -function canonicalDir(dir: string): string { +async function canonicalDir(dir: string): Promise { try { - return realpathSync(dir); + return await realpath(dir); } catch { return resolve(dir); } } /** - * Stop host Portal process groups that no live session owns. Portal records - * are intentionally stored with the worktree, which survives a coordinator - * restart, so this closes the gap between a crashed delete and the next human - * action. Legacy records without sessionId are only reaped when no session - * owns their worktree at all. + * Reconcile host Portal lifetime: missing or archived owners and idle services. + * Durable records survive coordinator restarts. Legacy ownerless records are + * considered archived only when every owner of their worktree is archived. */ export async function reapOrphanedPortalServices( sessions: readonly PortalOwnerSession[], + options: { + now?: number; + activity?: HostPortalActivity; + activePorts?: ReadonlySet | null; + } = {}, ): Promise { - const owners = new Map>(); - const addOwner = (dir: string | null | undefined, sessionId: string) => { + const now = options.now ?? Date.now(); + const activity = options.activity ?? hostPortalActivity; + const activePorts = + options.activePorts === undefined + ? await activeHostPortalPorts() + : options.activePorts; + const owners = new Map>(); + const addOwner = async ( + dir: string | null | undefined, + session: PortalOwnerSession, + ) => { if (!dir) return; - const key = canonicalDir(dir); - const set = owners.get(key) ?? new Set(); - set.add(sessionId); + const key = await canonicalDir(dir); + const set = owners.get(key) ?? new Map(); + set.set(session.id, session.archived === true); owners.set(key, set); }; for (const session of sessions) { - addOwner(session.worktreeDir, session.id); + await addOwner(session.worktreeDir, session); for (const repo of session.attachedRepos ?? []) - addOwner(repo.dir, session.id); + await addOwner(repo.dir, session); } - // Include session worktrees outside the normal worktree root, then discover - // deleted-session worktrees below the managed root. We only act on explicit - // OpenSession Portal records, never arbitrary processes in those directories. + // Discover deleted-session worktrees too, but act only on explicit Portal + // records. Never enumerate or open session actor databases here. const dirs = new Set(owners.keys()); try { - for (const entry of readdirSync(configuredPaths().worktreesDir, { + for (const entry of await readdir(configuredPaths().worktreesDir, { withFileTypes: true, })) { if (entry.isDirectory()) dirs.add( - canonicalDir(join(configuredPaths().worktreesDir, entry.name)), + await canonicalDir(join(configuredPaths().worktreesDir, entry.name)), ); } } catch {} const stopped: PortalReapResult["stopped"] = []; + const observedPorts = new Set(); for (const worktreeDir of dirs) { - const liveOwners = owners.get(worktreeDir) ?? new Set(); - for (const portal of readPortalRegistry(worktreeDir)) { + const liveOwners = owners.get(worktreeDir) ?? new Map(); + for (const portal of await readHostPortalRegistry(worktreeDir)) { if (portal.state === "stopped" || portal.state === "failed") continue; + const generation = portalGeneration(portal); + observedPorts.add(portal.port); + activity.observe(portal.port, generation, now); + if (activePorts?.has(portal.port)) activity.touch(portal.port, now); const orphaned = portal.sessionId ? !liveOwners.has(portal.sessionId) : liveOwners.size === 0; - if (!orphaned) continue; + const archived = portal.sessionId + ? liveOwners.get(portal.sessionId) === true + : liveOwners.size > 0 && [...liveOwners.values()].every(Boolean); + const reason = orphaned + ? "orphaned" + : archived + ? "archived" + : activePorts !== null && activity.idle(portal.port, generation, now) + ? "idle" + : null; + if (!reason) continue; const sessionId = portal.sessionId || "orphaned-portal"; try { - await stopPortalService({ sessionId, worktreeDir, name: portal.name }); + await stopPortalService({ + sessionId, + worktreeDir, + name: portal.name, + expectedGeneration: generation, + }); stopped.push({ sessionId, worktreeDir, name: portal.name }); audit({ - msg: "portal_orphan_reaped", + msg: "portal_reaped", session_id: sessionId, portal: portal.name, + reason, }); } catch (error) { console.warn( @@ -797,9 +924,37 @@ export async function reapOrphanedPortalServices( } } } + activity.retain(observedPorts); return { stopped }; } +/** Archive only the named session's services, never a sibling's Portal in a + * shared checkout. Legacy ownerless records are handled by the full reaper. */ +export async function stopArchivedSessionPortals( + sessionId: string, +): Promise { + const { findSessionAsync } = await import("./session-cache"); + const session = await findSessionAsync(sessionId); + if (!session || session.runner || session.sandbox?.sandboxId) return; + const dirs = new Set([ + session.worktreeDir, + ...(session.attachedRepos ?? []).map((repo) => repo.dir), + ]); + for (const dir of dirs) { + if (!dir) continue; + for (const portal of await readHostPortalRegistry(dir)) { + if (portal.sessionId !== sessionId || portal.state === "stopped") + continue; + await stopPortalService({ + sessionId, + worktreeDir: dir, + name: portal.name, + expectedGeneration: portalGeneration(portal), + }); + } + } +} + /** * A release predating Portal scopes can leave live preview trees inside the * gateway service cgroup. Restart only durable, live-owned Portal records so @@ -811,22 +966,25 @@ export async function migrateUnscopedPortalServices( ): Promise { if (!systemdUserScopesAvailable()) return { migrated: [] }; const owners = new Map>(); - const addOwner = (dir: string | null | undefined, sessionId: string) => { + const addOwner = async ( + dir: string | null | undefined, + sessionId: string, + ) => { if (!dir) return; - const key = canonicalDir(dir); + const key = await canonicalDir(dir); const set = owners.get(key) ?? new Set(); set.add(sessionId); owners.set(key, set); }; for (const session of sessions) { - addOwner(session.worktreeDir, session.id); + await addOwner(session.worktreeDir, session.id); for (const repo of session.attachedRepos ?? []) - addOwner(repo.dir, session.id); + await addOwner(repo.dir, session.id); } const migrated: PortalContainmentMigrationResult["migrated"] = []; for (const [worktreeDir, liveOwners] of owners) { - const records = readPortalRegistry(worktreeDir); + const records = await readHostPortalRegistry(worktreeDir); for (const portal of portalsNeedingContainment(records, true)) { const owned = portal.sessionId ? liveOwners.has(portal.sessionId) @@ -863,46 +1021,35 @@ let portalReconcileInFlight = false; /** Reconcile Portal process groups after boot and every five minutes. */ export function startPortalReaper( - getSessions: () => readonly PortalOwnerSession[] = () => [], + getSessions: () => Promise, ): void { if (portalReapTimer) return; - const run = () => { + const run = async () => { if (portalReconcileInFlight) return; - let sessions: readonly PortalOwnerSession[]; + portalReconcileInFlight = true; try { - sessions = getSessions(); - } catch (error) { - console.error( - "[portals] session snapshot failed; skipping orphan reap:", - error, + const sessions = await getSessions(); + const { stopped } = await reapOrphanedPortalServices(sessions); + if (stopped.length) + console.log(`[portals] reaped ${stopped.length} Portal service(s)`); + const { migrated } = await migrateUnscopedPortalServices( + sessions.filter((session) => !session.archived), ); - return; + if (migrated.length) + console.log( + `[portals] migrated ${migrated.length} Portal service(s) into private scopes`, + ); + } catch (error) { + console.error("[portals] reconciliation failed:", error); + } finally { + portalReconcileInFlight = false; } - portalReconcileInFlight = true; - void reapOrphanedPortalServices(sessions) - .then(async ({ stopped }) => { - if (stopped.length) - console.log( - `[portals] reaped ${stopped.length} orphaned Portal service(s)`, - ); - const { migrated } = await migrateUnscopedPortalServices(sessions); - if (migrated.length) - console.log( - `[portals] migrated ${migrated.length} Portal service(s) into private scopes`, - ); - }) - .catch((error) => - console.error("[portals] reconciliation failed:", error), - ) - .finally(() => { - portalReconcileInFlight = false; - }); }; - run(); - portalReapTimer = setInterval(run, PORTAL_REAP_INTERVAL_MS); + void run(); + portalReapTimer = setInterval(() => void run(), PORTAL_REAP_INTERVAL_MS); portalReapTimer.unref?.(); console.log( - `[portals] orphan reaper started (every ${PORTAL_REAP_INTERVAL_MS / 60_000}m)`, + `[portals] lifecycle reaper started (every ${PORTAL_REAP_INTERVAL_MS / 60_000}m)`, ); } @@ -914,7 +1061,7 @@ export async function restartPortalService(input: { readyTimeoutMs?: number; }): Promise { const name = validateName(input.name); - const current = readPortalRegistry(input.worktreeDir).find( + const current = (await readHostPortalRegistry(input.worktreeDir)).find( (record) => record.name === name, ); if (!current) throw new Error(`Portal '${name}' does not exist.`); @@ -929,13 +1076,16 @@ export async function restartPortalService(input: { }); } -export function setPortalPath( +export async function setPortalPath( worktreeDir: string, path: string, name?: string, -): PortalRecord[] { - const next = withPortalPath(readPortalRegistry(worktreeDir), path, name); - writePortalRegistry(worktreeDir, next); +): Promise { + let next: PortalRecord[] = []; + await updateHostPortalRegistry(worktreeDir, (records) => { + next = withPortalPath(records, path, name); + return next; + }); return next; } diff --git a/packages/core/opensession-server/src/server/portals-mcp.ts b/packages/core/opensession-server/src/server/portals-mcp.ts index eea6bca741..3c497d26cf 100644 --- a/packages/core/opensession-server/src/server/portals-mcp.ts +++ b/packages/core/opensession-server/src/server/portals-mcp.ts @@ -546,7 +546,7 @@ export function createPortalsMcpServer(ctx: PortalsMcpContext) { "Could not set Portal route: this session's Sandbox is sleeping or unavailable.", ); if (sandbox) await setSandboxPortalPath(sandbox, path, name); - else setPortalPath(dir, path, name); + else await setPortalPath(dir, path, name); } else { const normalized = normalizePortalPath(path) ?? null; await ctx.setDefaultPath(normalized); diff --git a/packages/core/opensession-server/src/server/routes/preview.ts b/packages/core/opensession-server/src/server/routes/preview.ts index e7eb1c08c8..81aa7ee8f3 100644 --- a/packages/core/opensession-server/src/server/routes/preview.ts +++ b/packages/core/opensession-server/src/server/routes/preview.ts @@ -34,6 +34,7 @@ import { import { getRepo } from "../worktree"; import { configuredServer } from "../config"; import { portalNavigationRequest } from "../portal-sign-in"; +import { hostPortalActivity } from "../portal-lifecycle"; import { portalWaitingResponse } from "../portal-waiting-page"; import { sleepingSandboxPortalStatus } from "../sandbox-portals"; import type { UnifiedSession } from "../types"; @@ -154,6 +155,9 @@ export async function handlePreviewRoutes( }, }); } + // Only authenticated, authorized Portal traffic counts. Session-list and + // readiness polling never extend a host preview's idle lifetime. + hostPortalActivity.touch(httpsPort - 6_000); return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" }, From 01f0ca6c45595db5059833b7cab64a7f074cf362 Mon Sep 17 00:00:00 2001 From: "open-session-os-tella-dev[bot]" <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:13:52 +0000 Subject: [PATCH 2/3] Serialize host Portal operations and archive by canonical owner Review round 1 on the lifecycle change: - A stop validated a record's generation, then terminated the process and wrote the stopped record without excluding a concurrent start or restart of the same Portal. Because the scope unit is stable per worktree and name, a stale sweep could kill the replacement and overwrite its record. Start, stop, and restart now queue on one lock per canonical worktree and name, with restart as a single transaction, so the generation check holds through termination and persistence. The lock queues rather than coalesces: a stop after a start runs after it. - Archive cleanup compared Portal owners against the archived id, which may be a historical alias; the record is owned by the canonical session that the lookup resolves to. Compare and stop with the resolved id. Tests: a stale stop queued behind a restart rejects and leaves the awake replacement supervised; archiving through an alias stops the canonical owner's Portal. Co-authored-by: Jaap Frolich --- .../src/server/portal-supervisor.test.ts | 54 ++++++++ .../src/server/portal-supervisor.ts | 124 ++++++++++++++---- 2 files changed, 155 insertions(+), 23 deletions(-) diff --git a/packages/core/opensession-server/src/server/portal-supervisor.test.ts b/packages/core/opensession-server/src/server/portal-supervisor.test.ts index 82d059af18..87a8d2bfa7 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.test.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.test.ts @@ -15,14 +15,17 @@ import { listSandboxPortalServices, normalizePortalPath, portalsNeedingContainment, + portalGeneration, type PortalRecord, portalsToRestore, readPortalRegistry, reapOrphanedPortalServices, + restartPortalService, SANDBOX_PORTAL_AGENT_ENTRY, setPortalPath, startPortalService, startSandboxPortalService, + stopArchivedSessionPortals, stopPortalService, stopSandboxPortalService, } from "./portal-supervisor"; @@ -176,6 +179,57 @@ describe("host Portal lifecycle cleanup", () => { }); }); + test("archiving through an alias stops the canonical owner's Portal", async () => { + registry("canonical"); + await stopArchivedSessionPortals("slack-C998-1719860000.000000", { + findSession: async () => ({ + id: "canonical", + worktreeDir: worktree, + attachedRepos: [], + }), + }); + expect(readPortalRegistry(worktree)[0]?.state).toBe("stopped"); + }); + + test("a stale stop queued behind a restart cannot kill the replacement", async () => { + const port = 18094; + const first = await startPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + port, + command: + "bun -e 'Bun.serve({port:Number(process.env.PORT),fetch(){return new Response(\"web\")}})'", + }); + const restart = restartPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + }); + // The restart is inside its stop (SIGTERM plus a grace sleep) when the + // sweep's stop arrives holding the old generation. + await Bun.sleep(100); + const sweep = stopPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + expectedGeneration: portalGeneration(first), + }); + const replacement = await restart; + await expect(sweep).rejects.toThrow("Portal changed"); + expect(replacement.state).toBe("awake"); + expect(replacement.pid).not.toBe(first.pid); + expect((await listPortalServices(worktree))[0]).toMatchObject({ + state: "awake", + pid: replacement.pid, + }); + await stopPortalService({ + sessionId: "owner", + worktreeDir: worktree, + name: "web", + }); + }, 20_000); + test("an expired generation cannot stop a replacement", async () => { registry(); await expect( diff --git a/packages/core/opensession-server/src/server/portal-supervisor.ts b/packages/core/opensession-server/src/server/portal-supervisor.ts index 435a8b7a1f..355f51ec20 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.ts @@ -196,7 +196,9 @@ async function readHostPortalRegistry( return parsePortalRegistry(await readHostRegistryText(worktreeDir)); } -function portalGeneration(portal: PortalRecord): string { +/** Identifies one process incarnation of a Portal record, so a cleanup + * decided against an old incarnation cannot act on its replacement. */ +export function portalGeneration(portal: PortalRecord): string { return JSON.stringify([ portal.sessionId, portal.name, @@ -226,6 +228,33 @@ function serializedPortalRegistry( } const hostRegistryWrites = new Map>(); +const hostPortalOperations = new Map>(); + +/** + * Serialize start, stop, and restart of one host Portal by canonical worktree + * and name. A stop validates the record's generation, terminates the process + * group (the scope unit is stable per worktree and name), and persists the + * stopped record; none of that may interleave with a start or restart that + * replaces the record, or the stale stop kills the replacement and overwrites + * its record. Unlike the Sandbox seam this queues rather than coalesces: a + * stop after a start must run after it, not return the start's result. + */ +async function withHostPortalOperation( + worktreeDir: string, + name: string, + operation: () => Promise, +): Promise { + const key = `${await canonicalDir(worktreeDir)}:${name}`; + const previous = hostPortalOperations.get(key) ?? Promise.resolve(); + const task = previous.catch(() => {}).then(operation); + hostPortalOperations.set(key, task); + try { + return await task; + } finally { + if (hostPortalOperations.get(key) === task) + hostPortalOperations.delete(key); + } +} async function updateHostPortalRegistry( worktreeDir: string, @@ -663,7 +692,7 @@ export async function listPortalServices( return listPortals(hostPortalOps(worktreeDir)); } -export async function startPortalService(input: { +type HostPortalStartInput = { sessionId: string; worktreeDir: string; name: string; @@ -674,7 +703,22 @@ export async function startPortalService(input: { readyTimeoutMs?: number; /** Narrow, caller-owned additions for a trusted declared recipe. */ env?: Record; -}): Promise { +}; + +export function startPortalService( + input: HostPortalStartInput, +): Promise { + return withHostPortalOperation( + input.worktreeDir, + validateName(input.name), + () => startHostPortal(input), + ); +} + +/** The start itself; callers hold this Portal's operation lock. */ +async function startHostPortal( + input: HostPortalStartInput, +): Promise { const logDir = join(sessionScratchRoot(), input.sessionId, "portals"); const logPath = join(logDir, `${input.name}.log`); // The same short-lived AWS credentials the agent's own shell gets (a pointer @@ -748,12 +792,28 @@ export async function startPortalService(input: { return started; } -export async function stopPortalService(input: { +type HostPortalStopInput = { sessionId: string; worktreeDir: string; name: string; + /** Refuse to stop a record that no longer matches `portalGeneration`. */ expectedGeneration?: string; -}): Promise { +}; + +export function stopPortalService( + input: HostPortalStopInput, +): Promise { + return withHostPortalOperation( + input.worktreeDir, + validateName(input.name), + () => stopHostPortal(input), + ); +} + +/** The stop itself; callers hold this Portal's operation lock. */ +async function stopHostPortal( + input: HostPortalStopInput, +): Promise { const stopped = await stopPortal( hostPortalOps(input.worktreeDir), validateName(input.name), @@ -932,10 +992,25 @@ export async function reapOrphanedPortalServices( * shared checkout. Legacy ownerless records are handled by the full reaper. */ export async function stopArchivedSessionPortals( sessionId: string, + options: { + findSession?: ( + id: string, + ) => Promise< + | Pick< + UnifiedSession, + "id" | "worktreeDir" | "attachedRepos" | "runner" | "sandbox" + > + | undefined + >; + } = {}, ): Promise { - const { findSessionAsync } = await import("./session-cache"); - const session = await findSessionAsync(sessionId); + const findSession = + options.findSession ?? (await import("./session-cache")).findSessionAsync; + const session = await findSession(sessionId); if (!session || session.runner || session.sandbox?.sandboxId) return; + // The lookup resolves historical aliases to the canonical session, and the + // Portal record is owned by that canonical id, not the alias archived. + const ownerId = session.id; const dirs = new Set([ session.worktreeDir, ...(session.attachedRepos ?? []).map((repo) => repo.dir), @@ -943,10 +1018,9 @@ export async function stopArchivedSessionPortals( for (const dir of dirs) { if (!dir) continue; for (const portal of await readHostPortalRegistry(dir)) { - if (portal.sessionId !== sessionId || portal.state === "stopped") - continue; + if (portal.sessionId !== ownerId || portal.state === "stopped") continue; await stopPortalService({ - sessionId, + sessionId: ownerId, worktreeDir: dir, name: portal.name, expectedGeneration: portalGeneration(portal), @@ -1053,7 +1127,7 @@ export function startPortalReaper( ); } -export async function restartPortalService(input: { +export function restartPortalService(input: { sessionId: string; worktreeDir: string; name: string; @@ -1061,18 +1135,22 @@ export async function restartPortalService(input: { readyTimeoutMs?: number; }): Promise { const name = validateName(input.name); - const current = (await readHostPortalRegistry(input.worktreeDir)).find( - (record) => record.name === name, - ); - if (!current) throw new Error(`Portal '${name}' does not exist.`); - await stopPortalService(input); - return startPortalService({ - ...input, - name, - key: current.key, - command: current.command, - port: current.port, - description: current.description, + // One transaction: nothing may stop or start this Portal between the stop + // of the old process and the registration of its replacement. + return withHostPortalOperation(input.worktreeDir, name, async () => { + const current = (await readHostPortalRegistry(input.worktreeDir)).find( + (record) => record.name === name, + ); + if (!current) throw new Error(`Portal '${name}' does not exist.`); + await stopHostPortal(input); + return startHostPortal({ + ...input, + name, + key: current.key, + command: current.command, + port: current.port, + description: current.description, + }); }); } From 8495804471425b1ed5e8a6cf120be0076da43221 Mon Sep 17 00:00:00 2001 From: "open-session-os-tella-dev[bot]" <309495949+open-session-os-tella-dev[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:38:48 +0000 Subject: [PATCH 3/3] Recognize merged aliases throughout host Portal ownership Co-authored-by: Jaap Frolich --- .../src/server/portal-supervisor.test.ts | 40 +++++++++++++++++++ .../src/server/portal-supervisor.ts | 26 ++++++++---- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/core/opensession-server/src/server/portal-supervisor.test.ts b/packages/core/opensession-server/src/server/portal-supervisor.test.ts index 38ae77b51e..9abbf254e2 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.test.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.test.ts @@ -147,6 +147,46 @@ describe("host Portal lifecycle cleanup", () => { expect(result.stopped).toEqual([]); }); + for (const attached of [false, true]) { + test(`alias-owned Portals follow canonical archive state in ${attached ? "attached" : "primary"} worktrees`, async () => { + const aliasId = "slack-C998-1719860000.000000"; + registry(aliasId); + const canonical = { + ...owner(), + aliasIds: [aliasId], + worktreeDir: attached ? "" : worktree, + attachedRepos: attached + ? [{ repo: "attached", branch: "main", dir: worktree }] + : [], + }; + expect((await reapOrphanedPortalServices([canonical])).stopped).toEqual( + [], + ); + expect(readPortalRegistry(worktree)[0]?.state).toBe("awake"); + expect( + (await reapOrphanedPortalServices([{ ...canonical, archived: true }])) + .stopped, + ).toHaveLength(1); + expect(readPortalRegistry(worktree)[0]?.state).toBe("stopped"); + }); + } + + test("alias-owned Portals retain idle expiry without sleeping a running owner", async () => { + registry("slack-alias"); + const canonical = { ...owner(), aliasIds: ["slack-alias"] }; + const activity = new HostPortalActivity(); + const sweep = (now: number, isRunning = false) => + sleepIdlePortalServices([{ ...canonical, isRunning }], { + now, + activity, + activePorts: new Set(), + }); + expect((await sweep(0)).slept).toEqual([]); + expect((await sweep(PORTAL_IDLE_MS, true)).slept).toEqual([]); + expect((await sweep(PORTAL_IDLE_MS)).slept).toHaveLength(1); + expect(readPortalRegistry(worktree)[0]?.state).toBe("sleeping"); + }); + test("legacy ownerless Portals survive until every worktree owner archives", async () => { registry(""); expect( diff --git a/packages/core/opensession-server/src/server/portal-supervisor.ts b/packages/core/opensession-server/src/server/portal-supervisor.ts index 70f4104aaf..e8aca3e82f 100644 --- a/packages/core/opensession-server/src/server/portal-supervisor.ts +++ b/packages/core/opensession-server/src/server/portal-supervisor.ts @@ -964,9 +964,15 @@ export async function stopAllPortalServices(input: { export type PortalOwnerSession = Pick< UnifiedSession, - "id" | "worktreeDir" | "attachedRepos" + "id" | "worktreeDir" | "attachedRepos" | "aliasIds" > & Partial>; +function portalOwnerIds( + session: Pick, +): string[] { + return [session.id, ...(session.aliasIds ?? [])]; +} + export type PortalReapResult = { stopped: Array<{ sessionId: string; worktreeDir: string; name: string }>; }; @@ -997,7 +1003,10 @@ export async function sleepIdlePortalServices( // The catalog deliberately omits live runner overlays. Probe only Portal // owners in the in-memory engine registry, never every historical actor. const { isAgentEngineBusy } = await import("./agent-runner"); - const owners = new Map(sessions.map((session) => [session.id, session])); + const owners = new Map(); + for (const session of sessions) { + for (const id of portalOwnerIds(session)) owners.set(id, session); + } const ownerDirs = sessions .flatMap((session) => [ session.worktreeDir, @@ -1350,7 +1359,8 @@ export async function reapOrphanedPortalServices( if (!dir) return; const key = await canonicalDir(dir); const set = owners.get(key) ?? new Map(); - set.set(session.id, session.archived === true); + for (const id of portalOwnerIds(session)) + set.set(id, session.archived === true); owners.set(key, set); }; for (const session of sessions) { @@ -1426,7 +1436,7 @@ export async function stopArchivedSessionPortals( if (!session || session.runner || session.sandbox?.sandboxId) return; // A Portal record carries the id its session ran under, which may be the // canonical id or an alias merged into it. Every spelling owns the Portal. - const ownerIds = new Set([session.id, ...(session.aliasIds ?? [])]); + const ownerIds = new Set(portalOwnerIds(session)); const dirs = new Set([ session.worktreeDir, ...(session.attachedRepos ?? []).map((repo) => repo.dir), @@ -1487,18 +1497,18 @@ export async function migrateUnscopedPortalServices( const owners = new Map>(); const addOwner = async ( dir: string | null | undefined, - sessionId: string, + session: PortalOwnerSession, ) => { if (!dir) return; const key = await canonicalDir(dir); const set = owners.get(key) ?? new Set(); - set.add(sessionId); + for (const id of portalOwnerIds(session)) set.add(id); owners.set(key, set); }; for (const session of sessions) { - await addOwner(session.worktreeDir, session.id); + await addOwner(session.worktreeDir, session); for (const repo of session.attachedRepos ?? []) - await addOwner(repo.dir, session.id); + await addOwner(repo.dir, session); } const migrated: PortalContainmentMigrationResult["migrated"] = [];