Skip to content

Commit 1570e29

Browse files
fix(session-notification): revert PR #543 and add proper notification plugin conflict detection (#575)
* revert: undo PR #543 changes (bun shell GC crash was misdiagnosed) This reverts commit 4a38e70 (PR #543) and 2064568 (follow-up fix). ## Why This Revert The original diagnosis was incorrect. PR #543 assumed Bun's ShellInterpreter GC bug was causing Windows crashes, but further investigation revealed the actual root cause: **The crash occurs when oh-my-opencode's session-notification runs alongside external notification plugins (e.g., @mohak34/opencode-notifier).** Evidence: - User removed opencode-notifier plugin → crashes stopped - Release version (with original ctx.$ code) works fine when used alone - No widespread crash reports from users without external notifiers - Both plugins listen to session.idle and send concurrent notifications The real issue is a conflict between two notification systems: 1. oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications 2. opencode-notifier: node-notifier → SnoreToast.exe A proper fix will detect and handle this conflict gracefully. Refs: #543, oven-sh/bun#23177, oven-sh/bun#24368 See: docs/CRASH_INVESTIGATION_TIMELINE.md (in follow-up commit) * fix(session-notification): detect and avoid conflict with external notification plugins When oh-my-opencode's session-notification runs alongside external notification plugins like opencode-notifier, both listen to session.idle and send concurrent notifications. This can cause crashes on Windows due to resource contention between different notification mechanisms: - oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications - opencode-notifier: node-notifier → SnoreToast.exe This commit adds: 1. External plugin detection (checks opencode.json for known notifiers) 2. Auto-disable of session-notification when conflict detected 3. Console warning explaining the situation 4. Config option 'notification.force_enable' to override Known notification plugins detected: - opencode-notifier - @mohak34/opencode-notifier - mohak34/opencode-notifier This is the actual fix for the Windows crash issue previously misdiagnosed as a Bun.spawn GC bug (PR #543). Refs: #543 * docs: add crash investigation timeline explaining the real root cause Documents the investigation journey from initial misdiagnosis (Bun GC bug) to discovering the actual root cause (notification plugin conflict). Key findings: - PR #543 was based on incorrect assumption - The real issue is concurrent notification plugins - oh-my-opencode + opencode-notifier = crash on Windows - Either plugin alone works fine * fix: address review feedback - add PowerShell escaping and use existing JSONC parser - Add back single-quote escaping for PowerShell soundPath to prevent command failures - Replace custom stripJsonComments with existing parseJsoncSafe from jsonc-parser - All 655 tests pass --------- Co-authored-by: sisyphus-dev-ai <[email protected]>
1 parent cccd159 commit 1570e29

File tree

8 files changed

+463
-52
lines changed

8 files changed

