Skip to content

Commit 970a679

Browse files
committed
fix(session-notification): use node:child_process to avoid Bun shell GC crash
Replace Bun shell template literals (ctx.$) with node:child_process.spawn to work around Bun's ShellInterpreter garbage collection bug on Windows. This bug causes segmentation faults in deinitFromFinalizer during heap sweeping when shell operations are used repeatedly over time. Bug references: - oven-sh/bun#23177 (closed incomplete) - oven-sh/bun#24368 (still open) - Pending fix: oven-sh/bun#24093 The fix applies to all platforms for consistency and safety.
1 parent 556262e commit 970a679

File tree

2 files changed

+48
-19
lines changed

2 files changed

+48
-19
lines changed

src/hooks/session-notification.test.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
1-
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
1+
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
2+
import { EventEmitter } from "node:events"
3+
import * as childProcess from "node:child_process"
24

35
import { createSessionNotification } from "./session-notification"
46
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
57
import * as utils from "./session-notification-utils"
68

79
describe("session-notification", () => {
810
let notificationCalls: string[]
11+
let spawnMock: ReturnType<typeof spyOn>
912

1013
function createMockPluginInput() {
1114
return {
12-
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
13-
// #given - track notification commands (osascript, notify-send, powershell)
14-
const cmdStr = typeof cmd === "string"
15-
? cmd
16-
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
17-
18-
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
19-
notificationCalls.push(cmdStr)
20-
}
21-
return { stdout: "", stderr: "", exitCode: 0 }
22-
},
15+
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
2316
client: {
2417
session: {
2518
todo: async () => ({ data: [] }),
@@ -32,6 +25,18 @@ describe("session-notification", () => {
3225
beforeEach(() => {
3326
notificationCalls = []
3427

28+
// Mock spawn to track notification commands
29+
// Uses node:child_process.spawn instead of Bun shell to avoid GC crash
30+
spawnMock = spyOn(childProcess, "spawn").mockImplementation((cmd: string, args?: string[]) => {
31+
// Track notification commands (osascript, notify-send, powershell)
32+
if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) {
33+
notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`)
34+
}
35+
const emitter = new EventEmitter()
36+
setTimeout(() => emitter.emit("close", 0), 0)
37+
return emitter as any
38+
})
39+
3540
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
3641
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
3742
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")

src/hooks/session-notification.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PluginInput } from "@opencode-ai/plugin"
22
import { platform } from "os"
3+
import { spawn } from "node:child_process"
34
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
45
import {
56
getOsascriptPath,
@@ -11,6 +12,21 @@ import {
1112
startBackgroundCheck,
1213
} from "./session-notification-utils"
1314

15+
/**
16+
* Execute a command using node:child_process instead of Bun shell.
17+
* This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368).
18+
*/
19+
function execCommand(command: string, args: string[]): Promise<void> {
20+
return new Promise((resolve) => {
21+
const proc = spawn(command, args, {
22+
stdio: "ignore",
23+
detached: false,
24+
})
25+
proc.on("close", () => resolve())
26+
proc.on("error", () => resolve())
27+
})
28+
}
29+
1430
interface Todo {
1531
content: string
1632
status: string
@@ -65,14 +81,17 @@ async function sendNotification(
6581

6682
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
6783
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
68-
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
84+
const script = `display notification "${esMessage}" with title "${esTitle}"`
85+
// Use node:child_process instead of Bun shell to avoid potential GC issues
86+
await execCommand(osascriptPath, ["-e", script]).catch(() => {})
6987
break
7088
}
7189
case "linux": {
7290
const notifySendPath = await getNotifySendPath()
7391
if (!notifySendPath) return
7492

75-
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
93+
// Use node:child_process instead of Bun shell to avoid potential GC issues
94+
await execCommand(notifySendPath, [title, message]).catch(() => {})
7695
break
7796
}
7897
case "win32": {
@@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
93112
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
94113
$Notifier.Show($Toast)
95114
`.trim().replace(/\n/g, "; ")
96-
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
115+
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
116+
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
97117
break
98118
}
99119
}
@@ -104,25 +124,29 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
104124
case "darwin": {
105125
const afplayPath = await getAfplayPath()
106126
if (!afplayPath) return
107-
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
127+
// Use node:child_process instead of Bun shell to avoid potential GC issues
128+
execCommand(afplayPath, [soundPath]).catch(() => {})
108129
break
109130
}
110131
case "linux": {
111132
const paplayPath = await getPaplayPath()
112133
if (paplayPath) {
113-
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
134+
// Use node:child_process instead of Bun shell to avoid potential GC issues
135+
execCommand(paplayPath, [soundPath]).catch(() => {})
114136
} else {
115137
const aplayPath = await getAplayPath()
116138
if (aplayPath) {
117-
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
139+
execCommand(aplayPath, [soundPath]).catch(() => {})
118140
}
119141
}
120142
break
121143
}
122144
case "win32": {
123145
const powershellPath = await getPowershellPath()
124146
if (!powershellPath) return
125-
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
147+
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
148+
const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`
149+
execCommand(powershellPath, ["-Command", soundScript]).catch(() => {})
126150
break
127151
}
128152
}

0 commit comments

Comments
 (0)