>): ComponentType {
+ let loaded: ComponentType
| undefined;
+ let pending: Promise> | undefined;
+ const get = () =>
+ (pending ??= load().then(
+ (c) => (loaded = c),
+ (err) => {
+ // A failed fetch can be tried again on the next visit.
+ pending = undefined;
+ throw err;
+ },
+ ));
+ loaders.push(get);
+ const Lazy = lazy(() => get().then((c) => ({ default: c })));
+ function View(props: P) {
+ // Chosen once per mount: switching from Lazy to the loaded component
+ // under a mounted view would remount it.
+ const [Ready] = useState(() => loaded);
+ return Ready ? : ;
+ }
+ return View;
+}
+
+// preloadViews loads every lazy view's code a while after the app starts,
+// one at a time when the main thread is free. WebKit (the macOS and Linux
+// app) has no requestIdleCallback; there it is a timer.
+export function preloadViews(): () => void {
+ const ric = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
+ const idle = (cb: () => void) => (ric ? ric(cb, { timeout: 2000 }) : window.setTimeout(cb, 50));
+ const cancel = (h: number) => (ric ? window.cancelIdleCallback(h) : window.clearTimeout(h));
+ let i = 0;
+ let handle = 0;
+ let stopped = false;
+ const next = () => {
+ const load = loaders[i++];
+ if (!load || stopped) return;
+ void load()
+ .catch(() => {})
+ .finally(() => {
+ if (!stopped) handle = idle(next);
+ });
+ };
+ // After the first screen has settled.
+ const start = window.setTimeout(() => (handle = idle(next)), 1500);
+ return () => {
+ stopped = true;
+ window.clearTimeout(start);
+ cancel(handle);
+ };
+}
diff --git a/app/src/lib/project-groups.ts b/app/src/lib/project-groups.ts
index 032fc671..2fe1a897 100644
--- a/app/src/lib/project-groups.ts
+++ b/app/src/lib/project-groups.ts
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { create } from "zustand";
+import { useShallow } from "zustand/react/shallow";
import type { BoxStatus, Location } from "@/lib/api";
import { projectKey } from "@/lib/projects";
@@ -156,9 +157,15 @@ export function deriveProjects(boxes: BoxStatus[], data: Record s.status?.boxes ?? NONE);
- const data = useStore((s) => s.boxes);
+ // Only the boxes' locations: an agent's state changing (every few
+ // seconds on a busy box) leaves the projects, and the sidebar's rows drawn
+ // from them, as they are.
+ const locations = useStore(useShallow((s) => Object.fromEntries(Object.entries(s.boxes).map(([name, d]) => [name, d.locations]))));
const doc = useProjectsDoc((s) => s.doc);
- return useMemo(() => ({ projects: deriveProjects(boxes, data, doc), sections: doc.sections }), [boxes, data, doc]);
+ return useMemo(() => {
+ const data = Object.fromEntries(Object.entries(locations).map(([name, l]) => [name, { locations: l }]));
+ return { projects: deriveProjects(boxes, data, doc), sections: doc.sections };
+ }, [boxes, locations, doc]);
}
export function projectOf(box: string, location: string): Project | undefined {
diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts
index 91e7eec3..edfad43f 100644
--- a/app/src/lib/start-work.ts
+++ b/app/src/lib/start-work.ts
@@ -10,7 +10,6 @@ import { startBroadcast } from "@/lib/broadcast";
import type { AgentPick, ComposerTarget } from "@/lib/composer";
import { agentLabel } from "@/lib/derive";
import { plainError } from "@/lib/errors";
-import { playTurn } from "@/lib/mock-conversation";
import { handoff, loop, review } from "@/lib/orchestrate";
import { worktreeSlug } from "@/lib/projects";
import { usePrompts } from "@/lib/prompts";
@@ -140,7 +139,7 @@ export async function startWork(d: StartDraft): Promise {
}
// The demo plays a scripted turn; it starts before the pane opens, so
// the pane finds the conversation already begun.
- if (isMock() && d.text) void playTurn(d.box, session, d.text);
+ if (isMock() && d.text) void import("@/lib/mock-conversation").then((m) => m.playTurn(d.box, session, d.text));
await useStore.getState().refreshBox(d.box, ["locations", "sessions"]);
await focusSession(d.box, session);
save(`berth.composer.picks.${d.box}/${d.location}`, d.picks);
diff --git a/app/src/lib/transcript-feed.ts b/app/src/lib/transcript-feed.ts
index b9798b3e..ebbc5d4a 100644
--- a/app/src/lib/transcript-feed.ts
+++ b/app/src/lib/transcript-feed.ts
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { boxApi, type Client, type QueuedPrompt } from "@/lib/api";
+import type { ChatSignals } from "@/lib/chat-controls";
+import { noteSignals } from "@/lib/chat-signals";
import { keyOf, offOf, useConversations } from "@/lib/conversation-store";
import { dropOlder, historyApi } from "@/lib/history";
import { useEventLog } from "@/lib/events";
@@ -35,6 +37,9 @@ export interface TranscriptResult {
reset?: boolean;
start?: number;
file?: string;
+ // The agent's mode, model, context and tasks, for the chat's controls
+ // (lib/chat-signals).
+ signals?: ChatSignals;
}
// The record each chat shows (newer boxes), kept while the app runs: a
@@ -142,6 +147,7 @@ export function useTranscriptFeed(box: string, session: string, dir: string | un
const r = await client.box(box, "GET", `sessions/${encodeURIComponent(session)}/transcript?since=${since}${g}`, undefined, ctl.signal);
if (!alive) return;
failures = 0;
+ noteSignals(key, r.signals);
if (r.source === "none") {
setState("none");
return;
diff --git a/app/src/plugins/dynamic-icon.tsx b/app/src/plugins/dynamic-icon.tsx
new file mode 100644
index 00000000..f366844b
--- /dev/null
+++ b/app/src/plugins/dynamic-icon.tsx
@@ -0,0 +1,21 @@
+import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic";
+
+// lucide's map of every icon by name (some 1,700 lazy imports) is large, so
+// it loads with the first icon a plugin or a sidebar item names, not at
+// start (plugins/ui.tsx's Icon).
+
+const known = new Set(iconNames);
+
+// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name.
+export function iconName(name: string): IconName | undefined {
+ const kebab = name
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
+ .toLowerCase();
+ return known.has(kebab) ? (kebab as IconName) : undefined;
+}
+
+export default function NamedIcon({ name, className }: { name: string; className?: string }) {
+ const n = iconName(name);
+ return n ? : ;
+}
diff --git a/app/src/plugins/ui.tsx b/app/src/plugins/ui.tsx
index 4f08c161..5d7f6a65 100644
--- a/app/src/plugins/ui.tsx
+++ b/app/src/plugins/ui.tsx
@@ -1,4 +1,4 @@
-import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic";
+import { lazy, Suspense } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -34,21 +34,16 @@ import { cn } from "@/lib/utils";
import { WidgetEmpty, WidgetRow, WidgetSkeleton } from "@/views/home/widgets/parts";
import { PluginPage, ViewHeader } from "@/views/view-header";
-const known = new Set(iconNames);
-
-// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name.
-export function iconName(name: string): IconName | undefined {
- const kebab = name
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
- .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
- .toLowerCase();
- return known.has(kebab) ? (kebab as IconName) : undefined;
-}
+const NamedIcon = lazy(() => import("@/plugins/dynamic-icon"));
// Icon draws any lucide icon by name, for plugins and their sidebar items.
+// Like lucide's DynamicIcon it draws nothing until the icon has loaded.
export function Icon({ name, className }: { name: string; className?: string }) {
- const n = iconName(name);
- return n ? : ;
+ return (
+
+
+
+ );
}
// pluginUi is what @berth/plugin/ui resolves to inside a plugin.
diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx
index 64919636..c05baa83 100644
--- a/app/src/views/home/widgets/agents.tsx
+++ b/app/src/views/home/widgets/agents.tsx
@@ -5,11 +5,12 @@ import { AgentIcon, StateGlyph } from "@/components/agent-glyph";
import { toastError } from "@/components/error-note";
import { Button } from "@/components/ui/button";
import { type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts";
+import { lastOf } from "@/lib/last-of";
import { boxApi, type Session } from "@/lib/api";
import { agentLabel, agentOf, worktreeOf } from "@/lib/derive";
import { parseScreen } from "@/lib/screen-status";
import { permissionChoices } from "@/lib/screen";
-import { useStore } from "@/lib/store";
+import { type BoxData, useStore } from "@/lib/store";
import { useAsk } from "@/lib/transcript-feed";
import { cn } from "@/lib/utils";
import { focusSession, homeBox, selectWorktree, useWorkspaces, type WorktreeRef } from "@/lib/workspaces";
@@ -41,29 +42,32 @@ export interface AgentRow {
export function useAgentRows(): AgentRow[] {
const all = useAllSessions();
const boxes = useStore((s) => s.boxes);
- return useMemo(() => {
- const rows: AgentRow[] = [];
- for (const e of all as SessionEntry[]) {
- const agent = agentOf(e.session);
- if (!agent || (e.state !== "waiting" && e.state !== "running" && e.state !== "finished")) continue;
- const wt = worktreeOf(boxes[e.box]?.locations, e.session);
- const place = wt ? (wt.worktree.main ? wt.location.name : `${wt.location.name} / ${worktreeLabel(wt.worktree)}`) : "";
- rows.push({
- key: `${e.box}/${e.session.name}`,
- box: e.box,
- session: e.session,
- agent,
- title: e.session.title?.trim() || place || e.session.name,
- where: [e.session.title?.trim() ? place : "", e.box].filter(Boolean).join(" · "),
- worktree: wt ? { box: e.box, location: wt.location.name, worktree: wt.worktree.name, path: wt.worktree.path, main: wt.worktree.main } : undefined,
- state: e.state,
- since: e.session.state_since ?? e.session.created,
- });
- }
- return rows.sort((a, b) => (b.since ?? "").localeCompare(a.since ?? ""));
- }, [all, boxes]);
+ return agentRows(all, boxes);
}
+// Worked out once for every widget and count that asks (lastOf).
+const agentRows = lastOf((all: SessionEntry[], boxes: Record): AgentRow[] => {
+ const rows: AgentRow[] = [];
+ for (const e of all) {
+ const agent = agentOf(e.session);
+ if (!agent || (e.state !== "waiting" && e.state !== "running" && e.state !== "finished")) continue;
+ const wt = worktreeOf(boxes[e.box]?.locations, e.session);
+ const place = wt ? (wt.worktree.main ? wt.location.name : `${wt.location.name} / ${worktreeLabel(wt.worktree)}`) : "";
+ rows.push({
+ key: `${e.box}/${e.session.name}`,
+ box: e.box,
+ session: e.session,
+ agent,
+ title: e.session.title?.trim() || place || e.session.name,
+ where: [e.session.title?.trim() ? place : "", e.box].filter(Boolean).join(" · "),
+ worktree: wt ? { box: e.box, location: wt.location.name, worktree: wt.worktree.name, path: wt.worktree.path, main: wt.worktree.main } : undefined,
+ state: e.state,
+ since: e.session.state_since ?? e.session.created,
+ });
+ }
+ return rows.sort((a, b) => (b.since ?? "").localeCompare(a.since ?? ""));
+});
+
// Until the agent and every online box have answered, agent lists are
// loading, not empty.
export function useAgentsLoading(): boolean {
diff --git a/app/src/views/onboarding/add-box-dialog.tsx b/app/src/views/onboarding/add-box-dialog.tsx
index 2757a166..cc558e51 100644
--- a/app/src/views/onboarding/add-box-dialog.tsx
+++ b/app/src/views/onboarding/add-box-dialog.tsx
@@ -1,12 +1,16 @@
+import { Suspense } from "react";
import { create } from "zustand";
import { Dialog, DialogPopup } from "@/components/ui/dialog";
import { toastManager } from "@/components/ui/toast";
import { useStore } from "@/lib/store";
-import { AddBoxFlow } from "@/views/onboarding/add-box-flow";
+import { lazyView } from "@/lib/lazy-view";
import { prefetchTailnets } from "@/views/onboarding/tailnet";
import { openTeam, TeamBoxEntry } from "@/views/team/team-entry";
+// The flow (its install, SSH and tailnet steps) loads after the app starts.
+const AddBoxFlow = lazyView(() => import("@/views/onboarding/add-box-flow").then((m) => m.AddBoxFlow));
+
const useAddBox = create<{ open: boolean }>()(() => ({ open: false }));
// openAddBox shows the add-a-box flow from anywhere: Settings, the sidebar,
@@ -25,26 +29,28 @@ export function AddBoxDialog() {
would move its title with each one. */}
{open && (
- {
- close();
- openTeam(undefined, "addbox");
- }}
- />
- ) : undefined
- }
- onDone={(box) => {
- close();
- toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" });
- }}
- />
+
+ {
+ close();
+ openTeam(undefined, "addbox");
+ }}
+ />
+ ) : undefined
+ }
+ onDone={(box) => {
+ close();
+ toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" });
+ }}
+ />
+
)}
diff --git a/app/src/views/pr-review/pr-review-sheet.tsx b/app/src/views/pr-review/pr-review-sheet.tsx
new file mode 100644
index 00000000..7d1d427e
--- /dev/null
+++ b/app/src/views/pr-review/pr-review-sheet.tsx
@@ -0,0 +1,21 @@
+import { Suspense, useRef } from "react";
+
+import { Sheet, SheetPopup } from "@/components/ui/sheet";
+import { lazyView } from "@/lib/lazy-view";
+import { closeReviewSheet, usePrReview } from "@/lib/pr-review";
+
+// PrReviewSheet's frame (review-sheet.tsx is what it shows): its content
+// loads after the app has started.
+const PrReviewBody = lazyView(() => import("@/views/pr-review/review-sheet").then((m) => m.PrReviewBody));
+
+export function PrReviewSheet() {
+ const sheet = usePrReview((s) => s.sheet);
+ const popup = useRef(null);
+ return (
+ !open && closeReviewSheet()}>
+
+ {sheet && }
+
+
+ );
+}
diff --git a/app/src/views/pr-review/review-sheet.tsx b/app/src/views/pr-review/review-sheet.tsx
index 960d9d45..a2fa0b50 100644
--- a/app/src/views/pr-review/review-sheet.tsx
+++ b/app/src/views/pr-review/review-sheet.tsx
@@ -5,7 +5,7 @@ import { ErrorText } from "@/components/error-note";
import { SimpleSelect } from "@/components/simple-select";
import { Tip } from "@/components/tip";
import { Button } from "@/components/ui/button";
-import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "@/components/ui/sheet";
+import { SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { openBrowserAt } from "@/lib/actions";
import { ApiError, type Worktree } from "@/lib/api";
@@ -28,21 +28,11 @@ import { GitHubMark, StateIcon } from "@/views/team/team-parts";
// worktree at the head commit shown, set up by the team's kit, then opens
// it with its dev server in a Browser tab and the Diff beside it.
-export function PrReviewSheet() {
- const sheet = usePrReview((s) => s.sheet);
- const popup = useRef(null);
- return (
- !open && closeReviewSheet()}>
-
- {sheet && }
-
-
- );
-}
-
type Phase = { kind: "review" } | { kind: "running"; started: number; opened?: ReviewOpened; error?: string };
-function Body() {
+// PrReviewBody is the sheet's content; its frame (pr-review-sheet.tsx) loads
+// it when it first opens.
+export function PrReviewBody() {
const req = usePrReview((s) => s.sheet)!;
const client = useStore((s) => s.client);
const [plan, setPlan] = useState();
diff --git a/app/src/views/review/review-store.ts b/app/src/views/review/review-store.ts
index 912b11de..2d171747 100644
--- a/app/src/views/review/review-store.ts
+++ b/app/src/views/review/review-store.ts
@@ -120,28 +120,36 @@ export const visibleEntries = (s: Pick) =>
let inflight: Promise | undefined;
-export function refreshReview(): Promise {
+// refreshReview reads the inbox again: every online box's, or only those
+// named (an event on one box changes only its inbox; a box's GET review
+// runs git in each of its worktrees with finished work).
+export function refreshReview(only?: Iterable): Promise {
if (inflight) return inflight;
- inflight = doRefresh().finally(() => {
+ inflight = doRefresh(only ? new Set(only) : undefined).finally(() => {
inflight = undefined;
});
return inflight;
}
-async function doRefresh() {
+async function doRefresh(only?: Set) {
const { client, status } = useStore.getState();
if (!client || !status) return;
useReview.setState({ loading: true });
- const boxes = status.boxes.filter((b) => b.state === "online").map((b) => b.name);
- const outdated: string[] = [];
- const errors: Record = {};
- const runs: Record = {};
+ const online = status.boxes.filter((b) => b.state === "online").map((b) => b.name);
+ const boxes = only ? online.filter((b) => only.has(b)) : online;
+ // The other online boxes keep what they had.
+ const kept = (box: string) => !!only && !only.has(box) && online.includes(box);
+ const was = useReview.getState();
+ const outdated: string[] = was.outdated.filter(kept);
+ const errors: Record = Object.fromEntries(Object.entries(was.errors).filter(([b]) => kept(b)));
+ const runs: Record = Object.fromEntries(Object.entries(was.runs).filter(([k]) => kept(k.slice(0, k.indexOf("|")))));
const lists = await Promise.all(
boxes.map(async (box) => {
try {
+ // Both at once: the last check a flow ran in each worktree, if any.
+ const checks = flowsApi.runs(client, box, undefined, 50).catch(() => [] as FlowRun[]);
const items = (await client.box(box, "GET", "review")) ?? [];
- // The last check a flow ran in each worktree, if any.
- const recent = await flowsApi.runs(client, box, undefined, 50).catch(() => [] as FlowRun[]);
+ const recent = await checks;
for (const r of recent) {
const path = r.event?.data?.path as string | undefined;
if (!path || !r.steps.some((s) => s.kind === "run")) continue;
@@ -157,7 +165,7 @@ async function doRefresh() {
}
}),
);
- const entries = lists.flat().sort((a, b) => (b.state_since ?? "").localeCompare(a.state_since ?? ""));
+ const entries = [...useReview.getState().entries.filter((e) => kept(e.box)), ...lists.flat()].sort((a, b) => (b.state_since ?? "").localeCompare(a.state_since ?? ""));
useReview.setState({ entries, runs, outdated, errors, loading: false, loaded: true });
void fetchPullRequests(client, entries);
}
@@ -204,19 +212,35 @@ export function watchReview() {
if (started) return;
started = true;
let timer = 0;
- const soon = () => {
+ // The boxes whose events came since the last read; all: every box.
+ let all = false;
+ const boxes = new Set();
+ const soon = (box?: string) => {
+ if (box) boxes.add(box);
+ else all = true;
window.clearTimeout(timer);
- timer = window.setTimeout(() => void refreshReview(), 1500);
+ timer = window.setTimeout(() => {
+ const only = all ? undefined : [...boxes];
+ all = false;
+ boxes.clear();
+ void refreshReview(only);
+ }, 1500);
};
let last: BerthEvent | undefined;
useEventLog.subscribe((s) => {
const e = s.events[0];
if (!e || e === last) return;
last = e;
- if (RELEVANT.test(e.type)) soon();
+ if (RELEVANT.test(e.type)) soon(e.box);
});
+ const onlineOf = (st: ReturnType) =>
+ (st.status?.boxes ?? [])
+ .filter((b) => b.state === "online")
+ .map((b) => b.name)
+ .join(",");
useStore.subscribe((s, prev) => {
- if (s.client !== prev.client || s.status?.boxes.length !== prev.status?.boxes.length) soon();
+ // A box that came back is read too.
+ if (s.client !== prev.client || s.status?.boxes.length !== prev.status?.boxes.length || (s.status !== prev.status && onlineOf(s) !== onlineOf(prev))) soon();
});
// A backstop to the events above, and not while the window is hidden.
window.setInterval(() => !document.hidden && void refreshReview(), 60_000);
diff --git a/app/src/views/settings/open.ts b/app/src/views/settings/open.ts
new file mode 100644
index 00000000..6fbdbaf5
--- /dev/null
+++ b/app/src/views/settings/open.ts
@@ -0,0 +1,10 @@
+import { useStore } from "@/lib/store";
+
+// Settings' sections and how to open one, apart from the view itself, which
+// loads when it is first shown (App.tsx).
+export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer";
+
+// openSettings shows Settings, on a section when given one.
+export function openSettings(section?: SettingsSectionId) {
+ useStore.getState().setView({ kind: "settings", section });
+}
diff --git a/app/src/views/settings/settings-view.tsx b/app/src/views/settings/settings-view.tsx
index 9cc4b586..e3ed56fb 100644
--- a/app/src/views/settings/settings-view.tsx
+++ b/app/src/views/settings/settings-view.tsx
@@ -19,7 +19,9 @@ import { ShortcutsSection } from "@/views/settings/shortcuts-section";
import { TerminalSection } from "@/views/settings/terminal-section";
import { ViewHeaderHost } from "@/views/view-header";
-export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer";
+import { openSettings, type SettingsSectionId } from "@/views/settings/open";
+
+export { openSettings, type SettingsSectionId };
const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ className?: string }>; Component: ComponentType }[] = [
{ id: "general", title: "General", icon: SlidersHorizontalIcon, Component: GeneralSection },
@@ -37,11 +39,6 @@ const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ cl
{ id: "developer", title: "Developer", icon: WrenchIcon, Component: DeveloperSection },
];
-// openSettings shows Settings, on a section when given one.
-export function openSettings(section?: SettingsSectionId) {
- useStore.getState().setView({ kind: "settings", section });
-}
-
// Below 1200px of window the section list shrinks to its icons, so the
// settings themselves keep their width.
export function SettingsView() {
diff --git a/app/src/views/team/org-field.tsx b/app/src/views/team/org-field.tsx
new file mode 100644
index 00000000..98131807
--- /dev/null
+++ b/app/src/views/team/org-field.tsx
@@ -0,0 +1,13 @@
+import { Input } from "@/components/ui/input";
+
+// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder
+// after the github.com/ it shows, or a whole URL pasted, which hides it).
+export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) {
+ const whole = /github\.com|:\/\//i.test(value);
+ return (
+
+ {!whole && github.com/ }
+ onChange(e.target.value)} className="font-mono [&_input]:pl-0.5" />
+
+ );
+}
diff --git a/app/src/views/team/team-entry.tsx b/app/src/views/team/team-entry.tsx
index 95c83406..cb6eacb9 100644
--- a/app/src/views/team/team-entry.tsx
+++ b/app/src/views/team/team-entry.tsx
@@ -11,7 +11,7 @@ import { activeRun, keyOf, loadTeams, onRepoReady, recentlyDone, teamRef, type T
import { cn } from "@/lib/utils";
import { GitHubMark } from "@/views/team/team-parts";
import { runSummary } from "@/views/team/team-rail";
-import { OrgField } from "@/views/team/team-view";
+import { OrgField } from "@/views/team/org-field";
// The ways into Team setup, and what shows of it around the app while it
// runs: the welcome screen's "Joining a team?", Add a box's "Set this box up
diff --git a/app/src/views/team/team-view.tsx b/app/src/views/team/team-view.tsx
index 79479902..13e29dd2 100644
--- a/app/src/views/team/team-view.tsx
+++ b/app/src/views/team/team-view.tsx
@@ -27,6 +27,7 @@ import { GitHubMark, OrgAvatar, OrgHeader, Section } from "@/views/team/team-par
import { Plan } from "@/views/team/team-plan";
import { Checklist, RunCard } from "@/views/team/team-rail";
import { ViewHeader } from "@/views/view-header";
+import { OrgField } from "@/views/team/org-field";
type From = "onboarding" | "addbox" | "link" | "sidebar" | "palette" | "suggestion";
@@ -217,18 +218,6 @@ function AskOrg({ onOrg, initial = "", note }: { onOrg(org: string): void; initi
);
}
-// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder
-// after the github.com/ it shows, or a whole URL pasted, which hides it).
-export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) {
- const whole = /github\.com|:\/\//i.test(value);
- return (
-
- {!whole && github.com/ }
- onChange(e.target.value)} className="font-mono [&_input]:pl-0.5" />
-
- );
-}
-
function OrgPage({ org, from, wantBox, wantUpdate, github, onOrg }: { org: string; from?: From; wantBox?: string; wantUpdate?: boolean; github: GitHubState; onOrg(org: string): void }) {
const client = useStore((s) => s.client);
const status = useStore((s) => s.status);
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 2c32c72c..4c1a3eae 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -105,6 +105,19 @@ function workerScript(): Plugin {
};
}
+// startupChunks puts all the code the app starts with ($initial: what the
+// entry imports, statically, all the way down) in two files: the libraries
+// and the app's own. Left to itself the bundler cut it into some 300, as
+// lazily loaded code shares much of it (lucide's icons one file each), and
+// the window waited on every one of them before its first paint. Two load
+// and compile side by side. What loads later is split as before.
+const startupChunks = {
+ groups: [
+ { name: "vendor", test: /[\\/]node_modules[\\/]/, tags: ["$initial" as const] },
+ { name: "startup", tags: ["$initial" as const] },
+ ],
+};
+
// commit is the checkout's short hash, which Copy diagnostics names as the
// app's build; empty outside a git checkout.
function commit(): string {
@@ -149,10 +162,11 @@ export default defineConfig(({ mode }) => ({
},
base: mode === "demo" ? "./" : "/",
// A desktop app loads from disk; one large chunk (xterm, React) is fine.
- build:
- mode === "demo"
- ? { chunkSizeWarningLimit: 2000, outDir: path.resolve(import.meta.dirname, "../site/demo"), emptyOutDir: true }
- : { chunkSizeWarningLimit: 2000 },
+ build: {
+ chunkSizeWarningLimit: 2000,
+ ...(mode === "demo" ? { outDir: path.resolve(import.meta.dirname, "../site/demo"), emptyOutDir: true } : {}),
+ rolldownOptions: { output: { codeSplitting: startupChunks } },
+ },
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 9e5609b8..5c71b713 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -17,6 +17,25 @@ again.
app.** Opening `http://localhost:3000` before its dev server started closed
Shipyard half a second later, and again on every launch after, since the
tab came back.
+- **Shipyard opens faster.** It now loads four files before it first paints
+ instead of about three hundred, and settings, dialogs and the browser pane
+ load just after start. In our tests, the window drew in about a fifth of the
+ time.
+- **Chats keep up while an agent writes.** A streamed reply costs about a
+ third of the work it did on the page, and typing in a chat, Home's composer
+ or the sidebar's menus stays quick with hundreds of worktrees.
+- **The app asks the agent and your boxes less.** An idle chat makes about
+ half the requests it did, starting reads every box once, and a box's large
+ answers cross the link to your laptop about ten times smaller.
+- **berthd uses less of a box.** Following a chat, a reply as it streams and
+ the agents' screens runs fewer tmux commands and less CPU; a chat's own
+ poll takes about a quarter of what it did.
+ Opening a long conversation uses a tenth of the memory, and a cache that grew
+ with every conversation now stays small.
+- **The laptop agent is lighter per request.** Proxied pages, port forwards
+ and calls to your boxes allocate a fraction of what they did, and the kits
+ and team suggestions ask all your boxes at once.
+- **The Mac app is 20% smaller.**
## v0.3.13
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index bb6d12bf..9460f5de 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -269,6 +269,8 @@ type Agent struct {
hostsMu sync.Mutex
hostsAt time.Time
hosts []string
+ // boxRoutes keeps box-routes.json between changes (boxroutes.go).
+ boxRoutes statefile.Cache[map[string]BoxRoutes]
mu sync.Mutex
svc map[string]serviceCache
@@ -334,7 +336,7 @@ func Run(ctx context.Context, cfg Config) error {
id: id,
boxes: trust.NewStore(filepath.Join(cfg.Dir, "boxes.json")),
forwards: forwardStore{path: filepath.Join(cfg.Dir, "forwards.json")},
- routes: routeStore{path: filepath.Join(cfg.Dir, "routes.json")},
+ routes: newRouteStore(filepath.Join(cfg.Dir, "routes.json")),
clients: map[string]*boxState{},
seqs: newSeqStore(cfg.Dir),
running: map[string]*runningForward{},
@@ -942,7 +944,7 @@ func (a *Agent) status() Status {
a.mu.Lock()
defer a.mu.Unlock()
s := Status{Boxes: []BoxStatus{}, Forwards: []ForwardStatus{}, Routes: []Route{}, Proxy: a.proxySt, TeamSuggestions: suggestions}
- if routes, err := a.routes.list(); err == nil && routes != nil {
+ if routes, err := a.routes.current(); err == nil && routes != nil {
s.Routes = routes
}
s.Proxy.URLPort = s.Proxy.Port
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index 560b057f..e0b0c0a6 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -32,7 +32,7 @@ import (
type testBox struct {
services []box.Service
bus *events.Bus
- t *testing.T
+ t testing.TB
dir string
address string
server *wire.Server
@@ -41,6 +41,8 @@ type testBox struct {
done chan error
// extra mounts more box routes each time the box starts.
extra func(*wire.Server)
+ // servicesCalls counts GET /v1/services.
+ servicesCalls atomic.Int64
}
type countingListener struct {
@@ -56,13 +58,13 @@ func (l countingListener) Accept() (net.Conn, error) {
return c, err
}
-func newBox(t *testing.T) *testBox {
+func newBox(t testing.TB) *testBox {
t.Helper()
return newBoxWith(t, nil)
}
// newBoxWith is newBox with more routes, such as sessions.
-func newBoxWith(t *testing.T, extra func(*wire.Server)) *testBox {
+func newBoxWith(t testing.TB, extra func(*wire.Server)) *testBox {
t.Helper()
b := &testBox{t: t, dir: t.TempDir(), extra: extra}
b.start("127.0.0.1:0")
@@ -86,6 +88,7 @@ func (b *testBox) start(addr string) {
b.bus = &events.Bus{}
}
b.server.Handle("GET /v1/services", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b.servicesCalls.Add(1)
json.NewEncoder(w).Encode(b.services)
}))
b.server.Handle("GET /v1/events", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -185,7 +188,7 @@ func (c *clock) jump(d time.Duration) {
// shortSocket returns a socket path under /tmp: test temp dirs on macOS are
// longer than the 104-byte limit for Unix socket paths.
-func shortSocket(t *testing.T) string {
+func shortSocket(t testing.TB) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "cp")
if err != nil {
@@ -195,19 +198,19 @@ func shortSocket(t *testing.T) string {
return filepath.Join(dir, "a.sock")
}
-func startAgent(t *testing.T, dir string) *runningAgent {
+func startAgent(t testing.TB, dir string) *runningAgent {
t.Helper()
return startAgentWith(t, dir, &fakeNetworks{})
}
-func startAgentWith(t *testing.T, dir string, nets Networks) *runningAgent {
+func startAgentWith(t testing.TB, dir string, nets Networks) *runningAgent {
t.Helper()
return startAgentConfig(t, dir, nets, nil)
}
// startAgentConfig is startAgentWith with changes to the agent's Config,
// such as its log or how long a health check waits.
-func startAgentConfig(t *testing.T, dir string, nets Networks, change func(*Config)) *runningAgent {
+func startAgentConfig(t testing.TB, dir string, nets Networks, change func(*Config)) *runningAgent {
t.Helper()
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -266,7 +269,7 @@ func (a *runningAgent) stop() {
}
}
-func eventually(t *testing.T, what string, cond func() bool) {
+func eventually(t testing.TB, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
@@ -277,7 +280,7 @@ func eventually(t *testing.T, what string, cond func() bool) {
}
}
-func stateOf(t *testing.T, a *runningAgent) string {
+func stateOf(t testing.TB, a *runningAgent) string {
t.Helper()
s, err := a.client.Status(context.Background())
if err != nil || len(s.Boxes) != 1 {
@@ -305,7 +308,7 @@ func echoServer(t *testing.T) int {
return ln.Addr().(*net.TCPAddr).Port
}
-func freePort(t *testing.T) int {
+func freePort(t testing.TB) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go
new file mode 100644
index 00000000..96499ceb
--- /dev/null
+++ b/internal/agent/bench_test.go
@@ -0,0 +1,195 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/box"
+ "github.com/cosscom/shipyard/internal/identity"
+ "github.com/cosscom/shipyard/internal/pairing"
+ "github.com/cosscom/shipyard/internal/trust"
+ "github.com/cosscom/shipyard/internal/wire"
+)
+
+// pairAs pairs the laptop in dir with b under name, so one laptop can hold
+// several boxes.
+func (b *testBox) pairAs(t testing.TB, dir, name string) {
+ t.Helper()
+ id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, err := b.server.Pending.Issue(time.Minute, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ tok := pairing.Token{Address: b.address, Fingerprint: b.server.Identity.Fingerprint(), Code: code}
+ if _, err := wire.Pair(context.Background(), id, tok, "laptop"); err != nil {
+ t.Fatal(err)
+ }
+ peer := trust.Peer{Name: name, Address: b.address, Fingerprint: tok.Fingerprint, PairedAt: time.Now()}
+ if err := trust.NewStore(filepath.Join(dir, "boxes.json")).Add(peer); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func benchDevServer(b *testing.B) int {
+ b.Helper()
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "ok")
+ })}
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ b.Fatal(err)
+ }
+ go srv.Serve(ln)
+ b.Cleanup(func() { srv.Close() })
+ return ln.Addr().(*net.TCPAddr).Port
+}
+
+// BenchmarkProxyHosts relays small GETs through a running agent's proxy to
+// a dev server on a box, for each form of address a page uses. A second
+// box, without the worktree, is paired too, and a few routes are saved,
+// as on a laptop in use. services/op is how often the boxes were asked
+// for their services.
+func BenchmarkProxyHosts(b *testing.B) {
+ port := benchDevServer(b)
+ one := newBox(b)
+ one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: port}, {Location: "shop", Worktree: "shop", Port: port, Main: true}}
+ two := newBox(b)
+ two.services = []box.Service{{Location: "other", Worktree: "other", Port: port + 1}}
+ dir := b.TempDir()
+ one.pairAs(b, dir, "devbox")
+ two.pairAs(b, dir, "spare")
+ a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour })
+ eventually(b, "boxes online", func() bool {
+ s, err := a.client.Status(context.Background())
+ if err != nil || len(s.Boxes) != 2 {
+ return false
+ }
+ return s.Boxes[0].State == StateOnline && s.Boxes[1].State == StateOnline
+ })
+ for _, r := range []Route{
+ {Pattern: "*.cal.test.localhost", Box: "devbox", Port: port},
+ {Pattern: "*.docs.test.localhost", Box: "devbox", Port: port},
+ {Pattern: "admin.test.localhost", Box: "spare", Port: port},
+ } {
+ if err := a.client.Call(context.Background(), "POST", "/v1/routes", r, nil); err != nil {
+ b.Fatal(err)
+ }
+ }
+ c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}}
+ for _, form := range []struct{ name, host string }{
+ {"port.box", strconv.Itoa(port) + ".devbox.localhost"},
+ {"worktree.location.box", "checkout.shop.devbox.localhost"},
+ {"worktree.location", "checkout.shop.localhost"},
+ } {
+ host := form.host
+ b.Run(form.name, func(b *testing.B) {
+ before := one.servicesCalls.Load() + two.servicesCalls.Load()
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ req, _ := http.NewRequest(http.MethodGet, "http://"+a.proxy+"/app.js", nil)
+ req.Host = host
+ resp, err := c.Do(req)
+ if err != nil {
+ b.Error(err)
+ return
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b.Errorf("%s: %d", host, resp.StatusCode)
+ return
+ }
+ }
+ })
+ b.ReportMetric(float64(one.servicesCalls.Load()+two.servicesCalls.Load()-before)/float64(b.N), "services/op")
+ })
+ }
+}
+
+// BenchmarkRouteLookup resolves a request host against the saved routes,
+// which the proxy does first for every request it relays.
+func BenchmarkRouteLookup(b *testing.B) {
+ a := &Agent{routes: newRouteStore(filepath.Join(b.TempDir(), "routes.json"))}
+ all := []Route{
+ {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000},
+ {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001},
+ {Pattern: "admin.test.localhost", Box: "spare", Port: 3002},
+ }
+ if err := a.routes.update(func([]Route) ([]Route, error) { return all, nil }); err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ if _, _, ok := a.route("checkout.shop.devbox.localhost"); ok {
+ b.Fatal("matched")
+ }
+ }
+}
+
+// BenchmarkInstalledKits asks every box for its kits, as the app's Kits
+// view does, with boxes that each take 50ms to answer.
+func BenchmarkInstalledKits(b *testing.B) {
+ const n, delay = 4, 50 * time.Millisecond
+ dir := b.TempDir()
+ for i := range n {
+ tb := newBoxWith(b, func(s *wire.Server) {
+ s.Handle("GET /v1/kits", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(delay)
+ json.NewEncoder(w).Encode([]box.InstalledKitAt{{Location: "shop", Kit: box.InstalledKit{ID: "acme"}}})
+ }))
+ })
+ tb.pairAs(b, dir, "box"+strconv.Itoa(i))
+ }
+ a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour })
+ eventually(b, "boxes online", func() bool {
+ s, err := a.client.Status(context.Background())
+ if err != nil || len(s.Boxes) != n {
+ return false
+ }
+ for _, bs := range s.Boxes {
+ if bs.State != StateOnline {
+ return false
+ }
+ }
+ return true
+ })
+ b.ResetTimer()
+ for range b.N {
+ var out []InstalledKitOn
+ if err := a.client.Call(context.Background(), "GET", "/v1/kits/installed", nil, &out); err != nil {
+ b.Fatal(err)
+ }
+ if len(out) != n {
+ b.Fatalf("got %d kits", len(out))
+ }
+ }
+ b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op")
+}
+
+// BenchmarkStatus is GET /v1/status, which the app reads on every box
+// event and on its backstop poll.
+func BenchmarkStatus(b *testing.B) {
+ one := newBox(b)
+ dir := one.pairLaptop()
+ a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour })
+ eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline })
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ if _, err := a.client.Status(context.Background()); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/agent/bench_ui_test.go b/internal/agent/bench_ui_test.go
new file mode 100644
index 00000000..d002beff
--- /dev/null
+++ b/internal/agent/bench_ui_test.go
@@ -0,0 +1,56 @@
+package agent
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/box"
+)
+
+// BenchmarkUIBoxAPI is one app request relayed to a box through the
+// laptop agent's app API (/v1/boxes/{box}/api/…), the way the app reads
+// every box's sessions, worktrees and files.
+func BenchmarkUIBoxAPI(b *testing.B) {
+ one := newBox(b)
+ one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: 3000}}
+ a := startAgentConfig(b, one.pairLaptop(), &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour })
+ eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline })
+ var tok string
+ eventually(b, "ui token", func() bool {
+ raw, err := os.ReadFile(filepath.Join(a.dir, UITokenFile))
+ tok = strings.TrimSpace(string(raw))
+ return err == nil && tok != ""
+ })
+ c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}}
+ get := func() error {
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://"+a.ui+"/v1/boxes/devbox/api/services", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := c.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b.Fatalf("status %d", resp.StatusCode)
+ }
+ return nil
+ }
+ eventually(b, "ui up", func() bool { return get() == nil })
+ b.ReportAllocs()
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ if err := get(); err != nil {
+ b.Error(err)
+ return
+ }
+ }
+ })
+}
diff --git a/internal/agent/boxroutes.go b/internal/agent/boxroutes.go
index cc5d837d..3d8c8c97 100644
--- a/internal/agent/boxroutes.go
+++ b/internal/agent/boxroutes.go
@@ -142,9 +142,12 @@ func RecordSSHRoute(dir, box, fingerprint, host, identity, forward string) error
})
}
-// routeSettings is the settings file, read at most once per sync.
+// routeSettings is the settings file, read again only once it changes:
+// every request the app relays to a box syncs the boxes first. The map is
+// shared: callers must not change it.
func (a *Agent) routeSettings() map[string]BoxRoutes {
- all, err := readBoxRoutes(routeStorePath(a.cfg.Dir))
+ path := routeStorePath(a.cfg.Dir)
+ all, err := a.boxRoutes.Load(path, func() (map[string]BoxRoutes, error) { return readBoxRoutes(path) })
if err != nil {
a.cfg.Log.Printf("box routes: %v", err)
return map[string]BoxRoutes{}
diff --git a/internal/agent/fanout.go b/internal/agent/fanout.go
new file mode 100644
index 00000000..e8d22922
--- /dev/null
+++ b/internal/agent/fanout.go
@@ -0,0 +1,25 @@
+package agent
+
+import "sync"
+
+// fanOutLimit is how many boxes one question is put to at a time.
+const fanOutLimit = 8
+
+// fanOut runs ask for each of n boxes, at most fanOutLimit at a time, and
+// waits for them all: asking every box in turn would take as long as all
+// their answers together, and a box that is slow or away holds up the
+// rest by its whole timeout.
+func fanOut(n int, ask func(i int)) {
+ sem := make(chan struct{}, fanOutLimit)
+ var wg sync.WaitGroup
+ for i := range n {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func() {
+ defer wg.Done()
+ defer func() { <-sem }()
+ ask(i)
+ }()
+ }
+ wg.Wait()
+}
diff --git a/internal/agent/fanout_test.go b/internal/agent/fanout_test.go
new file mode 100644
index 00000000..0b587ce8
--- /dev/null
+++ b/internal/agent/fanout_test.go
@@ -0,0 +1,34 @@
+package agent
+
+import (
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestFanOutAsksEveryBoxAFewAtATime(t *testing.T) {
+ const n = 3*fanOutLimit + 1
+ var running, most atomic.Int32
+ asked := make([]bool, n)
+ fanOut(n, func(i int) {
+ now := running.Add(1)
+ for {
+ m := most.Load()
+ if now <= m || most.CompareAndSwap(m, now) {
+ break
+ }
+ }
+ time.Sleep(20 * time.Millisecond)
+ asked[i] = true
+ running.Add(-1)
+ })
+ for i, ok := range asked {
+ if !ok {
+ t.Fatalf("box %d was not asked", i)
+ }
+ }
+ // More than one at once (in turn would be one), never past the limit.
+ if m := most.Load(); m > fanOutLimit || m < 2 {
+ t.Errorf("%d asked at once", m)
+ }
+}
diff --git a/internal/agent/kits.go b/internal/agent/kits.go
index c50105fb..cb36ac36 100644
--- a/internal/agent/kits.go
+++ b/internal/agent/kits.go
@@ -390,25 +390,31 @@ func (a *Agent) installedKits(ctx context.Context) []InstalledKitOn {
for _, k := range a.kits() {
kept[k.ID] = k.Hash
}
- out := []InstalledKitOn{}
+ var online []string
for _, b := range a.status().Boxes {
- if b.State != StateOnline {
- continue
+ if b.State == StateOnline {
+ online = append(online, b.Name)
}
- c, ok := a.client(b.Name)
+ }
+ // Every box at once; the answers keep the boxes' order.
+ each := make([][]box.InstalledKitAt, len(online))
+ fanOut(len(online), func(i int) {
+ c, ok := a.client(online[i])
if !ok {
- continue
+ return
}
- var at []box.InstalledKitAt
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
- err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at)
- cancel()
- if err != nil {
- continue
+ defer cancel()
+ var at []box.InstalledKitAt
+ if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/kits", nil, &at) == nil {
+ each[i] = at
}
+ })
+ out := []InstalledKitOn{}
+ for n, at := range each {
for _, i := range at {
h, ok := kept[i.Kit.ID]
- out = append(out, InstalledKitOn{Box: b.Name, InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash})
+ out = append(out, InstalledKitOn{Box: online[n], InstalledKitAt: i, Outdated: ok && h != i.Kit.Hash})
}
}
return out
diff --git a/internal/agent/routes.go b/internal/agent/routes.go
index 7ecfb9e9..93dc78c7 100644
--- a/internal/agent/routes.go
+++ b/internal/agent/routes.go
@@ -21,7 +21,25 @@ type Route struct {
var errUnknownRoute = errors.New("no route with that pattern")
-type routeStore struct{ path string }
+type routeStore struct {
+ path string
+ // cache keeps the routes until the file changes: the proxy looks them
+ // up for every request it relays. Nil reads the file every time.
+ cache *statefile.Cache[[]Route]
+}
+
+func newRouteStore(path string) routeStore {
+ return routeStore{path: path, cache: new(statefile.Cache[[]Route])}
+}
+
+// current is list, read again only once the file has changed. Its result
+// is shared: callers must not change it.
+func (s routeStore) current() ([]Route, error) {
+ if s.cache == nil {
+ return s.list()
+ }
+ return s.cache.Load(s.path, s.list)
+}
func (s routeStore) list() ([]Route, error) {
b, err := os.ReadFile(s.path)
@@ -93,10 +111,10 @@ func (a *Agent) removeRoute(pattern string) error {
})
}
-// route resolves a request host. Routes are read from disk each time: they
-// are few and small, and edits then apply without restarting the agent.
+// route resolves a request host. Routes are read from disk whenever the
+// file changes (a stat tells), so edits apply without restarting the agent.
func (a *Agent) route(host string) (string, int, bool) {
- all, err := a.routes.list()
+ all, err := a.routes.current()
if err != nil {
return "", 0, false
}
diff --git a/internal/agent/routes_test.go b/internal/agent/routes_test.go
new file mode 100644
index 00000000..ceb7461e
--- /dev/null
+++ b/internal/agent/routes_test.go
@@ -0,0 +1,39 @@
+package agent
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/cosscom/shipyard/internal/statefile"
+)
+
+// The proxy keeps the routes between requests, yet a route another
+// process saves (the CLI) applies to the very next request.
+func TestRoutesEditedOnDiskApplyAtOnce(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "routes.json")
+ a := &Agent{routes: newRouteStore(path)}
+ write := func(routes ...Route) {
+ t.Helper()
+ b, _ := json.Marshal(routes)
+ if err := statefile.Write(path, b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, _, ok := a.route("x.cal.test.localhost"); ok {
+ t.Fatal("routed with no routes file")
+ }
+ write(Route{Pattern: "*.cal.test.localhost", Box: "devl", Port: 3000})
+ if box, port, ok := a.route("x.cal.test.localhost"); !ok || box != "devl" || port != 3000 {
+ t.Fatalf("route = %s %d %v", box, port, ok)
+ }
+ // Same length, straight after: another box.
+ write(Route{Pattern: "*.cal.test.localhost", Box: "devm", Port: 3000})
+ if box, _, ok := a.route("x.cal.test.localhost"); !ok || box != "devm" {
+ t.Fatalf("after an edit: %s %v", box, ok)
+ }
+ write()
+ if _, _, ok := a.route("x.cal.test.localhost"); ok {
+ t.Fatal("a removed route still routes")
+ }
+}
diff --git a/internal/agent/teamsuggest.go b/internal/agent/teamsuggest.go
index 2083d33c..cd873a0f 100644
--- a/internal/agent/teamsuggest.go
+++ b/internal/agent/teamsuggest.go
@@ -597,21 +597,29 @@ func (a *Agent) suggestLocations(ctx context.Context) map[string][]suggestLoc {
a.suggest.mu.Lock()
prev := a.suggest.locs
a.suggest.mu.Unlock()
- out := map[string][]suggestLoc{}
- for _, b := range boxes {
- if b.online {
- if c, ok := a.client(b.name); ok {
- var locs []box.Location
- cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
- err := box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs)
- cancel()
- if err == nil {
- out[b.name] = suggestLocs(b.name, locs)
- continue
- }
- }
+ // Every box at once: one that is slow or away holds up no other.
+ fresh := make([][]suggestLoc, len(boxes))
+ fanOut(len(boxes), func(i int) {
+ b := boxes[i]
+ if !b.online {
+ return
}
- if p, ok := prev[b.name]; ok {
+ c, ok := a.client(b.name)
+ if !ok {
+ return
+ }
+ cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
+ defer cancel()
+ var locs []box.Location
+ if box.NewClient(c).Call(cctx, http.MethodGet, "/v1/locations", nil, &locs) == nil {
+ fresh[i] = suggestLocs(b.name, locs)
+ }
+ })
+ out := map[string][]suggestLoc{}
+ for i, b := range boxes {
+ if fresh[i] != nil {
+ out[b.name] = fresh[i]
+ } else if p, ok := prev[b.name]; ok {
out[b.name] = p
}
}
diff --git a/internal/agent/ui.go b/internal/agent/ui.go
index f703091d..9988d746 100644
--- a/internal/agent/ui.go
+++ b/internal/agent/ui.go
@@ -23,6 +23,7 @@ import (
"github.com/coder/websocket"
"github.com/cosscom/shipyard/internal/box"
+ "github.com/cosscom/shipyard/internal/copybuf"
"github.com/cosscom/shipyard/internal/hooks"
"github.com/cosscom/shipyard/internal/statefile"
"github.com/cosscom/shipyard/internal/terminal"
@@ -121,6 +122,11 @@ func (a *Agent) ui(token, hostport string, inner http.Handler) http.Handler {
h.Set("Access-Control-Allow-Origin", o)
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
+ // Every call carries the token, so every one is preflighted:
+ // without this the webview keeps a preflight for 5s only, and
+ // each poll costs two requests and two round trips. 600s is
+ // the most WebKit keeps one for.
+ h.Set("Access-Control-Max-Age", "600")
h.Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
@@ -272,7 +278,8 @@ func (a *Agent) relayBox(w http.ResponseWriter, r *http.Request, c *wire.Client,
}
w.WriteHeader(resp.StatusCode)
rc := http.NewResponseController(w)
- buf := make([]byte, 32<<10)
+ buf := copybuf.Pool{}.Get()
+ defer copybuf.Pool{}.Put(buf)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
diff --git a/internal/agent/ui_test.go b/internal/agent/ui_test.go
index 4d60c293..7581c2ba 100644
--- a/internal/agent/ui_test.go
+++ b/internal/agent/ui_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -281,3 +282,23 @@ func TestTheAppHearsEventsAsServerSentEvents(t *testing.T) {
}
}
}
+
+// The app's every call carries its token, so the webview asks before each
+// one (a CORS preflight). It may keep that answer for ten minutes, so a
+// poll is one request rather than two.
+func TestTheAppsPreflightIsKeptForTenMinutes(t *testing.T) {
+ h := (&Agent{}).ui("token", "127.0.0.1:1378", http.NotFoundHandler())
+ req := httptest.NewRequest("OPTIONS", "http://127.0.0.1:1378/v1/boxes/devl/api/sessions", nil)
+ req.Host = "127.0.0.1:1378"
+ req.Header.Set("Origin", "tauri://localhost")
+ req.Header.Set("Access-Control-Request-Method", "GET")
+ req.Header.Set("Access-Control-Request-Headers", "authorization")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "tauri://localhost" {
+ t.Fatalf("preflight: %d %v", rec.Code, rec.Header())
+ }
+ if got := rec.Header().Get("Access-Control-Max-Age"); got != "600" {
+ t.Fatalf("Access-Control-Max-Age = %q, want 600", got)
+ }
+}
diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go
index fa65f69e..a60a1481 100644
--- a/internal/agent/worktrees.go
+++ b/internal/agent/worktrees.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "slices"
"time"
"github.com/cosscom/shipyard/internal/box"
@@ -88,15 +89,29 @@ func (a *Agent) worktree(labels []string) (string, int, bool) {
}
wt, loc := labels[0], labels[1]
found, foundPort := "", 0
- for _, b := range a.status().Boxes {
- if port, ok := lowest(b.Name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok {
+ // Only the boxes' names: this runs for every request the proxy
+ // relays, and the whole status reads files.
+ for _, name := range a.boxNames() {
+ if port, ok := lowest(name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok {
if found != "" {
return "", 0, false // ambiguous: name the box
}
- found, foundPort = b.Name, port
+ found, foundPort = name, port
}
}
return found, foundPort, found != ""
}
return "", 0, false
}
+
+// boxNames is every paired box's name, sorted.
+func (a *Agent) boxNames() []string {
+ a.mu.Lock()
+ names := make([]string, 0, len(a.clients))
+ for name := range a.clients {
+ names = append(names, name)
+ }
+ a.mu.Unlock()
+ slices.Sort(names)
+ return names
+}
diff --git a/internal/box/api.go b/internal/box/api.go
index 41650fec..d15d3811 100644
--- a/internal/box/api.go
+++ b/internal/box/api.go
@@ -118,11 +118,12 @@ func (b *Box) own(path string) {
// laptops and, through ServeLocal, by the box's own user.
func (b *Box) Mount(s *wire.Server) {
route := func(pattern string, h func(http.ResponseWriter, *http.Request) error) {
- s.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Large JSON answers go gzipped to a client that asks (gzip.go).
+ s.Handle(pattern, gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeErr(w, err)
}
- }))
+ })))
}
route("GET /v1/ports", b.ports)
route("GET /v1/locations", b.listLocations)
diff --git a/internal/box/controls.go b/internal/box/controls.go
index d4b6bf61..2be3a3ad 100644
--- a/internal/box/controls.go
+++ b/internal/box/controls.go
@@ -185,7 +185,7 @@ func (b *Box) sessionControls(w http.ResponseWriter, r *http.Request) error {
agent := controlAgent(sess)
out := Controls{Agent: agent}
if !sess.Exited {
- screen, err := b.Sessions.Screen(r.Context(), sess.Name, 0)
+ screen, err := b.Sessions.capture(r.Context(), sess.Name, 0)
if err != nil {
return err
}
diff --git a/internal/box/draft.go b/internal/box/draft.go
index b6a0281a..56d5f300 100644
--- a/internal/box/draft.go
+++ b/internal/box/draft.go
@@ -47,16 +47,17 @@ type DraftStatus struct {
}
func (b *Box) draft(w http.ResponseWriter, r *http.Request) error {
- sess, err := b.Sessions.Get(r.Context(), r.PathValue("name"))
- if err != nil {
+ // The session and its styled screen in one tmux command: a working
+ // chat polls this every second or so.
+ sess, raw, err := b.Sessions.getAndCapture(r.Context(), r.PathValue("name"), "-J", "-e")
+ if err != nil && sess.Name == "" {
return err
}
agent := firstNonEmpty(sess.Preset, firstNonEmpty(sess.Agent, agentOf(sess.Command)))
out := Draft{Agent: agent}
if agent == "claude" && sess.Service == "" && !sess.Exited {
- raw, err := b.Sessions.tmux(r.Context(), "capture-pane", "-p", "-J", "-e", "-t", "="+sess.Name+":")
if err != nil {
- return tmuxError("capture-pane", raw, err)
+ return err
}
out = ParseClaudeDraft(string(raw))
out.Agent = agent
diff --git a/internal/box/gzip.go b/internal/box/gzip.go
new file mode 100644
index 00000000..e06d59d7
--- /dev/null
+++ b/internal/box/gzip.go
@@ -0,0 +1,170 @@
+package box
+
+import (
+ "compress/gzip"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+)
+
+// A box's JSON answers cross the link to the laptop, often a tailnet or an
+// SSH tunnel: a long conversation's first read or a box with hundreds of
+// worktrees is tens or hundreds of KB that gzip cuts by about ten times.
+// Go's HTTP client asks for gzip on its own and unpacks it on its own, so
+// the laptop agent, the CLI and the phone's browser all get the same JSON
+// as before; anything that doesn't ask gets it plain.
+//
+// Only a whole 200 answer of application/json of at least gzipMin bytes is
+// packed. Streams (events, NDJSON, terminals), files, images and errors
+// pass through as they are, and an answer flushed before gzipMin goes out
+// plain from there on, so a long poll or a stream is never held back.
+
+const gzipMin = 1024
+
+var gzipPool = sync.Pool{New: func() any {
+ w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
+ return w
+}}
+
+// acceptsGzip says whether the request asks for gzip (and not with q=0).
+func acceptsGzip(r *http.Request) bool {
+ for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
+ name, params, _ := strings.Cut(strings.TrimSpace(part), ";")
+ if strings.EqualFold(strings.TrimSpace(name), "gzip") {
+ q := strings.ReplaceAll(strings.TrimSpace(params), " ", "")
+ return q != "q=0" && q != "q=0.0" && q != "q=0.00" && q != "q=0.000"
+ }
+ }
+ return false
+}
+
+// gzipJSON packs h's large JSON answers for clients that ask for gzip.
+func gzipJSON(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !acceptsGzip(r) || r.Header.Get("Upgrade") != "" {
+ h.ServeHTTP(w, r)
+ return
+ }
+ g := &gzipWriter{w: w}
+ defer g.finish()
+ h.ServeHTTP(g, r)
+ })
+}
+
+type gzipMode int
+
+const (
+ gzDeciding gzipMode = iota // holding the first bytes of a JSON answer
+ gzPlain // passing everything through
+ gzPacking // writing through gzip
+)
+
+type gzipWriter struct {
+ w http.ResponseWriter
+ status int
+ header bool // WriteHeader has been called (by the handler or a Write)
+ mode gzipMode
+ buf []byte
+ gz *gzip.Writer
+}
+
+func (g *gzipWriter) Header() http.Header { return g.w.Header() }
+
+// Unwrap lets http.ResponseController reach the connection (full duplex,
+// deadlines, hijacking) behind the wrapper.
+func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.w }
+
+func (g *gzipWriter) WriteHeader(status int) {
+ if g.header {
+ return
+ }
+ g.header = true
+ g.status = status
+ h := g.w.Header()
+ ct := h.Get("Content-Type")
+ if status != http.StatusOK || h.Get("Content-Encoding") != "" || !strings.HasPrefix(ct, "application/json") {
+ g.mode = gzPlain
+ g.w.WriteHeader(status)
+ }
+}
+
+func (g *gzipWriter) Write(p []byte) (int, error) {
+ if !g.header {
+ g.WriteHeader(http.StatusOK)
+ }
+ switch g.mode {
+ case gzPlain:
+ return g.w.Write(p)
+ case gzPacking:
+ return g.gz.Write(p)
+ }
+ g.buf = append(g.buf, p...)
+ if len(g.buf) < gzipMin {
+ return len(p), nil
+ }
+ h := g.w.Header()
+ h.Set("Content-Encoding", "gzip")
+ h.Add("Vary", "Accept-Encoding")
+ h.Del("Content-Length")
+ g.w.WriteHeader(g.status)
+ g.gz = gzipPool.Get().(*gzip.Writer)
+ g.gz.Reset(g.w)
+ g.mode = gzPacking
+ buf := g.buf
+ g.buf = nil
+ if _, err := g.gz.Write(buf); err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
+
+// plain sends what is held as it is, and everything after it too.
+func (g *gzipWriter) plain() error {
+ g.mode = gzPlain
+ g.w.WriteHeader(g.status)
+ if len(g.buf) == 0 {
+ return nil
+ }
+ buf := g.buf
+ g.buf = nil
+ _, err := g.w.Write(buf)
+ return err
+}
+
+// Flush sends what is written so far: a handler that flushes is streaming,
+// so one that does before gzipMin goes out plain.
+func (g *gzipWriter) Flush() {
+ _ = g.FlushError()
+}
+
+func (g *gzipWriter) FlushError() error {
+ if !g.header {
+ g.WriteHeader(http.StatusOK)
+ }
+ switch g.mode {
+ case gzDeciding:
+ if err := g.plain(); err != nil {
+ return err
+ }
+ case gzPacking:
+ if err := g.gz.Flush(); err != nil {
+ return err
+ }
+ }
+ return http.NewResponseController(g.w).Flush()
+}
+
+func (g *gzipWriter) finish() {
+ switch g.mode {
+ case gzDeciding:
+ if g.header {
+ _ = g.plain()
+ }
+ case gzPacking:
+ _ = g.gz.Close()
+ g.gz.Reset(io.Discard)
+ gzipPool.Put(g.gz)
+ g.gz = nil
+ }
+}
diff --git a/internal/box/gzip_test.go b/internal/box/gzip_test.go
new file mode 100644
index 00000000..52bbd61f
--- /dev/null
+++ b/internal/box/gzip_test.go
@@ -0,0 +1,247 @@
+package box
+
+import (
+ "compress/gzip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/transcript"
+)
+
+// big is a JSON answer well past gzipMin.
+func big() any {
+ out := make([]map[string]string, 60)
+ for i := range out {
+ out[i] = map[string]string{"name": fmt.Sprintf("worktree-%d", i), "path": fmt.Sprintf("/home/me/code/shop/worktree-%d", i)}
+ }
+ return out
+}
+
+func serveGzip(t *testing.T, h http.HandlerFunc, method string, header http.Header) *http.Response {
+ t.Helper()
+ req := httptest.NewRequest(method, "/v1/x", nil)
+ for k, v := range header {
+ req.Header[k] = v
+ }
+ rec := httptest.NewRecorder()
+ gzipJSON(h).ServeHTTP(rec, req)
+ return rec.Result()
+}
+
+func readBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ var r io.Reader = resp.Body
+ if resp.Header.Get("Content-Encoding") == "gzip" {
+ gz, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r = gz
+ }
+ b, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+var askGzip = http.Header{"Accept-Encoding": {"gzip"}}
+
+func TestLargeJSONIsGzippedForAClientThatAsks(t *testing.T) {
+ want, _ := json.Marshal(big())
+ resp := serveGzip(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", askGzip)
+ if resp.Header.Get("Content-Encoding") != "gzip" || !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
+ t.Fatalf("headers = %v", resp.Header)
+ }
+ if got := strings.TrimSpace(readBody(t, resp)); got != string(want) {
+ t.Fatalf("body changed:\n%s\n%s", got, want)
+ }
+}
+
+func TestAnswersPassThroughPlainWhenGzipWouldNotHelpOrIsNotAsked(t *testing.T) {
+ cases := map[string]struct {
+ h http.HandlerFunc
+ method string
+ header http.Header
+ }{
+ "no Accept-Encoding": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", nil},
+ "gzip refused": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", http.Header{"Accept-Encoding": {"gzip;q=0, identity"}}},
+ "small": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"screen": "$ "}) }, "GET", askGzip},
+ "not GET": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "POST", askGzip},
+ "an error": {func(w http.ResponseWriter, r *http.Request) {
+ writeErr(w, httpError{http.StatusNotFound, strings.Repeat("gone ", 400)})
+ }, "GET", askGzip},
+ "plain text": {func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ io.WriteString(w, strings.Repeat("log line\n", 400))
+ }, "GET", askGzip},
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ resp := serveGzip(t, c.h, c.method, c.header)
+ if resp.Header.Get("Content-Encoding") != "" {
+ t.Fatalf("Content-Encoding = %q", resp.Header.Get("Content-Encoding"))
+ }
+ if readBody(t, resp) == "" {
+ t.Fatal("empty body")
+ }
+ })
+ }
+}
+
+func TestAStreamFlushedEarlyGoesOutPlainAndAtOnce(t *testing.T) {
+ srv := httptest.NewServer(gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"first":true}`+"\n")
+ http.NewResponseController(w).Flush()
+ time.Sleep(300 * time.Millisecond)
+ io.WriteString(w, strings.Repeat(" ", 4096)+`{"second":true}`+"\n")
+ })))
+ defer srv.Close()
+ req, _ := http.NewRequest("GET", srv.URL, nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ start := time.Now()
+ resp, err := http.DefaultTransport.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.Header.Get("Content-Encoding") != "" {
+ t.Fatal("a flushed answer was gzipped")
+ }
+ buf := make([]byte, 64)
+ n, _ := resp.Body.Read(buf)
+ if !strings.Contains(string(buf[:n]), "first") || time.Since(start) > 250*time.Millisecond {
+ t.Fatalf("the first part waited: %q after %v", buf[:n], time.Since(start))
+ }
+}
+
+// Over the real link: the laptop's client asks for gzip and unpacks it on
+// its own, so callers read the same JSON as before.
+func TestBoxAnswersReachTheLaptopGzippedAndReadTheSame(t *testing.T) {
+ c, _ := servedBox(t)
+ root := t.TempDir()
+ for i := range 15 {
+ dir := filepath.Join(root, fmt.Sprintf("project-with-a-long-name-%02d", i))
+ os.MkdirAll(dir, 0o755)
+ if code := call(t, c, "POST", "/v1/locations", "", map[string]string{"name": fmt.Sprintf("project-%02d", i), "path": dir}, nil); code != 200 {
+ t.Fatalf("add location: %d", code)
+ }
+ }
+ resp, err := c.Do(t.Context(), "GET", "/v1/locations", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if !resp.Uncompressed {
+ t.Fatal("the answer wasn't gzipped on the link")
+ }
+ var locs []Location
+ if err := json.NewDecoder(resp.Body).Decode(&locs); err != nil || len(locs) != 15 {
+ t.Fatalf("decoded %d locations: %v", len(locs), err)
+ }
+}
+
+// ---- What it saves, on sampleAnswers shaped as a busy box's ----------------------
+
+func fleetLocations(worktrees int) []Location {
+ var out []Location
+ for p := 0; len(out)*10 < worktrees; p++ {
+ name := fmt.Sprintf("project-%02d", p)
+ loc := Location{Name: name, Path: "/home/me/code/" + name, Repo: true, Scripts: Scripts{Setup: "pnpm install", From: "repo"}, Remote: "git@github.com:acme/" + name + ".git", Slug: "acme/" + name, DefaultBranch: "main", RepoTrust: "trusted", Check: "pnpm test", CheckFrom: "config"}
+ loc.Worktrees = append(loc.Worktrees, Worktree{Name: name, Path: loc.Path, Branch: "main", Head: "4f1c2a9be0d3", Main: true})
+ for w := 1; w < 10; w++ {
+ wn := fmt.Sprintf("%s-fix-%d", name, w)
+ loc.Worktrees = append(loc.Worktrees, Worktree{Name: wn, Path: "/home/me/.berth/worktrees/" + name + "/" + wn, Branch: "me/" + wn, Head: fmt.Sprintf("%012x", p*100+w), Port: 3000 + p*10 + w, Title: "Make checkout retries safe to repeat"})
+ }
+ out = append(out, loc)
+ }
+ return out
+}
+
+func fleetSessions(n int) []Session {
+ out := make([]Session, n)
+ t0 := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
+ for i := range out {
+ name := fmt.Sprintf("project-%02d-fix-%d", i/9, i%9+1)
+ out[i] = Session{Name: name + "-claude", Location: fmt.Sprintf("project-%02d/%s", i/9, name), Dir: "/home/me/.berth/worktrees/" + name, Command: "claude", Created: t0, Agent: "claude", AgentState: "finished", StateSince: t0.Add(time.Duration(i) * time.Minute), Preset: "claude", Turn: fmt.Sprintf("%s-claude#%d", name, i), StateSeq: int64(i), Fidelity: "hooks", Title: "Make checkout retries safe to repeat"}
+ }
+ return out
+}
+
+func chatWindow(items int) transcript.Result {
+ words := strings.Fields("The ledger retries now carry an idempotency key so a slow answer from the payment provider can't make a second order and the test sends the same webhook twice and checks there is one order and one charge")
+ res := transcript.Result{Source: "claude", Next: items, Crew: []transcript.CrewMember{}, Gen: "1.0", File: "6b1f0c2e-1d2c-4a8e-9a51-0c3e2f1d9b7a"}
+ for i := range items {
+ it := transcript.Item{ID: fmt.Sprintf("i%d", i), Off: int64(i) * 2048}
+ switch i % 4 {
+ case 0:
+ it.Kind, it.Text, it.UUID = "user", fmt.Sprintf("Please look at step %d of the checkout flow.", i), fmt.Sprintf("u-%d", i)
+ case 1:
+ it.Kind, it.Verb, it.Done = "tools", "Read", true
+ it.Items = []transcript.ToolCall{{Verb: "Read", Target: fmt.Sprintf("src/checkout/step%d.ts", i)}, {Verb: "Read", Target: fmt.Sprintf("src/payments/retry%d.ts", i)}}
+ default:
+ var b strings.Builder
+ for j := range 60 {
+ b.WriteString(words[(i+j)%len(words)])
+ b.WriteByte(' ')
+ }
+ it.Kind, it.Text = "text", b.String()
+ }
+ res.Items = append(res.Items, it)
+ }
+ return res
+}
+
+var sampleAnswers = map[string]func() any{
+ "locations_300_worktrees": func() any { return fleetLocations(300) },
+ "sessions_100": func() any { return fleetSessions(100) },
+ "transcript_window_100": func() any { return chatWindow(100) },
+}
+
+// TestGzipSavings logs what each answer is, plain and gzipped (go test -run
+// GzipSavings -v ./internal/box).
+func TestGzipSavings(t *testing.T) {
+ for name, v := range sampleAnswers {
+ h := func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v()) }
+ plain := serveGzip(t, h, "GET", nil)
+ packed := serveGzip(t, h, "GET", askGzip)
+ p, _ := io.ReadAll(plain.Body)
+ g, _ := io.ReadAll(packed.Body)
+ if len(g) >= len(p)/3 {
+ t.Errorf("%s: gzip %d of %d bytes", name, len(g), len(p))
+ }
+ t.Logf("%s: %d bytes plain, %d gzipped (%.1fx)", name, len(p), len(g), float64(len(p))/float64(len(g)))
+ }
+}
+
+func BenchmarkJSONAnswer(b *testing.B) {
+ for _, name := range []string{"locations_300_worktrees", "sessions_100", "transcript_window_100"} {
+ v := sampleAnswers[name]()
+ h := gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v) }))
+ for _, mode := range []string{"plain", "gzip"} {
+ b.Run(name+"/"+mode, func(b *testing.B) {
+ req := httptest.NewRequest("GET", "/v1/x", nil)
+ if mode == "gzip" {
+ req.Header.Set("Accept-Encoding", "gzip")
+ }
+ b.ReportAllocs()
+ var n int
+ for b.Loop() {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ n = rec.Body.Len()
+ }
+ b.ReportMetric(float64(n), "B/answer")
+ })
+ }
+ }
+}
diff --git a/internal/box/history.go b/internal/box/history.go
index fafda8ae..9a60fdab 100644
--- a/internal/box/history.go
+++ b/internal/box/history.go
@@ -51,11 +51,11 @@ func olderPage(w http.ResponseWriter, r *http.Request, agent, path, dir string)
// claudeRecord is a session's Claude Code record, or why there is none.
func (b *Box) claudeRecord(r *http.Request) (Session, string, error) {
- sess, err := b.Sessions.Get(r.Context(), r.PathValue("name"))
+ sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name"))
if err != nil {
return Session{}, "", err
}
- agent, path, _ := b.transcriptFile(r, sess)
+ agent, path, _ := b.transcriptFile(r, sess, all)
switch {
case agent != "claude":
return sess, "", httpError{http.StatusBadRequest, "this works with Claude Code's conversations; this session runs " + firstNonEmpty(agent, "no agent")}
diff --git a/internal/box/locations.go b/internal/box/locations.go
index ffadf0c7..b9db9742 100644
--- a/internal/box/locations.go
+++ b/internal/box/locations.go
@@ -196,6 +196,25 @@ func (l *Locations) List(ctx context.Context) ([]Location, error) {
return out, nil
}
+// worktreePaths is each location with its worktrees' names and paths only,
+// as List names them: for finding which worktree a directory is in, it
+// skips what else List reads (remote, default branch, configs).
+func (l *Locations) worktreePaths(ctx context.Context) []Location {
+ saved, err := l.read()
+ if err != nil {
+ return nil
+ }
+ out := make([]Location, 0, len(saved))
+ for _, s := range saved {
+ loc := Location{Name: s.Name, Path: s.Path}
+ if b, err := git(ctx, "-C", s.Path, "worktree", "list", "--porcelain"); err == nil {
+ loc.Worktrees = parseWorktrees(b, s.Path)
+ }
+ out = append(out, loc)
+ }
+ return out
+}
+
// withPorts adds each worktree's first port.
func (l *Locations) withPorts(loc Location) Location {
for i := range loc.Worktrees {
diff --git a/internal/box/notify_text.go b/internal/box/notify_text.go
index b11f6a3f..f3e0c1bb 100644
--- a/internal/box/notify_text.go
+++ b/internal/box/notify_text.go
@@ -358,7 +358,7 @@ func (h *boxNotifyHost) describe(ctx context.Context, r *Report) {
sumFiles(r)
return
}
- agent, path, _ := b.transcriptFile(requestFor(ctx), sess)
+ agent, path, _ := b.transcriptFile(requestFor(ctx), sess, nil)
if path == "" || (agent != "claude" && agent != "codex") {
sumFiles(r)
return
diff --git a/internal/box/perf_bench_test.go b/internal/box/perf_bench_test.go
new file mode 100644
index 00000000..bb4632ed
--- /dev/null
+++ b/internal/box/perf_bench_test.go
@@ -0,0 +1,337 @@
+package box
+
+import (
+ "context"
+ "fmt"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync/atomic"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/events"
+ "github.com/cosscom/shipyard/internal/transcript"
+)
+
+// Benchmarks for what the box does over and over while chats are open and
+// agents work: answering a chat's poll, reading screens, writing the
+// ledger. Each reports how many tmux commands one op ran (execs/op), as a
+// fork and exec of tmux costs far more than anything else here.
+
+// benchSessions is testSessions for a benchmark: tmux under a private
+// TMUX_TMPDIR, never the developer's own server.
+func benchSessions(b *testing.B) (*Sessions, *atomic.Int64) {
+ b.Helper()
+ if _, err := exec.LookPath("tmux"); err != nil {
+ b.Skip("tmux not installed")
+ }
+ tmp, err := os.MkdirTemp("/tmp", "cpb")
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Setenv("TMUX_TMPDIR", tmp)
+ b.Setenv("TMUX", "")
+ b.Setenv("SHELL", "/bin/sh")
+ s, err := NewSessions(b.TempDir())
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ exec.Command("tmux", "-L", tmuxSocket, "kill-server").Run()
+ os.RemoveAll(tmp)
+ })
+ var execs atomic.Int64
+ s.trace = func([]string) { execs.Add(1) }
+ return s, &execs
+}
+
+// cpuPerOp reports berthd's own CPU time per op (cpu-ns/op) when the
+// benchmark ends: tmux's is not in it, so execs/op says what it costs.
+func cpuPerOp(b *testing.B) func() {
+ start := cpuTime()
+ return func() {
+ if b.N > 0 {
+ b.ReportMetric(float64(cpuTime()-start)/float64(b.N), "cpu-ns/op")
+ }
+ }
+}
+
+func cpuTime() int64 {
+ var ru syscall.Rusage
+ syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
+ return ru.Utime.Nano() + ru.Stime.Nano()
+}
+
+// BenchmarkTranscriptPoll is one chat's poll of GET …/transcript with
+// nothing new, in a folder where three Claude sessions run.
+func BenchmarkTranscriptPoll(b *testing.B) {
+ ctx := context.Background()
+ s, execs := benchSessions(b)
+ cfg := b.TempDir()
+ b.Setenv("CLAUDE_CONFIG_DIR", cfg)
+ dir := b.TempDir()
+ for i := range 3 {
+ if _, err := s.create(ctx, fmt.Sprintf("acme-%d", i), "acme", dir, "sleep 600", "claude", nil, nil); err != nil {
+ b.Fatal(err)
+ }
+ }
+ proj := transcript.ClaudeDir(dir)
+ os.MkdirAll(proj, 0o700)
+ ts := time.Now().UTC().Format(time.RFC3339Nano)
+ var rec strings.Builder
+ for i := range 200 {
+ fmt.Fprintf(&rec, `{"type":"assistant","timestamp":%q,"uuid":"u%d","message":{"role":"assistant","content":[{"type":"text","text":"step %d of the acme fix"}]}}`+"\n", ts, i, i)
+ }
+ for i := range 3 {
+ os.WriteFile(filepath.Join(proj, fmt.Sprintf("0000000%d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(rec.String()), 0o600)
+ }
+ bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}}
+ poll := func() {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/v1/sessions/acme-1/transcript", nil)
+ r.SetPathValue("name", "acme-1")
+ if err := bx.transcript(w, r); err != nil || !strings.Contains(w.Body.String(), `"source":"claude"`) {
+ b.Fatalf("transcript: %v %s", err, w.Body.String())
+ }
+ }
+ poll()
+ execs.Store(0)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ n := 0
+ for b.Loop() {
+ poll()
+ n++
+ }
+ b.ReportMetric(float64(execs.Load())/float64(n), "execs/op")
+}
+
+// BenchmarkDraftPoll is a working chat's poll of GET …/draft, the reply
+// being written read off the agent's screen.
+func BenchmarkDraftPoll(b *testing.B) {
+ ctx := context.Background()
+ s, execs := benchSessions(b)
+ if _, err := s.create(ctx, "acme-0", "acme", b.TempDir(), "printf 'acme ready\\n'; sleep 600", "claude", nil, nil); err != nil {
+ b.Fatal(err)
+ }
+ bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}}
+ poll := func() {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/v1/sessions/acme-0/draft", nil)
+ r.SetPathValue("name", "acme-0")
+ if err := bx.draft(w, r); err != nil || !strings.Contains(w.Body.String(), `"agent":"claude"`) {
+ b.Fatalf("draft: %v %s", err, w.Body.String())
+ }
+ }
+ poll()
+ execs.Store(0)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ n := 0
+ for b.Loop() {
+ poll()
+ n++
+ }
+ b.ReportMetric(float64(execs.Load())/float64(n), "execs/op")
+}
+
+// BenchmarkPollScreens is the 2-second look at the screens of agents no
+// hook reports for: four of them, each mid-turn.
+func BenchmarkPollScreens(b *testing.B) {
+ ctx := context.Background()
+ s, execs := benchSessions(b)
+ dir := b.TempDir()
+ t := &Turns{}
+ t.init()
+ for i := range 4 {
+ name := fmt.Sprintf("acme-%d", i)
+ if _, err := s.create(ctx, name, "acme", dir, "sleep 600", "", nil, nil); err != nil {
+ b.Fatal(err)
+ }
+ t.sess[name] = &sessTrack{Name: name, Agent: "acme-cli", Dir: dir, State: "running", Turns: []*Turn{{ID: name + "#1", Session: name, N: 1, State: "running", Fidelity: "screen"}}}
+ }
+ bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}, Turns: t}
+ bx.pollScreens(ctx)
+ execs.Store(0)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ n := 0
+ for b.Loop() {
+ bx.pollScreens(ctx)
+ n++
+ }
+ b.ReportMetric(float64(execs.Load())/float64(n), "execs/op")
+}
+
+// BenchmarkTurnsSave is the ledger written after a change, on a box with
+// 20 agent sessions of 50 turns each.
+func BenchmarkTurnsSave(b *testing.B) {
+ dir := b.TempDir()
+ t := &Turns{Path: filepath.Join(dir, "turns.json"), LegacyPath: filepath.Join(dir, "agent-states.json")}
+ t.init()
+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+ for i := range 20 {
+ name := fmt.Sprintf("acme-%d", i)
+ s := &sessTrack{Name: name, Agent: "claude", Dir: "/home/acme/w" + fmt.Sprint(i), State: "running", Since: at, N: 50}
+ for n := 1; n <= 50; n++ {
+ s.Turns = append(s.Turns, &Turn{ID: fmt.Sprintf("%s#%d", name, n), Session: name, N: n, State: "finished", Sent: at, Started: at, Ended: at, Fidelity: "hooks", Origin: "laptop:acme"})
+ }
+ t.sess[name] = s
+ }
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ // A tool started: the ledger moved on, no agent changed state.
+ t.mu.Lock()
+ t.applied++
+ t.dirty = true
+ t.mu.Unlock()
+ t.save()
+ }
+}
+
+// statsBox is a box with four locations of two worktrees each, and an
+// agent's directory in the last one's second worktree.
+func statsBox(tb testing.TB) (*Box, string) {
+ tb.Helper()
+ if _, err := exec.LookPath("git"); err != nil {
+ tb.Skip("git not installed")
+ }
+ root, err := filepath.EvalSymlinks(tb.TempDir())
+ if err != nil {
+ tb.Fatal(err)
+ }
+ run := func(dir string, args ...string) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ tb.Fatalf("git %v: %s", args, out)
+ }
+ }
+ bx := &Box{Name: "devbox", Locations: NewLocations(filepath.Join(root, "locations.json")), Events: &events.Bus{}}
+ var agentDir string
+ for i := range 4 {
+ repo := filepath.Join(root, fmt.Sprintf("acme%d", i))
+ os.MkdirAll(repo, 0o755)
+ run(repo, "init", "-q", "-b", "main")
+ run(repo, "commit", "-q", "--allow-empty", "-m", "init")
+ agentDir = filepath.Join(root, fmt.Sprintf("acme%d-feat", i))
+ run(repo, "worktree", "add", "-q", "-b", "feat", agentDir)
+ if _, err := bx.Locations.Add(context.Background(), fmt.Sprintf("acme%d", i), repo); err != nil {
+ tb.Fatal(err)
+ }
+ }
+ return bx, filepath.Join(agentDir, "src")
+}
+
+// An agent is placed in the same location and worktree as the full list
+// of locations names.
+func TestPlaceAgentsAsLocationsList(t *testing.T) {
+ bx, dir := statsBox(t)
+ agents := []Agent{{Tool: "claude", PID: 1, Path: dir}}
+ bx.placeAgents(context.Background(), agents)
+ locs, err := bx.Locations.List(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ loc, wt := worktreeFor(locs, dir)
+ if agents[0].Location != loc || agents[0].Worktree != wt || loc != "acme3" || wt == "" || agents[0].State != "running" {
+ t.Fatalf("placed in %q/%q (%s), want %q/%q", agents[0].Location, agents[0].Worktree, agents[0].State, loc, wt)
+ }
+}
+
+// BenchmarkPlaceAgents is GET /v1/stats naming the worktree of an agent,
+// on a box with four locations: the app reads stats on every agent event.
+func BenchmarkPlaceAgents(b *testing.B) {
+ bx, dir := statsBox(b)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ agents := []Agent{{Tool: "claude", PID: 1, Path: dir}}
+ bx.placeAgents(context.Background(), agents)
+ if agents[0].Location != "acme3" {
+ b.Fatalf("placed in %q", agents[0].Location)
+ }
+ }
+}
+
+// writeTurnRecord writes a Claude Code record whose latest turn edited
+// three files of about 20 KB each, as Claude Code keeps them.
+func writeTurnRecord(tb testing.TB, p string) {
+ tb.Helper()
+ orig := strings.Repeat("export const acme = 1 // a line of the file as it was\n", 380)
+ var rec strings.Builder
+ rec.WriteString(`{"type":"user","timestamp":"2026-01-02T03:04:05Z","message":{"role":"user","content":"Fix the acme widgets"}}` + "\n")
+ for i := range 3 {
+ f := fmt.Sprintf("/home/acme/w/src/f%d.ts", i)
+ fmt.Fprintf(&rec, `{"type":"assistant","timestamp":"2026-01-02T03:04:06Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_%d","name":"Edit","input":{"file_path":%q,"old_string":"1","new_string":"2"}}]}}`+"\n", i, f)
+ fmt.Fprintf(&rec, `{"type":"user","timestamp":"2026-01-02T03:04:07Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_%d","content":"ok"}]},"toolUseResult":{"filePath":%q,"originalFile":%q}}`+"\n", i, f, orig)
+ }
+ if err := os.WriteFile(p, []byte(rec.String()), 0o600); err != nil {
+ tb.Fatal(err)
+ }
+}
+
+// BenchmarkTurnCacheRetained is the memory the touched-files cache keeps
+// once the box has shown 500 conversations' latest turns (MB-kept/op).
+func BenchmarkTurnCacheRetained(b *testing.B) {
+ dir := b.TempDir()
+ paths := make([]string, 500)
+ for i := range paths {
+ paths[i] = filepath.Join(dir, fmt.Sprintf("%08d-acme.jsonl", i))
+ writeTurnRecord(b, paths[i])
+ }
+ var kept float64
+ for b.Loop() {
+ resetTurnCache()
+ runtime.GC()
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ for _, p := range paths {
+ if _, ok := cachedTurn("claude", p, "/home/acme/w"); !ok {
+ b.Fatal("no turn")
+ }
+ }
+ runtime.GC()
+ runtime.ReadMemStats(&after)
+ kept += float64(int64(after.HeapAlloc)-int64(before.HeapAlloc)) / (1 << 20)
+ }
+ resetTurnCache()
+ b.ReportMetric(kept/float64(b.N), "MB-kept/op")
+}
+
+// resetTurnCache forgets every cached turn.
+func resetTurnCache() {
+ turnCache.Lock()
+ clear(turnCache.m)
+ turnCache.Unlock()
+}
+
+// The cache of latest turns keeps the conversations read most recently,
+// never all of them.
+func TestTurnCacheIsBounded(t *testing.T) {
+ defer resetTurnCache()
+ dir := t.TempDir()
+ var last string
+ for i := range maxTurnCache + 20 {
+ last = filepath.Join(dir, fmt.Sprintf("%08d-acme.jsonl", i))
+ writeTurnRecord(t, last)
+ turn, ok := cachedTurn("claude", last, "/home/acme/w")
+ if !ok || len(turn.Files) != 3 || turn.Files[0].Original == nil {
+ t.Fatalf("turn = %+v", turn)
+ }
+ }
+ turnCache.Lock()
+ n := len(turnCache.m)
+ _, kept := turnCache.m[last]
+ turnCache.Unlock()
+ if n != maxTurnCache || !kept {
+ t.Fatalf("cache holds %d (latest kept: %v), want %d", n, kept, maxTurnCache)
+ }
+}
diff --git a/internal/box/sessions.go b/internal/box/sessions.go
index 44591b7d..719a418c 100644
--- a/internal/box/sessions.go
+++ b/internal/box/sessions.go
@@ -1,6 +1,7 @@
package box
import (
+ "bytes"
"context"
"encoding/base64"
"errors"
@@ -130,7 +131,13 @@ func (s *Sessions) EnvVar(ctx context.Context, sess Session, key string) string
}
out, err := s.tmux(ctx, "show-environment", "-t", "="+sess.Name, key)
if err != nil {
- // Unset, or the session is gone: nothing to remember.
+ // Unset is remembered too: it is the usual answer (no account
+ // folder of its own), and every chat's poll asks it again for
+ // each agent in the folder. A session that is gone, or a tmux
+ // that failed, leaves nothing to remember.
+ if strings.Contains(string(out), "unknown variable") {
+ s.envs.Store(cacheKey, "")
+ }
return ""
}
v, ok := strings.CutPrefix(strings.TrimSpace(string(out)), key+"=")
@@ -420,16 +427,23 @@ func (s *Sessions) SetTitle(ctx context.Context, name, title string) error {
}
func (s *Sessions) Get(ctx context.Context, name string) (Session, error) {
+ sess, _, err := s.getWithAll(ctx, name)
+ return sess, err
+}
+
+// getWithAll is Get that also returns every session it listed, for a
+// caller that needs the others too without asking tmux again.
+func (s *Sessions) getWithAll(ctx context.Context, name string) (Session, []Session, error) {
all, err := s.List(ctx)
if err != nil {
- return Session{}, err
+ return Session{}, nil, err
}
for _, sess := range all {
if sess.Name == name {
- return sess, nil
+ return sess, all, nil
}
}
- return Session{}, ErrUnknownSession
+ return Session{}, nil, ErrUnknownSession
}
// Kill ends a session and, in the background, everything it started
@@ -486,9 +500,48 @@ func (s *Sessions) Screen(ctx context.Context, name string, history int) (string
if _, err := s.Get(ctx, name); err != nil {
return "", err
}
+ return s.capture(ctx, name, history)
+}
+
+// capture is Screen for a session the caller has just found in a list:
+// without asking tmux for the list again.
+func (s *Sessions) capture(ctx context.Context, name string, history int) (string, error) {
out, err := s.tmux(ctx, "capture-pane", "-p", "-J", "-t", "="+name+":", "-S", "-"+strconv.Itoa(max(history, 0)))
if err != nil {
return "", tmuxError("capture-pane", out, err)
}
return strings.TrimRight(string(out), "\n") + "\n", nil
}
+
+// getAndCapture is Get and a capture-pane of the session's pane (with
+// args, such as -e) in one tmux command rather than two: display-message
+// prints the session's line as list-sessions would, then capture-pane its
+// pane. A session that is gone, or a tmux that answers otherwise, is
+// asked again the long way, so its error is Get's.
+func (s *Sessions) getAndCapture(ctx context.Context, name string, args ...string) (Session, []byte, error) {
+ target := "=" + name + ":"
+ cmd := append([]string{"display-message", "-p", "-t", target, listFormat, ";", "capture-pane", "-p"}, args...)
+ out, err := s.tmux(ctx, append(cmd, "-t", target)...)
+ if err == nil {
+ if line, rest, ok := bytes.Cut(out, []byte("\n")); ok {
+ if all := parseSessions(line); len(all) == 1 && all[0].Name == name {
+ sess := all[0]
+ if sess.commandFile != "" {
+ if command, err := readCommand(sess.commandFile); err == nil {
+ sess.Command = command
+ }
+ }
+ return sess, rest, nil
+ }
+ }
+ }
+ sess, err := s.Get(ctx, name)
+ if err != nil {
+ return Session{}, nil, err
+ }
+ out, err = s.tmux(ctx, append(append([]string{"capture-pane", "-p"}, args...), "-t", target)...)
+ if err != nil {
+ return sess, out, tmuxError("capture-pane", out, err)
+ }
+ return sess, out, nil
+}
diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go
index 78641f9a..2b63245f 100644
--- a/internal/box/sessions_test.go
+++ b/internal/box/sessions_test.go
@@ -234,3 +234,95 @@ func TestScreenShowsTheSessionsOutput(t *testing.T) {
time.Sleep(50 * time.Millisecond)
}
}
+
+// getAndCapture reads a session as Get does, and its pane, in one tmux
+// command.
+func TestGetAndCaptureIsGetAndAScreen(t *testing.T) {
+ s := testSessions(t)
+ ctx := context.Background()
+ // Set before any list starts the background sweep, which reads it.
+ var mu sync.Mutex
+ asked := map[string]int{}
+ s.trace = func(args []string) { mu.Lock(); asked[args[0]]++; mu.Unlock() }
+ if _, err := s.create(ctx, "both", "acme", t.TempDir(), "printf 'acme ready\\n'; sleep 30", "claude", nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ want, err := s.Get(ctx, "both")
+ if err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ got, raw, err := s.getAndCapture(ctx, "both", "-J", "-e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Fatalf("session = %+v, want %+v", got, want)
+ }
+ if strings.Contains(string(raw), "acme ready") {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("screen = %q", raw)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ // One read is one display-message, the capture riding along in it.
+ mu.Lock()
+ clear(asked)
+ mu.Unlock()
+ s.getAndCapture(ctx, "both")
+ mu.Lock()
+ if asked["display-message"] != 1 || asked["capture-pane"] != 0 {
+ t.Fatalf("one read asked tmux %v", asked)
+ }
+ mu.Unlock()
+ if _, _, err := s.getAndCapture(ctx, "gone"); !errors.Is(err, ErrUnknownSession) {
+ t.Fatalf("gone: %v", err)
+ }
+}
+
+// A session's environment never changes, so EnvVar asks tmux once per
+// variable, an unset one included: every chat poll asks it for each agent
+// in the folder.
+func TestEnvVarAsksTmuxOnce(t *testing.T) {
+ s := testSessions(t)
+ ctx := context.Background()
+ // Set before any list starts the background sweep, which reads it.
+ var mu sync.Mutex
+ asked := 0
+ s.trace = func(args []string) {
+ mu.Lock()
+ defer mu.Unlock()
+ if len(args) > 0 && args[0] == "show-environment" {
+ asked++
+ }
+ }
+ sess, err := s.Create(ctx, "envs", "acme", t.TempDir(), "sleep 30", []string{"ACME_HOME=/srv/acme"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range 3 {
+ if v := s.EnvVar(ctx, sess, "ACME_HOME"); v != "/srv/acme" {
+ t.Fatalf("ACME_HOME = %q", v)
+ }
+ if v := s.EnvVar(ctx, sess, "ACME_UNSET"); v != "" {
+ t.Fatalf("ACME_UNSET = %q", v)
+ }
+ }
+ mu.Lock()
+ if asked != 2 {
+ t.Fatalf("asked tmux %d times, want 2", asked)
+ }
+ mu.Unlock()
+ // A session that is gone is not remembered as having it unset.
+ gone := Session{Name: "gone", Created: sess.Created}
+ s.EnvVar(ctx, gone, "ACME_HOME")
+ s.EnvVar(ctx, gone, "ACME_HOME")
+ mu.Lock()
+ defer mu.Unlock()
+ if asked != 4 {
+ t.Fatalf("asked tmux %d times for a gone session, want 2 more", asked-2)
+ }
+}
diff --git a/internal/box/startup.go b/internal/box/startup.go
index 83f87b4b..5be18ca8 100644
--- a/internal/box/startup.go
+++ b/internal/box/startup.go
@@ -177,7 +177,7 @@ func (b *Box) atStartupQuestion(ctx context.Context, sess Session) bool {
s, _ := v.(*startup).get()
return s == startAsking
}
- screen, err := b.Sessions.Screen(ctx, sess.Name, 0)
+ screen, err := b.Sessions.capture(ctx, sess.Name, 0)
if err != nil {
return false
}
@@ -227,7 +227,7 @@ func (b *Box) watchStartup(from string, sess Session, st *startup) {
if err != nil || cur.Exited {
return
}
- screen, err := b.Sessions.Screen(ctx, sess.Name, 0)
+ screen, err := b.Sessions.capture(ctx, sess.Name, 0)
if err != nil {
return
}
diff --git a/internal/box/stats.go b/internal/box/stats.go
index c514227a..932fcd23 100644
--- a/internal/box/stats.go
+++ b/internal/box/stats.go
@@ -3,6 +3,7 @@ package box
import (
"bufio"
"bytes"
+ "context"
"net/http"
"os"
"path/filepath"
@@ -58,9 +59,24 @@ var agentTools = map[string]string{"claude": "claude", "codex": "codex", "cursor
func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error {
s := collectStats("/proc")
- locs, _ := b.Locations.List(r.Context())
- for i := range s.Agents {
- ag := &s.Agents[i]
+ b.placeAgents(r.Context(), s.Agents)
+ home, _ := os.UserHomeDir()
+ s.Hooks = hooksInstalled(home)
+ writeJSON(w, s)
+ return nil
+}
+
+// placeAgents names the location and worktree each agent works in, and
+// its state. It needs only where the worktrees are: one git command a
+// location, where Locations.List runs four and reads every config (the
+// app reads stats on every agent event), and none when no agent runs.
+func (b *Box) placeAgents(ctx context.Context, agents []Agent) {
+ if len(agents) == 0 {
+ return
+ }
+ locs := b.Locations.worktreePaths(ctx)
+ for i := range agents {
+ ag := &agents[i]
ag.Location, ag.Worktree = worktreeFor(locs, ag.Path)
ag.State = "running"
if b.Turns != nil {
@@ -69,10 +85,6 @@ func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error {
}
}
}
- home, _ := os.UserHomeDir()
- s.Hooks = hooksInstalled(home)
- writeJSON(w, s)
- return nil
}
// worktreeFor names the location and worktree a directory is in, choosing
diff --git a/internal/box/touched.go b/internal/box/touched.go
index ac50409d..cbb95a64 100644
--- a/internal/box/touched.go
+++ b/internal/box/touched.go
@@ -107,7 +107,7 @@ func (b *Box) touchedIn(r *http.Request, wt Worktree) []FileTurn {
if _, inside := relInside(root, dir); !inside && dir != root {
continue
}
- agent, path, _ := b.transcriptFile(r, s)
+ agent, path, _ := b.transcriptFile(r, s, sessions)
if path == "" || (agent != "claude" && agent != "codex") {
continue
}
@@ -201,14 +201,23 @@ func touchedFile(ctx context.Context, root, dir string, f transcript.TurnFile) (
return ft, true
}
-// turnCache keeps each record's latest turn until the record grows.
-var turnCache sync.Map // record path → turnEntry
+// turnCache keeps each record's latest turn until the record grows, for
+// the maxTurnCache records read most recently. A turn holds every file it
+// edited as it found it, so a box that runs for weeks must not keep one
+// for every conversation it ever showed.
+var turnCache = struct {
+ sync.Mutex
+ m map[string]turnEntry // record path → its latest turn
+}{m: map[string]turnEntry{}}
+
+const maxTurnCache = 64
type turnEntry struct {
size int64
mtime time.Time
dir string
turn transcript.Turn
+ used time.Time
}
func cachedTurn(agent, path, dir string) (transcript.Turn, bool) {
@@ -216,15 +225,30 @@ func cachedTurn(agent, path, dir string) (transcript.Turn, bool) {
if err != nil {
return transcript.Turn{}, false
}
- if v, ok := turnCache.Load(path); ok {
- if e := v.(turnEntry); e.size == st.Size() && e.mtime.Equal(st.ModTime()) && e.dir == dir {
- return e.turn, true
- }
+ turnCache.Lock()
+ if e, ok := turnCache.m[path]; ok && e.size == st.Size() && e.mtime.Equal(st.ModTime()) && e.dir == dir {
+ e.used = time.Now()
+ turnCache.m[path] = e
+ turnCache.Unlock()
+ return e.turn, true
}
+ turnCache.Unlock()
turn, err := transcript.LastTurn(agent, path, dir)
if err != nil {
return transcript.Turn{}, false
}
- turnCache.Store(path, turnEntry{size: st.Size(), mtime: st.ModTime(), dir: dir, turn: turn})
+ turnCache.Lock()
+ defer turnCache.Unlock()
+ turnCache.m[path] = turnEntry{size: st.Size(), mtime: st.ModTime(), dir: dir, turn: turn, used: time.Now()}
+ for len(turnCache.m) > maxTurnCache {
+ var oldest string
+ var at time.Time
+ for p, e := range turnCache.m {
+ if oldest == "" || e.used.Before(at) {
+ oldest, at = p, e.used
+ }
+ }
+ delete(turnCache.m, oldest)
+ }
return turn, true
}
diff --git a/internal/box/transcriptapi.go b/internal/box/transcriptapi.go
index 93d173b2..cc2d39ef 100644
--- a/internal/box/transcriptapi.go
+++ b/internal/box/transcriptapi.go
@@ -13,7 +13,9 @@ import (
// transcriptFile is which agent a session runs and the file holding its
// conversation, for the conversation and for a tool call's details alike.
-func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where string) {
+// all is the box's sessions when the caller has just listed them (each
+// list is a tmux command); nil lists them here.
+func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agent, path, where string) {
// Sessions.Get doesn't name the agent (the list does, in enrich): the
// preset a session was started with, else its command's first word,
// which is all a session from before presets has.
@@ -41,22 +43,23 @@ func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where
// accounts set CLAUDE_CONFIG_DIR per box or project).
configDir := b.Sessions.EnvVar(r.Context(), sess, "CLAUDE_CONFIG_DIR")
claims := []transcript.Claim{{Name: sess.Name, ID: id, Started: sess.Created, ConfigDir: configDir}}
- if all, err := b.Sessions.List(r.Context()); err == nil {
- for _, o := range all {
- if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" {
- continue
- }
- if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" {
- continue
- }
- var oid string
- if b.Turns != nil {
- if st, ok := b.Turns.State(o.Name); ok {
- oid = st.AgentSessionID
- }
+ if all == nil {
+ all, _ = b.Sessions.List(r.Context())
+ }
+ for _, o := range all {
+ if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" {
+ continue
+ }
+ if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" {
+ continue
+ }
+ var oid string
+ if b.Turns != nil {
+ if st, ok := b.Turns.State(o.Name); ok {
+ oid = st.AgentSessionID
}
- claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")})
}
+ claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")})
}
path = transcript.AssignClaude(sess.Dir, claims)[sess.Name]
where = transcript.ClaudeDirIn(configDir, sess.Dir)
@@ -80,12 +83,14 @@ var (
func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error {
transcriptsOnce.Do(func() { transcripts = transcript.NewReader() })
- sess, err := b.Sessions.Get(r.Context(), r.PathValue("name"))
+ // One list of the sessions finds this one and the others in its
+ // folder: a chat polls this every few seconds.
+ sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name"))
if err != nil {
return err
}
since, _ := strconv.Atoi(r.URL.Query().Get("since"))
- agent, path, where := b.transcriptFile(r, sess)
+ agent, path, where := b.transcriptFile(r, sess, all)
none := func(reason string) error {
writeJSON(w, transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: reason})
return nil
@@ -115,11 +120,11 @@ func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error {
// call opened up (the full command and its output, an edit's exact change),
// read when someone expands it and never kept.
func (b *Box) toolDetail(w http.ResponseWriter, r *http.Request) error {
- sess, err := b.Sessions.Get(r.Context(), r.PathValue("name"))
+ sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name"))
if err != nil {
return err
}
- agent, path, _ := b.transcriptFile(r, sess)
+ agent, path, _ := b.transcriptFile(r, sess, all)
if path == "" {
return httpError{http.StatusNotFound, "this session's conversation can't be read"}
}
diff --git a/internal/box/turns.go b/internal/box/turns.go
index b5da72dd..6e3af0ee 100644
--- a/internal/box/turns.go
+++ b/internal/box/turns.go
@@ -2,6 +2,7 @@ package box
import (
"bufio"
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -135,6 +136,11 @@ type Turns struct {
saveSoon chan struct{}
loaded bool
stop func()
+
+ // writeMu orders the ledger's writes; legacyOut is what LegacyPath
+ // was last written with.
+ writeMu sync.Mutex
+ legacyOut []byte
}
type sessTrack struct {
@@ -1358,12 +1364,19 @@ func (t *Turns) save() {
archive := t.archive
t.archive = nil
t.mu.Unlock()
+ t.writeMu.Lock()
+ defer t.writeMu.Unlock()
if err == nil && t.Path != "" {
statefile.Write(t.Path, b)
}
if t.LegacyPath != "" {
- if lb, err := json.Marshal(legacy); err == nil {
- statefile.Write(t.LegacyPath, lb)
+ // Its states change far less often than the ledger: each write
+ // is a full fsync, so one that would write the same bytes is
+ // left out.
+ if lb, err := json.Marshal(legacy); err == nil && !bytes.Equal(lb, t.legacyOut) {
+ if statefile.Write(t.LegacyPath, lb) == nil {
+ t.legacyOut = lb
+ }
}
}
if inbox != nil && t.InboxPath != "" {
@@ -1562,7 +1575,8 @@ func (b *Box) pollScreens(ctx context.Context) {
// not a turn that ended.
continue
}
- screen, err := b.Sessions.Screen(ctx, name, 0)
+ // Found in the list just read: capture it without listing again.
+ screen, err := b.Sessions.capture(ctx, name, 0)
if err != nil {
continue
}
diff --git a/internal/copybuf/copybuf.go b/internal/copybuf/copybuf.go
new file mode 100644
index 00000000..a4aec3a9
--- /dev/null
+++ b/internal/copybuf/copybuf.go
@@ -0,0 +1,47 @@
+// Package copybuf copies between connections with pooled buffers. io.Copy
+// between a TCP connection and anything but another socket (an HTTP/2
+// stream, a hijacked HTTP connection) makes a fresh 32 KB buffer each time,
+// and so does httputil.ReverseProxy for every request it relays: the
+// *.localhost proxy, port forwards and box streams do that for every
+// connection and request, so the buffers are kept here and reused.
+package copybuf
+
+import (
+ "io"
+ "net"
+ "sync"
+)
+
+// Size is the buffer size, io.Copy's own.
+const Size = 32 << 10
+
+var pool = sync.Pool{New: func() any { return new([Size]byte) }}
+
+// Copy is io.Copy with a pooled buffer. Between two TCP connections it is
+// io.Copy itself, which on Linux moves the data in the kernel instead.
+func Copy(dst io.Writer, src io.Reader) (int64, error) {
+ if _, ok := dst.(*net.TCPConn); ok {
+ if _, ok := src.(*net.TCPConn); ok {
+ return io.Copy(dst, src)
+ }
+ }
+ buf := pool.Get().(*[Size]byte)
+ defer pool.Put(buf)
+ // Hiding ReadFrom and WriteTo makes CopyBuffer use buf: the
+ // connections' own fallbacks would make a buffer of their own.
+ return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, buf[:])
+}
+
+type writerOnly struct{ io.Writer }
+type readerOnly struct{ io.Reader }
+
+// Pool is a httputil.BufferPool over the same buffers.
+type Pool struct{}
+
+func (Pool) Get() []byte { return pool.Get().(*[Size]byte)[:] }
+
+func (Pool) Put(b []byte) {
+ if cap(b) >= Size {
+ pool.Put((*[Size]byte)(b[:Size]))
+ }
+}
diff --git a/internal/copybuf/copybuf_test.go b/internal/copybuf/copybuf_test.go
new file mode 100644
index 00000000..e83180c9
--- /dev/null
+++ b/internal/copybuf/copybuf_test.go
@@ -0,0 +1,75 @@
+package copybuf
+
+import (
+ "bytes"
+ "io"
+ "net"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestCopyCopiesEverything(t *testing.T) {
+ src := strings.Repeat("shipyard ", 20000) // several buffers' worth
+ var dst bytes.Buffer
+ n, err := Copy(&dst, strings.NewReader(src))
+ if err != nil || n != int64(len(src)) || dst.String() != src {
+ t.Fatalf("copied %d, %v", n, err)
+ }
+}
+
+func TestCopyBetweenConnectionsReusesItsBuffer(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() { io.Copy(c, bytes.NewReader(make([]byte, 4096))); c.Close() }()
+ }
+ }()
+ copyOne := func() {
+ c, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+ if n, err := Copy(io.Discard, c); err != nil || n != 4096 {
+ t.Fatalf("copied %d, %v", n, err)
+ }
+ }
+ copyOne()
+ if raceEnabled {
+ return
+ }
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ const runs = 20
+ for range runs {
+ copyOne()
+ }
+ runtime.ReadMemStats(&after)
+ // io.Copy from a TCP connection makes a 32 KB buffer every time; the
+ // pooled one is reused (the dial itself allocates a little).
+ if per := (after.TotalAlloc - before.TotalAlloc) / runs; per > 16<<10 {
+ t.Errorf("%d bytes allocated per copy", per)
+ }
+}
+
+func TestPoolHandsBackFullBuffers(t *testing.T) {
+ var p Pool
+ b := p.Get()
+ if len(b) != Size {
+ t.Fatalf("got %d bytes", len(b))
+ }
+ p.Put(b)
+ p.Put(make([]byte, 10)) // too small: dropped, not kept
+ if b := p.Get(); len(b) != Size {
+ t.Fatalf("got %d bytes", len(b))
+ }
+}
diff --git a/internal/copybuf/race_off_test.go b/internal/copybuf/race_off_test.go
new file mode 100644
index 00000000..02a452e6
--- /dev/null
+++ b/internal/copybuf/race_off_test.go
@@ -0,0 +1,5 @@
+//go:build !race
+
+package copybuf
+
+const raceEnabled = false
diff --git a/internal/copybuf/race_on_test.go b/internal/copybuf/race_on_test.go
new file mode 100644
index 00000000..c8189145
--- /dev/null
+++ b/internal/copybuf/race_on_test.go
@@ -0,0 +1,7 @@
+//go:build race
+
+package copybuf
+
+// The race detector drops pooled items at random, so pooling cannot be
+// measured under it.
+const raceEnabled = true
diff --git a/internal/forward/bench_test.go b/internal/forward/bench_test.go
new file mode 100644
index 00000000..9b1531f0
--- /dev/null
+++ b/internal/forward/bench_test.go
@@ -0,0 +1,95 @@
+package forward
+
+import (
+ "context"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// pairs hands out both ends of Unix socket connections: like loopback TCP
+// connections, with half-close, but using no ephemeral ports, which a
+// benchmark opening thousands would run out of.
+type pairs struct{ ln net.Listener }
+
+func newPairs(b *testing.B) pairs {
+ b.Helper()
+ dir, err := os.MkdirTemp("", "fwd")
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { os.RemoveAll(dir) })
+ ln, err := net.Listen("unix", filepath.Join(dir, "s"))
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { ln.Close() })
+ return pairs{ln}
+}
+
+func (p pairs) next(b *testing.B) (net.Conn, net.Conn) {
+ c, err := net.Dial("unix", p.ln.Addr().String())
+ if err != nil {
+ b.Fatal(err)
+ }
+ s, err := p.ln.Accept()
+ if err != nil {
+ b.Fatal(err)
+ }
+ return c, s
+}
+
+// BenchmarkBridgeConn bridges one short connection per op (a request and
+// its answer), as a forwarded port's clients make them.
+func BenchmarkBridgeConn(b *testing.B) {
+ p := newPairs(b)
+ msg := make([]byte, 4<<10)
+ b.SetBytes(int64(len(msg)))
+ b.ReportAllocs()
+ for range b.N {
+ client, local := p.next(b)
+ upstream, server := p.next(b)
+ done := make(chan struct{})
+ go func() { Bridge(context.Background(), local, upstream); close(done) }()
+ go func() {
+ // The far end answers what it reads, with a buffer of its own.
+ buf := make([]byte, len(msg))
+ for {
+ n, err := server.Read(buf)
+ server.Write(buf[:n])
+ if err != nil {
+ break
+ }
+ }
+ server.Close()
+ }()
+ client.Write(msg)
+ client.(*net.UnixConn).CloseWrite()
+ io.Copy(io.Discard, client)
+ client.Close()
+ <-done
+ }
+}
+
+// BenchmarkBridgeStream pushes data one way through a long bridge.
+func BenchmarkBridgeStream(b *testing.B) {
+ p := newPairs(b)
+ client, local := p.next(b)
+ upstream, server := p.next(b)
+ go Bridge(context.Background(), local, upstream)
+ go io.Copy(io.Discard, server)
+ chunk := make([]byte, 64<<10)
+ b.SetBytes(int64(len(chunk)))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ if _, err := client.Write(chunk); err != nil {
+ b.Fatal(err)
+ }
+ }
+ b.StopTimer()
+ client.Close()
+ server.Close()
+}
diff --git a/internal/forward/forward.go b/internal/forward/forward.go
index 177a3600..d94d5bcf 100644
--- a/internal/forward/forward.go
+++ b/internal/forward/forward.go
@@ -5,11 +5,12 @@ import (
"context"
"errors"
"fmt"
- "io"
"net"
"strconv"
"sync"
"syscall"
+
+ "github.com/cosscom/shipyard/internal/copybuf"
)
// DialFunc opens a stream to a port on the box. It is looked up per
@@ -79,7 +80,7 @@ func Bridge(ctx context.Context, a, b net.Conn) {
wg.Add(2)
pipe := func(dst, src net.Conn) {
defer wg.Done()
- _, err := io.Copy(dst, src)
+ _, err := copybuf.Copy(dst, src)
if cw, ok := dst.(closeWriter); ok && err == nil {
cw.CloseWrite()
return
diff --git a/internal/proxy/bench_test.go b/internal/proxy/bench_test.go
new file mode 100644
index 00000000..1eaeca9d
--- /dev/null
+++ b/internal/proxy/bench_test.go
@@ -0,0 +1,111 @@
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+)
+
+// benchUpstream is a dev server on the "box" that answers every request
+// with size bytes.
+func benchUpstream(b *testing.B, size int) int {
+ b.Helper()
+ body := bytes.Repeat([]byte("x"), size)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(io.Discard, r.Body)
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+ w.Write(body)
+ }))
+ b.Cleanup(srv.Close)
+ return srv.Listener.Addr().(*net.TCPAddr).Port
+}
+
+// benchProxy is a proxy whose one box, devl, is this machine's loopback.
+func benchProxy(b *testing.B) string {
+ b.Helper()
+ var d net.Dialer
+ p := &Proxy{Dialer: func(box string) (DialFunc, bool) {
+ if box != "devl" {
+ return nil, false
+ }
+ return func(ctx context.Context, port int) (net.Conn, error) {
+ return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+ }, true
+ }}
+ srv := httptest.NewServer(p)
+ b.Cleanup(srv.Close)
+ return srv.Listener.Addr().String()
+}
+
+func benchClient() *http.Client {
+ return &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64, DisableCompression: true}}
+}
+
+// BenchmarkProxyGet relays GETs of a few sizes through the proxy to a dev
+// server, as a page's assets are.
+func BenchmarkProxyGet(b *testing.B) {
+ for _, size := range []int{1 << 10, 64 << 10, 1 << 20} {
+ b.Run(strconv.Itoa(size>>10)+"KB", func(b *testing.B) {
+ port := benchUpstream(b, size)
+ addr := benchProxy(b)
+ c := benchClient()
+ host := strconv.Itoa(port) + ".devl.localhost"
+ b.SetBytes(int64(size))
+ b.ReportAllocs()
+ b.ResetTimer()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/asset.js", nil)
+ req.Host = host
+ resp, err := c.Do(req)
+ if err != nil {
+ b.Error(err)
+ return
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ }
+ })
+ })
+ }
+}
+
+// BenchmarkRequestLogAdd logs requests for one busy page host, past the
+// per-host limit, as a dev server's live page does all day.
+func BenchmarkRequestLogAdd(b *testing.B) {
+ var l requestLog
+ req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200, Type: "script", MIME: "text/javascript"}
+ for range logLimit {
+ l.add(req, req.Host, "")
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ l.add(req, req.Host, "shop.devl.localhost")
+ }
+}
+
+// BenchmarkRequests reads a full host's log after the app's last seq, as
+// the Network drawer polls it.
+func BenchmarkRequests(b *testing.B) {
+ p := &Proxy{}
+ req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200}
+ for range 3 * logLimit {
+ p.requests.add(req, req.Host)
+ }
+ _, last := p.Requests(req.Host, 0)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ list, _ := p.Requests(req.Host, last-10)
+ if len(list) != 10 {
+ b.Fatalf("got %d", len(list))
+ }
+ }
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index c32df199..27dde12f 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -11,6 +11,8 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/cosscom/shipyard/internal/copybuf"
)
// DialFunc opens a stream to a port on a box.
@@ -107,7 +109,8 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) {
// (devtools.go).
devtools := preview == "" && devtoolsPage(r)
rp := &httputil.ReverseProxy{
- Transport: p.transport(target.Box),
+ Transport: p.transport(target.Box),
+ BufferPool: copybuf.Pool{},
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetXForwarded()
pr.Out.URL.Scheme = "http"
@@ -165,7 +168,8 @@ func (p *Proxy) passThrough(w http.ResponseWriter, r *http.Request, box string,
return
}
rp := &httputil.ReverseProxy{
- Transport: p.transport(box),
+ Transport: p.transport(box),
+ BufferPool: copybuf.Pool{},
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetXForwarded()
pr.Out.URL.Scheme = "http"
diff --git a/internal/proxy/requestlog.go b/internal/proxy/requestlog.go
index c8be231b..1b44af3d 100644
--- a/internal/proxy/requestlog.go
+++ b/internal/proxy/requestlog.go
@@ -5,6 +5,7 @@ import (
"net"
"net/http"
"net/url"
+ "slices"
"strings"
"sync"
"time"
@@ -60,10 +61,21 @@ type requestLog struct {
}
type hostLog struct {
+ // list holds the host's requests, oldest first: the last logLimit of
+ // them are its log (recent). It grows to twice that before the old half
+ // is dropped, in place, so logging a request costs no copy of the log.
list []Request
used time.Time
}
+// recent is the host's log: its last logLimit requests, oldest first.
+func (hl *hostLog) recent() []Request {
+ if n := len(hl.list); n > logLimit {
+ return hl.list[n-logLimit:]
+ }
+ return hl.list
+}
+
func (l *requestLog) add(req Request, hosts ...string) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -73,12 +85,10 @@ func (l *requestLog) add(req Request, hosts ...string) {
l.hosts = map[string]*hostLog{}
}
now := time.Now()
- seen := map[string]bool{}
- for _, h := range hosts {
- if h == "" || seen[h] {
+ for i, h := range hosts {
+ if h == "" || slices.Contains(hosts[:i], h) {
continue
}
- seen[h] = true
hl := l.hosts[h]
if hl == nil {
if len(l.hosts) >= logHosts {
@@ -88,10 +98,16 @@ func (l *requestLog) add(req Request, hosts ...string) {
l.hosts[h] = hl
}
hl.used = now
- hl.list = append(hl.list, req)
- if len(hl.list) > logLimit {
- hl.list = append([]Request(nil), hl.list[len(hl.list)-logLimit:]...)
+ switch n := len(hl.list); {
+ case n == 2*logLimit:
+ copy(hl.list, hl.list[logLimit:])
+ clear(hl.list[logLimit:])
+ hl.list = hl.list[:logLimit]
+ case n == cap(hl.list):
+ // Grown by hand, to never more than the 2*logLimit it needs.
+ hl.list = append(make([]Request, 0, min(max(2*n, 16), 2*logLimit)), hl.list...)
}
+ hl.list = append(hl.list, req)
}
}
@@ -115,7 +131,7 @@ func (p *Proxy) Requests(host string, after int64) ([]Request, int64) {
defer l.mu.Unlock()
out := []Request{}
if hl := l.hosts[hostOnly(host)]; hl != nil {
- for _, r := range hl.list {
+ for _, r := range hl.recent() {
if r.Seq > after {
out = append(out, r)
}
diff --git a/internal/proxy/requestlog_test.go b/internal/proxy/requestlog_test.go
index 99a76963..d8ed2aa5 100644
--- a/internal/proxy/requestlog_test.go
+++ b/internal/proxy/requestlog_test.go
@@ -137,15 +137,38 @@ func TestRequestLogMarksACanceledRequest(t *testing.T) {
}
}
+func TestRequestLogKeepsTheLatestAcrossManyTrims(t *testing.T) {
+ p := &Proxy{}
+ const total = 5*logLimit + 7
+ for i := range total {
+ p.requests.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost")
+ if hl := p.requests.hosts["a.localhost"]; cap(hl.list) > 2*logLimit {
+ t.Fatalf("log grew to %d", cap(hl.list))
+ }
+ }
+ list, last := p.Requests("a.localhost", 0)
+ if len(list) != logLimit || last != total {
+ t.Fatalf("got %d requests, last %d", len(list), last)
+ }
+ for i, r := range list {
+ if want := "/" + strconv.Itoa(total-logLimit+i); r.Path != want {
+ t.Fatalf("request %d is %q, want %q", i, r.Path, want)
+ }
+ }
+ if list, _ := p.Requests("a.localhost", total-3); len(list) != 3 || list[2].Path != "/"+strconv.Itoa(total-1) {
+ t.Fatalf("after: %+v", list)
+ }
+}
+
func TestRequestLogIsBounded(t *testing.T) {
var l requestLog
for i := range logLimit + 50 {
l.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost")
}
- if got := len(l.hosts["a.localhost"].list); got != logLimit {
+ if got := len(l.hosts["a.localhost"].recent()); got != logLimit {
t.Fatalf("kept %d", got)
}
- if first := l.hosts["a.localhost"].list[0].Path; first != "/50" {
+ if first := l.hosts["a.localhost"].recent()[0].Path; first != "/50" {
t.Errorf("oldest kept %q", first)
}
for i := range logHosts + 5 {
diff --git a/internal/statefile/cache.go b/internal/statefile/cache.go
new file mode 100644
index 00000000..b5181bfc
--- /dev/null
+++ b/internal/statefile/cache.go
@@ -0,0 +1,70 @@
+package statefile
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "sync"
+)
+
+// Cache keeps what was read from one small state file and reads it again
+// only once the file has changed. Some files are read on every request
+// (the proxy's routes on each one it relays), so edits made by another
+// process (the CLI) still apply at once, without a read of the file each
+// time: a stat says whether it changed.
+//
+// Files here are replaced whole (Write renames a new file over the old),
+// so a change shows as a new inode, size, modification or change time.
+type Cache[T any] struct {
+ mu sync.Mutex
+ key fileKey
+ ok bool
+ val T
+}
+
+// fileKey is what tells one version of a file from another. missing is a
+// file that isn't there.
+type fileKey struct {
+ missing bool
+ dev, ino uint64
+ size int64
+ mtime, ctim int64 // nanoseconds
+}
+
+func keyOf(path string) (fileKey, error) {
+ fi, err := os.Stat(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return fileKey{missing: true}, nil
+ }
+ if err != nil {
+ return fileKey{}, err
+ }
+ k := fileKey{size: fi.Size(), mtime: fi.ModTime().UnixNano()}
+ statKey(fi, &k)
+ return k, nil
+}
+
+// Load returns what read made of path, from the last time it ran if the
+// file is as it was then. read's result is shared by every caller until
+// the file changes: callers must not change it. Errors are not kept, so a
+// file that could not be read is tried again next time.
+func (c *Cache[T]) Load(path string, read func() (T, error)) (T, error) {
+ // The stat comes before the read: a file replaced in between is read
+ // again next time, rather than its old version kept under its new key.
+ k, err := keyOf(path)
+ if err != nil {
+ return read()
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.ok && c.key == k {
+ return c.val, nil
+ }
+ v, err := read()
+ if err != nil {
+ c.ok = false
+ return v, err
+ }
+ c.key, c.val, c.ok = k, v, true
+ return v, nil
+}
diff --git a/internal/statefile/cache_darwin.go b/internal/statefile/cache_darwin.go
new file mode 100644
index 00000000..c0e4c5a7
--- /dev/null
+++ b/internal/statefile/cache_darwin.go
@@ -0,0 +1,13 @@
+package statefile
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+func statKey(fi fs.FileInfo, k *fileKey) {
+ if st, ok := fi.Sys().(*syscall.Stat_t); ok {
+ k.dev, k.ino = uint64(st.Dev), st.Ino
+ k.ctim = st.Ctimespec.Nano()
+ }
+}
diff --git a/internal/statefile/cache_linux.go b/internal/statefile/cache_linux.go
new file mode 100644
index 00000000..62b91e7d
--- /dev/null
+++ b/internal/statefile/cache_linux.go
@@ -0,0 +1,13 @@
+package statefile
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+func statKey(fi fs.FileInfo, k *fileKey) {
+ if st, ok := fi.Sys().(*syscall.Stat_t); ok {
+ k.dev, k.ino = uint64(st.Dev), st.Ino
+ k.ctim = st.Ctim.Nano()
+ }
+}
diff --git a/internal/statefile/cache_test.go b/internal/statefile/cache_test.go
new file mode 100644
index 00000000..7837d3fc
--- /dev/null
+++ b/internal/statefile/cache_test.go
@@ -0,0 +1,74 @@
+package statefile
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCacheReadsAFileAgainOnlyOnceItChanges(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "routes.json")
+ var c Cache[string]
+ reads := 0
+ load := func() string {
+ t.Helper()
+ v, err := c.Load(path, func() (string, error) {
+ reads++
+ b, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return "none", nil
+ }
+ return string(b), err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return v
+ }
+ if v := load(); v != "none" || reads != 1 {
+ t.Fatalf("missing file: %q after %d reads", v, reads)
+ }
+ if v := load(); v != "none" || reads != 1 {
+ t.Fatalf("still missing: %q after %d reads", v, reads)
+ }
+ if err := Write(path, []byte("aaaa")); err != nil {
+ t.Fatal(err)
+ }
+ if v := load(); v != "aaaa" || reads != 2 {
+ t.Fatalf("new file: %q after %d reads", v, reads)
+ }
+ for range 3 {
+ if v := load(); v != "aaaa" || reads != 2 {
+ t.Fatalf("unchanged file: %q after %d reads", v, reads)
+ }
+ }
+ // The same size, written straight after: still a new version.
+ if err := Write(path, []byte("bbbb")); err != nil {
+ t.Fatal(err)
+ }
+ if v := load(); v != "bbbb" || reads != 3 {
+ t.Fatalf("rewritten file: %q after %d reads", v, reads)
+ }
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if v := load(); v != "none" || reads != 4 {
+ t.Fatalf("removed file: %q after %d reads", v, reads)
+ }
+}
+
+func TestCacheKeepsNoErrors(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "routes.json")
+ if err := Write(path, []byte("x")); err != nil {
+ t.Fatal(err)
+ }
+ var c Cache[int]
+ broken := errors.New("unreadable")
+ if _, err := c.Load(path, func() (int, error) { return 0, broken }); err != broken {
+ t.Fatalf("err = %v", err)
+ }
+ if v, err := c.Load(path, func() (int, error) { return 7, nil }); err != nil || v != 7 {
+ t.Fatalf("after an error: %d, %v", v, err)
+ }
+}
diff --git a/internal/transcript/bench_test.go b/internal/transcript/bench_test.go
new file mode 100644
index 00000000..f878f7b5
--- /dev/null
+++ b/internal/transcript/bench_test.go
@@ -0,0 +1,200 @@
+package transcript
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// Benchmarks on a synthetic Claude Code record shaped like a real one: each
+// step an assistant line (thinking, text, a tool call) and the user line
+// with its result, whose toolUseResult carries what Claude Code keeps
+// beside it (a Read's whole file, an Edit's original file and patch).
+// Every name and path is made up ("acme").
+
+// synthClaude writes a record of about n lines to a temp file and returns
+// its path and the ID of a tool call near its end.
+func synthClaude(tb testing.TB, n int) (string, string) {
+ tb.Helper()
+ dir := tb.TempDir()
+ p := filepath.Join(dir, "0b8f3a52-acme-4c1e-9d55-2f7e0c1a9b01.jsonl")
+ f, err := os.Create(p)
+ if err != nil {
+ tb.Fatal(err)
+ }
+ defer f.Close()
+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+ file := strings.Repeat("export function acme(x: number): number {\n return x * 2 // keep it simple\n}\n", 60) // ~5KB
+ out := strings.Repeat("ok github.com/acme/widgets/pkg/thing\t0.123s\n", 40)
+ parent := ""
+ uuid := 0
+ line := func(v map[string]any) {
+ uuid++
+ id := fmt.Sprintf("00000000-0000-4000-8000-%012d", uuid)
+ v["uuid"], v["parentUuid"], v["isSidechain"] = id, parent, false
+ v["timestamp"] = at.Format(time.RFC3339Nano)
+ v["cwd"], v["sessionId"], v["version"], v["gitBranch"], v["userType"] = "/home/acme/widgets", "0b8f3a52-acme", "2.1.0", "main", "external"
+ at = at.Add(1500 * time.Millisecond)
+ parent = id
+ b, _ := json.Marshal(v)
+ f.Write(append(b, '\n'))
+ }
+ usage := map[string]any{"input_tokens": 12, "cache_creation_input_tokens": 3400, "cache_read_input_tokens": 88000, "output_tokens": 420}
+ assistant := func(blocks ...map[string]any) {
+ line(map[string]any{"type": "assistant", "requestId": "req_acme", "message": map[string]any{
+ "id": "msg_acme", "type": "message", "role": "assistant", "model": "claude-acme-1", "content": blocks, "usage": usage}})
+ }
+ result := func(tool string, content string, extra any) {
+ line(map[string]any{"type": "user", "message": map[string]any{"role": "user", "content": []map[string]any{
+ {"type": "tool_result", "tool_use_id": tool, "content": content}}}, "toolUseResult": extra})
+ }
+ lastTool := ""
+ for i := 0; uuid < n; i++ {
+ if i%25 == 0 {
+ line(map[string]any{"type": "user", "permissionMode": "default", "message": map[string]any{"role": "user", "content": fmt.Sprintf("Please fix widget %d in the acme repo and run the tests", i)}})
+ }
+ assistant(map[string]any{"type": "thinking", "thinking": "", "signature": strings.Repeat("Q2xhdWRl", 120)})
+ assistant(map[string]any{"type": "text", "text": fmt.Sprintf("Looking at widget %d now; I'll read the file first.", i)})
+ tool := fmt.Sprintf("toolu_%06d", i)
+ lastTool = tool
+ path := fmt.Sprintf("/home/acme/widgets/src/widget%d.ts", i%40)
+ switch i % 4 {
+ case 0:
+ assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Read", "input": map[string]any{"file_path": path}})
+ result(tool, file, map[string]any{"type": "text", "file": map[string]any{"filePath": path, "content": file, "numLines": 180, "startLine": 1, "totalLines": 180}})
+ case 1:
+ assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Edit", "input": map[string]any{"file_path": path, "old_string": "x * 2", "new_string": "x * 3"}})
+ result(tool, "The file "+path+" has been updated.", map[string]any{"filePath": path, "oldString": "x * 2", "newString": "x * 3", "originalFile": file,
+ "structuredPatch": []map[string]any{{"oldStart": 2, "oldLines": 1, "newStart": 2, "newLines": 1, "lines": []string{"- return x * 2", "+ return x * 3"}}}})
+ case 2:
+ assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Bash", "input": map[string]any{"command": "go test ./...", "description": "Run tests"}})
+ result(tool, out, map[string]any{"stdout": out, "stderr": "", "interrupted": false, "isImage": false})
+ case 3:
+ assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Grep", "input": map[string]any{"pattern": "acme", "path": "/home/acme/widgets"}})
+ result(tool, "Found 3 files\nsrc/a.ts\nsrc/b.ts\nsrc/c.ts", map[string]any{"mode": "files_with_matches", "filenames": []string{"src/a.ts", "src/b.ts", "src/c.ts"}, "numFiles": 3})
+ }
+ }
+ return p, lastTool
+}
+
+// cpuPerOp reports the process's CPU time per op (cpu-ns/op) when the
+// benchmark ends: on a busy machine it moves far less than wall time.
+func cpuPerOp(b *testing.B) func() {
+ start := cpuTime()
+ return func() {
+ if b.N > 0 {
+ b.ReportMetric(float64(cpuTime()-start)/float64(b.N), "cpu-ns/op")
+ }
+ }
+}
+
+func cpuTime() int64 {
+ var ru syscall.Rusage
+ syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
+ return ru.Utime.Nano() + ru.Stime.Nano()
+}
+
+func fileSize(tb testing.TB, p string) int64 {
+ st, err := os.Stat(p)
+ if err != nil {
+ tb.Fatal(err)
+ }
+ return st.Size()
+}
+
+// BenchmarkFollowCold is a chat opening a long conversation: the last
+// maxStart bytes read and parsed from scratch.
+func BenchmarkFollowCold(b *testing.B) {
+ p, _ := synthClaude(b, 20000)
+ b.SetBytes(min(fileSize(b, p), maxStart))
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ if _, err := NewReader().Read("claude", p, "/home/acme/widgets", 0); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkFollowWarm is the 2-second poll of an open chat with nothing
+// new: the answer is built from what is kept.
+func BenchmarkFollowWarm(b *testing.B) {
+ p, _ := synthClaude(b, 5000)
+ r := NewReader()
+ res, err := r.Read("claude", p, "/home/acme/widgets", 0)
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ if _, err := r.Follow("claude", p, "/home/acme/widgets", res.Next, res.Gen); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkBefore is a page of 80 older items from the end of a long file.
+func BenchmarkBefore(b *testing.B) {
+ p, _ := synthClaude(b, 20000)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ if _, err := Before("claude", p, "/home/acme/widgets", 0, 80); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkLastTurn finds the latest prompt and reads its edits.
+func BenchmarkLastTurn(b *testing.B) {
+ p, _ := synthClaude(b, 20000)
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ if _, err := LastTurn("claude", p, "/home/acme/widgets"); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkDetail opens one tool call near the end of a long file.
+func BenchmarkDetail(b *testing.B) {
+ p, id := synthClaude(b, 20000)
+ b.SetBytes(fileSize(b, p))
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ if _, err := Detail("claude", p, "/home/acme/widgets", id); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkAssignClaude assigns a folder's transcripts to its sessions,
+// as every chat poll does, in a project folder holding 200 records.
+func BenchmarkAssignClaude(b *testing.B) {
+ cfg := b.TempDir()
+ b.Setenv("CLAUDE_CONFIG_DIR", cfg)
+ dir := "/home/acme/widgets"
+ proj := ClaudeDir(dir)
+ if err := os.MkdirAll(proj, 0o755); err != nil {
+ b.Fatal(err)
+ }
+ start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+ for i := range 200 {
+ ts := start.Add(time.Duration(i) * time.Minute).Format(time.RFC3339Nano)
+ os.WriteFile(filepath.Join(proj, fmt.Sprintf("%08d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(`{"type":"user","timestamp":"`+ts+`"}`+"\n"), 0o600)
+ }
+ claims := []Claim{{Name: "a", Started: start.Add(150 * time.Minute)}, {Name: "b", Started: start.Add(170 * time.Minute)}}
+ b.ReportAllocs()
+ defer cpuPerOp(b)()
+ for b.Loop() {
+ AssignClaude(dir, claims)
+ }
+}
diff --git a/internal/transcript/claude.go b/internal/transcript/claude.go
index 026483dc..ed9893cf 100644
--- a/internal/transcript/claude.go
+++ b/internal/transcript/claude.go
@@ -1,7 +1,9 @@
package transcript
import (
+ "bytes"
"encoding/json"
+ "errors"
"path/filepath"
"regexp"
"strings"
@@ -33,20 +35,54 @@ type claudeLine struct {
ParentUUID string `json:"parentUuid"`
// Attachment is what Claude Code hands the model mid-turn: a message
// typed while it worked arrives as a queued_command, at the point the
- // model reads it, and is not written as a user line.
- Attachment *struct {
- Type string `json:"type"`
- Prompt json.RawMessage `json:"prompt"`
- Origin *Origin `json:"origin"`
- } `json:"attachment"`
+ // model reads it, and is not written as a user line (claudeAttached).
+ // The signals read its other kinds.
+ Attachment json.RawMessage `json:"attachment"`
// Origin says who a user turn is from (Claude Code 2.1): the person
// (human), another agent (peer), the lead (coordinator) or a task's
// notification (peer.go).
Origin *Origin `json:"origin"`
+
+ // The signals' own fields (claudeExtra), read in the same pass: a
+ // line is decoded once, not again for them.
+ Level string `json:"level"`
+ PermissionMode string `json:"permissionMode"`
+ Effort string `json:"effort"`
+ IsAPIError bool `json:"isApiErrorMessage"`
+ Error json.RawMessage `json:"error"`
+ RetryAttempt int `json:"retryAttempt"`
+ MaxRetries int `json:"maxRetries"`
+}
+
+// signalFields are claudeLine's fields only the signals read: one of a
+// type they never had leaves the line to the conversation, unread by the
+// signals, as when they read the line apart.
+var signalFields = map[string]bool{"level": true, "permissionMode": true, "effort": true, "isApiErrorMessage": true, "retryAttempt": true, "maxRetries": true}
+
+// claudeAttached is an attachment line's queued_command.
+type claudeAttached struct {
+ Type string `json:"type"`
+ Prompt json.RawMessage `json:"prompt"`
+ Origin *Origin `json:"origin"`
}
type claudeMessage struct {
Content json.RawMessage `json:"content"`
+ // Model and Usage are the signals' (an assistant line's).
+ Model string `json:"model"`
+ Usage *struct {
+ Input int `json:"input_tokens"`
+ CacheCreate int `json:"cache_creation_input_tokens"`
+ CacheRead int `json:"cache_read_input_tokens"`
+ } `json:"usage"`
+}
+
+// isArray says whether raw JSON is an array: content is a string or an
+// array of blocks, and which is told by its first byte without trying to
+// decode it as the other.
+func isArray(raw []byte) bool {
+ raw = bytes.TrimLeft(raw, " \t\r\n")
+ return len(raw) > 0 && raw[0] == '['
}
type claudeBlock struct {
@@ -62,22 +98,36 @@ type claudeBlock struct {
func (claudeParser) line(c *conv, b []byte) {
var l claudeLine
+ sigOK := true
+ if err := json.Unmarshal(b, &l); err != nil {
+ var te *json.UnmarshalTypeError
+ if !errors.As(err, &te) || !signalFields[te.Field] {
+ return
+ }
+ sigOK = false
+ }
// A helper's own record (a subagent's file) is all sidechain.
- if json.Unmarshal(b, &l) != nil || (l.Sidechain && !c.side) {
+ if l.Sidechain && !c.side {
return
}
+ // The message is decoded once, for the conversation and the signals.
+ var msg claudeMessage
+ msgOK := len(l.Message) > 0 && json.Unmarshal(l.Message, &msg) == nil
+ if !msgOK {
+ msg = claudeMessage{}
+ }
c.lineUUID, c.lineParent = l.UUID, l.ParentUUID
if l.IsMeta {
// A message from another agent, Claude Code's notification or a
// note around the person's mid-turn message, marked meta: read for
// what it is (peer.go), not dropped.
if l.Type == "user" && l.Origin != nil && metaOrigins[l.Origin.Kind] {
- if s := userString(l.Message); s != "" {
+ if s := userString(msg.Content); s != "" {
c.userTurn(s, l.Origin, turnMeta, parseTime(l.Timestamp))
}
return
}
- commandMeta(c, l)
+ commandMeta(c, l.Type, msg.Content)
return
}
at := parseTime(l.Timestamp)
@@ -87,7 +137,12 @@ func (claudeParser) line(c *conv, b []byte) {
}
// Its mode, model, context, errors and background work (signals.go):
// an API error's synthetic reply is a notice, not the agent's words.
- if claudeSignals(c, l.Type, b, at) {
+ var x *claudeExtra
+ if sigOK && (len(l.Message) == 0 || msgOK) {
+ x = &claudeExtra{Subtype: l.Subtype, Level: l.Level, PermissionMode: l.PermissionMode, Effort: l.Effort, IsAPIError: l.IsAPIError,
+ Error: l.Error, RetryAttempt: l.RetryAttempt, MaxRetries: l.MaxRetries, Attachment: l.Attachment, Message: msg}
+ }
+ if claudeSignals(c, l.Type, x, b, at) {
return
}
switch {
@@ -111,31 +166,33 @@ func (claudeParser) line(c *conv, b []byte) {
helperDone(c, l.Content, at)
return
}
- if l.Type == "attachment" && l.Attachment != nil && l.Attachment.Type == "queued_command" {
- // Shown where the model read it, so a reply never sits above the
- // message it answers.
- if t := strings.TrimSpace(unwrapPasted(resultFull(l.Attachment.Prompt))); t != "" {
- c.userTurn(t, l.Attachment.Origin, turnQueued, at)
+ if l.Type == "attachment" && len(l.Attachment) > 0 {
+ var a claudeAttached
+ if json.Unmarshal(l.Attachment, &a) == nil && a.Type == "queued_command" {
+ // Shown where the model read it, so a reply never sits above
+ // the message it answers.
+ if t := strings.TrimSpace(unwrapPasted(resultFull(a.Prompt))); t != "" {
+ c.userTurn(t, a.Origin, turnQueued, at)
+ }
+ return
}
- return
}
if l.Type != "user" && l.Type != "assistant" {
return
}
- var m claudeMessage
- if json.Unmarshal(l.Message, &m) != nil || len(m.Content) == 0 {
+ if !msgOK || len(msg.Content) == 0 {
return
}
// A prompt can be a plain string.
- var s string
- if json.Unmarshal(m.Content, &s) == nil {
- if l.Type == "user" {
+ if !isArray(msg.Content) {
+ var s string
+ if json.Unmarshal(msg.Content, &s) == nil && l.Type == "user" {
c.userTurn(s, l.Origin, turnPrompt, at)
}
return
}
var blocks []claudeBlock
- if json.Unmarshal(m.Content, &blocks) != nil {
+ if json.Unmarshal(msg.Content, &blocks) != nil {
return
}
var typed []string
@@ -206,18 +263,16 @@ func unwrapPasted(s string) string {
// metaOrigins are the origins of the isMeta lines worth reading.
var metaOrigins = map[string]bool{"human": true, "peer": true, "coordinator": true, "task-notification": true}
-// userString is a user line's text: a plain string, or its text blocks.
-func userString(raw json.RawMessage) string {
- var m claudeMessage
- if json.Unmarshal(raw, &m) != nil {
- return ""
- }
- var s string
- if json.Unmarshal(m.Content, &s) == nil {
+// userString is a user line's text, from its message's content: a plain
+// string, or its text blocks.
+func userString(content json.RawMessage) string {
+ if !isArray(content) {
+ var s string
+ _ = json.Unmarshal(content, &s)
return s
}
var blocks []claudeBlock
- if json.Unmarshal(m.Content, &blocks) != nil {
+ if json.Unmarshal(content, &blocks) != nil {
return ""
}
var parts []string
@@ -350,8 +405,9 @@ func claudeTool(c *conv, bl claudeBlock, at int64) {
// resultText is a tool result's text: a string, or its first text block.
func resultText(raw json.RawMessage) string {
- var s string
- if json.Unmarshal(raw, &s) == nil {
+ if !isArray(raw) {
+ var s string
+ _ = json.Unmarshal(raw, &s)
return s
}
var blocks []claudeBlock
@@ -503,13 +559,12 @@ var hookReports = regexp.MustCompile(`(?m)^(?:Pre|Post|Session|Stop|User|Notific
// commandMeta keeps the Markdown Claude Code writes for its model after a
// command's output (/context's table) as that output, which reads better
// than the terminal's drawing of it.
-func commandMeta(c *conv, l claudeLine) {
- if l.Type != "user" {
+func commandMeta(c *conv, typ string, content json.RawMessage) {
+ if typ != "user" {
return
}
- var m claudeMessage
var s string
- if json.Unmarshal(l.Message, &m) != nil || json.Unmarshal(m.Content, &s) != nil {
+ if json.Unmarshal(content, &s) != nil {
return
}
s = strings.TrimSpace(s)
diff --git a/internal/transcript/detail.go b/internal/transcript/detail.go
index 732788ef..91c4c7c6 100644
--- a/internal/transcript/detail.go
+++ b/internal/transcript/detail.go
@@ -83,8 +83,10 @@ func Detail(source, path, dir, id string) (ToolDetail, error) {
needle := []byte(`"` + id + `"`)
d := ToolDetail{ID: id}
found, answered := false, false
+ var line []byte
for {
- line, err := readLine(r)
+ var err error
+ line, err = readLineInto(r, line)
if len(line) > 0 && bytes.Contains(line, needle) {
var call, result bool
if source == "codex" {
@@ -109,14 +111,19 @@ func Detail(source, path, dir, id string) (ToolDetail, error) {
// readLine reads one line, skipping (not keeping) any longer than
// detailLine.
-func readLine(r *bufio.Reader) ([]byte, error) {
- var buf []byte
+func readLine(r *bufio.Reader) ([]byte, error) { return readLineInto(r, nil) }
+
+// readLineInto is readLine into buf's space: a scan of a long record reuses
+// one buffer rather than allocating every line again. The line is good
+// until the next call.
+func readLineInto(r *bufio.Reader, buf []byte) ([]byte, error) {
+ buf = buf[:0]
over := false
for {
chunk, err := r.ReadSlice('\n')
if !over {
if len(buf)+len(chunk) > detailLine {
- over, buf = true, nil
+ over, buf = true, buf[:0]
} else {
buf = append(buf, chunk...)
}
diff --git a/internal/transcript/signals.go b/internal/transcript/signals.go
index b5c93af5..456bb097 100644
--- a/internal/transcript/signals.go
+++ b/internal/transcript/signals.go
@@ -174,40 +174,33 @@ func (c *conv) interrupted(text string) bool {
// --- Claude Code ---
// claudeExtra are the fields of a Claude Code line the signals read, past
-// the ones the conversation does.
+// the ones the conversation does. The parser decodes them with the line
+// (claudeLine).
type claudeExtra struct {
- Subtype string `json:"subtype"`
- Level string `json:"level"`
- PermissionMode string `json:"permissionMode"`
- Effort string `json:"effort"`
- IsAPIError bool `json:"isApiErrorMessage"`
- Error json.RawMessage `json:"error"`
- RetryAttempt int `json:"retryAttempt"`
- MaxRetries int `json:"maxRetries"`
- Attachment json.RawMessage `json:"attachment"`
- ToolUseResult json.RawMessage `json:"toolUseResult"`
- Message struct {
- Model string `json:"model"`
- Content json.RawMessage
- Usage *struct {
- Input int `json:"input_tokens"`
- CacheCreate int `json:"cache_creation_input_tokens"`
- CacheRead int `json:"cache_read_input_tokens"`
- } `json:"usage"`
- } `json:"message"`
+ Subtype string
+ Level string
+ PermissionMode string
+ Effort string
+ IsAPIError bool
+ Error json.RawMessage
+ RetryAttempt int
+ MaxRetries int
+ Attachment json.RawMessage
+ Message claudeMessage
}
-// claudeSignals reads what a Claude Code line says about the agent. It
-// reports true when the line is wholly a signal and the conversation
-// should not read it as a message too (an API error's synthetic reply).
-func claudeSignals(c *conv, typ string, b []byte, at int64) bool {
+// claudeSignals reads what a Claude Code line says about the agent: x is
+// the line's fields, or nil when one is not of the type they read (the line
+// is no signal then). It reports true when the line is wholly a signal and
+// the conversation should not read it as a message too (an API error's
+// synthetic reply).
+func claudeSignals(c *conv, typ string, x *claudeExtra, b []byte, at int64) bool {
// A tool's result or a prompt is read only when it holds what the
// signals want: most are long and say nothing about the agent.
if typ == "user" && !bytes.Contains(b, []byte(`"permissionMode"`)) && !bytes.Contains(b, []byte("[Request interrupted by user")) && !bytes.Contains(b, []byte("")) {
return false
}
- var x claudeExtra
- if json.Unmarshal(b, &x) != nil {
+ if x == nil {
return false
}
if x.PermissionMode != "" {
diff --git a/internal/transcript/signals_test.go b/internal/transcript/signals_test.go
index 493e678f..4e06df82 100644
--- a/internal/transcript/signals_test.go
+++ b/internal/transcript/signals_test.go
@@ -47,6 +47,24 @@ func TestClaudeSignals(t *testing.T) {
}
}
+// The signals are read in the same pass as the conversation: a signal's
+// field of a shape they don't know leaves that line's signals unread, never
+// its words.
+func TestClaudeSignalOfAnotherShape(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "s.jsonl")
+ write(t, p,
+ m{"type": "assistant", "effort": "high", "message": m{"model": "claude-acme-1", "role": "assistant", "content": []m{{"type": "text", "text": "First."}}}},
+ m{"type": "assistant", "effort": m{"level": "max"}, "message": m{"model": "claude-acme-2", "role": "assistant", "content": []m{{"type": "text", "text": "Second."}}}},
+ )
+ res := read(t, "claude", p)
+ if n := len(res.Items); n != 2 || res.Items[1].Text != "Second." {
+ t.Fatalf("items = %+v", res.Items)
+ }
+ if s := res.Signals; s.Model != "claude-acme-1" || s.Effort != "high" {
+ t.Fatalf("model/effort = %q %q", s.Model, s.Effort)
+ }
+}
+
func TestClaudeTasks(t *testing.T) {
p := filepath.Join(t.TempDir(), "s.jsonl")
write(t, p,
diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go
index 6687b8fc..9d74fd2d 100644
--- a/internal/transcript/transcript.go
+++ b/internal/transcript/transcript.go
@@ -257,13 +257,21 @@ func (c *conv) add(it Item) int {
c.items[n-1].pending = nil
}
it.Off = c.lineOff
- c.items = append(c.items, it)
n := keep
if c.limit > 0 {
n = c.limit
}
+ // The window slides along a backing array with room for n more: the
+ // oldest item drops off by reslicing, and only when the array is full
+ // do the kept items move to a fresh one. Copying the whole window for
+ // every item past n made opening a long conversation quadratic.
+ if len(c.items) == cap(c.items) && len(c.items) >= n {
+ c.items = append(make([]Item, 0, 2*n), c.items...)
+ }
+ c.items = append(c.items, it)
if over := len(c.items) - n; over > 0 {
- c.items = append(c.items[:0:0], c.items[over:]...)
+ clear(c.items[:over]) // let what they hold go
+ c.items = c.items[over:]
c.base += over
}
return c.base + len(c.items) - 1
@@ -359,8 +367,16 @@ func (c *conv) helper(m CrewMember) {
}
}
+// readers are the 64 KB buffers files are followed through, kept between
+// reads: every open chat reads its file every few seconds.
+var readers = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, 64<<10) }}
+
// read follows the file from where it left off.
func (c *conv) read(path string) error {
+ // Nothing new, the usual answer to a chat's poll, needs no open.
+ if st, err := os.Stat(path); err == nil && st.Size() == c.offset {
+ return nil
+ }
f, err := os.Open(path)
if err != nil {
return err
@@ -370,6 +386,9 @@ func (c *conv) read(path string) error {
if err != nil {
return err
}
+ if st.Size() == c.offset {
+ return nil
+ }
if st.Size() < c.offset { // replaced or truncated: start again
*c = conv{source: c.source, dir: c.dir, p: c.p, side: c.side, byTool: map[string]int{}, crewByID: map[string]int{}, gen: gens.Add(1)}
}
@@ -380,7 +399,12 @@ func (c *conv) read(path string) error {
if _, err := f.Seek(c.offset, io.SeekStart); err != nil {
return err
}
- r := bufio.NewReaderSize(f, 64<<10)
+ r := readers.Get().(*bufio.Reader)
+ r.Reset(f)
+ defer func() {
+ r.Reset(nil)
+ readers.Put(r)
+ }()
skipFirst := c.truncated && c.offset > 0 && len(c.items) == 0 && c.partial == nil
for {
if len(c.partial) == 0 {
diff --git a/internal/transcript/turn.go b/internal/transcript/turn.go
index 81146b47..c301c8a9 100644
--- a/internal/transcript/turn.go
+++ b/internal/transcript/turn.go
@@ -107,8 +107,10 @@ func claudeTurn(path string, off int64) (Turn, error) {
byFile := map[string]int{}
r := bufio.NewReaderSize(f, 256<<10)
first := true
+ var line []byte
for {
- line, err := readLine(r)
+ var err error
+ line, err = readLineInto(r, line)
if len(line) > 0 {
if first {
var l struct {
diff --git a/internal/trust/bench_test.go b/internal/trust/bench_test.go
new file mode 100644
index 00000000..b2849363
--- /dev/null
+++ b/internal/trust/bench_test.go
@@ -0,0 +1,32 @@
+package trust
+
+import (
+ "crypto/sha256"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/cosscom/shipyard/internal/identity"
+)
+
+// BenchmarkTrusted looks a key up in a store of a few peers, as a box does
+// for every request a laptop makes.
+func BenchmarkTrusted(b *testing.B) {
+ s := NewStore(filepath.Join(b.TempDir(), "clients.json"))
+ var last identity.Fingerprint
+ for i := range 4 {
+ last = identity.Fingerprint(sha256.Sum256([]byte{byte(i)}))
+ if err := s.Add(Peer{Name: "laptop-" + strconv.Itoa(i), Fingerprint: last, PairedAt: time.Now()}); err != nil {
+ b.Fatal(err)
+ }
+ }
+ b.ReportAllocs()
+ b.RunParallel(func(pb *testing.PB) {
+ for pb.Next() {
+ if _, ok, err := s.Trusted(last); !ok || err != nil {
+ b.Fatal(ok, err)
+ }
+ }
+ })
+}
diff --git a/internal/trust/store.go b/internal/trust/store.go
index dfdabb1b..cf5a8d67 100644
--- a/internal/trust/store.go
+++ b/internal/trust/store.go
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"regexp"
+ "slices"
"sort"
"strings"
"time"
@@ -35,22 +36,39 @@ var (
// ValidName reports whether name is safe to show in listings and use in URLs.
func ValidName(name string) bool { return validName.MatchString(name) }
-type Store struct{ path string }
+type Store struct {
+ path string
+ // cache keeps the peers until the file changes. A box looks the
+ // caller up for every request; reading the file each time took a
+ // file lock that every request queued behind.
+ cache statefile.Cache[[]Peer]
+}
func NewStore(path string) *Store { return &Store{path: path} }
func (s *Store) List() ([]Peer, error) {
- unlock, err := statefile.Lock(s.path)
- if err != nil {
- return nil, err
- }
- defer unlock()
- return s.read()
+ peers, err := s.peers()
+ return slices.Clone(peers), err
+}
+
+// peers is the store as last read, read again once the file changes (a
+// pairing, a revoke, by this process or another). It is shared: callers
+// must not change it. Errors are never kept: a store that cannot be read
+// fails every lookup until it can.
+func (s *Store) peers() ([]Peer, error) {
+ return s.cache.Load(s.path, func() ([]Peer, error) {
+ unlock, err := statefile.Lock(s.path)
+ if err != nil {
+ return nil, err
+ }
+ defer unlock()
+ return s.read()
+ })
}
// Trusted looks a peer up by the key it presented.
func (s *Store) Trusted(fp identity.Fingerprint) (Peer, bool, error) {
- peers, err := s.List()
+ peers, err := s.peers()
if err != nil {
return Peer{}, false, err
}
@@ -63,7 +81,7 @@ func (s *Store) Trusted(fp identity.Fingerprint) (Peer, bool, error) {
}
func (s *Store) ByName(name string) (Peer, bool, error) {
- peers, err := s.List()
+ peers, err := s.peers()
if err != nil {
return Peer{}, false, err
}
diff --git a/internal/trust/store_test.go b/internal/trust/store_test.go
index 835228f3..18a091fa 100644
--- a/internal/trust/store_test.go
+++ b/internal/trust/store_test.go
@@ -132,6 +132,64 @@ func TestACorruptStoreFailsClosedAndIsNotOverwritten(t *testing.T) {
}
}
+// A box keeps its laptops between requests, yet a laptop that another
+// process (berthd revoke) removes is refused on its very next request, and
+// a store that turns unreadable fails closed at once.
+func TestARevokeByAnotherProcessAppliesAtOnce(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "clients.json")
+ box, cli := NewStore(path), NewStore(path)
+ alex := Peer{Name: "alex", Fingerprint: fp()}
+ sam := Peer{Name: "sam1", Fingerprint: fp()} // same length: same file size
+ if err := cli.Add(alex); err != nil {
+ t.Fatal(err)
+ }
+ for range 3 {
+ if _, ok, err := box.Trusted(alex.Fingerprint); !ok || err != nil {
+ t.Fatalf("trusted = %v, %v", ok, err)
+ }
+ }
+ if _, err := cli.Remove("alex"); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, _ := box.Trusted(alex.Fingerprint); ok {
+ t.Fatal("a revoked laptop was still trusted")
+ }
+ if err := cli.Add(alex); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, _ := box.Trusted(alex.Fingerprint); !ok {
+ t.Fatal("a paired laptop was not trusted")
+ }
+ // One peer swapped for another of the same size, straight after.
+ if _, err := cli.Remove("alex"); err != nil {
+ t.Fatal(err)
+ }
+ if err := cli.Add(Peer{Name: sam.Name, Fingerprint: sam.Fingerprint, PairedAt: alex.PairedAt}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, _ := box.Trusted(alex.Fingerprint); ok {
+ t.Fatal("a replaced laptop was still trusted")
+ }
+ if _, ok, _ := box.Trusted(sam.Fingerprint); !ok {
+ t.Fatal("its replacement was not trusted")
+ }
+ if list, _ := box.List(); len(list) == 1 {
+ list[0].Fingerprint = alex.Fingerprint
+ }
+ if _, ok, _ := box.Trusted(sam.Fingerprint); !ok {
+ t.Fatal("changing a listing changed the store")
+ }
+ if err := os.WriteFile(path+".tmp", []byte("{not json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(path+".tmp", path); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := box.Trusted(sam.Fingerprint); err == nil {
+ t.Fatal("a corrupt store answered a trust lookup")
+ }
+}
+
func TestInvalidNamesAreRefused(t *testing.T) {
s := NewStore(filepath.Join(t.TempDir(), "boxes.json"))
for _, name := range []string{"", "-leading", "has space", "a/b", "ünïcode", string(make([]byte, 64))} {
diff --git a/internal/wire/bench_test.go b/internal/wire/bench_test.go
new file mode 100644
index 00000000..615278f1
--- /dev/null
+++ b/internal/wire/bench_test.go
@@ -0,0 +1,74 @@
+package wire
+
+import (
+ "context"
+ "io"
+ "net"
+ "testing"
+)
+
+// benchEcho is a server on the box's loopback that echoes what it reads.
+func benchEcho(b *testing.B) int {
+ b.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() { defer c.Close(); io.Copy(c, c) }()
+ }
+ }()
+ return ln.Addr().(*net.TCPAddr).Port
+}
+
+// BenchmarkDialPortRoundTrip opens a stream to a box port, sends a small
+// request, reads the answer and closes it: one proxied connection.
+func BenchmarkDialPortRoundTrip(b *testing.B) {
+ c := paired(b, startBox(b))
+ port := benchEcho(b)
+ msg := make([]byte, 1<<10)
+ buf := make([]byte, len(msg))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ conn, err := c.DialPort(context.Background(), port)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if _, err := conn.Write(msg); err != nil {
+ b.Fatal(err)
+ }
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ b.Fatal(err)
+ }
+ conn.Close()
+ }
+}
+
+// BenchmarkStreamEcho pushes data through one long stream and back.
+func BenchmarkStreamEcho(b *testing.B) {
+ c := paired(b, startBox(b))
+ port := benchEcho(b)
+ conn, err := c.DialPort(context.Background(), port)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer conn.Close()
+ chunk := make([]byte, 64<<10)
+ buf := make([]byte, len(chunk))
+ b.SetBytes(int64(len(chunk)))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ go conn.Write(chunk)
+ if _, err := io.ReadFull(conn, buf); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/wire/server.go b/internal/wire/server.go
index e5de1226..2bceb40a 100644
--- a/internal/wire/server.go
+++ b/internal/wire/server.go
@@ -16,6 +16,7 @@ import (
"sync/atomic"
"time"
+ "github.com/cosscom/shipyard/internal/copybuf"
"github.com/cosscom/shipyard/internal/identity"
"github.com/cosscom/shipyard/internal/pairing"
"github.com/cosscom/shipyard/internal/trust"
@@ -341,12 +342,12 @@ func (s *Server) handleTCP(w http.ResponseWriter, r *http.Request) {
done := make(chan struct{})
go func() {
defer close(done)
- io.Copy(upstream, r.Body)
+ copybuf.Copy(upstream, r.Body)
if tc, ok := upstream.(*net.TCPConn); ok {
tc.CloseWrite()
}
}()
- io.Copy(flushWriter{w: w, rc: rc}, upstream)
+ copybuf.Copy(flushWriter{w: w, rc: rc}, upstream)
upstream.Close()
r.Body.Close()
<-done
diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go
index 5ecc9406..f172018b 100644
--- a/internal/wire/wire_test.go
+++ b/internal/wire/wire_test.go
@@ -47,9 +47,9 @@ func (l countingListener) Accept() (net.Conn, error) {
return c, err
}
-func startBox(t *testing.T) *box { return startBoxWith(t, nil) }
+func startBox(t testing.TB) *box { return startBoxWith(t, nil) }
-func startBoxWith(t *testing.T, configure func(*Server)) *box {
+func startBoxWith(t testing.TB, configure func(*Server)) *box {
t.Helper()
dir := t.TempDir()
id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem"))
@@ -82,7 +82,7 @@ func startBoxWith(t *testing.T, configure func(*Server)) *box {
return &box{server: s, address: ln.Addr().String(), dir: dir, accepted: accepted}
}
-func (b *box) issue(t *testing.T) pairing.Token {
+func (b *box) issue(t testing.TB) pairing.Token {
t.Helper()
code, err := b.server.Pending.Issue(10*time.Minute, time.Now())
if err != nil {
@@ -95,7 +95,7 @@ func (b *box) peer() trust.Peer {
return trust.Peer{Name: "dev-test", Address: b.address, Fingerprint: b.server.Identity.Fingerprint()}
}
-func laptop(t *testing.T) *identity.Identity {
+func laptop(t testing.TB) *identity.Identity {
t.Helper()
id, err := identity.LoadOrCreate(filepath.Join(t.TempDir(), "identity.pem"))
if err != nil {
@@ -105,7 +105,7 @@ func laptop(t *testing.T) *identity.Identity {
}
// paired returns a client for a laptop that has completed pairing with b.
-func paired(t *testing.T, b *box) *Client {
+func paired(t testing.TB, b *box) *Client {
t.Helper()
me := laptop(t)
if _, err := Pair(context.Background(), me, b.issue(t), "alex-mbp"); err != nil {
diff --git a/plugins/dev-servers/src/index.tsx b/plugins/dev-servers/src/index.tsx
index 5257b61b..31a16c2e 100644
--- a/plugins/dev-servers/src/index.tsx
+++ b/plugins/dev-servers/src/index.tsx
@@ -60,8 +60,10 @@ interface Group {
}
// useServers polls the listening servers of every online box, and reads the
-// repositories' declared services when worktrees or services change.
-function useServers(berth: BerthPluginContext) {
+// repositories' declared services when worktrees or services change. That
+// is a request per worktree on every box, so only the screen that lists
+// them asks (withConfigured); the status bar's count needs none of it.
+function useServers(berth: BerthPluginContext, withConfigured = true) {
const boxes = useBoxes();
const online = useMemo(() => boxes.filter((b) => b.state === "online").map((b) => b.name), [boxes]);
const key = online.join(",");
@@ -94,6 +96,7 @@ function useServers(berth: BerthPluginContext) {
}, [key, stamp, berth]);
useEffect(() => {
+ if (!withConfigured) return;
let live = true;
void (async () => {
const found: Configured[] = [];
@@ -116,7 +119,7 @@ function useServers(berth: BerthPluginContext) {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [key, stamp, berth]);
+ }, [key, stamp, berth, withConfigured]);
useEvent("service.*", reload);
useEvent("worktree.created", reload);
@@ -125,7 +128,7 @@ function useServers(berth: BerthPluginContext) {
}
function ServersStatus({ berth }: ScreenProps) {
- const { listening } = useServers(berth);
+ const { listening } = useServers(berth, false);
if (!listening?.length) return null;
return (
berth.openScreen("servers")}>