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
133 changes: 133 additions & 0 deletions ISSUES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
**Status:** Last reviewed 2026-06-14. 2/2 fixed (brain), 3/3 fixed (plugin-lib), 2/2 fixed (local-bus), 2/2 fixed (context-curator).

# Known Issues

## #1 — TUI reconnect retries dead port instead of re-discovering

**Symptom:** After the bus restarts (idle-timeout shutdown → new spawn on different port),
the TUI status bar stays stuck on "connecting…" forever. Brain publishes arrive at the new
port; TUI keeps knocking on the old one.

**Root cause:** `BusTui.scheduleReconnect()` calls `this.open()` which uses `this.port` —
the port captured at initial `connect()` time. It never reads the discovery file again, so
a bus restart on a new port is invisible to the TUI.

**Location:** `src/bus-tui.ts` — `scheduleReconnect()` (line ~164).

**Fix:** Before calling `this.open()`, re-read the discovery file and update `this.port` if
a new port is found. Fall back to the current port if discovery fails (bus not yet restarted).

```typescript
private scheduleReconnect(): void {
if (this.closed) return;
this.reconnectTimer = setTimeout(async () => {
if (this.closed) return;
try {
// Re-discover — bus may have restarted on a different port.
const newPort = await discoverPort(2000).catch(() => this.port);
this.port = newPort;
await this.open();
// success: ws.onopen already resets reconnectDelay to 1000
} catch {
// Failed — back off for next attempt
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
}, this.reconnectDelay);
}
```

Note: move the backoff increment into the `catch` block (currently it runs unconditionally,
doubling the delay even on successful reconnect).

---

✅ FIXED — commit b96489a (`discoverPort` in `scheduleReconnect`).

## #2 — Spawn lock missing: concurrent BusClient.connect() calls spawn duplicate buses

