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

A small forge loads only what it uses: lazy-load the TUI, boards, studio and the like #12355

Merged opened this

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

Merged

mason/forge:mason/12349-a-small-forge-loads-only-what-it-uses-la → main 6 commits fork 3 threads · 3 unresolved

  • Merged. mason/12349-a-small-forge-loads-only-what-it-uses-la was merged into main as 23f74da.

Review

Sign in to review this change.
8 files changed +226 −30
src/actions/tell_answer.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * The words of one tell, refused or told, with the tone they are shown in
3 * (shared by the terminal screen and the ssh `tell` command).
4 */
5
6 import { toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "./tell.ts";
7 import { NOT_TOLD_WORDS } from "./tell_words.ts";
8 import { DIRECTION_NOTE } from "../crew/direct.ts";
9 import type { Result } from "../kernel/result.ts";
10
11 export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } {
12 if (told.ok) return { tone: told.value.routed === undefined || told.value.assigned ? "success" : "danger", text: toldWords(told.value) };
13 return {
14 tone: "danger",
15 text:
16 told.error === "forbidden"
17 ? DIRECTION_NOTE
18 : told.error === "unverified"
19 ? UNVERIFIED_WORDS
20 : told.error === "body_too_long"
21 ? TOO_LONG_WORDS
22 : NOT_TOLD_WORDS,
23 };
24 }
src/tui/screens/answers.ts +1 −1
@@ -20,7 +20,7 @@ import { emptyState } from "../ui/widgets.ts";
2020 import { style } from "../term/style.ts";
2121 import { truncate } from "../term/width.ts";
2222 import { ListState, drawList, hitList, metaOf, openOnWeb, promptScreen, relTime, type ListRow } from "./common.ts";
23 import { tellAnswer } from "./tell.ts";
23 import { tellAnswer } from "../../actions/tell_answer.ts";
2424
2525 /** One line of plain text: a tell or an answer may carry escapes the terminal must not obey. */
2626 const plain = (text: string): string =>
src/tui/screens/tell.ts +4 −20
@@ -8,33 +8,17 @@
88 * the same `parseContext` and `contextNote` the pill's tell and the MCP use.
99 */
1010
11 import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts";
11 import { SENT_WORDS, tell } from "../../actions/tell.ts";
12 import { tellAnswer } from "../../actions/tell_answer.ts";
1213 import { contextNote, parseContext } from "../../actions/tell_context.ts";
13 import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts";
14 import { NOT_TOLD_FROM_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts";
1415 import type { Repo } from "../../core/repos.ts";
15 import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts";
16 import type { Result } from "../../kernel/result.ts";
16 import { mayDirectCrew } from "../../crew/direct.ts";
1717 import type { Ctx, Screen } from "../app/app.ts";
1818 import type { Command } from "../app/palette.ts";
1919 import type { Session } from "../session.ts";
2020 import { later, promptScreen } from "./common.ts";
outdated

Re-export between imports; drop it and keep imports together.

2121
22 /** The words of one tell, refused or told, with the tone they are shown in. */
23 export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } {
24 if (told.ok) return { tone: told.value.routed === undefined || told.value.assigned ? "success" : "danger", text: toldWords(told.value) };
25 return {
26 tone: "danger",
27 text:
28 told.error === "forbidden"
29 ? DIRECTION_NOTE
30 : told.error === "unverified"
31 ? UNVERIFIED_WORDS
32 : told.error === "body_too_long"
33 ? TOO_LONG_WORDS
34 : NOT_TOLD_WORDS,
35 };
36 }
37
3822 /** The palette row for `repo`, or none for a reader who may not direct the crew. */
3923 export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo | undefined): Command[] {
4024 if (repo === undefined || !mayDirectCrew(f.db, repo, f.user)) return [];
src/tui/ssh-tell.ts +1 −1
@@ -19,7 +19,7 @@ import type { Repo } from "../core/repos.ts";
1919 import type { User } from "../core/users.ts";
2020 import type { EnvName } from "../apps/data.ts";
2121 import type { SshSession } from "../ssh/server.ts";
22 import { tellAnswer } from "./screens/tell.ts";
22 import { tellAnswer } from "../actions/tell_answer.ts";
2323 import { AUTONOMY_FAILED_WORDS, asksMakeANewApp, firstAsk } from "../actions/first_ask.ts";
2424 import { crewCanRun } from "../web/studio/crew_key.ts";
2525
src/tui/ssh.ts +45 −7
@@ -29,10 +29,10 @@ import { heldPushConfig, readerScope } from "../git/own_refs.ts";
2929 import { holdFor, lendersBeyond } from "../core/fork_reach.ts";
3030 import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
3131 import { err, ok, type Result } from "../kernel/result.ts";
32 import { createForgeApp } from "./forge.ts";
3332 import { appForLogin, attachAppTty } from "../apps/tty.ts";
3433 import { servePulls } from "../apps/tty_pulls.ts";
3534 import type { Terminal } from "./term/terminal.ts";
35 import type { createForgeApp as CreateForgeApp } from "./forge.ts";
3636 import type { TuiIdentity } from "./session.ts";
3737 import { GUEST_RATE_WORDS, GuestRate } from "./ssh-guest-rate.ts";
3838 import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts";
@@ -117,12 +117,42 @@ export function sshTerminal(
117117 };
118118 }
119119
120 /** Run the terminal UI for one shell session. */
121 export function attachTui(
120 /**
121 * The session as the screens will see it, with its input held from now: the
122 * first shell waits on the import of the screens, and what the person types
123 * meanwhile is replayed, in order, to the handler that registers after it.
124 * (A channel keeps one data handler; size and window-change are read live,
125 * so the size the screens ask for is the window as it is by then.)
126 */
127 function held(session: SshSession): SshSession {
128 const early: Uint8Array[] = [];
129 let live: ((data: Uint8Array) => void) | undefined;
130 session.onData((data) => (live === undefined ? early.push(data) : live(data)));
131 return new Proxy(session, {
132 get(target, key) {
133 if (key === "onData")
134 return (cb: (data: Uint8Array) => void) => {
135 live = cb;
136 for (const data of early.splice(0)) cb(data);
137 };
138 const value = Reflect.get(target, key, target);
139 return typeof value === "function" ? value.bind(target) : value;
140 },
141 });
142 }
outdated

Awaiting this import before wiring the session can lose early input/window-change on the first shell. Register handlers first or buffer.

143
144 /**
145 * Run the terminal UI for one shell session. The screens (some three hundred
146 * modules) are imported here, on the first shell, so a forge nobody opens a
147 * terminal on never holds them (#12349).
148 */
149 export async function attachTui(
122150 forge: Forge,
123 session: SshSession,
151 shell: SshSession,
124152 identity: TuiIdentity,
125 ): void {
153 load: () => Promise<{ createForgeApp: typeof CreateForgeApp }> = () => import("./forge.ts"),
154 ): Promise<void> {
155 const session = held(shell);
126156 // A pty request is the ask; its *size* may legitimately be 0×0 (OpenSSH
127157 // sends that whenever its own stdin is not a terminal), so `term` is what
128158 // decides, and the size falls back to 80×24 until a window-change arrives.
@@ -136,6 +166,7 @@ export function attachTui(
136166 // The session reads as its person: in a fork whose parent they may not
137167 // read, an id is held to the fork's own refs, as on the web (`git/own_refs.ts`).
138168 const asPerson = readerScope(lendersBeyond(forge.db, forge.reposRoot, identity.user));
169 const { createForgeApp } = await load();
139170 const { app } = createForgeApp(forge, identity, {
140171 terminal: sshTerminal(
141172 session,
@@ -223,7 +254,10 @@ const guestRates = new WeakMap<Forge, GuestRate>();
223254 * is git, `tell`, or nothing. Never throws — a fault here would take the listener with
224255 * it, and one bad client must not.
225256 */
226 export function forgeSshSession(forge: Forge): (session: SshSession) => void {
257 export function forgeSshSession(
258 forge: Forge,
259 loadTui?: Parameters<typeof attachTui>[3],
260 ): (session: SshSession) => void {
227261 let guests = guestRates.get(forge);
228262 if (guests === undefined) guestRates.set(forge, (guests = new GuestRate()));
229263 // A guest's tell and talk start work; they share one rate (see ssh-guest-rate.ts).
@@ -251,7 +285,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
251285 // that app's own terminal (docs/APPS.md).
252286 const app = appForLogin(forge, session.username);
253287 if (app !== undefined) attachAppTty(forge, session, identity.user, app);
254 else attachTui(forge, session, identity);
288 else
289 void attachTui(forge, session, identity, loadTui).catch((error) => {
290 forge.log.error("tui session fault", { error: String(error), peer: session.remoteAddress });
291 session.exit(1);
292 });
255293 return;
256294 }
257295 // An exec on an app's login that git does not speak: a small table.
test/boot-lazy.test.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * A small forge loads only what it uses (#12349): the terminal UI is imported
outdated

"the terminal UI are imported" — grammar; and the test never proves the lazy path actually loads.

3 * when a shell opens, not by the boot's static imports.
4 */
5 import { expect, test } from "bun:test";
6 import { existsSync, readFileSync } from "node:fs";
7 import { dirname, resolve } from "node:path";
8
9 const FROM = /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)"|(?:^|\n)\s*import\s+"(\.[^"]+)"/g;
10
11 function staticClosure(entry: string): Set<string> {
12 const seen = new Set<string>();
13 const todo = [entry];
14 while (todo.length) {
15 const file = todo.pop()!;
16 if (seen.has(file)) continue;
17 seen.add(file);
18 for (const m of readFileSync(file, "utf8").matchAll(FROM)) {
19 const path = resolve(dirname(file), m[1] ?? m[2]!);
20 if (existsSync(path)) todo.push(path);
21 }
22 }
23 return seen;
24 }
25
26 test("the boot's static imports do not reach the terminal UI", () => {
27 const closure = [...staticClosure(resolve("src/main.ts"))];
28 const tui = closure.filter((f) => f.includes("/src/tui/screens/") || f.endsWith("/src/tui/forge.ts"));
29 expect(tui).toEqual([]);
30 });
test/ssh-shell-cold.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The first shell waits on the import of the terminal UI (#12349): what the
3 * person types meanwhile is not lost, a failed import ends the session with
4 * exit 1 and a log, and the real import is reached on a shell.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { forgeSshSession } from "../src/tui/ssh.ts";
14 import type { createForgeApp as CreateForgeApp } from "../src/tui/forge.ts";
15 import type { SshSession } from "../src/ssh/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo carol";
21
22 let dir = "";
23 let app: App;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-ssh-cold-"));
27 app = createApp({ dataDir: dir, port: 0, forgeRepo: "carol/forge", url: "https://forge.test" });
28 const u = await createUser(app.db, {
29 origin: "seed",
30 trusted: true,
31 username: "carol",
32 email: "[email protected]",
33 password: "a-good-passphrase",
34 adminIfFirst: true,
35 });
36 if (!u.ok) throw new Error(u.error);
37 const parsed = parseAuthorizedKey(KEY);
38 if (!parsed.ok) throw new Error("key");
39 addKey(app.db, u.value.id, "laptop", parsed.value);
40 });
41
42 afterAll(async () => {
43 app.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const shell = () => {
48 const parsed = parseAuthorizedKey(KEY);
49 if (!parsed.ok) throw new Error("key");
50 const seen = { out: "", code: undefined as number | undefined, data: undefined as ((d: Uint8Array) => void) | undefined };
51 const session: SshSession = {
52 key: { algo: parsed.value.algo, blob: parsed.value.blob, fingerprint: fingerprint(parsed.value.blob), line: KEY },
53 username: "whoever",
54 remoteAddress: "203.0.113.9",
55 kind: "shell",
56 command: undefined,
57 term: "xterm-256color",
58 env: {},
59 cols: 80,
60 rows: 24,
61 widthPx: 0,
62 heightPx: 0,
63 ended: false,
64 pending: 0,
65 write: (d: Uint8Array | string) => void (seen.out += typeof d === "string" ? d : Buffer.from(d).toString()),
66 writeStderr: () => {},
67 onData(cb: (d: Uint8Array) => void) {
68 seen.data = cb;
69 },
70 onResize() {},
71 onEof() {},
72 onClose() {},
73 exit(c: number) {
74 seen.code ??= c;
75 },
76 drain: async () => {},
77 } as unknown as SshSession;
78 return { session, seen };
79 };
80
81 test("input typed while the screens load reaches them, in order, with the window as it is", async () => {
82 const { session, seen } = shell();
83 const got: string[] = [];
84 let started: { cols: number; rows: number } | undefined;
85 let release!: () => void;
86 const gate = new Promise<void>((r) => (release = r));
87 forgeSshSession(app, async () => {
88 await gate;
89 return {
90 createForgeApp: ((_f: unknown, _i: unknown, opts: { terminal: { onInput(cb: (d: Uint8Array) => void): void; size(): { cols: number; rows: number } } }) => {
91 opts.terminal.onInput((d) => got.push(Buffer.from(d).toString()));
92 return { app: { start: () => void (started = opts.terminal.size()), stop() {} } };
93 }) as unknown as typeof CreateForgeApp,
94 };
95 })(session);
96 // Before the import resolves: typing, and a window-change.
97 seen.data!(Buffer.from("a"));
98 seen.data!(Buffer.from("b"));
99 (session as { cols: number }).cols = 120;
100 release();
101 await Bun.sleep(10); // real-timer: the import promise settles on the microtask queue; a tick is enough
102 seen.data!(Buffer.from("c"));
103 expect(got).toEqual(["a", "b", "c"]);
104 expect(started).toEqual({ cols: 120, rows: 24 });
105 });
106
107 test("a failing import ends the session with exit 1 and a log", async () => {
108 const { session, seen } = shell();
109 forgeSshSession(app, () => Promise.reject(new Error("no such module")))(session);
110 await Bun.sleep(10); // real-timer: the import promise settles on the microtask queue; a tick is enough
111 expect(seen.code).toBe(1);
112 });
113
114 test("the real import is reached on a shell: the terminal UI draws its first frame", async () => {
115 const { session, seen } = shell();
116 forgeSshSession(app)(session);
117 for (let i = 0; i < 100 && seen.out === ""; i++) await Bun.sleep(50); // real-timer: the real dynamic import has no hook to await
118 expect(seen.out).not.toBe("");
119 expect(seen.code).toBeUndefined();
120 });
test/tui-tell.test.ts +1 −1
@@ -11,7 +11,7 @@ import { createRepo } from "../src/actions/repos.ts";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { listIssues, MAX_BODY } from "../src/core/issues.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
14 import { tellAnswer } from "../src/tui/screens/tell.ts";
14 import { tellAnswer } from "../src/actions/tell_answer.ts";
1515 import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told } from "../src/actions/tell.ts";
1616 import { createForgeApp } from "../src/tui/forge.ts";
1717 import { repoScreen } from "../src/tui/screens/repo.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs