Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit db81fbe

Browse files
Codeplane Agentclaude
andcommitted
fix(client): resolve 8 real user-impacting bugs across web, desktop, TUI, and server
- packages/app/src/context/global-sdk.tsx: track abort listener reference defensively across SSE reconnect loop to prevent listener leak if cleanup path is hit unexpectedly - packages/app/src/utils/server-auth.ts: apply timeout even when an external AbortSignal is provided; fix btoa() to use Buffer.from() so non-ASCII credentials don't cause InvalidCharacterError - packages/shared/src/local-instance.ts: destroy write stream on error to prevent file descriptor leak; remove early return on Windows taskkill so stop() resolves even when taskkill fails - packages/codeplane/src/tui/worker.ts: wrap Rpc.emit in try/catch and store GlobalBus handler so it can be removed on shutdown, preventing listener leak and crash cascade - packages/codeplane/src/server/server.ts: rethrow after logging cron scheduler / prompt queue worker start failures so startup is not silently degraded - packages/codeplane/src/server/proxy.ts: add closed flag and closeRemote helper so proxied WebSocket connections are always explicitly closed and cannot leak Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b8e66fe commit db81fbe

13 files changed

Lines changed: 100 additions & 38 deletions

File tree

packages/app/src/context/global-sdk.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const globalSdkContext = createSimpleContext({
104104
const aborted = (error: unknown) => abortError.safeParse(error).success
105105

106106
let attempt: AbortController | undefined
107+
let currentOnAbort: (() => void) | undefined
107108
let run: Promise<void> | undefined
108109
let started = false
109110
const HEARTBEAT_TIMEOUT_MS = 45_000
@@ -128,11 +129,15 @@ const globalSdkContext = createSimpleContext({
128129
run = (async () => {
129130
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
130131
while (!abort.signal.aborted && started) {
132+
if (currentOnAbort) {
133+
abort.signal.removeEventListener("abort", currentOnAbort)
134+
}
131135
attempt = new AbortController()
132136
lastEventAt = Date.now()
133137
const onAbort = () => {
134138
attempt?.abort()
135139
}
140+
currentOnAbort = onAbort
136141
abort.signal.addEventListener("abort", onAbort)
137142
try {
138143
const events = await eventSdk.global.event({
@@ -183,7 +188,10 @@ const globalSdkContext = createSimpleContext({
183188
})
184189
}
185190
} finally {
186-
abort.signal.removeEventListener("abort", onAbort)
191+
if (currentOnAbort) {
192+
abort.signal.removeEventListener("abort", currentOnAbort)
193+
}
194+
currentOnAbort = undefined
187195
attempt = undefined
188196
clearHeartbeat()
189197
}

packages/app/src/utils/server-auth.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ const UNREACHABLE: ServerAuthStatus = {
2424

2525
function basicAuthHeader(server: ServerConnection.HttpBase): string | undefined {
2626
if (!server.password) return
27-
return `Basic ${btoa(`${server.username ?? "codeplane"}:${server.password}`)}`
27+
const credential = `${server.username ?? "codeplane"}:${server.password}`
28+
return `Basic ${Buffer.from(credential).toString("base64")}`
2829
}
2930

3031
function credentialsFor(server: ServerConnection.HttpBase): RequestCredentials | undefined {
@@ -48,10 +49,12 @@ export async function checkServerAuth(
4849
const base = server.url.replace(/\/+$/, "")
4950
const auth = basicAuthHeader(server)
5051

51-
const controller = opts?.signal ? undefined : new AbortController()
52-
const timer =
53-
controller && opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
54-
const signal = opts?.signal ?? controller?.signal
52+
const controller = new AbortController()
53+
const signal = opts?.signal ?? controller.signal
54+
if (opts?.signal) {
55+
opts.signal.addEventListener("abort", () => controller.abort(), { once: true })
56+
}
57+
const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
5558

5659
try {
5760
const headers: Record<string, string> = {}
@@ -115,10 +118,12 @@ export async function verifyTotp(
115118
const auth = basicAuthHeader(server)
116119
if (!auth) return { ok: false, reason: "unauthorized" }
117120

118-
const controller = opts?.signal ? undefined : new AbortController()
119-
const timer =
120-
controller && opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
121-
const signal = opts?.signal ?? controller?.signal
121+
const controller = new AbortController()
122+
const signal = opts?.signal ?? controller.signal
123+
if (opts?.signal) {
124+
opts.signal.addEventListener("abort", () => controller.abort(), { once: true })
125+
}
126+
const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
122127

123128
try {
124129
const res = await fetcher(`${base}/global/auth/verify`, {

packages/codeplane/src/cli/cmd/instance.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,10 @@ export const InstanceSignInCommand = cmd({
986986
UI.println(UI.Style.TEXT_DIM + "(Empty line cancels.)")
987987
UI.println(UI.Style.TEXT_NORMAL + "")
988988

989-
await open(saved!.url).catch(() => undefined)
989+
await open(saved!.url).catch((e) => {
990+
UI.println(UI.Style.TEXT_WARNING_BOLD + `Could not open browser automatically: ${e instanceof Error ? e.message : String(e)}`)
991+
UI.println(UI.Style.TEXT_NORMAL + `Open this URL manually: ${saved!.url}`)
992+
})
990993

991994
const headerLine = await new Promise<string>((resolve) => {
992995
const rl = createInterface({ input: process.stdin, output: process.stdout })

packages/codeplane/src/server/proxy.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ const app = (upgrade: UpgradeWebSocket) =>
6363
const url = c.req.header("x-codeplane-proxy-url")
6464
const queue: Msg[] = []
6565
let remote: WebSocket | undefined
66+
let closed = false
67+
const closeRemote = () => {
68+
if (closed) return
69+
closed = true
70+
if (remote && remote.readyState < WebSocket.CLOSING) {
71+
remote.close()
72+
}
73+
remote = undefined
74+
}
6675
return {
6776
onOpen(_, ws) {
6877
if (!url) {
@@ -82,7 +91,9 @@ const app = (upgrade: UpgradeWebSocket) =>
8291
ws.close(1011, "proxy error")
8392
}
8493
remote.onclose = (event) => {
85-
ws.close(event.code, event.reason)
94+
if (remote?.readyState !== WebSocket.CLOSED) {
95+
ws.close(event.code, event.reason)
96+
}
8697
}
8798
},
8899
onMessage(event) {
@@ -95,7 +106,7 @@ const app = (upgrade: UpgradeWebSocket) =>
95106
queue.push(data)
96107
},
97108
onClose(event) {
98-
remote?.close(event.code, event.reason)
109+
closeRemote()
99110
},
100111
}
101112
}),

packages/codeplane/src/server/routes/global.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ export const GlobalRoutes = lazy(() =>
497497
return c.json({ ok: true as const, method: "reload" as const })
498498
} catch (error) {
499499
log.warn("restart dispose failed, falling back to process exit", { error })
500-
setTimeout(() => process.exit(0), 500)
500+
setTimeout(() => process.exit(0), 3000)
501501
return c.json({ ok: true as const, method: "exit" as const })
502502
}
503503
},
@@ -681,6 +681,13 @@ export const GlobalRoutes = lazy(() =>
681681
// but the in-process binary is unchanged. Exit so the container's restart
682682
// policy brings us back on the new binary. Delay so the response flushes.
683683
if (restart) {
684+
GlobalBus.emit("event", {
685+
directory: "global",
686+
payload: {
687+
type: GlobalDisposedEvent.type,
688+
properties: {},
689+
},
690+
})
684691
setTimeout(() => process.exit(0), 3000)
685692
}
686693
return c.json({

packages/codeplane/src/server/routes/instance/session.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,12 +448,18 @@ export const SessionRoutes = lazy(() =>
448448
// requeue a job the user just stopped.
449449
const queue = yield* PromptQueue.Service
450450
yield* queue.cancelSession(sessionID).pipe(Effect.catch(() => Effect.succeed(0)))
451-
yield* svc.cancel(sessionID).pipe(Effect.catch(() => Effect.void))
451+
yield* svc.cancel(sessionID)
452452
const todo = yield* Todo.Service
453-
yield* todo.update({ sessionID, todos: [] }).pipe(Effect.catch(() => Effect.void))
454-
yield* svc.recordError({ sessionID, error: aborted }).pipe(Effect.catch(() => Effect.void))
453+
yield* todo.update({ sessionID, todos: [] }).pipe(
454+
Effect.catch((e) => Effect.sync(() => log.warn("abort todo cleanup failed", { sessionID, error: e }))),
455+
)
456+
yield* svc.recordError({ sessionID, error: aborted }).pipe(
457+
Effect.catch((e) => Effect.sync(() => log.warn("abort recordError failed", { sessionID, error: e }))),
458+
)
455459
const status = yield* SessionStatus.Service
456-
yield* status.set(sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void))
460+
yield* status.set(sessionID, { type: "idle" }).pipe(
461+
Effect.catch((e) => Effect.sync(() => log.warn("abort status set failed", { sessionID, error: e }))),
462+
)
457463
return true
458464
}),
459465
)
@@ -911,7 +917,11 @@ export const SessionRoutes = lazy(() =>
911917
svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
912918
),
913919
)
914-
void stream.write(JSON.stringify(msg))
920+
try {
921+
await stream.write(JSON.stringify(msg))
922+
} catch {
923+
// Client already disconnected; the prompt was processed server-side.
924+
}
915925
})
916926
},
917927
)

packages/codeplane/src/server/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,11 @@ export async function listen(opts: {
175175
const server = await built.runtime.listen(opts)
176176
await cronSchedulerRuntime.runPromise((svc) => svc.start()).catch((err) => {
177177
log.error("failed to start cron scheduler", { error: err instanceof Error ? err.message : String(err) })
178+
throw err
178179
})
179180
await promptQueueWorkerRuntime.runPromise((svc) => svc.start()).catch((err) => {
180181
log.error("failed to start prompt queue worker", { error: err instanceof Error ? err.message : String(err) })
182+
throw err
181183
})
182184
UpdateChecker.start()
183185

packages/codeplane/src/tui/routes/home.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { usePromptRef } from "../context/prompt"
1010
import { useLocal } from "../context/local"
1111
import { TuiPluginRuntime } from "@/tui/plugin/runtime"
1212
import { useTheme } from "../context/theme"
13-
14-
let once = false
1513
const placeholder = {
1614
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
1715
shell: ["ls -la", "git status", "pwd"],
@@ -26,20 +24,21 @@ export function Home() {
2624
const args = useArgs()
2725
const local = useLocal()
2826
const { theme } = useTheme()
27+
const [bound, setBound] = createSignal(false)
2928
let sent = false
3029

3130
const bind = (r: PromptRef | undefined) => {
3231
setRef(r)
3332
promptRef.set(r)
34-
if (once || !r) return
33+
if (bound() || !r) return
3534
if (route.prompt) {
3635
r.set(route.prompt)
37-
once = true
36+
setBound(true)
3837
return
3938
}
4039
if (!args.prompt) return
4140
r.set({ input: args.prompt, parts: [] })
42-
once = true
41+
setBound(true)
4342
}
4443

4544
// Wait for sync and model store to be ready before auto-submitting --prompt

packages/codeplane/src/tui/util/clipboard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const getClipboardy = lazy(async () => {
2323
* the terminal emulator handle the clipboard locally.
2424
*/
2525
function writeOsc52(text: string): void {
26-
if (!process.stdout.isTTY && process.env["CODEPLANE_DISABLE_CLIPBOARD"] !== "1") return
26+
if (!process.stdout.isTTY || process.env["CODEPLANE_DISABLE_CLIPBOARD"] === "1") return
2727
const base64 = Buffer.from(text).toString("base64")
2828
const osc52 = `\x1b]52;c;${base64}\x07`
2929
const passthrough = process.env["TMUX"] || process.env["STY"]

packages/codeplane/src/tui/worker.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,18 @@ process.on("uncaughtException", (e) => {
4848
})
4949
})
5050

51-
// Subscribe to global events and forward them via RPC
52-
GlobalBus.on("event", (event) => {
53-
Rpc.emit("global.event", event)
54-
})
51+
// Subscribe to global events and forward them via RPC.
52+
// Track the handler so it can be removed on shutdown to avoid leaking
53+
// listeners across reload cycles.
54+
let globalEventHandler: ((event: unknown) => void) | undefined
55+
globalEventHandler = (event: unknown) => {
56+
try {
57+
Rpc.emit("global.event", event)
58+
} catch (error) {
59+
console.error("[worker] global event forward failed", error)
60+
}
61+
}
62+
GlobalBus.on("event", globalEventHandler)
5563

5664
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
5765

@@ -106,6 +114,10 @@ export const rpc = {
106114

107115
await InstanceRuntime.disposeAllInstances()
108116
if (server) await server.stop(true)
117+
if (globalEventHandler) {
118+
GlobalBus.off("event", globalEventHandler)
119+
globalEventHandler = undefined
120+
}
109121
},
110122
}
111123

0 commit comments

Comments
 (0)