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
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,21 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
}
});

webServer.setOnPortsExhaustedCallback(() => {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: `Web UI unavailable: ports ${CONFIG.webServerPort}-${CONFIG.webServerPort + 10} are held by non-responsive processes`,
variant: "error",
duration: 5000,
},
})
.catch(() => {});
}
});

if (webServer.isServerOwner()) {
if (ctx.client?.tui) {
ctx.client.tui
Expand Down
104 changes: 94 additions & 10 deletions src/services/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ interface PortableServerHandle {

const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";

const MIN_FAILED_TAKEOVERS = 3;

function serveFetch(opts: {
port: number;
hostname: string;
Expand Down Expand Up @@ -156,6 +158,24 @@ function serveFetch(opts: {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/**
* Port fallback policy for the takeover loop. A port that fails to bind AND
* answers no HTTP is treated as orphaned Windows kernel residue; after
* `minFailedTakeovers` consecutive failed takeovers the web server moves to
* `currentPort + 1`, bounded by `maxFallbackPort`.
*/
export function nextFallbackPort(
currentPort: number,
failedTakeovers: number,
maxFallbackPort: number,
minFailedTakeovers: number = MIN_FAILED_TAKEOVERS
): number {
if (failedTakeovers < minFailedTakeovers || currentPort >= maxFallbackPort) {
return currentPort;
}
return currentPort + 1;
}

interface WebServerConfig {
port: number;
host: string;
Expand All @@ -171,15 +191,24 @@ export class WebServer {
private startPromise: Promise<void> | null = null;
private healthCheckInterval: NodeJS.Timeout | null = null;
private onTakeoverCallback: (() => Promise<void>) | null = null;
private onPortsExhaustedCallback: (() => void) | null = null;
private portsExhaustedNotified = false;
private takeoverFailures: number = 0;
private readonly maxFallbackPort: number;

constructor(config: WebServerConfig) {
this.config = config;
this.maxFallbackPort = config.port + 10;
}

setOnTakeoverCallback(callback: () => Promise<void>): void {
this.onTakeoverCallback = callback;
}

setOnPortsExhaustedCallback(callback: () => void): void {
this.onPortsExhaustedCallback = callback;
}

async start(): Promise<void> {
if (this.startPromise) {
return this.startPromise;
Expand Down Expand Up @@ -255,28 +284,75 @@ export class WebServer {
await new Promise((resolve) => setTimeout(resolve, jitterMs));

if (await this.checkServerAvailable()) {
// The original owner recovered. Reset the failure counter so a stale
// count can't advance the port on the next, unrelated failure.
this.takeoverFailures = 0;
this.startHealthCheckLoop();
return;
}

// Windows can leave an orphaned LISTEN socket in the TCP table after a
// crash: the port refuses to bind (EADDRINUSE) yet nothing answers HTTP,
// so repeated takeovers fail forever. After a few consecutive failures,
// fall back to a free neighbor port instead of looping.
this.takeoverFailures += 1;
const nextPort = nextFallbackPort(
this.config.port,
this.takeoverFailures,
this.maxFallbackPort
);
if (nextPort !== this.config.port) {
this.takeoverFailures = 0;
log("Web server port held by a non-responsive process; falling back to next port", {
previousPort: this.config.port,
newPort: nextPort,
});
this.config.port = nextPort;
} else if (
this.config.port >= this.maxFallbackPort &&
this.takeoverFailures >= MIN_FAILED_TAKEOVERS
) {
// Every candidate port is held by a non-responsive process. Stop the
// health loop instead of retrying every five seconds forever.
this.stopHealthCheckLoop();
this.notifyPortsExhausted();
return;
}

try {
// Reset startPromise so _start() can run again
this.startPromise = null;
await this._start();
} catch (error) {
this.startHealthCheckLoop();
return;
}

if (this.isOwner) {
log("Web server takeover successful", { port: this.config.port });
if (this.isOwner) {
this.takeoverFailures = 0;
log("Web server takeover successful", { port: this.config.port });

if (this.onTakeoverCallback) {
try {
await this.onTakeoverCallback();
} catch (error) {
log("Takeover callback error", { error: String(error) });
}
if (this.onTakeoverCallback) {
try {
await this.onTakeoverCallback();
} catch (error) {
log("Takeover callback error", { error: String(error) });
}
}
}
}

private notifyPortsExhausted(): void {
if (this.portsExhaustedNotified) return;
this.portsExhaustedNotified = true;
log("Web server unavailable: every candidate port is held by a non-responsive process", {
port: this.config.port,
maxFallbackPort: this.maxFallbackPort,
});
try {
this.onPortsExhaustedCallback?.();
} catch (error) {
this.startHealthCheckLoop();
log("Ports exhausted callback error", { error: String(error) });
}
}

Expand Down Expand Up @@ -315,7 +391,15 @@ export class WebServer {
headers,
signal: AbortSignal.timeout(2000),
});
return response.ok;
if (!response.ok) return false;
// Fallback spans 10 neighbor ports; any 2xx from an unrelated local
// service must not be mistaken for an opencode-mem owner. Require the
// response to carry our API envelope.
const body = (await response.json()) as { success?: boolean; status?: string };
if (endpoint === "/api/health") {
return body.success === true && body.status === "ok";
}
return body.success === true;
} catch {
return false;
}
Expand Down
16 changes: 10 additions & 6 deletions tests/memory-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,13 @@ mock.module(${JSON.stringify(readyUrl)}, () => ({
mock.module(${JSON.stringify(shardManagerUrl)}, () => ({
tursoShardManager: {
async getAllShards(scope, hash) {
return scope === "project" && hash === ""
? [makeShard("shard-a"), makeShard("shard-b")]
: [makeShard("shard-current")];
if (scope === "project" && hash === "") {
return [makeShard("shard-a"), makeShard("shard-b")];
}
if (scope === "user" && hash === "") {
return [makeShard("shard-current")];
}
return [makeShard("shard-current")];
},
async getWriteShard() {
return makeShard("shard-write");
Expand Down Expand Up @@ -177,7 +181,7 @@ console.log(JSON.stringify(res));

expect(result.exitCode).toBe(0);
expect(result.parsed.success).toBe(true);
expect(result.parsed.results.length).toBe(2);
expect(result.parsed.results.length).toBe(3);
});

it("lets tool params override config", () => {
Expand All @@ -188,7 +192,7 @@ console.log(JSON.stringify(res));

expect(result.exitCode).toBe(0);
expect(result.parsed.success).toBe(true);
expect(result.parsed.memories.length).toBe(2);
expect(result.parsed.memories.length).toBe(3);
});

it("queries across shards for all-projects", () => {
Expand All @@ -198,6 +202,6 @@ console.log(JSON.stringify({ ids: res.results.map((r) => r.id) }));
`);

expect(result.exitCode).toBe(0);
expect(result.parsed.ids).toEqual(["shard-a", "shard-b"]);
expect(result.parsed.ids).toEqual(["shard-current", "shard-a", "shard-b"]);
});
});
Loading