Public: anyone can read this; the crew can change it.

An app terminal you have open is in your sessions list and stops like a shell #10643

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10549-an-app-terminal-you-have-open-is-in-your → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/10549-an-app-terminal-you-have-open-is-in-your was merged into main as 178d6d4.

Review

Sign in to review this change.
9 files changed +422 −20
docs/features/my-shells.md +4
@@ -19,3 +19,7 @@ attached to and that is past half the idle limit. Both are same-origin form post
1919 records `end_reason = 'stopped'`. MCP: `sessions` with `kind: "shells"` and `action: "end"` + `id`, or `"end_idle"`. Terminal: `s` and `i` as above, answering in a toast.
2020
2121 Pinned by `test/my-shells.test.ts` and `test/tui-my-shells.test.ts`.
22
23 ## App terminals (#10549)
24
25 An app's terminal (`tty` in forge.json, over SSH or the forge's UI) is held in `src/apps/tty_registry.ts` while it is open: app, environment, holder, start, last input and output. `myShells` lists it as a `kind: "app"` row beside the machine shells, on the account page and in `sessions` (`kind: "shells"`). Stop and Stop all idle hang up its pty (SIGHUP, its process group ends); one idle for the shell idle limit stops itself. Only live terminals are listed: an ended one leaves no row.
src/apps/tty.ts +59 −14
@@ -36,6 +36,8 @@ import {
3636 } from "./data.ts";
3737 import { appEnv, signalGroup } from "./process.ts";
3838 import { appsRuntime } from "./runtime.ts";
39 import { openAppTty, type AppTtyHandle } from "./tty_registry.ts";
40 import { shellIdleMs } from "../machines/shell.ts";
3941
4042 const CRLF = "\r\n";
4143
@@ -165,11 +167,6 @@ export async function runAppTty(
165167 }
166168 if (live.tty === null) return err({ refusal: "no_tty", detail: "" });
167169
168 const terminal = new Bun.Terminal({
169 cols: Math.max(1, host.cols || 80),
170 rows: Math.max(1, host.rows || 24),
171 data: (_t, chunk) => host.write(chunk),
172 });
173170 const env = appEnv({
174171 port: live.port,
175172 dataDir: live.dataDir,
@@ -201,19 +198,65 @@ export async function runAppTty(
201198 user === undefined ? "" : canWrite(forge.db, repo, user) ? "admin" : "user";
202199 // The terminal is held exactly as the app is (`apps/contain.ts`).
203200 const cell = live.cell === undefined ? undefined : openCell(live.cell);
204 const program = startTerminal(shellCommand(live.tty), {
205 terminal,
201 const code = await serveTerminal(host, {
202 command: shellCommand(live.tty),
206203 env,
207204 cwd: live.dir,
208205 cell,
206 register: (program) => {
207 forge.log.info("app terminal opened", {
208 app: target.app.slug,
209 env: target.env,
210 user: user?.username ?? "guest",
211 });
212 return openAppTty(forge, {
213 userId: user?.id,
214 repository: `${repo.ownerName}/${repo.name}`,
215 slug: target.app.slug,
216 env: target.env,
outdated

Tests: this registration, the held?.input()/output() stamps and the held.close() in finally are the behaviour the issue asks for, and no test reaches them (the test file rebuilds this by hand). Removing them leaves the suite green. Cover the real path or share one helper.

217 hangup: program.hangup,
218 idleMs: () => shellIdleMs(forge.db),
219 });
220 },
209221 });
210 forge.log.info("app terminal opened", {
211 app: target.app.slug,
212 env: target.env,
213 user: user?.username ?? "guest",
214 });
222 return ok(code);
223 }
215224
225 /**
226 * Run `command` on a new pty for `host` until it exits: pty bytes to the
227 * host (stamping the registry's output), host bytes and resizes to the pty
228 * (stamping its input), a hangup when the host goes away, and the registry
229 * entry forgotten however the program ends. `register` is told the started
230 * program and returns the registry's handle for it.
231 */
232 export async function serveTerminal(
233 host: TtyHost,
234 options: {
235 command: string;
236 env: Record<string, string>;
237 cwd: string;
238 cell: Cell | undefined;
239 register: (program: TerminalProgram) => AppTtyHandle;
240 },
241 ): Promise<number> {
242 let held: AppTtyHandle | undefined;
243 const terminal = new Bun.Terminal({
244 cols: Math.max(1, host.cols || 80),
245 rows: Math.max(1, host.rows || 24),
246 data: (_t, chunk) => {
247 held?.output();
248 host.write(chunk);
249 },
250 });
251 const program = startTerminal(options.command, {
252 terminal,
253 env: options.env,
254 cwd: options.cwd,
255 cell: options.cell,
256 });
257 held = options.register(program);
216258 host.onData((chunk) => {
259 held?.input();
217260 if (terminal.closed) return;
218261 try {
219262 terminal.write(chunk);
@@ -232,8 +275,9 @@ export async function runAppTty(
232275 host.onClose?.(program.hangup);
233276 try {
234277 // `exited`, never the pty's own exit callback (see runEditor).
235 return ok(await program.exited);
278 return await program.exited;
236279 } finally {
280 held.close();
237281 terminal.close();
238282 }
239283 }
@@ -292,8 +336,9 @@ export function startTerminal(
292336 cell?.watch(proc.pid, () => killTree(proc.pid));
293337 const hangup = (): void => {
294338 if (cell === undefined) {
339 // Hung up right after the start, `setsid` may not have made the group
340 // yet: signal the program itself too, so the hangup is never lost.
295341 signalGroup(proc.pid, "SIGHUP");
296 return;
297342 }
298343 try {
299344 proc.kill("SIGHUP");
src/apps/tty_registry.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * The app terminals open now (#10549): a plain registry, one entry per
3 * open terminal — which app, preview or production, who holds it, when it
4 * started, its last input and output. Account sessions list it beside the
5 * machine shells; Stop and Stop all idle end it with SIGHUP to its pty; one
6 * with no input and no output for the idle limit ends itself.
7 *
8 * Nothing here knows how a terminal is run: `runAppTty` registers what it
9 * started and tells the registry about bytes; the registry holds a hangup.
10 */
11
12 import type { App } from "../app.ts";
13
14 export interface AppTtyEntry {
15 readonly id: string;
16 /** Null for a guest: listed to no one, but still ended when idle. */
17 readonly userId: string | null;
18 readonly repository: string;
19 readonly slug: string;
20 /** `production`, `preview-12`, … */
21 readonly env: string;
22 readonly startedAt: number;
23 lastInput: number;
24 lastOutput: number;
25 /** The host went away or someone stopped it: SIGHUP to the pty and its group. */
26 readonly hangup: () => void;
27 ended: boolean;
28 timer: ReturnType<typeof setInterval>;
outdated

Clean: peers is always 1 and nothing reads it except to be copied into ShellRow, where it falsely says one person is attached. Remove it, or make it real; an app row should not claim an attached count.

29 }
30
31 const registries = new WeakMap<App, Map<string, AppTtyEntry>>();
32
33 function entriesOf(app: App): Map<string, AppTtyEntry> {
34 let map = registries.get(app);
35 if (map === undefined) registries.set(app, (map = new Map()));
36 return map;
37 }
38
39 export interface AppTtyHandle {
40 readonly id: string;
41 input(): void;
42 output(): void;
43 /** The terminal ended by itself: forget it. */
44 close(): void;
45 }
46
47 export interface OpenAppTty {
48 readonly userId: string | undefined;
49 readonly repository: string;
50 readonly slug: string;
51 readonly env: string;
52 readonly hangup: () => void;
53 /** The idle limit in ms, read at every check. */
54 readonly idleMs: () => number;
55 readonly checkMs?: number;
56 }
57
58 export const CHECK_MS = 5_000;
59
60 export function idleFor(e: { lastInput: number; lastOutput: number }, limitMs: number, now = Date.now()): boolean {
61 return now - e.lastInput >= limitMs && now - e.lastOutput >= limitMs;
62 }
63
64 /** Register an open app terminal. */
65 export function openAppTty(app: App, open: OpenAppTty): AppTtyHandle {
66 const now = Date.now();
67 const entry: AppTtyEntry = {
68 id: `app-${crypto.randomUUID()}`,
69 userId: open.userId ?? null,
70 repository: open.repository,
71 slug: open.slug,
72 env: open.env,
73 startedAt: now,
74 lastInput: now,
75 lastOutput: now,
76 hangup: open.hangup,
77 ended: false,
78 timer: setInterval(
79 () => {
80 if (idleFor(entry, open.idleMs())) stop(app, entry);
81 },
82 Math.max(10, Math.min(open.idleMs(), open.checkMs ?? CHECK_MS)),
83 ),
84 };
85 entriesOf(app).set(entry.id, entry);
86 return {
87 id: entry.id,
88 input: () => void (entry.lastInput = Date.now()),
89 output: () => void (entry.lastOutput = Date.now()),
90 close: () => forget(app, entry),
91 };
92 }
93
94 function forget(app: App, entry: AppTtyEntry): void {
95 entry.ended = true;
96 clearInterval(entry.timer);
97 entriesOf(app).delete(entry.id);
98 }
99
100 function stop(app: App, entry: AppTtyEntry): void {
101 if (entry.ended) return;
102 forget(app, entry);
103 entry.hangup();
104 }
105
106 /** The app terminals a person holds, newest first. */
107 export function appTtysOf(app: App, userId: string): AppTtyEntry[] {
108 return [...entriesOf(app).values()].filter((e) => e.userId === userId).sort((a, b) => b.startedAt - a.startedAt);
109 }
110
111 /** Stop one app terminal a person holds: false when they hold no such one. */
112 export function stopAppTty(app: App, userId: string, id: string): boolean {
113 const entry = entriesOf(app).get(id);
114 if (entry === undefined || entry.userId !== userId) return false;
115 stop(app, entry);
116 return true;
117 }
118
119 /** Stop every app terminal of a person's past half the idle limit; returns how many. */
120 export function stopIdleAppTtys(app: App, userId: string, idleMs: number): number {
121 let n = 0;
122 for (const e of appTtysOf(app, userId)) {
123 if (!idleFor(e, idleMs / 2)) continue;
124 stop(app, e);
125 n++;
126 }
127 return n;
128 }
129
130 /** The forge is stopping: end them all. */
131 export function stopAllAppTtys(app: App): void {
132 for (const e of [...entriesOf(app).values()]) stop(app, e);
133 }
src/http/mcp/tools/sessions.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "sessions",
7 doc: "`{ username?, action?, id?, kind? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; `kind: \"shells\"` lists your machine shells, live then ended, `action: \"end\"` with an `id` stops one and `\"end_idle\"` stops those detached and past half the idle limit; an admin-scope token, the route's refusals",
7 doc: "`{ username?, action?, id?, kind? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; `kind: \"shells\"` lists your machine shells and app terminals (rows `kind: \"app\"`), live then ended, `action: \"end\"` with an `id` stops one and `\"end_idle\"` stops those detached and past half the idle limit; an admin-scope token, the route's refusals",
88 description: "Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out.",
99 inputSchema: {
1010 type: "object",
src/machines/shell.ts +24 −1
@@ -27,6 +27,7 @@ import { dirname, join } from "node:path";
2727 import type { Database } from "bun:sqlite";
2828 import type { App } from "../app.ts";
2929 import { cellCounters, openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
30 import { appTtysOf, stopAllAppTtys, stopAppTty, stopIdleAppTtys } from "../apps/tty_registry.ts";
3031 import { startTerminal, type TerminalProgram } from "../apps/tty.ts";
3132 import { holdOpen } from "../http/caller.ts";
3233 import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts";
@@ -547,6 +548,8 @@ export const cellLabel = (repoId: string, machine: string, userId: string): stri
547548 /** One shell a person holds or held: plain data for the account page and the `sessions` tool. */
548549 export interface ShellRow {
549550 readonly id: string;
551 /** A machine shell, or an app's terminal (#10549); an app row's `machine` is its environment. */
552 readonly kind: "machine" | "app";
550553 /** `owner/name`. */
551554 readonly repository: string;
552555 readonly machine: string;
@@ -581,6 +584,7 @@ export function liveShells(app: App, userId: string): ShellRow[] {
581584 const counter = counters.get(cellLabel(s.repoId, started.machine, userId));
582585 rows.push({
583586 id: s.row,
587 kind: "machine",
584588 repository: `${repo.ownerName}/${repo.name}`,
585589 machine: started.machine,
586590 startedAt: started.started_at,
@@ -594,6 +598,22 @@ export function liveShells(app: App, userId: string): ShellRow[] {
594598 endedBecause: null,
595599 });
596600 }
601 for (const e of appTtysOf(app, userId))
602 rows.push({
603 id: e.id,
604 kind: "app",
605 repository: e.repository,
606 machine: `${e.slug} · ${e.env}`,
607 startedAt: iso(e.startedAt),
608 lastInputAt: iso(e.lastInput),
609 lastOutputAt: iso(e.lastOutput),
610 peers: 0,
611 cpuCores: null,
612 memoryMiB: null,
613 endedAt: null,
614 endReason: null,
615 endedBecause: null,
616 });
597617 return rows.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
598618 }
599619
@@ -615,6 +635,7 @@ export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellR
615635 .map(
616636 (r): ShellRow => ({
617637 id: r.id,
638 kind: "machine",
618639 repository: `${r.owner_name}/${r.name}`,
619640 machine: r.machine,
620641 startedAt: r.started_at,
@@ -633,6 +654,7 @@ export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellR
633654
634655 /** Stop one shell a person holds, by its row id: false when they hold no such live shell (another person's is the same answer). */
635656 export function stopShell(app: App, userId: string, id: string): boolean {
657 if (stopAppTty(app, userId, id)) return true;
636658 for (const s of sessionsOf(app).values()) {
637659 if (s.userId !== userId || s.row !== id) continue;
638660 end(app, s, "stopped");
@@ -649,7 +671,7 @@ export function stopIdleShells(app: App, userId: string, idleMs = shellIdleMs(ap
649671 end(app, s, "stopped");
650672 n++;
651673 }
652 return n;
674 return n + stopIdleAppTtys(app, userId, idleMs);
653675 }
654676
655677 /** The receipts, one line each; the page and the `sessions` tool say them in the same words. */
@@ -661,6 +683,7 @@ export const stoppedIdleSays = (n: number): string =>
661683 /** End every session (the forge is stopping). */
662684 export function stopShells(app: App): void {
663685 for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped");
686 stopAllAppTtys(app);
664687 }
665688
666689 /** Whether a socket's data is a shell's, so the forge's one handler can tell. */
src/tui/screens/shells.ts +1
@@ -18,6 +18,7 @@ const reading = (n: number | null, unit: string): string => (n === null ? "-" :
1818 /** One shell as a line of plain words. */
1919 export function shellLine(s: ShellRow): string {
2020 if (s.endedAt !== null) return `${s.repository} · ${s.machine} ended ${s.endedAt.slice(0, 16).replace("T", " ")}, ${s.endedBecause}`;
21 if (s.kind === "app") return `${s.repository} · ${s.machine} app terminal, running`;
2122 const cpu = reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%");
2223 return `${s.repository} · ${s.machine} running, ${s.peers === 0 ? "nobody attached" : `${s.peers} attached`}, CPU ${cpu}, memory ${reading(s.memoryMiB, " MiB")}`;
2324 }
src/web/pages/account.ts +8 −4
@@ -410,15 +410,19 @@ export function shellsView(ctx: Ctx, user: User): Html {
410410 s.endedAt === null
411411 ? row({
412412 title: html`${s.repository} · ${s.machine}`,
413 meta: html`Started ${time(s.startedAt)}`,
413 meta: html`${s.kind === "app" ? "App terminal · " : ""}Started ${time(s.startedAt)}`,
414414 trail: html`${badge("running", { tone: "success" })}
415415 <form method="post" action="/settings/shells/${s.id}/stop">${btn("Stop", { type: "submit", size: "sm", label: `Stop ${s.repository} · ${s.machine}` })}</form>`,
416416 facts: [
417417 { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" },
418418 { label: "Last output", value: s.lastOutputAt ? time(s.lastOutputAt) : "–" },
419 { label: "Attached", value: s.peers === 0 ? "nobody" : String(s.peers) },
420 { label: "CPU", value: reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%") },
421 { label: "Memory", value: reading(s.memoryMiB, " MiB") },
419 ...(s.kind === "app"
420 ? []
421 : [
422 { label: "Attached", value: s.peers === 0 ? "nobody" : String(s.peers) },
423 { label: "CPU", value: reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%") },
424 { label: "Memory", value: reading(s.memoryMiB, " MiB") },
425 ]),
422426 ],
423427 data: { session: s.id },
424428 })
test/app-tty-sessions.test.ts added +177
@@ -0,0 +1,177 @@
1 /**
2 * An open app terminal is listed beside the machine shells (#10549) and
3 * stops like one: Stop and Stop all idle hang up its pty, and one with no
4 * input and no output for the idle limit ends itself.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { serveTerminal, startTerminal, type TtyHost } from "../src/apps/tty.ts";
12 import { appTtysOf, openAppTty } from "../src/apps/tty_registry.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { myShells, stopIdleShells, stopShell, stopShells } from "../src/machines/shell.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let app: App;
20 let aliceId: string;
21 let bobId: string;
22
23 beforeAll(async () => {
24 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "app-tty-")), port: 0 });
25 const a = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: true, trusted: true });
26 const b = await createUser(app.db, { username: "bob", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true });
27 if (!a.ok || !b.ok) throw new Error("seed");
28 aliceId = a.value.id;
29 bobId = b.value.id;
30 });
31
32 afterAll(() => {
33 stopShells(app);
34 app.close();
35 });
36
37 /** A host that records what the pty printed and hands the test its callbacks. */
38 function fakeHost() {
39 const out: string[] = [];
40 let data: (chunk: Uint8Array) => void = () => {};
41 let close: () => void = () => {};
42 const host: TtyHost = {
43 cols: 80,
44 rows: 24,
45 term: "xterm",
46 write: (bytes) => void out.push(new TextDecoder().decode(bytes)),
47 onData: (cb) => void (data = cb),
48 onResize: () => {},
49 onClose: (cb) => void (close = cb),
50 };
51 return { host, out, send: (text: string) => data(new TextEncoder().encode(text)), hangUp: () => close() };
52 }
53
54 /** What `runAppTty` hands `serveTerminal`: the real bridge, on a real pty. */
55 function open(userId: string | undefined, command = "exec sleep 300", idleMs = 600_000, checkMs?: number) {
56 const fake = fakeHost();
57 let pid = 0;
58 let id = "";
59 const done = serveTerminal(fake.host, {
60 command,
61 env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin" },
62 cwd: tmpdir(),
63 cell: undefined,
64 register: (program) => {
65 pid = program.pid;
66 const handle = openAppTty(app, { userId, repository: "alice/notes", slug: "notes", env: "preview-12", hangup: program.hangup, idleMs: () => idleMs, ...(checkMs ? { checkMs } : {}) });
67 id = handle.id;
68 return handle;
69 },
70 });
71 return { ...fake, done, pid, id };
72 }
73
74 /** Make a person's terminals look idle for `ms` without waiting for it. */
75 function age(userId: string, ms: number) {
76 for (const e of appTtysOf(app, userId)) {
77 e.lastInput = Date.now() - ms;
78 e.lastOutput = Date.now() - ms;
79 }
80 }
81
82 async function until(ok: () => boolean) {
83 for (let i = 0; i < 600 && !ok(); i++) await Bun.sleep(25);
84 expect(ok()).toBe(true);
85 }
86
87 const alive = (pid: number): boolean => {
88 try {
89 process.kill(pid, 0);
outdated

Tests must never flake: 200 ms limit, 150 ms sleep, and a self-idle timer firing every 200 ms race each other on a loaded machine (the program can be stopped by its own timer before stopIdleShells counts it). Use a long idle limit with explicit lastInput ageing, or an injected clock, instead of wall-clock margins.

90 return true;
91 } catch {
92 return false;
93 }
94 };
95
96 test("an open app terminal is listed, as an app row, for its holder only", async () => {
97 const { done } = open(aliceId);
98 const rows = myShells(app, aliceId);
99 expect(rows).toHaveLength(1);
100 expect(rows[0]).toMatchObject({ kind: "app", repository: "alice/notes", machine: "notes · preview-12", peers: 0, endedAt: null });
101 expect(myShells(app, bobId)).toEqual([]);
102 expect(stopShell(app, bobId, rows[0]!.id)).toBe(false);
103 expect(stopShell(app, aliceId, rows[0]!.id)).toBe(true);
104 await done;
105 });
106
107 test("Stop hangs up the pty and its process ends", async () => {
108 const { done, pid } = open(aliceId);
109 expect(alive(pid)).toBe(true);
110 const id = myShells(app, aliceId)[0]!.id;
111 expect(stopShell(app, aliceId, id)).toBe(true);
112 await done;
113 expect(myShells(app, aliceId)).toEqual([]);
114 expect(stopShell(app, aliceId, id)).toBe(false);
115 });
116
117 test("the real bridge stamps input and output, and forgets the terminal when the host goes", async () => {
118 const t = open(aliceId, "exec cat");
119 age(aliceId, 500_000);
120 const before = appTtysOf(app, aliceId)[0]!;
121 const [oldIn, oldOut] = [before.lastInput, before.lastOutput];
122 t.send("hello\n");
123 await until(() => t.out.join("").includes("hello"));
124 const e = appTtysOf(app, aliceId)[0]!;
125 expect(e.lastInput).toBeGreaterThan(oldIn + 400_000);
126 expect(e.lastOutput).toBeGreaterThan(oldOut + 400_000);
127 t.hangUp();
128 await t.done;
129 expect(appTtysOf(app, aliceId)).toEqual([]);
130 });
131
132 test("a program that exits by itself is forgotten and its code returned", async () => {
133 const t = open(aliceId, "exit 3");
134 expect(await t.done).toBe(3);
135 expect(appTtysOf(app, aliceId)).toEqual([]);
136 });
137
138 test("a hangup straight after the start still ends the process", async () => {
139 for (let i = 0; i < 5; i++) {
140 const program = startTerminal("exec sleep 300", {
141 terminal: new Bun.Terminal({ cols: 80, rows: 24, data: () => {} }),
142 env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin" },
143 cwd: tmpdir(),
144 cell: undefined,
145 });
146 program.hangup();
147 await program.exited;
148 expect(alive(program.pid)).toBe(false);
149 }
150 });
151
152 test("Stop all idle stops an app terminal past half the limit, not a busy one", async () => {
153 const idle = open(aliceId);
154 const busy = open(aliceId);
155 const aged = appTtysOf(app, aliceId).find((e) => e.id === idle.id)!;
156 aged.lastInput = aged.lastOutput = Date.now() - 400_000;
157 expect(stopIdleShells(app, aliceId, 600_000)).toBe(1);
158 await idle.done;
159 expect(appTtysOf(app, aliceId).map((e) => e.id)).toEqual([busy.id]);
160 expect(stopShell(app, aliceId, busy.id)).toBe(true);
161 await busy.done;
162 });
163
164 test("one with no input and no output for the idle limit ends itself", async () => {
165 const { done } = open(aliceId, "exec sleep 300", 600_000, 10);
166 expect(myShells(app, aliceId)).toHaveLength(1);
167 age(aliceId, 3_600_000);
168 await done;
169 expect(myShells(app, aliceId)).toEqual([]);
170 });
171
172 test("a guest's terminal is listed to no one but still ends when idle", async () => {
173 // A tiny limit, and the test only waits for the end: no margin to lose.
174 const { done } = open(undefined, "exec sleep 300", 50, 10);
175 expect(myShells(app, aliceId)).toEqual([]);
176 await done;
177 });
test/my-shells.test.ts +15
@@ -12,6 +12,8 @@ import { createRepo, type Repo } from "../src/core/repos.ts";
1212 import { createSession } from "../src/core/sessions.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import { openAppTty } from "../src/apps/tty_registry.ts";
16 import { shellLine } from "../src/tui/screens/shells.ts";
1517 import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopIdleShells, stopShells } from "../src/machines/shell.ts";
1618 import { createForgeApp } from "../src/tui/forge.ts";
1719 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -264,3 +266,16 @@ test("terminal: s stops the picked live shell with the page's receipt", async ()
264266 tui.stop();
265267 ws.close();
266268 });
269
270 test("an app terminal is a row on the account page and a line in the terminal, claiming no attached count", async () => {
271 const handle = openAppTty(app, { userId: aliceId, repository: "alice/lab", slug: "lab", env: "production", hangup: () => {}, idleMs: () => 600_000 });
272 const row = myShells(app, aliceId).find((r) => r.id === handle.id)!;
273 expect(row.kind).toBe("app");
274 expect(shellLine(row)).toBe("alice/lab · lab · production app terminal, running");
275 const html = await page();
276 expect(html).toContain("App terminal · Started");
277 expect(html).toContain(`/settings/shells/${handle.id}/stop`);
278 const mine = html.slice(html.indexOf(`data-session="${handle.id}"`));
279 expect(mine.slice(0, mine.indexOf("</li>"))).not.toContain("Attached");
280 handle.close();
281 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs