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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10549-an-app-terminal-you-have-open-is-in-your → main 4 commits fork 3 threads · 3 unresolved
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 | ||
| 19 | 19 | 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. |
| 20 | 20 | |
| 21 | 21 | 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 { | ||
| 36 | 36 | } from "./data.ts"; |
| 37 | 37 | import { appEnv, signalGroup } from "./process.ts"; |
| 38 | 38 | import { appsRuntime } from "./runtime.ts"; |
| 39 | import { openAppTty, type AppTtyHandle } from "./tty_registry.ts"; | |
| 40 | import { shellIdleMs } from "../machines/shell.ts"; | |
| 39 | 41 | |
| 40 | 42 | const CRLF = "\r\n"; |
| 41 | 43 | |
| @@ -165,11 +167,6 @@ export async function runAppTty( | ||
| 165 | 167 | } |
| 166 | 168 | if (live.tty === null) return err({ refusal: "no_tty", detail: "" }); |
| 167 | 169 | |
| 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 | }); | |
| 173 | 170 | const env = appEnv({ |
| 174 | 171 | port: live.port, |
| 175 | 172 | dataDir: live.dataDir, |
| @@ -201,19 +198,65 @@ export async function runAppTty( | ||
| 201 | 198 | user === undefined ? "" : canWrite(forge.db, repo, user) ? "admin" : "user"; |
| 202 | 199 | // The terminal is held exactly as the app is (`apps/contain.ts`). |
| 203 | 200 | 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), | |
| 206 | 203 | env, |
| 207 | 204 | cwd: live.dir, |
| 208 | 205 | 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, | |
| 217 | hangup: program.hangup, | |
| 218 | idleMs: () => shellIdleMs(forge.db), | |
| 219 | }); | |
| 220 | }, | |
| 209 | 221 | }); |
| 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 | } | |
| 215 | 224 | |
| 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); | |
| 216 | 258 | host.onData((chunk) => { |
| 259 | held?.input(); | |
| 217 | 260 | if (terminal.closed) return; |
| 218 | 261 | try { |
| 219 | 262 | terminal.write(chunk); |
| @@ -232,8 +275,9 @@ export async function runAppTty( | ||
| 232 | 275 | host.onClose?.(program.hangup); |
| 233 | 276 | try { |
| 234 | 277 | // `exited`, never the pty's own exit callback (see runEditor). |
| 235 | return ok(await program.exited); | |
| 278 | return await program.exited; | |
| 236 | 279 | } finally { |
| 280 | held.close(); | |
| 237 | 281 | terminal.close(); |
| 238 | 282 | } |
| 239 | 283 | } |
| @@ -292,8 +336,9 @@ export function startTerminal( | ||
| 292 | 336 | cell?.watch(proc.pid, () => killTree(proc.pid)); |
| 293 | 337 | const hangup = (): void => { |
| 294 | 338 | 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. | |
| 295 | 341 | signalGroup(proc.pid, "SIGHUP"); |
| 296 | return; | |
| 297 | 342 | } |
| 298 | 343 | try { |
| 299 | 344 | 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: |
||
| 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"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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", | |
| 8 | 8 | 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.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/machines/shell.ts +24 −1
| @@ -27,6 +27,7 @@ import { dirname, join } from "node:path"; | ||
| 27 | 27 | import type { Database } from "bun:sqlite"; |
| 28 | 28 | import type { App } from "../app.ts"; |
| 29 | 29 | import { cellCounters, openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts"; |
| 30 | import { appTtysOf, stopAllAppTtys, stopAppTty, stopIdleAppTtys } from "../apps/tty_registry.ts"; | |
| 30 | 31 | import { startTerminal, type TerminalProgram } from "../apps/tty.ts"; |
| 31 | 32 | import { holdOpen } from "../http/caller.ts"; |
| 32 | 33 | import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts"; |
| @@ -547,6 +548,8 @@ export const cellLabel = (repoId: string, machine: string, userId: string): stri | ||
| 547 | 548 | /** One shell a person holds or held: plain data for the account page and the `sessions` tool. */ |
| 548 | 549 | export interface ShellRow { |
| 549 | 550 | 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"; | |
| 550 | 553 | /** `owner/name`. */ |
| 551 | 554 | readonly repository: string; |
| 552 | 555 | readonly machine: string; |
| @@ -581,6 +584,7 @@ export function liveShells(app: App, userId: string): ShellRow[] { | ||
| 581 | 584 | const counter = counters.get(cellLabel(s.repoId, started.machine, userId)); |
| 582 | 585 | rows.push({ |
| 583 | 586 | id: s.row, |
| 587 | kind: "machine", | |
| 584 | 588 | repository: `${repo.ownerName}/${repo.name}`, |
| 585 | 589 | machine: started.machine, |
| 586 | 590 | startedAt: started.started_at, |
| @@ -594,6 +598,22 @@ export function liveShells(app: App, userId: string): ShellRow[] { | ||
| 594 | 598 | endedBecause: null, |
| 595 | 599 | }); |
| 596 | 600 | } |
| 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 | }); | |
| 597 | 617 | return rows.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); |
| 598 | 618 | } |
| 599 | 619 | |
| @@ -615,6 +635,7 @@ export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellR | ||
| 615 | 635 | .map( |
| 616 | 636 | (r): ShellRow => ({ |
| 617 | 637 | id: r.id, |
| 638 | kind: "machine", | |
| 618 | 639 | repository: `${r.owner_name}/${r.name}`, |
| 619 | 640 | machine: r.machine, |
| 620 | 641 | startedAt: r.started_at, |
| @@ -633,6 +654,7 @@ export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellR | ||
| 633 | 654 | |
| 634 | 655 | /** 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). */ |
| 635 | 656 | export function stopShell(app: App, userId: string, id: string): boolean { |
| 657 | if (stopAppTty(app, userId, id)) return true; | |
| 636 | 658 | for (const s of sessionsOf(app).values()) { |
| 637 | 659 | if (s.userId !== userId || s.row !== id) continue; |
| 638 | 660 | end(app, s, "stopped"); |
| @@ -649,7 +671,7 @@ export function stopIdleShells(app: App, userId: string, idleMs = shellIdleMs(ap | ||
| 649 | 671 | end(app, s, "stopped"); |
| 650 | 672 | n++; |
| 651 | 673 | } |
| 652 | return n; | |
| 674 | return n + stopIdleAppTtys(app, userId, idleMs); | |
| 653 | 675 | } |
| 654 | 676 | |
| 655 | 677 | /** 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 => | ||
| 661 | 683 | /** End every session (the forge is stopping). */ |
| 662 | 684 | export function stopShells(app: App): void { |
| 663 | 685 | for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped"); |
| 686 | stopAllAppTtys(app); | |
| 664 | 687 | } |
| 665 | 688 | |
| 666 | 689 | /** 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 ? "-" : | ||
| 18 | 18 | /** One shell as a line of plain words. */ |
| 19 | 19 | export function shellLine(s: ShellRow): string { |
| 20 | 20 | 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`; | |
| 21 | 22 | const cpu = reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%"); |
| 22 | 23 | return `${s.repository} · ${s.machine} running, ${s.peers === 0 ? "nobody attached" : `${s.peers} attached`}, CPU ${cpu}, memory ${reading(s.memoryMiB, " MiB")}`; |
| 23 | 24 | } |
src/web/pages/account.ts +8 −4
| @@ -410,15 +410,19 @@ export function shellsView(ctx: Ctx, user: User): Html { | ||
| 410 | 410 | s.endedAt === null |
| 411 | 411 | ? row({ |
| 412 | 412 | 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)}`, | |
| 414 | 414 | trail: html`${badge("running", { tone: "success" })} |
| 415 | 415 | <form method="post" action="/settings/shells/${s.id}/stop">${btn("Stop", { type: "submit", size: "sm", label: `Stop ${s.repository} · ${s.machine}` })}</form>`, |
| 416 | 416 | facts: [ |
| 417 | 417 | { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" }, |
| 418 | 418 | { 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 | ]), | |
| 422 | 426 | ], |
| 423 | 427 | data: { session: s.id }, |
| 424 | 428 | }) |
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"; | ||
| 12 | 12 | import { createSession } from "../src/core/sessions.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | 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"; | |
| 15 | 17 | import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopIdleShells, stopShells } from "../src/machines/shell.ts"; |
| 16 | 18 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 19 | 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 () | ||
| 264 | 266 | tui.stop(); |
| 265 | 267 | ws.close(); |
| 266 | 268 | }); |
| 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 | }); |
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.