**Symptom:** If two plugin instances call `BusClient.connect()` at the same moment (e.g.,
two opencode windows starting simultaneously), both may see no port file, both call
`startBus()`, and two bus binaries are spawned. The "loser" writes to `port.json` first,
gets overwritten, and runs forever (see `four-local-bus` ISSUES #1 for the orphan side).

**Root cause:** No mutual-exclusion guard around `startBus()`. The check
(`discoverPort(500)` → health check → spawn) is a non-atomic read-check-act sequence.

**Location:** `src/bus-client.ts` — `connect()` / `startBus()`.

**Fix:** Add a module-level promise that deduplicates concurrent spawn attempts:

```typescript
let _spawnLock: Promise<number> | null = null;

// Inside connect(), replace the direct startBus() call:
if (!_spawnLock) {
_spawnLock = BusClient.startBus(timeoutMs).finally(() => {
_spawnLock = null;
});
}
const port = await _spawnLock;
return new BusClient(port);
```

---

✅ FIXED — commit b96489a (`_spawnLock` promise deduplicating concurrent starts).

## #3 — MemoryBusTui fallback is a dead end (no upgrade to real bus)

**Symptom:** If the TUI mounts before the bus port file exists (timing race on startup),
`BusTui.connect()` times out after 5 s and returns a `MemoryBusTui`. The TUI never retries
the real bus. Brain (a different process) publishes to the real Go bus; TUI consumes from
an in-process memory bus that never receives those messages. Status bar shows "connecting…"
for the session lifetime.

**Root cause:** `BusTui.connect()` catches the `discoverPort` timeout and returns
`MemoryBusTui` as a permanent fallback with no retry path.

**Location:** `src/bus-tui.ts` — `connect()` (line ~43).

**Fix:** Instead of returning `MemoryBusTui`, implement periodic retry in a real `BusTui`
instance. The initial timeout can be shorter (e.g., 2 s) to stay responsive; reconnect
logic (with re-discovery, see Issue #1) then handles the retry naturally.

```typescript
static async connect(timeoutMs = 5000): Promise<BusTui> {
let port: number;
try {
port = await discoverPort(timeoutMs);
} catch {
// Bus not yet running — return a BusTui that will reconnect once it appears.
// Port 0 signals "unknown"; scheduleReconnect will discover the real port.
const bus = new BusTui(0);
bus.scheduleReconnect(); // starts immediate retry loop
return bus;
}
const bus = new BusTui(port);
await bus.open();
return bus;
}
```

`scheduleReconnect()` with re-discovery (Issue #1 fix) then handles finding the bus once
it appears, without needing a separate `MemoryBusTui` class.

---

✅ FIXED — commit ccde741 (`BusTui.connect()` throws on missing bus; `useServiceBus` is the recommended path).

## Lifecycle patterns (should live in this lib, not in each plugin)

The following patterns are currently implemented ad-hoc in individual plugins
(e.g., `four-opencode-brain/src/status.ts`). They should be encapsulated here:

| Pattern | Where needed | Current state |
|---------|-------------|---------------|
| Start bus on plugin init, reuse if already running | `BusClient.connect()` | ✅ done |
| Spawn lock — no concurrent starts | `BusClient.connect()` | ✅ done (#2) |
| TUI: reconnect with port re-discovery | `BusTui.scheduleReconnect()` | ❌ missing (Issue #1) |
| TUI: don't dead-end in MemoryBusTui | `BusTui.connect()` | ❌ missing (Issue #3) |
| Server: clean shutdown when opencode exits | Go idle timer + explicit `close()` | ⚠️ idle only |

✅ `useServiceBus` hook extracted — commit 1879f2b (reactive bus subscription for TUIs;
replaces ad-hoc `MemoryBusTui` + reconnect juggling in plugin code).
7 changes: 7 additions & 0 deletions dist/bus-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@ export declare class BusClient {
private static findBusBinary;
/**
* Spawn the bus binary and read the port from stdout.
* Guarded by a module-level spawn lock so concurrent connect() calls share
* the same in-flight spawn instead of forking multiple bus processes.
*/
private static startBus;
/**
* Inner spawn implementation — wraps the child process in a Promise.
* Kept separate from startBus() so the spawn lock can be released cleanly.
*/
private static spawnBus;
/**
* Check if bus at given port is healthy.
*/
Expand Down
30 changes: 23 additions & 7 deletions dist/bus-tui.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { BusCallback, Unsubscribe } from "./types.js";
/**
* TUI-side client for the plugin bus.
* Connects via WebSocket, subscribes to channels, receives real-time messages.
*
* Falls back to in-memory EventBus when the Go binary is not available
* (same-process only, no cross-process IPC, no wildcard matching).
* TUI-side client for the plugin bus. Connects via WebSocket, subscribes to
* channels, receives real-time messages. Throws on `connect()` if the Go bus
* is not available — use `new MemoryBusTui()` explicitly for in-process mode.
*
* Usage:
* const bus = await BusTui.connect();
Expand All @@ -23,8 +21,7 @@ export declare class BusTui {
private closed;
protected constructor(port: number);
/**
* Connect to the plugin bus via WebSocket.
* Falls back to in-memory EventBus if the Go binary is not available.
* Connect to the plugin bus via WebSocket. Throws if the Go bus is not available.
*/
static connect(timeoutMs?: number): Promise<BusTui>;
/**
Expand All @@ -51,3 +48,22 @@ export declare class BusTui {
private scheduleReconnect;
private updateSubscriptions;
}
/**
* In-memory TUI client. Implements same API as BusTui.
* Uses shared MemoryBus singleton — works within same process only.
* Port is 0 (sentinel value for in-memory mode).
*
* Opt-in: instantiate directly with `new MemoryBusTui()`. `BusTui.connect()`
* no longer falls back to this class automatically.
*
* No WebSocket connection needed — messages flow through the shared
* in-process EventBus. Wildcard patterns are NOT supported; use the
* real Go bus for cross-process IPC and wildcard matching.
*/
export declare class MemoryBusTui extends BusTui {
private memorySubs;
constructor();
subscribe(pattern: string, callback: BusCallback): Unsubscribe;
publish(channel: string, payload: unknown): void;
close(): void;
}
6 changes: 6 additions & 0 deletions dist/derive-project-id.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Derives a stable project ID from a directory path.
* Uses FNV-1a 32-bit hash — works in both Bun (server) and TUI (browser).
* No crypto dependency required.
*/
export declare function deriveProjectId(directory: string): string;
1 change: 1 addition & 0 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { discoverPort } from "./discovery";
export type { BusEnvelope, BusHealth, PortInfo, BusCallback, Unsubscribe } from "./types";
export { BusClient } from "./bus-client.js";
export { memoryBus } from "./memory-bus.js";
export { deriveProjectId } from "./derive-project-id.js";
6 changes: 3 additions & 3 deletions dist/index.js

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

Loading
Loading