* revert: undo PR #543 changes (bun shell GC crash was misdiagnosed) This reverts commit4a38e70(PR #543) and2064568(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 <sisyphus-dev-ai@users.noreply.github.com>
361 lines
10 KiB
TypeScript
361 lines
10 KiB
TypeScript
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
|
|
|
import { createSessionNotification } from "./session-notification"
|
|
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
|
import * as utils from "./session-notification-utils"
|
|
|
|
describe("session-notification", () => {
|
|
let notificationCalls: string[]
|
|
|
|
function createMockPluginInput() {
|
|
return {
|
|
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
|
// #given - track notification commands (osascript, notify-send, powershell)
|
|
const cmdStr = typeof cmd === "string"
|
|
? cmd
|
|
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
|
|
|
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
|
notificationCalls.push(cmdStr)
|
|
}
|
|
return { stdout: "", stderr: "", exitCode: 0 }
|
|
},
|
|
client: {
|
|
session: {
|
|
todo: async () => ({ data: [] }),
|
|
},
|
|
},
|
|
directory: "/tmp/test",
|
|
} as any
|
|
}
|
|
|
|
beforeEach(() => {
|
|
notificationCalls = []
|
|
|
|
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
|
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
|
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
|
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
|
|
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
|
|
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
|
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
// #given - cleanup after each test
|
|
subagentSessions.clear()
|
|
setMainSession(undefined)
|
|
})
|
|
|
|
test("should not trigger notification for subagent session", async () => {
|
|
// #given - a subagent session exists
|
|
const subagentSessionID = "subagent-123"
|
|
subagentSessions.add(subagentSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 0,
|
|
})
|
|
|
|
// #when - subagent session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: subagentSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - notification should NOT be sent
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should not trigger notification when mainSessionID is set and session is not main", async () => {
|
|
// #given - main session is set, but a different session goes idle
|
|
const mainSessionID = "main-123"
|
|
const otherSessionID = "other-456"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 0,
|
|
})
|
|
|
|
// #when - non-main session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: otherSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - notification should NOT be sent
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should trigger notification for main session when idle", async () => {
|
|
// #given - main session is set
|
|
const mainSessionID = "main-789"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 10,
|
|
skipIfIncompleteTodos: false,
|
|
})
|
|
|
|
// #when - main session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for idle confirmation delay + buffer
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
// #then - notification should be sent
|
|
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
test("should skip notification for subagent even when mainSessionID is set", async () => {
|
|
// #given - both mainSessionID and subagent session exist
|
|
const mainSessionID = "main-999"
|
|
const subagentSessionID = "subagent-888"
|
|
setMainSession(mainSessionID)
|
|
subagentSessions.add(subagentSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 0,
|
|
})
|
|
|
|
// #when - subagent session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: subagentSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - notification should NOT be sent (subagent check takes priority)
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should handle subagentSessions and mainSessionID checks in correct order", async () => {
|
|
// #given - main session and subagent session exist
|
|
const mainSessionID = "main-111"
|
|
const subagentSessionID = "subagent-222"
|
|
const unknownSessionID = "unknown-333"
|
|
setMainSession(mainSessionID)
|
|
subagentSessions.add(subagentSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 0,
|
|
})
|
|
|
|
// #when - subagent session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: subagentSessionID },
|
|
},
|
|
})
|
|
|
|
// #when - unknown session goes idle (not main, not in subagentSessions)
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: unknownSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should cancel pending notification on session activity", async () => {
|
|
// #given - main session is set
|
|
const mainSessionID = "main-cancel"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 100, // Long delay
|
|
skipIfIncompleteTodos: false,
|
|
})
|
|
|
|
// #when - session goes idle
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// #when - activity happens before delay completes
|
|
await hook({
|
|
event: {
|
|
type: "tool.execute.before",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for original delay to pass
|
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
|
|
// #then - notification should NOT be sent (cancelled by activity)
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should handle session.created event without notification", async () => {
|
|
// #given - a new session is created
|
|
const hook = createSessionNotification(createMockPluginInput(), {})
|
|
|
|
// #when - session.created event fires
|
|
await hook({
|
|
event: {
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "new-session", title: "Test Session" },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - no notification should be triggered
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should handle session.deleted event and cleanup state", async () => {
|
|
// #given - a session exists
|
|
const hook = createSessionNotification(createMockPluginInput(), {})
|
|
|
|
// #when - session.deleted event fires
|
|
await hook({
|
|
event: {
|
|
type: "session.deleted",
|
|
properties: {
|
|
info: { id: "deleted-session" },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Wait for any pending timers
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - no notification should be triggered
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should mark session activity on message.updated event", async () => {
|
|
// #given - main session is set
|
|
const mainSessionID = "main-message"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 50,
|
|
skipIfIncompleteTodos: false,
|
|
})
|
|
|
|
// #when - session goes idle, then message.updated fires
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
await hook({
|
|
event: {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: { sessionID: mainSessionID, role: "user", finish: false },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Wait for idle delay to pass
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
// #then - notification should NOT be sent (activity cancelled it)
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should mark session activity on tool.execute.before event", async () => {
|
|
// #given - main session is set
|
|
const mainSessionID = "main-tool"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 50,
|
|
skipIfIncompleteTodos: false,
|
|
})
|
|
|
|
// #when - session goes idle, then tool.execute.before fires
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
await hook({
|
|
event: {
|
|
type: "tool.execute.before",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for idle delay to pass
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
// #then - notification should NOT be sent (activity cancelled it)
|
|
expect(notificationCalls).toHaveLength(0)
|
|
})
|
|
|
|
test("should not send duplicate notification for same session", async () => {
|
|
// #given - main session is set
|
|
const mainSessionID = "main-dup"
|
|
setMainSession(mainSessionID)
|
|
|
|
const hook = createSessionNotification(createMockPluginInput(), {
|
|
idleConfirmationDelay: 10,
|
|
skipIfIncompleteTodos: false,
|
|
})
|
|
|
|
// #when - session goes idle twice
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for first notification
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
await hook({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: mainSessionID },
|
|
},
|
|
})
|
|
|
|
// Wait for second potential notification
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
// #then - only one notification should be sent
|
|
expect(notificationCalls).toHaveLength(1)
|
|
})
|
|
})
|