diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 84b4cc63a2..4a866a8f44 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; @@ -316,6 +317,47 @@ export default function contribute(plugin) { return directory; } +/** Build command that holds the plugin lifecycle queue until the test writes the release file. */ +const gatedBuildCommand = [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[1], "started");', + "const wait = () => {", + " if (fs.existsSync(process.argv[2])) return;", + " setTimeout(wait, 50);", + "};", + "wait();", +].join("\n"); + +async function createGatedBuildRepository( + root: string, + gate: { started: string; release: string }, +): Promise { + const repository = path.join(root, "gated-repository"); + await mkdir(repository); + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "gated-build-plugin", + description: "Holds the plugin lifecycle queue while its build waits", + requirements: pluginRequirements, + build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]], + }), + ); + await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin")); + execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Paseo Tests"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.email", "paseo@example.test"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" }); + return repository; +} + async function installFailedPlugin( page: Page, client: Awaited>, @@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription( ); await environment.client.installPluginSource({ source: directory }); } + +/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */ +function observePluginReload(page: Page) { + const requests: number[] = []; + const responses: number[] = []; + const isSessionMessage = (payload: unknown, type: string): boolean => { + if (typeof payload !== "string") return false; + try { + const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } }; + const message = envelope.type === "session" ? envelope.message : envelope; + return message?.type === type; + } catch { + return false; + } + }; + page.on("websocket", (socket) => { + // The reload request travels client -> daemon (outbound); the answer comes back inbound. + socket.on("framesent", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now()); + }); + socket.on("framereceived", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now()); + }); + }); + return { + waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1), + waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1), + requestedAt: () => requests[0]!, + requestCount: () => requests.length, + }; +} + +/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */ +async function holdPluginLifecycleQueue( + client: PluginEnvironment["client"], + root: string, +): Promise<{ release: () => Promise; settle: () => Promise }> { + const gate = { + started: path.join(root, "build-started"), + release: path.join(root, "build-release"), + }; + const repository = await createGatedBuildRepository(root, gate); + const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` }); + // The marker wait below can outlive an early failure: a refused preparation, a dropped + // connection, or a build command that exits before writing its marker. Handle that rejection now + // so it never surfaces as an unhandled one, and drain the same promise in finish(). + const installDrained = install.catch(() => undefined); + // Every path releases the gate and drains the install: a build still polling for its gate holds + // the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall. + const finish = async () => { + await writeFile(gate.release, "go").catch(() => undefined); + await installDrained; + }; + try { + // Whichever comes first decides the scenario: the build reports that it started, or the setup + // fails with the daemon's own error instead of a bare marker timeout. + await Promise.race([ + expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true), + install, + ]); + } catch (error) { + // Setup that never reached the build must not hand the caller a queue it cannot settle. + await finish(); + throw error; + } + return { + release: () => writeFile(gate.release, "go"), + settle: finish, + }; +} + +function pluginRowActions(page: Page, pluginId: string) { + return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }); +} + +async function expectPluginRowPending(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeDisabled(); +} + +async function expectPluginRowSettled(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeEnabled(); + await expect(page.getByLabel(`${pluginId} running`)).toBeVisible(); +} + +async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise { + await page.screenshot({ + path: testInfo.outputPath(name), + fullPage: true, + animations: "disabled", + }); +} + +/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */ +async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise { + const remaining = 66_000 - (Date.now() - requestedAt); + if (remaining > 0) await page.waitForTimeout(remaining); +} + +test("keeps Reload pending past a minute while the plugin queue is busy", async ({ + page, + pluginEnvironment, +}, testInfo) => { + test.setTimeout(180_000); + const { client, directory } = pluginEnvironment; + const pluginDirectory = await createDirectoryPlugin( + directory, + "e2e-plugin", + "Exercises the complete local plugin lifecycle", + "Plugin v1", + ); + + // Observe before the app loads: its daemon socket comes up with the page. + const reload = observePluginReload(page); + await gotoAppShell(page); + await openPluginSettings(page); + // Plugins on is a precondition here, not the subject: set it through the daemon instead of the + // switch, whose transient "Plugins enabled" confirmation this scenario must not depend on. + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: pluginDirectory }); + await expectPluginRowSettled(page, "e2e-plugin"); + + const queue = await holdPluginLifecycleQueue(client, directory); + try { + await selectPluginAction(page, "e2e-plugin", "Reload"); + await reload.waitForRequest(); + // Pending: the row's actions stay disabled, so a second reload cannot be submitted. + await expectPluginRowPending(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-pending.png"); + + await waitPastFormerReloadDeadline(page, reload.requestedAt()); + + // Past the former deadline the reload is still pending, the row shows its previous state, and + // no timeout error reached the surface. + await expectPluginRowPending(page, "e2e-plugin"); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0); + await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png"); + + await queue.release(); + // The daemon's answer only arrives once the plugin really started again; a request timeout + // would have shown up as feedback during the wait above. + await reload.waitForResponse(); + expect(reload.requestCount()).toBe(1); + await expect(page.getByTestId("plugin-management-feedback")).toContainText( + "Reloaded e2e-plugin", + ); + await expectPluginRowSettled(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-completed.png"); + } finally { + await queue.settle(); + } +}); diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 3e71b6b608..ee5b525c12 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -29,6 +29,116 @@ async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor( + condition: () => Promise, + description: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await sleep(50); + } + throw new Error(`Timed out waiting for ${description}`); +} + +interface ObservedRequest { + type: string; + message: Record; + observedAt: number; +} + +/** + * Observes real CLI requests while forwarding them to the isolated daemon. + * + * Requests are recorded where the CLI sends them, so a scenario can measure a wait from the + * request boundary instead of from process start. + */ +async function startRequestObserver(upstreamPort: number): Promise<{ + port: number; + requests: ObservedRequest[]; + waitForRequest: (type: string) => Promise; + close: () => Promise; +}> { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const requests: ObservedRequest[] = []; + server.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const frame = JSON.parse(data.toString()) as { + type?: string; + message?: { type?: string } & Record; + }; + if (frame.type === "session" && typeof frame.message?.type === "string") { + requests.push({ + type: frame.message.type, + message: frame.message, + observedAt: Date.now(), + }); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + return { + port: address.port, + requests, + waitForRequest: async (type: string) => { + await waitFor( + async () => requests.some((entry) => entry.type === type), + `${type} to reach the daemon`, + ); + return requests.find((entry) => entry.type === type)!.observedAt; + }, + close: async () => { + for (const socket of server.clients) socket.close(); + await promisify(server.close.bind(server))(); + }, + }; +} + +/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */ +const gateWaitBuild = [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[1], "started");', + "const wait = () => {", + " if (fs.existsSync(process.argv[2])) return;", + " setTimeout(wait, 50);", + "};", + "wait();", +].join("\n"); + +/** Plugin whose every activation appends one line, so a test can count real reactivations. */ +function activationCountingPlugin(logPath: string): string { + return [ + 'import { appendFileSync } from "node:fs";', + `const activationLog = ${JSON.stringify(logPath)};`, + "export default function contribute(plugin: unknown) {", + ' appendFileSync(activationLog, "activate\\n");', + " void plugin;", + " return () => undefined;", + "}", + ].join("\n"); +} + +async function countActivations(logPath: string): Promise { + const content = await readFile(logPath, "utf8").catch(() => ""); + return content.split("\n").filter(Boolean).length; +} + async function main(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-")); @@ -48,31 +158,11 @@ async function main(): Promise { await writeFile(path.join(directory, "index.server.ts"), pluginSource); // Observe real CLI requests while forwarding them to the isolated daemon. - const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - const sources: string[] = []; - proxy.on("connection", (socket, request) => { - const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { - headers: { authorization: request.headers.authorization ?? "" }, - }); - const ready = once(upstream, "open"); - socket.on("message", async (data, isBinary) => { - const message = JSON.parse(data.toString()); - if ( - message.type === "session" && - message.message.type === "plugin.source.install.request" - ) { - sources.push(message.message.source); - } - await ready; - upstream.send(data, { binary: isBinary }); - }); - upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); - socket.on("close", () => upstream.close()); - upstream.on("close", () => socket.close()); - }); - await once(proxy, "listening"); - const address = proxy.address(); - assert.ok(address && typeof address !== "string"); + const observer = await startRequestObserver(context.port); + const installedSources = () => + observer.requests + .filter((entry) => entry.type === "plugin.source.install.request") + .map((entry) => entry.message.source); const sibling = path.join(context.workDir, "x"); const caller = path.join(context.workDir, "caller"); await mkdir(caller); @@ -96,12 +186,12 @@ async function main(): Promise { ["~", context.paseoHome, context.paseoHome, []], ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], ] as const) { - sources.length = 0; + observer.requests.length = 0; const result = await context.paseo( - ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"], { cwd: caller }, ); - assert.deepEqual(sources, [expectedSource]); + assert.deepEqual(installedSources(), [expectedSource]); assert.equal(result.exitCode, 0, result.stderr); assert.equal(JSON.parse(result.stdout).path, expectedDirectory); const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); @@ -109,17 +199,16 @@ async function main(): Promise { } // An absolute path belongs to the daemon's platform, which can differ from the CLI's. const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; - sources.length = 0; + observer.requests.length = 0; const missing = await context.paseo( - ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"], { cwd: caller }, ); - assert.deepEqual(sources, [windowsSource]); + assert.deepEqual(installedSources(), [windowsSource]); assert.equal(missing.exitCode, 1); assert.match(missing.stderr, /Plugin directory does not exist/); } finally { - for (const socket of proxy.clients) socket.close(); - await promisify(proxy.close.bind(proxy))(); + await observer.close(); } const install = await context.paseo(["plugin", "install", directory, "--json"]); @@ -242,6 +331,134 @@ async function main(): Promise { assert.equal(enable.exitCode, 0, enable.stderr); assert.equal(JSON.parse(enable.stdout).status, "running"); + // A reload that finishes after the client's former one-minute deadline must still report its + // real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time. + const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-")); + const buildStarted = path.join(gateDirectory, "build-started"); + const buildGate = path.join(gateDirectory, "build-gate"); + const activationLog = path.join(gateDirectory, "activations.log"); + const targetDirectory = path.join(gateDirectory, "slow-reload-target"); + const slowRepository = path.join(gateDirectory, "slow-build"); + const reloadObserver = await startRequestObserver(context.port); + let slowInstall: ReturnType | undefined; + let slowReload: ReturnType | undefined; + try { + await mkdir(targetDirectory); + await writeFile( + path.join(targetDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "slow-reload-target", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile( + path.join(targetDirectory, "index.server.ts"), + activationCountingPlugin(activationLog), + ); + const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]); + assert.equal(targetInstall.exitCode, 0, targetInstall.stderr); + assert.equal(await countActivations(activationLog), 1); + + await mkdir(slowRepository); + await git(slowRepository, ["init", "-b", "main"]); + await git(slowRepository, ["config", "user.name", "Paseo Tests"]); + await git(slowRepository, ["config", "user.email", "paseo@example.test"]); + await writeFile( + path.join(slowRepository, "paseo-plugin.json"), + JSON.stringify({ + id: "slow-build", + requirements: { paseo: `>=${resolveCliVersion()}` }, + build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]], + }), + ); + await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource); + await git(slowRepository, ["add", "-A"]); + await git(slowRepository, ["commit", "-m", "blocking build"]); + + // This install holds the plugin lifecycle queue while its build waits for the gate. + slowInstall = context.paseo( + ["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"], + { timeout: 180_000 }, + ); + // A failed launch must not arrive as an unhandled rejection: that ends this direct Node run + // before the finally block below releases the build gate. Its result is still asserted. + void slowInstall.catch(() => undefined); + await waitFor( + async () => + readFile(buildStarted, "utf8").then( + () => true, + () => false, + ), + "the blocking build to start", + ); + + slowReload = context.paseo( + [ + "plugin", + "reload", + "slow-reload-target", + "--host", + `127.0.0.1:${reloadObserver.port}`, + "--json", + ], + { timeout: 180_000 }, + ); + // Same as the install above: a launch failure must not end the run before the cleanup. + void slowReload.catch(() => undefined); + const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request"); + + // The interim listing is not the reload's outcome: it still reports the plugin running and + // no restart has happened while the reload waits behind the queue. + const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]); + assert.equal(duringReload.exitCode, 0, duringReload.stderr); + assert.equal(JSON.parse(duringReload.stdout)[0].status, "running"); + assert.equal(await countActivations(activationLog), 1); + + const heldMs = Date.now() - reloadRequestedAt; + if (heldMs < 66_000) { + await sleep(66_000 - heldMs); + } + await writeFile(buildGate, "go"); + + const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]); + assert.equal(buildResult.exitCode, 0, buildResult.stderr); + assert.equal(reloadResult.exitCode, 0, reloadResult.stderr); + assert.equal(JSON.parse(reloadResult.stdout).status, "running"); + assert.equal(await countActivations(activationLog), 2); + + // A plugin that cannot start still fails its reload with the daemon's error. + const brokenDirectory = path.join(gateDirectory, "broken-plugin"); + await mkdir(brokenDirectory); + await writeFile( + path.join(brokenDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "broken-reload", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!"); + const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]); + assert.equal(brokenInstall.exitCode, 1); + assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i); + const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], { + timeout: 60_000, + }); + assert.equal(brokenReload.exitCode, 1); + assert.match(brokenReload.stderr, /broken-reload|broken|failed/i); + + for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) { + const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + } finally { + // Release the gate, then let the install and the reload settle before their files go: the + // build polls for the gate file, so cleanup must not delete it while the child still waits. + await writeFile(buildGate, "go").catch(() => undefined); + await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]); + await reloadObserver.close(); + await rm(gateDirectory, { recursive: true, force: true }); + } + const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]); assert.equal(remove.exitCode, 0, remove.stderr); const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index c02e1720cb..6b82922841 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1963,6 +1963,200 @@ test("gets a structured plugin log snapshot", async () => { ]); }); +test("keeps a plugin reload pending past the session RPC deadline", async () => { + useHeartbeatClock(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + let settled: "pending" | "resolved" | "rejected" = "pending"; + void reload.then( + () => { + settled = "resolved"; + return null; + }, + () => { + settled = "rejected"; + return null; + }, + ); + + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual({ + type: "plugin.reload.request", + requestId: expect.any(String), + pluginId: "example", + }); + + // A daemon that reloads for almost two minutes is still reloading, not a failed request. + await vi.advanceTimersByTimeAsync(114_034); + expect(settled).toBe("pending"); + + // Another plugin's reload finishing early must not settle this request. + mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.reload.response", + payload: { + requestId: "req-other-plugin", + plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" }, + }, + }), + ); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe("pending"); + + mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.reload.response", + payload: { + requestId: request.requestId, + plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" }, + }, + }), + ); + + await expect(reload).resolves.toEqual({ + id: "example", + path: "/plugins/example", + enabled: true, + status: "running", + }); + } finally { + vi.useRealTimers(); + } +}); + +test("keeps enable and disable inside the session RPC deadline", async () => { + useHeartbeatClock(); + try { + for (const action of ["enable", "disable"] as const) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const pending = + action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example"); + let settled = false; + void pending.then( + () => { + settled = true; + return null; + }, + () => { + settled = true; + return null; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: `plugin.${action}.request`, + requestId: expect.any(String), + pluginId: "example", + }); + + await vi.advanceTimersByTimeAsync(59_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)"); + } + } finally { + vi.useRealTimers(); + } +}); + +test("reports a plugin reload failure that arrives after the session RPC deadline", async () => { + useHeartbeatClock(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + const request = parseSentFrame(mock.sent[0]); + + await vi.advanceTimersByTimeAsync(114_034); + mock.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: "plugin.reload.request", + error: "Request failed: Plugin failed to start: example", + code: "handler_error", + }, + }), + ); + + await expect(reload).rejects.toThrow( + "Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error", + ); + } finally { + vi.useRealTimers(); + } +}); + +test("rejects a pending plugin reload when the connection closes", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" }); + + mock.triggerClose({ code: 1006, reason: "network lost" }); + + await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i); + + // Reconnecting must not resend the lost reload: only the caller decides to reload again. + const reconnecting = client.connect(); + mock.triggerOpen(); + await reconnecting; + expect(mock.sent).toEqual([]); +}); + test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1aa0d3e995..e0e4d9ddc5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -5692,6 +5692,10 @@ export class DaemonClient { requestId, message: { type: `plugin.${action}.request`, requestId, pluginId }, responseType: `plugin.${action}.response`, + // Reload tears the plugin down and starts it again before the daemon answers, and it queues + // behind other plugin lifecycle work. Wait for that answer instead of failing the reload at + // the request deadline while the daemon keeps loading it. + ...(action === "reload" ? { timeout: 0 } : {}), }); return payload.plugin; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 48f37e89de..c8f3cc0e1a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -244,6 +244,13 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` +`paseo plugin reload ` reports the result of the reload itself: it waits while the daemon stops +and starts the plugin, however long that takes, and it queues behind the daemon's other plugin work. +Cancelling the command or losing the connection does not stop the daemon's work, and no listing or +log entry settles what a cancelled reload did — the [Plugin reference](/docs/plugins/reference) +covers those interim states. Running the reload again is what reports the result: it waits behind the +outstanding one, then stops and starts the plugin once more. + Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub source, or `./plugin` for a local directory. Append `:` for a plugin in a monorepo. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 525ffe1423..cc8023b251 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2269,6 +2269,12 @@ remove Command Center items and clear the installation's query state. An already remains as unavailable until its matching contribution returns or the user closes it. Panel render failures stay inside the plugin error boundary. +Reload queues behind the daemon's other plugin work, such as a running installation or preparation +command, and the daemon answers when the plugin is running again or has failed. Nothing reads the +plugin's status as the reload's outcome: while the reload is queued, `plugin ls`, Settings, and the +logs show the plugin's previous state, and while the daemon stops and starts it, a status read +reports `failed` because the plugin is absent from the runtime catalog until its start completes. + ## Plugin sources Paste one of these source identifiers into **Settings → Plugins**, or pass it to