+463
-52
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Windows Crash Investigation Timeline
2+
3+
## Executive Summary
4+
5+
**Initial Hypothesis**: Bun.spawn/ShellInterpreter GC bug causing crashes on Windows
6+
**Actual Root Cause**: Conflict between oh-my-opencode's session-notification and external notification plugins (specifically `@mohak34/opencode-notifier`)
7+
8+
**Evidence**: User removed `@mohak34/opencode-notifier` plugin → crashes stopped immediately. The release version of oh-my-opencode (with original Bun.spawn code) works fine when used alone.
9+
10+
---
11+
12+
## Timeline
13+
14+
### Phase 1: Initial Crash Reports (Early January 2026)
15+
16+
**Symptoms:**
17+
- Windows users experiencing crashes after extended oh-my-opencode usage
18+
- Stack traces pointed to Bun's ShellInterpreter finalizer:
19+
```
20+
Segmentation fault at address 0x337081E00E0
21+
- interpreter.zig:1239: deinitFromFinalizer
22+
- ZigGeneratedClasses.zig:19925: ShellInterpreterClass__finalize
23+
```
24+
25+
**Initial Analysis:**
26+
- Similar to known Bun issues: oven-sh/bun#23177, oven-sh/bun#24368
27+
- Focus on `ctx.$` (Bun shell template literals) in session-notification.ts
28+
29+
### Phase 2: PR #543 - Wrong Fix Merged (January 6, 2026)
30+
31+
**PR**: [#543 - fix(session-notification): avoid Bun shell GC crash on Windows](https://github.com/code-yeongyu/oh-my-opencode/pull/543)
32+
33+
**Changes Made:**
34+
- Replaced `ctx.$` with `node:child_process.spawn` in `session-notification.ts`
35+
- Updated tests to mock spawn instead of ctx.$
36+
37+
**Assumption**: The ShellInterpreter GC bug was causing crashes when notification commands were executed.
38+
39+
**Status**: ❌ MERGED (reverted in this PR)
40+
41+
### Phase 3: Continued Investigation - Debug Tracing (January 6-7, 2026)
42+
43+
Crashes continued after PR #543. Added debug tracing system (PR #571) to capture what happens before crashes.
44+
45+
**PR #571**: [feat(debug): add comprehensive crash tracing system](https://github.com/code-yeongyu/oh-my-opencode/pull/571)
46+
47+
Tracing revealed LSP ENOENT errors, leading to:
48+
49+
**PR #572**: [fix(lsp): add resilient handling for missing LSP server binaries](https://github.com/code-yeongyu/oh-my-opencode/pull/572)
50+
51+
### Phase 4: More Bun.spawn Changes (January 7, 2026) - WRONG PATH
52+
53+
Based on the assumption that Bun.spawn was the issue, additional files were modified locally:
54+
- `src/hooks/session-notification-utils.ts`
55+
- `src/hooks/comment-checker/cli.ts`
56+
- `src/hooks/comment-checker/downloader.ts`
57+
- `src/hooks/interactive-bash-session/index.ts`
58+
59+
**Status**: ❌ REVERTED (never committed)
60+
61+
### Phase 5: Root Cause Discovery (January 7, 2026)
62+
63+
**Critical Observation by User:**
64+
> "I removed `@mohak34/opencode-notifier` and crashes stopped. The release version with Bun.spawn works perfectly fine."
65+
66+
**Key Evidence:**
67+
1. Removing ONLY the notifier plugin fixed crashes
68+
2. Release version (before PR #543) works fine for user and most others
69+
3. No widespread complaints from other users about crashes
70+
4. PR #543 was based on superficial pattern matching with Bun issues
71+
72+
---
73+
74+
## The Real Root Cause: Notification Plugin Conflict
75+
76+
### Two Plugins, Same Event
77+
78+
Both plugins listen to `session.idle` and send notifications:
79+
80+
| Aspect | oh-my-opencode | opencode-notifier |
81+
|--------|---------------|-------------------|
82+
| **Event** | `session.idle` | `session.idle` |
83+
| **Delay** | 1.5s confirmation delay | Immediate |
84+
| **Windows Notification** | PowerShell + Windows.UI.Notifications API | `node-notifier` → WindowsToaster → SnoreToast.exe |
85+
| **Sound** | PowerShell Media.SoundPlayer | PowerShell Media.SoundPlayer |
86+
| **Process spawning** | `ctx.$` (Bun shell) | `node:child_process` |
87+
88+
### Conflict Points
89+
90+
1. **Different notification systems fighting**:
91+
- oh-my-opencode: Direct PowerShell → Windows.UI.Notifications
92+
- opencode-notifier: SnoreToast.exe binary via node-notifier
93+
94+
2. **Same app identity**: Both register with "OpenCode" as the toast notifier app
95+
96+
3. **Concurrent execution**: Both trigger within milliseconds of each other on `session.idle`
97+
98+
4. **Resource contention**: Windows Toast API may not handle concurrent registrations gracefully
99+
100+
### Why It Wasn't Bun.spawn
101+
102+
- Both plugins use different spawning methods - this didn't matter
103+
- Release version works fine when used alone
104+
- Most users don't have this issue (most don't use both plugins)
105+
- The stack trace pointed to ShellInterpreter, but correlation ≠ causation
106+
107+
---
108+
109+
## The Fix
110+
111+
### What This PR Does
112+
113+
1. **Reverts PR #543**: Restores original `ctx.$` usage (it was never the problem)
114+
115+
2. **Adds conflict detection**:
116+
- Scans `opencode.json` for known notification plugins
117+
- Known plugins: `opencode-notifier`, `@mohak34/opencode-notifier`
118+
119+
3. **Auto-disables on conflict**:
120+
- When external notifier detected, skips creating session-notification hook
121+
- Logs clear warning explaining why
122+
123+
4. **Config override**:
124+
```json
125+
{
126+
"notification": {
127+
"force_enable": true
128+
}
129+
}
130+
```
131+
Users can force-enable oh-my-opencode's notification if they want.
132+
133+
---
134+
135+
## Lessons Learned
136+
137+
1. **Correlation ≠ Causation**: Stack traces can be misleading; investigate root cause thoroughly
138+
2. **Test with user's exact environment**: The crash only happened with specific plugin combination
139+
3. **Challenge assumptions**: "Bun.spawn is buggy" was accepted too quickly without verifying
140+
4. **Evidence-based debugging**: User's discovery (removing notifier = no crash) was the key evidence
141+
142+
---
143+
144+
## Related Links
145+
146+
- PR #543 (merged, reverted in this PR): https://github.com/code-yeongyu/oh-my-opencode/pull/543
147+
- PR #571 (open): https://github.com/code-yeongyu/oh-my-opencode/pull/571
148+
- PR #572 (open): https://github.com/code-yeongyu/oh-my-opencode/pull/572
149+
- opencode-notifier: https://github.com/mohak34/opencode-notifier
150+
- Bun issues referenced (not actually the cause):
151+
- https://github.com/oven-sh/bun/issues/23177
152+
- https://github.com/oven-sh/bun/issues/24368

src/config/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@ export const BackgroundTaskConfigSchema = z.object({
238238
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
239239
})
240240

241+
export const NotificationConfigSchema = z.object({
242+
/** Force enable session-notification even if external notification plugins are detected (default: false) */
243+
force_enable: z.boolean().optional(),
244+
})
245+
241246
export const OhMyOpenCodeConfigSchema = z.object({
242247
$schema: z.string().optional(),
243248
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
@@ -255,6 +260,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
255260
skills: SkillsConfigSchema.optional(),
256261
ralph_loop: RalphLoopConfigSchema.optional(),
257262
background_task: BackgroundTaskConfigSchema.optional(),
263+
notification: NotificationConfigSchema.optional(),
258264
})
259265

260266
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -272,5 +278,6 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
272278
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
273279
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
274280
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
281+
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
275282

276283
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

src/hooks/session-notification.test.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
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"
1+
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
42

53
import { createSessionNotification } from "./session-notification"
64
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
75
import * as utils from "./session-notification-utils"
86

97
describe("session-notification", () => {
108
let notificationCalls: string[]
11-
let spawnMock: ReturnType<typeof spyOn>
129

1310
function createMockPluginInput() {
1411
return {
15-
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
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+
},
1623
client: {
1724
session: {
1825
todo: async () => ({ data: [] }),
@@ -25,18 +32,6 @@ describe("session-notification", () => {
2532
beforeEach(() => {
2633
notificationCalls = []
2734

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-
}) as typeof childProcess.spawn)
39-
4035
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
4136
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
4237
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")

src/hooks/session-notification.ts

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

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-
3014
interface Todo {
3115
content: string
3216
status: string
@@ -81,17 +65,14 @@ async function sendNotification(
8165

8266
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
8367
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
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(() => {})
68+
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
8769
break
8870
}
8971
case "linux": {
9072
const notifySendPath = await getNotifySendPath()
9173
if (!notifySendPath) return
9274

93-
// Use node:child_process instead of Bun shell to avoid potential GC issues
94-
await execCommand(notifySendPath, [title, message]).catch(() => {})
75+
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
9576
break
9677
}
9778
case "win32": {
@@ -112,8 +93,7 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
11293
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
11394
$Notifier.Show($Toast)
11495
`.trim().replace(/\n/g, "; ")
115-
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
116-
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
96+
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
11797
break
11898
}
11999
}
@@ -124,29 +104,25 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
124104
case "darwin": {
125105
const afplayPath = await getAfplayPath()
126106
if (!afplayPath) return
127-
// Use node:child_process instead of Bun shell to avoid potential GC issues
128-
execCommand(afplayPath, [soundPath]).catch(() => {})
107+
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
129108
break
130109
}
131110
case "linux": {
132111
const paplayPath = await getPaplayPath()
133112
if (paplayPath) {
134-
// Use node:child_process instead of Bun shell to avoid potential GC issues
135-
execCommand(paplayPath, [soundPath]).catch(() => {})
113+
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
136114
} else {
137115
const aplayPath = await getAplayPath()
138116
if (aplayPath) {
139-
execCommand(aplayPath, [soundPath]).catch(() => {})
117+
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
140118
}
141119
}
142120
break
143121
}
144122
case "win32": {
145123
const powershellPath = await getPowershellPath()
146124
if (!powershellPath) return
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(() => {})
125+
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath.replace(/'/g, "''") + "').PlaySync()"}`.catch(() => {})
150126
break
151127
}
152128
}

src/index.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
import { BackgroundManager } from "./features/background-agent";
6363
import { SkillMcpManager } from "./features/skill-mcp-manager";
6464
import { type HookName } from "./config";
65-
import { log } from "./shared";
65+
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared";
6666
import { loadPluginConfig } from "./plugin-config";
6767
import { createModelCacheState, getModelLimit } from "./plugin-state";
6868
import { createConfigHandler } from "./plugin-handlers";
@@ -83,9 +83,24 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
8383
const sessionRecovery = isHookEnabled("session-recovery")
8484
? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })
8585
: null;
86-
const sessionNotification = isHookEnabled("session-notification")
87-
? createSessionNotification(ctx)
88-
: null;
86+
87+
// Check for conflicting notification plugins before creating session-notification
88+
let sessionNotification = null;
89+
if (isHookEnabled("session-notification")) {
90+
const forceEnable = pluginConfig.notification?.force_enable ?? false;
91+
const externalNotifier = detectExternalNotificationPlugin(ctx.directory);
92+
93+
if (externalNotifier.detected && !forceEnable) {
94+
// External notification plugin detected - skip our notification to avoid conflicts
95+
console.warn(getNotificationConflictWarning(externalNotifier.pluginName!));
96+
log("session-notification disabled due to external notifier conflict", {
97+
detected: externalNotifier.pluginName,
98+
allPlugins: externalNotifier.allPlugins,
99+
});
100+
} else {
101+
sessionNotification = createSessionNotification(ctx);
102+
}
103+
}
89104

90105
const commentChecker = isHookEnabled("comment-checker")
91106
? createCommentCheckerHooks(pluginConfig.comment_checker)

0 commit comments

Comments
 (0)