From 84d5631cb2014844b595ec93d058b300a1436dd5 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:01:32 +0800 Subject: [PATCH] fix(coding-agent): isolate concurrent session shares --- .../src/modes/interactive/session-share.ts | 18 ++-- .../coding-agent/test/session-share.test.ts | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/session-share.test.ts diff --git a/packages/coding-agent/src/modes/interactive/session-share.ts b/packages/coding-agent/src/modes/interactive/session-share.ts index f9c0000e25cb..11150c222361 100644 --- a/packages/coding-agent/src/modes/interactive/session-share.ts +++ b/packages/coding-agent/src/modes/interactive/session-share.ts @@ -44,8 +44,9 @@ export function exportSessionForShare(filePath: string, session: AgentSession): /** Share the current session through Radius, falling back to a private gist. */ export async function shareSession(context: SessionShareContext): Promise { - const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); - let htmlFile: string | null = null; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-share-")); + const jsonlFile = path.join(tempDir, "session.jsonl"); + const htmlFile = path.join(tempDir, "session.html"); try { try { @@ -68,7 +69,6 @@ export async function shareSession(context: SessionShareContext): Promise } try { - htmlFile = path.join(os.tmpdir(), "session.html"); await context.session.exportToHtml(htmlFile, { themeName: theme.name }); } catch (error: unknown) { context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); @@ -76,14 +76,10 @@ export async function shareSession(context: SessionShareContext): Promise } await shareViaGist(htmlFile, context); } finally { - for (const tmpFile of [jsonlFile, htmlFile]) { - try { - if (tmpFile !== null) { - fs.unlinkSync(tmpFile); - } - } catch { - // Ignore cleanup errors - } + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors } } } diff --git a/packages/coding-agent/test/session-share.test.ts b/packages/coding-agent/test/session-share.test.ts new file mode 100644 index 000000000000..3c1bf52d96e6 --- /dev/null +++ b/packages/coding-agent/test/session-share.test.ts @@ -0,0 +1,88 @@ +import { EventEmitter } from "node:events"; +import { readFileSync, writeFileSync } from "node:fs"; +import { PassThrough } from "node:stream"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +const childProcessMocks = vi.hoisted(() => ({ + spawn: vi.fn(), + spawnSync: vi.fn(() => ({ status: 0 })), +})); + +vi.mock("node:child_process", () => childProcessMocks); + +import { shareSession } from "../src/modes/interactive/session-share.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("shareSession", () => { + beforeAll(() => initTheme("dark")); + + it("keeps concurrent session exports isolated", async () => { + const uploads: string[] = []; + childProcessMocks.spawn.mockImplementation((_command, args: string[]) => { + uploads.push(readFileSync(args.at(-1)!, "utf8")); + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }); + queueMicrotask(() => { + child.stdout.end(`https://gist.github.com/test/${uploads.length}\n`); + child.stderr.end(); + child.emit("close", 0); + }); + return child; + }); + + const aWritten = deferred(); + const bWritten = deferred(); + const releaseB = deferred(); + const errors: string[] = []; + const context = (name: "A" | "B") => ({ + session: { + sessionManager: { + getSessionId: () => name, + getCwd: () => "/tmp", + getBranch: () => [], + }, + state: { systemPrompt: name, tools: [] }, + modelRuntime: { getProvider: () => undefined }, + exportToHtml: async (filePath: string) => { + writeFileSync(filePath, name); + if (name === "A") { + aWritten.resolve(); + await bWritten.promise; + } else { + bWritten.resolve(); + await releaseB.promise; + } + }, + }, + ui: { setFocus() {}, requestRender() {} }, + editorContainer: { clear() {}, addChild() {} }, + editor: {}, + showStatus() {}, + showError(message: string) { + errors.push(message); + }, + }); + + const shareA = shareSession(context("A") as never); + await aWritten.promise; + const shareB = shareSession(context("B") as never); + await bWritten.promise; + await shareA; + releaseB.resolve(); + await shareB; + + expect(uploads).toEqual(["A", "B"]); + expect(errors).toEqual([]); + }); +});