Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
],
"scripts": {
"prepare": "lefthook install",
"build": "bun build src/index.ts --outdir dist --target bun --external bun-pty",
"build": "bun build src/index.ts --outdir dist --target node --external bun-pty --external jsonc-parser --external ws",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run check && bun run build",
"test": "bun test",
Expand Down Expand Up @@ -47,11 +47,13 @@
"bun-pty": "^0.4.5",
"jsonc-parser": "^3.3.1",
"valibot": "^1.2.0",
"ws": "^8.21.1",
"yaml": "^2.8.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@eslint/js": "^10.0.1",
"@types/ws": "^8.18.1",
"bun-types": "latest",
"eslint": "^10.8.0",
"eslint-plugin-sonarjs": "^4.0.2",
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/ledger-loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// src/hooks/ledger-loader.ts

import { readdir, readFile } from "node:fs/promises";
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import type { PluginInput } from "@opencode-ai/plugin";
import { config } from "@/utils/config";
Expand All @@ -13,8 +13,8 @@ export interface LedgerInfo {

async function getFileMtime(filePath: string): Promise<number> {
try {
const stat = await Bun.file(filePath).stat();
return stat ? stat.mtime.getTime() : 0;
const stats = await stat(filePath);
return stats.mtime.getTime();
} catch {
return 0;
}
Expand Down
15 changes: 10 additions & 5 deletions src/octto/session/browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// src/octto/session/browser.ts
// Cross-platform browser opener

import { spawn } from "node:child_process";

/**
* Opens the default browser to the specified URL.
* Detects platform and uses appropriate command.
Expand All @@ -23,10 +25,13 @@ export async function openBrowser(url: string): Promise<void> {
break;
}

const proc = Bun.spawn(command, {
stdout: "ignore",
stderr: "ignore",
});
const [executable, ...args] = command;

await proc.exited;
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, args, { stdio: "ignore" });
child.on("error", reject);
child.on("close", () => {
resolve();
});
});
}
212 changes: 116 additions & 96 deletions src/octto/session/server.ts
Original file line number Diff line number Diff line change
@@ -1,125 +1,145 @@
// src/octto/session/server.ts

import type { Server, ServerWebSocket } from "bun";
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import * as v from "valibot";
import { type WebSocket, WebSocketServer } from "ws";
import { getHtmlBundle } from "@/octto/ui";
import { config } from "@/utils/config";
import { extractErrorMessage } from "@/utils/errors";
import { log } from "@/utils/logger";
import { WsClientMessageSchema } from "./schemas";
import type { SessionStore } from "./sessions";
import type { WsClientMessage } from "./types";
import type { SessionServer, SessionSocket, SocketRouter, WsClientMessage } from "./types";

interface WsData {
sessionId: string;
const WS_PATH = "/ws";
const HTML_PATHS = new Set(["/", "/index.html"]);
const LOOPBACK = "127.0.0.1";
const LOG_MODULE = "octto";
const ERR_NO_PORT = "Failed to get server port";
const STATUS_OK = 200;
const STATUS_NOT_FOUND = 404;

function serveHttp(req: IncomingMessage, res: ServerResponse, htmlBundle: string): void {
const path = (req.url ?? "").split("?")[0];

if (HTML_PATHS.has(path)) {
res.writeHead(STATUS_OK, { "Content-Type": "text/html; charset=utf-8" });
res.end(htmlBundle);
return;
}

res.writeHead(STATUS_NOT_FOUND, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
}

export async function createServer(
sessionId: string,
store: SessionStore,
): Promise<{ server: Server<WsData>; port: number }> {
const htmlBundle = getHtmlBundle();
function sendError(socket: SessionSocket, error: string, details: string): void {
socket.send(JSON.stringify({ type: "error", error, details }));
}

const server = Bun.serve<WsData>({
port: 0, // Random available port
hostname: config.octto.allowRemoteBind ? config.octto.bindAddress : "127.0.0.1",
fetch(req, server) {
return handleFetch(req, server, sessionId, htmlBundle);
},
websocket: createWebSocketHandlers(store),
});
function handleWsMessage(socket: SessionSocket, sessionId: string, raw: string, store: SocketRouter): void {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
log.error(LOG_MODULE, "Failed to parse WebSocket message", error);
sendError(socket, "Invalid message format", extractErrorMessage(error));
return;
}

// Port is always defined when using port: 0
const port = server.port;
if (port === undefined) {
throw new Error("Failed to get server port");
const result = v.safeParse(WsClientMessageSchema, parsed);
if (!result.success) {
log.error(LOG_MODULE, "Invalid WebSocket message schema", result.issues);
sendError(socket, "Invalid message schema", result.issues.map((issue) => issue.message).join("; "));
return;
}

return {
server,
port,
};
store.handleWsMessage(sessionId, result.output as WsClientMessage);
}

function handleFetch(
req: Request,
server: Server<WsData>,
sessionId: string,
htmlBundle: string,
): Response | undefined {
const url = new URL(req.url);

// WebSocket upgrade
if (url.pathname === "/ws") {
const success = server.upgrade(req, {
data: { sessionId },
function attachWebSockets(wss: WebSocketServer, sessionId: string, store: SocketRouter): void {
wss.on("connection", (socket: WebSocket) => {
store.handleWsConnect(sessionId, socket);

socket.on("message", (data: Buffer | string) => {
handleWsMessage(socket, sessionId, data.toString(), store);
});
if (success) {
return undefined;
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
socket.on("close", () => {
store.handleWsDisconnect(sessionId);
});
socket.on("error", (error: unknown) => {
log.error(LOG_MODULE, "WebSocket connection error", error);
});
});
}

// Serve the bundled HTML app
if (url.pathname === "/" || url.pathname === "/index.html") {
return new Response(htmlBundle, {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
// ws attaches its own listener to the HTTP server and re-emits its errors on
// itself, so the only handler that sees a bind failure is one registered here.
// Without it an unhandled 'error' propagates out and takes the host process
// down with the session.
function listen(http: Server, wss: WebSocketServer, hostname: string): Promise<number> {
return new Promise((resolve, reject) => {
let settled = false;
const settle = (finish: () => void): void => {
if (settled) return;
settled = true;
finish();
};

wss.on("error", (error) => {
log.error(LOG_MODULE, "WebSocket server error", error);
settle(() => {
reject(error);
});
});
}

return new Response("Not Found", { status: 404 });
http.listen(0, hostname, () => {
const address = http.address();
if (address === null || typeof address === "string") {
settle(() => {
reject(new Error(ERR_NO_PORT));
});
return;
}
settle(() => {
resolve(address.port);
});
});
});
}

function createWebSocketHandlers(store: SessionStore): {
open: (ws: ServerWebSocket<WsData>) => void;
close: (ws: ServerWebSocket<WsData>) => void;
message: (ws: ServerWebSocket<WsData>, message: string | Buffer) => void;
} {
return {
open(ws: ServerWebSocket<WsData>) {
store.handleWsConnect(ws.data.sessionId, ws);
},
close(ws: ServerWebSocket<WsData>) {
store.handleWsDisconnect(ws.data.sessionId);
},
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
handleWsMessage(ws, message, store);
},
};
// Live sockets keep node:http from closing, so drop them before waiting.
function stop(http: Server, wss: WebSocketServer): Promise<void> {
return new Promise((resolve) => {
for (const client of wss.clients) {
client.terminate();
}
wss.close(() => {
// Absent before Node 18.2, which older Electron builds still ship. An
// unguarded call throws inside ws's close callback and strands stop().
http.closeAllConnections?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stopping a session can hang on older Electron/Node runtimes after an HTTP client has used keep-alive. The optional call avoids the throw but leaves no fallback to destroy those HTTP sockets, so http.close() may never invoke its callback; track HTTP connections and destroy them when this API is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/octto/session/server.ts, line 118:

<comment>Stopping a session can hang on older Electron/Node runtimes after an HTTP client has used keep-alive. The optional call avoids the throw but leaves no fallback to destroy those HTTP sockets, so `http.close()` may never invoke its callback; track HTTP connections and destroy them when this API is unavailable.</comment>

<file context>
@@ -93,7 +113,9 @@ function stop(http: Server, wss: WebSocketServer): Promise<void> {
-      http.closeAllConnections();
+      // Absent before Node 18.2, which older Electron builds still ship. An
+      // unguarded call throws inside ws's close callback and strands stop().
+      http.closeAllConnections?.();
       http.close(() => {
         resolve();
</file context>

http.close(() => {
resolve();
});
});
});
}

function handleWsMessage(ws: ServerWebSocket<WsData>, message: string | Buffer, store: SessionStore): void {
const { sessionId } = ws.data;
export async function createServer(
sessionId: string,
store: SocketRouter,
): Promise<{ server: SessionServer; port: number }> {
const htmlBundle = getHtmlBundle();
const hostname = config.octto.allowRemoteBind ? config.octto.bindAddress : LOOPBACK;

let raw: unknown;
try {
raw = JSON.parse(message.toString());
} catch (error) {
log.error("octto", "Failed to parse WebSocket message", error);
ws.send(
JSON.stringify({
type: "error",
error: "Invalid message format",
details: extractErrorMessage(error),
}),
);
return;
}
const http = createHttpServer((req, res) => {
serveHttp(req, res, htmlBundle);
});
const wss = new WebSocketServer({ server: http, path: WS_PATH, maxPayload: config.octto.maxFrameBytes });
attachWebSockets(wss, sessionId, store);

const result = v.safeParse(WsClientMessageSchema, raw);
if (!result.success) {
log.error("octto", "Invalid WebSocket message schema", result.issues);
ws.send(
JSON.stringify({
type: "error",
error: "Invalid message schema",
details: result.issues.map((i) => i.message).join("; "),
}),
);
return;
}
const port = await listen(http, wss, hostname);

store.handleWsMessage(sessionId, result.output as WsClientMessage);
return {
port,
server: { port, hostname, stop: () => stop(http, wss) },
};
}
6 changes: 3 additions & 3 deletions src/octto/session/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// src/octto/session/sessions.ts
import type { ServerWebSocket } from "bun";

import { DEFAULT_ANSWER_TIMEOUT_MS } from "@/octto/constants";
import { log } from "@/utils/logger";
Expand All @@ -18,6 +17,7 @@ import {
type Question,
type QuestionType,
type Session,
type SessionSocket,
STATUSES,
type StartSessionInput,
type StartSessionOutput,
Expand All @@ -41,7 +41,7 @@ export interface SessionStore {
getNextAnswer: (input: GetNextAnswerInput) => Promise<GetNextAnswerOutput>;
cancelQuestion: (questionId: string) => { ok: boolean };
listQuestions: (sessionId?: string) => ListQuestionsOutput;
handleWsConnect: (sessionId: string, ws: ServerWebSocket<unknown>) => void;
handleWsConnect: (sessionId: string, ws: SessionSocket) => void;
handleWsDisconnect: (sessionId: string) => void;
handleWsMessage: (sessionId: string, message: WsClientMessage) => void;
getSession: (sessionId: string) => Session | undefined;
Expand Down Expand Up @@ -364,7 +364,7 @@ function collectQuestions(sessions: Map<string, Session>, sessionId?: string): L
return { questions };
}

function onWsConnect(sessions: Map<string, Session>, sessionId: string, ws: ServerWebSocket<unknown>): void {
function onWsConnect(sessions: Map<string, Session>, sessionId: string, ws: SessionSocket): void {
const session = sessions.get(sessionId);
if (!session) return;

Expand Down
Loading